Working on email templating, updating with swagger

This commit is contained in:
2025-05-14 20:33:13 -04:00
parent 1e3c75624a
commit e46e8ab9b4
41 changed files with 1124 additions and 189 deletions

14
.env
View File

@@ -2,11 +2,11 @@ RUST_LOG=warn,api=info
NGINX_HOST=localhost
NGINX_SSL_ENABLED=false
NGINX_PROTOCOL=http
NGINX_HTTP_PORT=8080
NGINX_HTTPS_PORT=8443
# Set to 'localhost' or 'host.docker.internal' or '172.17.0.1'
NGINX_INTERNAL_HOST=host.docker.internal
EXTERNAL_URL=http://localhost:8080
POSTGRES_HOST=localhost
POSTGRES_USER=aviation
@@ -24,7 +24,7 @@ MINIO_BUCKET=aviation
MINIO_PROTOCOL=http
MINIO_PORT=9000
MINIO_INTERNAL_PORT=9001
MINIO_BROWSER_REDIRECT_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTP_PORT}/minio/
MINIO_BROWSER_REDIRECT_URL=${EXTERNAL_URL}/minio/
UI_PORT=3000
API_PORT=5000
@@ -35,11 +35,19 @@ SSL_CA_PATH=../ssl/${SSL_CA_NAME}.pem
SSL_CERT_PATH=../ssl/localhost.crt
SSL_CERT_KEY_PATH=../ssl/localhost.key
VITE_API_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTP_PORT}/api
SMTP_USERNAME=smtp-user
SMTP_PASSWORD=smtp-password
SMTP_FROM=noreply@example.com
SMTP_SERVER=smtp.example.com
VITE_API_URL=${EXTERNAL_URL}/api
VITE_DEFAULT_LIMIT=200
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=${NGINX_HOST}
ENVIRONMENT=development
API_CONTACT_NAME=changeme
API_CONTACT_EMAIL=contact@example.com
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme

View File

@@ -3,6 +3,8 @@
<h1 align="center">Aviation Data</h1>
</div>
[Swagger Docs](https://aviation.bensherriff.com/swagger/#/)
## Makefile
* `make` or `make help` to list all commands
* `make docker-up` to start all containers

445
api/Cargo.lock generated
View File

@@ -254,6 +254,18 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy 0.8.24",
]
[[package]]
name = "aho-corasick"
version = "1.1.3"
@@ -362,6 +374,8 @@ dependencies = [
"env_logger",
"futures",
"futures-util",
"handlebars",
"lettre",
"log",
"rand 0.9.0",
"rand_chacha 0.9.0",
@@ -373,7 +387,20 @@ dependencies = [
"serde_json",
"sqlx",
"tokio",
"utoipa",
"utoipa-actix-web",
"utoipa-swagger-ui",
"uuid",
"webpki-roots",
]
[[package]]
name = "arbitrary"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
dependencies = [
"derive_arbitrary",
]
[[package]]
@@ -468,9 +495,9 @@ dependencies = [
[[package]]
name = "backon"
version = "1.4.1"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "970d91570c01a8a5959b36ad7dd1c30642df24b6b3068710066f6809f7033bb7"
checksum = "fd0b50b1b78dbadd44ab18b3c794e496f3a139abb9fbc27d9c94c4eebbb96496"
dependencies = [
"fastrand",
]
@@ -609,6 +636,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chumsky"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
dependencies = [
"hashbrown 0.14.5",
"stacker",
]
[[package]]
name = "colorchoice"
version = "1.0.3"
@@ -817,6 +854,48 @@ dependencies = [
"serde",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn",
]
[[package]]
name = "derive_more"
version = "0.99.19"
@@ -904,6 +983,22 @@ dependencies = [
"serde",
]
[[package]]
name = "email-encoding"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [
"base64",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1214,11 +1309,31 @@ dependencies = [
"tracing",
]
[[package]]
name = "handlebars"
version = "6.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098"
dependencies = [
"derive_builder",
"log",
"num-order",
"pest",
"pest_derive",
"serde",
"serde_json",
"thiserror 2.0.12",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashbrown"
@@ -1279,6 +1394,17 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "hostname"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65"
dependencies = [
"cfg-if",
"libc",
"windows-link",
]
[[package]]
name = "http"
version = "0.2.12"
@@ -1637,6 +1763,7 @@ checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652"
dependencies = [
"equivalent",
"hashbrown 0.15.2",
"serde",
]
[[package]]
@@ -1715,6 +1842,34 @@ dependencies = [
"spin",
]
[[package]]
name = "lettre"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87ffd14fa289730e3ad68edefdc31f603d56fe716ec38f2076bb7410e09147c2"
dependencies = [
"async-trait",
"base64",
"chumsky",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"hostname",
"httpdate",
"idna",
"mime",
"native-tls",
"nom",
"percent-encoding",
"quoted_printable",
"socket2",
"tokio",
"tokio-native-tls",
"url",
]
[[package]]
name = "libc"
version = "0.2.170"
@@ -1821,6 +1976,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minidom"
version = "0.15.2"
@@ -1868,6 +2033,15 @@ dependencies = [
"tempfile",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -1921,6 +2095,21 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-modular"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f"
[[package]]
name = "num-order"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6"
dependencies = [
"num-modular",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2067,6 +2256,51 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "pest"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6"
dependencies = [
"memchr",
"thiserror 2.0.12",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pest_meta"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0"
dependencies = [
"once_cell",
"pest",
"sha2",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -2145,6 +2379,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psm"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f"
dependencies = [
"cc",
]
[[package]]
name = "quick-xml"
version = "0.32.0"
@@ -2164,6 +2407,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
[[package]]
name = "r2d2"
version = "0.8.10"
@@ -2237,13 +2486,14 @@ dependencies = [
[[package]]
name = "redis"
version = "0.29.5"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc42f3a12fd4408ce64d8efef67048a924e543bd35c6591c0447fda9054695f"
checksum = "0bc1ea653e0b2e097db3ebb5b7f678be339620b8041f66b30a308c1d45d36a7f"
dependencies = [
"arc-swap",
"backon",
"bytes",
"cfg-if",
"combine",
"futures-channel",
"futures-util",
@@ -2384,6 +2634,40 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rust-embed"
version = "8.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60e425e204264b144d4c929d126d0de524b40a961686414bab5040f7465c71be"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bf418c9a2e3f6663ca38b8a7134cc2c2167c9d69688860e8961e3faa731702e"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d55b95147fe01265d06b3955db798bdaed52e60e2211c41137701b3aba8e21"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rust-ini"
version = "0.21.1"
@@ -2528,6 +2812,15 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.27"
@@ -2687,6 +2980,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
[[package]]
name = "slab"
version = "0.4.9"
@@ -2946,6 +3245,19 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "stacker"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cddb07e32ddb770749da91081d8d0ac3a16f1a569a18b20348cd371f5dead06b"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.59.0",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
@@ -3296,6 +3608,18 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -3364,6 +3688,60 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utoipa"
version = "5.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435c6f69ef38c9017b4b4eea965dfb91e71e53d869e896db40d1cf2441dd75c0"
dependencies = [
"indexmap",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-actix-web"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7eda9c23c05af0fb812f6a177514047331dac4851a2c8e9c4b895d6d826967f"
dependencies = [
"actix-service",
"actix-web",
"utoipa",
]
[[package]]
name = "utoipa-gen"
version = "5.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77d306bc75294fd52f3e99b13ece67c02c1a2789190a6f31d32f736624326f7"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn",
"uuid",
]
[[package]]
name = "utoipa-swagger-ui"
version = "9.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29519b3c485df6b13f4478ac909a491387e9ef70204487c3b64b53749aec0be"
dependencies = [
"actix-web",
"base64",
"mime_guess",
"regex",
"rust-embed",
"serde",
"serde_json",
"url",
"utoipa",
"zip",
]
[[package]]
name = "uuid"
version = "1.16.0"
@@ -3386,6 +3764,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
@@ -3497,6 +3885,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "whoami"
version = "1.5.2"
@@ -3507,6 +3904,15 @@ dependencies = [
"wasite",
]
[[package]]
name = "winapi-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "windows-core"
version = "0.52.0"
@@ -3518,9 +3924,9 @@ dependencies = [
[[package]]
name = "windows-link"
version = "0.1.0"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3"
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]]
name = "windows-registry"
@@ -3898,6 +4304,33 @@ dependencies = [
"syn",
]
[[package]]
name = "zip"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]]
name = "zopfli"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"

View File

@@ -23,10 +23,16 @@ tokio = { version = "1.44.2", features = ["macros", "rt", "time"] }
uuid = { version = "1.16.0", features = ["serde", "v4"] }
log = "0.4.27"
argon2 = "0.5.3"
redis = { version = "0.29.5", features = ["tokio-comp", "connection-manager", "r2d2", "json"] }
redis = { version = "0.31.0", features = ["tokio-comp", "connection-manager", "r2d2", "json"] }
regex = "1.11.1"
futures-util = "0.3.31"
rust-s3 = "0.35.1"
rand = "0.9.0"
rand_chacha = "0.9.0"
futures = "0.3.31"
utoipa = { version = "5.3.1", features = ["chrono", "uuid", "actix_extras"] }
utoipa-swagger-ui = { version = "9.0.1", features = ["actix-web"] }
utoipa-actix-web = "0.1.2"
webpki-roots = "1.0.0"
lettre = { version = "0.11.16", features = ["builder", "smtp-transport", "tokio1-native-tls"] }
handlebars = "6.3.2"

View File

@@ -30,7 +30,7 @@ CREATE INDEX ON airports (metar_observation_time);
CREATE TABLE IF NOT EXISTS runways (
id UUID PRIMARY KEY NOT NULL,
icao TEXT NOT NULL,
icao TEXT NOT NULL REFERENCES airports(icao) ON DELETE CASCADE,
runway_id TEXT NOT NULL,
length_ft REAL NOT NULL,
width_ft REAL NOT NULL,
@@ -42,7 +42,7 @@ CREATE INDEX ON runways (runway_id);
CREATE TABLE IF NOT EXISTS communications (
id UUID PRIMARY KEY NOT NULL,
icao TEXT NOT NULL,
icao TEXT NOT NULL REFERENCES airports(icao) ON DELETE CASCADE,
frequency_id TEXT NOT NULL,
name TEXT,
frequencies_mhz REAL[] NOT NULL,
@@ -64,7 +64,7 @@ CREATE TABLE IF NOT EXISTS metars (
CREATE INDEX ON metars (observation_time DESC);
CREATE TABLE IF NOT EXISTS users (
id UUID NOT NULL,
id UUID UNIQUE NOT NULL,
email TEXT NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT false,
password_hash TEXT NOT NULL,

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,26 @@
use actix_web::{post, web, HttpResponse, ResponseError, HttpRequest, put, get};
use crate::{
account::{verify_hash, Session, SESSION_COOKIE_NAME},
account::{SESSION_COOKIE_NAME, Session, verify_hash},
error::Error,
smtp,
users::{LoginRequest, RegisterRequest, User, UserResponse},
};
use actix_web::{HttpRequest, HttpResponse, ResponseError, get, post, put, web};
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
use crate::account::Auth;
use crate::account::{Auth, csprng};
use crate::users::UpdateUser;
#[utoipa::path(
tag = "Account",
request_body(
content = RegisterRequest, content_type = "application/json"
),
responses(
(status = 200, description = "", body = UserResponse),
(status = 409, description = ""),
)
)]
#[post("/register")]
async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpResponse {
let register_user = user.into_inner();
@@ -38,13 +51,22 @@ async fn register(user: web::Json<RegisterRequest>, req: HttpRequest) -> HttpRes
);
HttpResponse::Conflict().finish()
} else {
log::error!("attemptFailed to register user [Email: {}]: {}", email, err);
log::error!("Failed to register user [Email: {}]: {}", email, err);
ResponseError::error_response(&err)
}
}
}
}
#[utoipa::path(
tag = "Account",
request_body(
content = LoginRequest, content_type = "application/json"
),
responses(
(status = 200, description = "", body = UserResponse),
),
)]
#[post("/login")]
async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpResponse {
let email = &request.email;
@@ -93,6 +115,17 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
}
}
#[utoipa::path(
tag = "Account",
responses(
(status = 200, description = ""),
(status = 401, description = ""),
(status = 500, description = ""),
),
security(
("session_auth" = [])
)
)]
#[post("/logout")]
async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
let email = auth.user.email;
@@ -132,6 +165,16 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
.finish()
}
#[utoipa::path(
tag = "Account",
responses(
(status = 200, description = "", body = UserResponse),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[get("/profile")]
async fn get_profile(req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
@@ -154,7 +197,7 @@ async fn get_profile(req: HttpRequest) -> HttpResponse {
.finish();
}
};
let id = &session.id;
let id = &session.user_id;
let query_user = match User::select(&id).await {
Some(query_user) => query_user,
None => {
@@ -186,6 +229,16 @@ async fn get_profile(req: HttpRequest) -> HttpResponse {
}
}
#[utoipa::path(
tag = "Account",
responses(
(status = 200, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[get("/session")]
async fn session_refresh(req: HttpRequest) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
@@ -208,7 +261,7 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse {
.finish();
}
};
let id = &session.id;
let id = &session.user_id;
let session_cookie = session.cookie();
let session_exp_cookie = session.expiration_cookie();
@@ -229,6 +282,19 @@ async fn session_refresh(req: HttpRequest) -> HttpResponse {
}
}
#[utoipa::path(
tag = "Account",
request_body(
content = String, content_type = "application/json"
),
responses(
(status = 200, description = "", body = UserResponse),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[put("/password")]
async fn change_password(
password: web::Json<String>,
@@ -269,25 +335,52 @@ async fn change_password(
ip_address,
err
);
ResponseError::error_response(&Error::new(500, err.to_string()))
ResponseError::error_response(&err)
}
}
}
#[post("/password-reset")]
async fn password_reset(req: HttpRequest, _auth: Auth) -> HttpResponse {
let _ip_address = req.peer_addr().unwrap().ip().to_string();
#[utoipa::path(
tag = "Account",
responses(
(status = 200, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[post("/password/reset")]
async fn reset_password(req: HttpRequest, auth: Auth) -> HttpResponse {
let ip_address = req.peer_addr().unwrap().ip().to_string();
let id = auth.user.id;
let email = auth.user.email;
let token = csprng(128);
match smtp::send_password_reset(&email, &token) {
Ok(_) => HttpResponse::Ok().finish(),
Err(err) => {
log::error!(
"Invalid password reset attempt [ID: {}] [IP Address: {}]: {}",
&id,
ip_address,
err
);
ResponseError::error_response(&err)
}
};
HttpResponse::Ok().finish()
}
pub fn init_routes(config: &mut web::ServiceConfig) {
pub fn init_routes(config: &mut ServiceConfig) {
config.service(
web::scope("account")
scope::scope("/account")
.service(register)
.service(login)
.service(logout)
.service(change_password)
.service(get_profile)
.service(session_refresh),
.service(session_refresh)
.service(change_password)
.service(reset_password),
);
}

View File

@@ -1,14 +1,14 @@
use actix_web::cookie::{time::Duration, Cookie};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use redis::{AsyncCommands, RedisResult};
use tokio::task;
use uuid::Uuid;
use super::{csprng, hash, verify_hash};
use crate::{
db::redis_async_connection,
error::{Error, ApiResult},
error::{ApiResult, Error},
};
use super::{csprng, hash, verify_hash};
use actix_web::cookie::{Cookie, time::Duration};
use chrono::{DateTime, Utc};
use redis::{AsyncCommands, RedisResult};
use serde::{Deserialize, Serialize};
use tokio::task;
use uuid::Uuid;
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
pub const SESSION_COOKIE_NAME: &str = "session";
@@ -17,22 +17,22 @@ pub const SESSION_EXPIRATION_COOKIE_NAME: &str = "session_expiration";
#[derive(Debug, Serialize, Deserialize)]
pub struct Session {
pub session_id: String,
pub id: Uuid,
pub user_id: Uuid,
pub ip_address: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
impl Session {
pub fn default(id: &Uuid, ip_address: &str) -> Self {
Self::new(64, id, ip_address, Some(DEFAULT_SESSION_TTL))
pub fn default(user_id: &Uuid, ip_address: &str) -> Self {
Self::new(64, user_id, ip_address, Some(DEFAULT_SESSION_TTL))
}
pub fn new(take: usize, id: &Uuid, ip_address: &str, ttl: Option<i64>) -> Self {
pub fn new(take: usize, user_id: &Uuid, ip_address: &str, ttl: Option<i64>) -> Self {
let now = Utc::now();
Self {
session_id: csprng(take),
id: id.clone(),
user_id: user_id.clone(),
ip_address: hash(&ip_address).unwrap(),
expires_at: match ttl {
Some(ttl) => Some(now + chrono::Duration::seconds(ttl)),
@@ -79,7 +79,7 @@ impl Session {
);
};
});
session = Session::default(&session.id, ip_address);
session = Session::default(&session.user_id, ip_address);
session.store().await?;
Ok(session)
}
@@ -120,8 +120,8 @@ impl Session {
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
log::trace!(
"Session cookie [ID: {}]: {}",
self.id,
"Session cookie [User ID: {}]: {}",
self.user_id,
self.session_id
);
cookie.set_secure(false);
@@ -148,8 +148,8 @@ impl Session {
if let Ok(environment) = std::env::var("ENVIRONMENT") {
if environment == "development" || environment == "dev" {
log::trace!(
"Session expiration cookie [ID: {}]: {}",
self.id,
"Session expiration cookie [User ID: {}]: {}",
self.user_id,
self.session_id
);
cookie.set_secure(false);

View File

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

View File

@@ -1,20 +1,22 @@
use std::collections::HashMap;
use std::str::FromStr;
use crate::airports::{
AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication,
UpdateRunway,
};
use crate::db;
use crate::error::{ApiResult, Error};
use crate::metars::Metar;
use chrono::{DateTime, Utc};
use futures_util::try_join;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use crate::airports::{
AirportCategory, Communication, CommunicationRow, Runway, RunwayRow, UpdateCommunication, UpdateRunway,
};
use crate::db;
use crate::error::{ApiResult, Error};
use crate::metars::Metar;
use std::collections::HashMap;
use std::str::FromStr;
use utoipa::{IntoParams, ToSchema};
const TABLE_NAME: &str = "airports";
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct Airport {
pub icao: String,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -40,7 +42,8 @@ pub struct Airport {
pub latest_metar: Option<Metar>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct AirportQuery {
pub page: Option<u32>,
pub limit: Option<u32>,
@@ -75,7 +78,7 @@ impl Default for AirportQuery {
}
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct Bounds {
pub north_east_lat: f32,
pub north_east_lon: f32,
@@ -125,7 +128,7 @@ struct AirportRow {
pub metar_observation_time: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateAirport {
pub icao: Option<String>,
pub iata: Option<String>,

View File

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

View File

@@ -1,13 +1,14 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use uuid::Uuid;
use crate::db;
use crate::error::ApiResult;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use std::collections::HashMap;
use utoipa::ToSchema;
use uuid::Uuid;
const TABLE_NAME: &str = "communications";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Communication {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -27,7 +28,7 @@ pub struct CommunicationRow {
pub phone: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateCommunication {
#[serde(skip_serializing_if = "Option::is_none")]
pub icao: Option<String>,

View File

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

View File

@@ -1,16 +1,39 @@
use futures_util::stream::StreamExt as _;
use crate::{
airports::Airport,
db::Paged,
account::{Auth, verify_role},
AppState,
};
use actix_multipart::Multipart;
use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError};
use crate::airports::{AirportQuery, UpdateAirport};
use crate::users::ADMIN_ROLE;
use crate::{
AppState,
account::{Auth, verify_role},
airports::Airport,
db::Paged,
};
use actix_multipart::Multipart;
use actix_web::{HttpRequest, HttpResponse, ResponseError, delete, get, post, put, web};
use utoipa::ToSchema;
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
#[derive(ToSchema)]
#[allow(unused)]
struct UploadedFile {
#[schema(value_type = String, format = Binary)]
file: Vec<u8>,
}
#[utoipa::path(
tag = "Airports",
request_body(
content = UploadedFile, content_type = "multipart/form-data"
),
responses(
(status = 200, description = "Successful import"),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[post("/import")]
async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
if let Err(err) = verify_role(&auth, ADMIN_ROLE) {
@@ -53,6 +76,15 @@ async fn import_airports(mut payload: Multipart, auth: Auth) -> HttpResponse {
HttpResponse::Ok().finish()
}
#[utoipa::path(
tag = "Airports",
params(
AirportQuery
),
responses(
(status = 200, description = "", body = [Airport]),
),
)]
#[get("")]
async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let mut query = match web::Query::<AirportQuery>::from_query(req.query_string()) {
@@ -87,6 +119,13 @@ async fn get_airports(data: web::Data<AppState>, req: HttpRequest) -> HttpRespon
}
}
#[utoipa::path(
tag = "Airports",
responses(
(status = 200, description = "", body = Airport),
(status = 404, description = ""),
),
)]
#[get("/{icao}")]
async fn get_airport(
data: web::Data<AppState>,
@@ -108,6 +147,17 @@ async fn get_airport(
}
}
#[utoipa::path(
tag = "Airports",
responses(
(status = 200, description = "", body = Airport),
(status = 401, description = ""),
(status = 409, description = ""),
),
security(
("session_auth" = [])
)
)]
#[post("")]
async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) {
@@ -123,6 +173,16 @@ async fn insert_airport(airport: web::Json<Airport>, auth: Auth) -> HttpResponse
}
}
#[utoipa::path(
tag = "Airports",
responses(
(status = 200, description = "", body = Airport),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[put("/{icao}")]
async fn update_airport(
icao: web::Path<String>,
@@ -142,6 +202,16 @@ async fn update_airport(
}
}
#[utoipa::path(
tag = "Airports",
responses(
(status = 201, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[delete("")]
async fn delete_airports(auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) {
@@ -157,6 +227,16 @@ async fn delete_airports(auth: Auth) -> HttpResponse {
}
}
#[utoipa::path(
tag = "Airports",
responses(
(status = 201, description = ""),
(status = 401, description = ""),
),
security(
("session_auth" = [])
)
)]
#[delete("/{icao}")]
async fn delete_airport(icao: web::Path<String>, auth: Auth) -> HttpResponse {
let _ = match verify_role(&auth, ADMIN_ROLE) {
@@ -172,9 +252,9 @@ async fn delete_airport(icao: web::Path<String>, auth: Auth) -> HttpResponse {
}
}
pub fn init_routes(config: &mut web::ServiceConfig) {
pub fn init_routes(config: &mut ServiceConfig) {
config.service(
web::scope("airports")
scope::scope("/airports")
.service(import_airports)
.service(get_airports)
.service(get_airport)

View File

@@ -1,11 +1,11 @@
use crate::error::ApiResult;
use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection, RedisResult};
use s3::{Bucket, Region, creds::Credentials, BucketConfiguration, request::ResponseData};
use redis::{Client as RedisClient, RedisResult, aio::MultiplexedConnection as RedisConnection};
use s3::{Bucket, BucketConfiguration, Region, creds::Credentials, request::ResponseData};
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions;
use sqlx::{Pool, Postgres};
use std::sync::OnceLock;
use std::time::Duration;
use sqlx::{Pool, Postgres};
use sqlx::postgres::PgPoolOptions;
static POOL: OnceLock<Pool<Postgres>> = OnceLock::new();
static REDIS: OnceLock<RedisClient> = OnceLock::new();
@@ -169,9 +169,3 @@ pub struct Paged<T> {
pub limit: u32,
pub total: i64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Coordinate {
pub lon: f64,
pub lat: f64,
}

View File

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

View File

@@ -1,12 +1,16 @@
use std::env;
use std::time::Duration;
use crate::account::hash;
use crate::users::{ADMIN_ROLE, User};
use actix_cors::Cors;
use actix_web::{App, HttpServer, middleware::Logger, web};
use dotenv::from_filename;
use reqwest::Certificate;
use std::env;
use std::time::Duration;
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
use utoipa::openapi::{Contact, SecurityRequirement};
use utoipa_actix_web::{AppExt, scope};
use utoipa_swagger_ui::SwaggerUi;
use uuid::Uuid;
use crate::account::hash;
use crate::users::{User, ADMIN_ROLE};
mod account;
mod airports;
@@ -14,6 +18,7 @@ mod db;
mod error;
mod metars;
mod scheduler;
mod smtp;
mod system;
mod users;
@@ -91,18 +96,49 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.allow_any_header()
.supports_credentials()
.max_age(3600);
App::new()
let (app, mut api) = App::new()
.wrap(cors)
.wrap(Logger::default())
.app_data(web::Data::new(state.clone()))
.into_utoipa_app()
.service(
web::scope("api")
scope::scope("/api")
.configure(airports::init_routes)
.configure(metars::init_routes)
.configure(account::init_routes)
.configure(users::init_routes)
.configure(system::init_routes),
)
.split_for_parts();
let contact_name = env::var("API_CONTACT_NAME").unwrap();
let contact_url = env::var("EXTERNAL_URL").unwrap();
let contact_email = env::var("API_CONTACT_EMAIL").unwrap();
let version = env::var("CARGO_PKG_VERSION").unwrap();
api.info.title = "Aviation Data".to_string();
api.info.description = None;
api.info.terms_of_service = None;
api.info.contact = Some(
Contact::builder()
.name(Some(contact_name))
.url(Some(format!("{}/support", contact_url)))
.email(Some(contact_email))
.build(),
);
api.info.license = None;
api.info.version = version;
let session_scheme = SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new("session")));
let mut components = api.components.take().unwrap_or_default();
components
.security_schemes
.insert("session_auth".to_string(), session_scheme);
api.components = Some(components);
// api.security = Some(vec![SecurityRequirement::new("session_auth", [""])]);
api.security = Some(vec![SecurityRequirement::default()]);
app.service(SwaggerUi::new("/swagger/{_:.*}").url("/api-docs/openapi.json", api))
})
.bind(format!("{}:{}", host, port))
{

View File

@@ -1,9 +1,9 @@
use chrono::{DateTime, Utc};
use redis::{AsyncCommands, RedisResult};
use serde::{Deserialize, Serialize};
use crate::db::redis_async_connection;
use crate::error::ApiResult;
use crate::metars::Metar;
use chrono::{DateTime, Utc};
use redis::{AsyncCommands, RedisResult};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct MetarCheck {

View File

@@ -2,6 +2,6 @@ mod metar_check;
mod model;
mod routes;
pub use model::*;
pub use metar_check::*;
pub use model::*;
pub use routes::init_routes;

View File

@@ -1,20 +1,21 @@
use crate::airports::{Airport, UpdateAirport};
use crate::db::redis_async_connection;
use crate::error::Error;
use crate::{error::ApiResult, db};
use crate::metars::MetarCheck;
use crate::{db, error::ApiResult};
use chrono::{DateTime, Datelike, NaiveDate, Utc};
use redis::{AsyncCommands, RedisResult};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::env;
use std::fmt::Display;
use std::str::FromStr;
use redis::{AsyncCommands, RedisResult};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::airports::{Airport, UpdateAirport};
use crate::db::redis_async_connection;
use crate::metars::MetarCheck;
use utoipa::ToSchema;
const TABLE_NAME: &str = "metars";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Metar {
pub icao: String,
pub raw_text: String,
@@ -60,7 +61,7 @@ pub struct Metar {
pub density_altitude: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum ReportModifier {
#[serde(rename = "AUTO")]
Auto,
@@ -88,7 +89,7 @@ impl Display for ReportModifier {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RunwayVisualRange {
pub runway: String,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -110,7 +111,7 @@ impl Default for RunwayVisualRange {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum AutomatedStationType {
#[serde(rename = "AO1")]
WithoutPrecipitationDiscriminator,
@@ -141,7 +142,7 @@ impl Display for AutomatedStationType {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Remarks {
#[serde(skip_serializing_if = "Option::is_none")]
pub peak_wind: Option<PeakWind>,
@@ -165,7 +166,7 @@ pub struct Remarks {
pub sky_condition_at_secondary_location_not_available: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct PeakWind {
pub degrees: i32,
pub speed: i32,
@@ -190,7 +191,7 @@ impl Default for Remarks {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SkyCondition {
pub sky_cover: String,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -209,7 +210,7 @@ impl Default for SkyCondition {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum FlightCategory {
VFR,
MVFR,
@@ -1134,8 +1135,8 @@ impl Metar {
#[cfg(test)]
mod tests {
use chrono::NaiveDateTime;
use super::*;
use chrono::NaiveDateTime;
#[test]
fn test_parse_time() {

View File

@@ -1,18 +1,34 @@
use crate::AppState;
use crate::metars::Metar;
use actix_web::{get, web, HttpResponse, HttpRequest};
use actix_web::{HttpRequest, HttpResponse, get, web};
use log::error;
use serde::{Deserialize, Serialize};
use crate::AppState;
use utoipa::{IntoParams, ToSchema};
use utoipa_actix_web::service_config::ServiceConfig;
#[derive(Debug, Serialize, Deserialize)]
struct FindAllParameters {
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
#[into_params(parameter_in = Query)]
struct MetarQuery {
icaos: Option<String>,
}
#[get("metars")]
#[utoipa::path(
tag = "METARs",
params(
MetarQuery,
),
responses(
(status = 200, description = "", body = [Metar]),
),
)]
#[get("/metars")]
async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let parameters = web::Query::<FindAllParameters>::from_query(req.query_string()).unwrap();
let parameters = web::Query::<MetarQuery>::from_query(req.query_string()).unwrap();
let icao_option = &parameters.icaos;
if let None = icao_option {
let empty_metars: Vec<Metar> = vec![];
return HttpResponse::Ok().json(empty_metars);
}
let icao_string = match icao_option {
Some(i) => i,
None => return HttpResponse::UnprocessableEntity().body("Missing icaos parameter"),
@@ -30,6 +46,6 @@ async fn find_all(data: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
HttpResponse::Ok().json(metars)
}
pub fn init_routes(config: &mut web::ServiceConfig) {
pub fn init_routes(config: &mut ServiceConfig) {
config.service(find_all);
}

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

@@ -0,0 +1,103 @@
use crate::error::ApiResult;
use chrono::{Datelike, Utc};
use handlebars::Handlebars;
use lettre::message::header::ContentType;
use lettre::message::{Mailbox, MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Address, Message, SmtpTransport, Transport};
use serde::Serialize;
use std::env;
use std::sync::OnceLock;
static MAILER: OnceLock<SmtpTransport> = OnceLock::new();
static FROM_ADDRESS: OnceLock<Mailbox> = OnceLock::new();
static REGISTRY: OnceLock<Handlebars> = OnceLock::new();
fn mailer() -> &'static SmtpTransport {
MAILER.get_or_init(|| {
let server = env::var("SMTP_SERVER").expect("SMTP_SERVER missing");
let username = env::var("SMTP_USERNAME").expect("SMTP_USERNAME missing");
let password = env::var("SMTP_PASSWORD").expect("SMTP_PASSWORD missing");
let creds = Credentials::new(username, password);
SmtpTransport::relay(&server)
.expect("invalid SMTP_SERVER")
.credentials(creds)
.build()
})
}
fn from_address() -> &'static Mailbox {
FROM_ADDRESS.get_or_init(|| {
let raw = env::var("SMTP_FROM").expect("SMTP_FROM missing");
let addr = raw.parse().expect("SMTP_FROM invalid");
Mailbox::new(Some("Aviation Data".into()), addr)
})
}
fn registry() -> &'static Handlebars<'static> {
REGISTRY.get_or_init(|| Handlebars::new())
}
#[derive(Serialize)]
struct PasswordResetCtx {
logo_url: String,
link: String,
domain: String,
year: i32,
}
pub fn send_password_reset(to: &str, token: &str) -> ApiResult<()> {
let base_url = env::var("EXTERNAL_URL")?.trim_end_matches('/').to_string();
let link = format!("{base_url}/profile/reset?token={token}");
let subject = "Reset your password";
let plain = format!(
"Hello,\n\n\
We received a password reset request. Click the link below:\n\n\
{link}\n\n\
This link expires in 24 hours. If you didn't request this, please ignore.\n\n\
Cheers,\n\
\tAviation Data",
link = link
);
let ctx = PasswordResetCtx {
logo_url: format!("{}/logo.svg", base_url),
link: link.clone(),
domain: base_url,
year: Utc::now().year(),
};
let template_html = include_str!("../.././templates/password_reset.html");
let html = registry().render_template(template_html, &ctx).unwrap();
send_email(to, subject, plain, html)
}
pub fn send_email(to: &str, subject: &str, header: String, html: String) -> ApiResult<()> {
let to_address = to.parse::<Address>()?;
let to_mailbox = Mailbox::new(None, to_address);
// Build the email
let email = Message::builder()
.from(from_address().clone())
.to(to_mailbox)
.subject(subject)
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(header),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html),
),
)?;
// Send the email
mailer().send(&email)?;
Ok(())
}

View File

@@ -1,13 +1,22 @@
use std::env;
use actix_web::{get, web, HttpResponse};
use actix_web::{HttpResponse, get};
use serde::{Deserialize, Serialize};
use std::env;
use utoipa::ToSchema;
use utoipa_actix_web::scope;
use utoipa_actix_web::service_config::ServiceConfig;
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SystemInfo {
version: String,
healthy: bool,
}
#[utoipa::path(
tag = "System",
responses(
(status = 200, description = "Successful system info"),
)
)]
#[get("/info")]
async fn info() -> HttpResponse {
let mut healthy = true;
@@ -24,6 +33,6 @@ async fn info() -> HttpResponse {
HttpResponse::Ok().json(info)
}
pub fn init_routes(config: &mut web::ServiceConfig) {
config.service(web::scope("/system").service(info));
pub fn init_routes(config: &mut ServiceConfig) {
config.service(scope::scope("/system").service(info));
}

View File

@@ -1,15 +1,17 @@
use crate::db;
use crate::{account::hash, error::ApiResult};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::{Postgres, QueryBuilder};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::{account::hash, error::ApiResult};
use crate::db;
pub const ADMIN_ROLE: &str = "ADMIN";
pub const USER_ROLE: &str = "USER";
const TABLE_NAME: &str = "users";
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
@@ -35,13 +37,21 @@ impl RegisterRequest {
}
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
#[schema(
example = json!(
{
"email": "user@example.com",
"password": "changeme"
}
)
)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
pub struct UserResponse {
pub id: Uuid,
pub role: String,
@@ -65,7 +75,7 @@ impl From<User> for UserResponse {
}
}
#[derive(Debug, Deserialize, sqlx::FromRow)]
#[derive(Debug, Deserialize, sqlx::FromRow, ToSchema)]
pub struct UpdateUser {
pub email: Option<String>,
pub email_verified: Option<bool>,

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
meta {
name: Reset Password
type: http
seq: 5
}
post {
url: {{API_URL}}/account/password/reset
body: none
auth: none
}

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ export function CommunicationTable({ communications }: { communications: Communi
<Table.Tr>
<Table.Th>ID</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Frequencies (MHz)</Table.Th>
<Table.Th>MHz</Table.Th>
<Table.Th>Phone</Table.Th>
</Table.Tr>
</Table.Thead>

View File

@@ -151,10 +151,10 @@ function AirportInfoRow({ style, children }: { style?: CSSProperties; children:
);
}
function AirportInfo({ map, airport }: { map: LeafletMap, airport: Airport }) {
function AirportInfo({ map, airport }: { map: LeafletMap; airport: Airport }) {
function goToLocation(map: LeafletMap, latitude: number, longitude: number) {
if (!map) return
map.setView([latitude, longitude], map.getZoom())
if (!map) return;
map.setView([latitude, longitude], map.getZoom());
}
return (
@@ -171,9 +171,11 @@ function AirportInfo({ map, airport }: { map: LeafletMap, airport: Airport }) {
</AirportInfoSlot>
<AirportInfoSlot title={'Elevation'} style={{ paddingLeft: '1rem' }} children={`${airport.elevation_ft} ft`} />
<AirportInfoSlot style={{ marginLeft: 'auto', paddingLeft: '1rem', paddingTop: '0.5rem' }}>
<UnstyledButton onClick={() => {
goToLocation(map, airport.latitude, airport.longitude)
}}>
<UnstyledButton
onClick={() => {
goToLocation(map, airport.latitude, airport.longitude);
}}
>
<IconViewfinder />
</UnstyledButton>
</AirportInfoSlot>
@@ -181,9 +183,7 @@ function AirportInfo({ map, airport }: { map: LeafletMap, airport: Airport }) {
<Accordion chevronPosition={'right'} variant={'contained'}>
{airport.runways != null && airport.runways.length > 0 && (
<Accordion.Item value={'runways'}>
<Accordion.Control>
Runways
</Accordion.Control>
<Accordion.Control>Runways</Accordion.Control>
<Accordion.Panel>
<RunwayTable runways={airport.runways} />
</Accordion.Panel>
@@ -191,9 +191,7 @@ function AirportInfo({ map, airport }: { map: LeafletMap, airport: Airport }) {
)}
{airport.communications != null && airport.communications.length > 0 && (
<Accordion.Item value={'communication'}>
<Accordion.Control>
Communication
</Accordion.Control>
<Accordion.Control>Communication</Accordion.Control>
<Accordion.Panel>
<CommunicationTable communications={airport.communications} />
</Accordion.Panel>

View File

@@ -20,7 +20,7 @@ export function CustomControl({ position = 'bottomright', onClick, active = fals
const ctrl = new L.Control({ position });
ctrl.onAdd = () => {
return L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
}
};
ctrl.addTo(map);
controlRef.current = ctrl;
@@ -28,6 +28,7 @@ export function CustomControl({ position = 'bottomright', onClick, active = fals
// @ts-expect-error ctrl is a L.Control
const container = (ctrl as unknown)._container as HTMLElement;
rootRef.current = createRoot(container);
L.DomEvent.disableClickPropagation(container);
return () => {
if (rootRef.current) {
@@ -45,7 +46,7 @@ export function CustomControl({ position = 'bottomright', onClick, active = fals
href={'#'}
title={title}
className={active ? 'active' : ''}
onClick={e => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick();
@@ -59,7 +60,7 @@ export function CustomControl({ position = 'bottomright', onClick, active = fals
>
{children}
</a>
)
);
}
}, [onClick, active, title, children]);

View File

@@ -34,6 +34,7 @@ export function GroupControl({ position = 'bottomright', buttons }: GroupControl
// @ts-expect-error ctrl is a L.Control
const container = (ctrl as unknown)._container as HTMLElement;
rootRef.current = createRoot(container);
L.DomEvent.disableClickPropagation(container);
return () => {
ctrl.remove();
@@ -48,10 +49,10 @@ export function GroupControl({ position = 'bottomright', buttons }: GroupControl
{buttons.map((b, i) => (
<a
key={i}
href="#"
href='#'
title={b.title}
className={b.active ? 'active' : ''}
onClick={e => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
b.onClick();

View File

@@ -7,7 +7,7 @@ export function LocateControl() {
function handleClick() {
if (!navigator.geolocation) {
alert('Geolocation is not supported by your browser');
console.warn('Geolocation is not supported by your browser');
return;
}
navigator.geolocation.getCurrentPosition(
@@ -17,14 +17,13 @@ export function LocateControl() {
map.setView([latitude, longitude], map.getZoom());
},
(err) => {
console.error(err);
alert('Unable to retrieve your location');
console.warn('Unable to retrieve your location', err);
}
);
}
return (
<CustomControl onClick={handleClick} title="Go to my location">
<CustomControl onClick={handleClick} title='Go to my location'>
<IconCurrentLocation />
</CustomControl>
);

View File

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