balls
This commit is contained in:
257
src/main.rs
Normal file
257
src/main.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
routing::{get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::{debug, error, info, warn, instrument, Level};
|
||||
// ── Shared state ──────────────────────────────────────────────────────────────
|
||||
type Countries = Arc<Mutex<HashMap<String, String>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
countries: Countries,
|
||||
api_keys: Arc<HashSet<String>>,
|
||||
}
|
||||
|
||||
// ── Request / Response bodies ─────────────────────────────────────────────────
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateLocation {
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GetLocation {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CountryResponse {
|
||||
country: String,
|
||||
}
|
||||
|
||||
// ── BigDataCloud response ────────────────────────────────────────────────────
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct BDCResponse {
|
||||
country: Option<String>,
|
||||
#[serde(rename = "countryCode")]
|
||||
country_code: Option<String>,
|
||||
}
|
||||
|
||||
// ── Middleware: API Key authentication with structured logging ───────────────
|
||||
#[instrument(skip(state, req, next))]
|
||||
async fn api_key_middleware(
|
||||
headers: HeaderMap,
|
||||
State(state): State<AppState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let client_ip: String = req
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().to_string())
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
|
||||
let api_key = headers
|
||||
.get("X-API-Key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
match api_key {
|
||||
Some(ref key) if state.api_keys.contains(key) => {
|
||||
info!(
|
||||
client_ip = %client_ip,
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
"AUTH OK"
|
||||
);
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
Some(_) => {
|
||||
warn!(client_ip = %client_ip, "INVALID API KEY");
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
None => {
|
||||
warn!(
|
||||
client_ip = %client_ip,
|
||||
method = %req.method(),
|
||||
uri = %req.uri(),
|
||||
"MISSING X-API-Key header"
|
||||
);
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reverse geocode using BigDataCloud ───────────────────────────────────────
|
||||
#[instrument(skip_all, fields(lat = %lat, lon = %lon))]
|
||||
async fn get_country_from_coords(lat: f64, lon: f64) -> Result<String, StatusCode> {
|
||||
debug!("Querying BigDataCloud");
|
||||
let url = format!(
|
||||
"https://api.bigdatacloud.net/data/reverse-geocode-client?latitude={}&longitude={}&localityLanguage=en",
|
||||
lat, lon
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("DeviceLocationServer/1.0 (contact: your-email@example.com)") // ← CHANGE THIS!
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
error!("Failed to build reqwest client: {}", e);
|
||||
StatusCode::BAD_GATEWAY
|
||||
})?;
|
||||
|
||||
let response = client.get(&url).send().await;
|
||||
|
||||
match response {
|
||||
Ok(res) if res.status().is_success() => {
|
||||
debug!("BigDataCloud responded with 200 OK");
|
||||
let data: BDCResponse = res.json().await.map_err(|e| {
|
||||
error!("Failed to parse JSON from BigDataCloud: {}", e);
|
||||
StatusCode::BAD_GATEWAY
|
||||
})?;
|
||||
|
||||
let country = data
|
||||
.country
|
||||
.or_else(|| data.country_code.map(|cc| cc.to_uppercase()))
|
||||
.ok_or_else(|| {
|
||||
warn!("No country information in BigDataCloud response");
|
||||
StatusCode::NOT_FOUND
|
||||
})?;
|
||||
|
||||
info!(country = %country, "Resolved country from coordinates");
|
||||
Ok(country)
|
||||
}
|
||||
Ok(res) => {
|
||||
warn!("BigDataCloud returned error status: {}", res.status());
|
||||
Err(StatusCode::BAD_GATEWAY)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Network error contacting BigDataCloud: {}", e);
|
||||
Err(StatusCode::BAD_GATEWAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT /location ─────────────────────────────────────────────────────────────
|
||||
#[instrument(skip(state, payload))]
|
||||
async fn update_location(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<UpdateLocation>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
info!(
|
||||
key = %payload.key,
|
||||
lat = payload.lat,
|
||||
lon = payload.lon,
|
||||
"Received location update"
|
||||
);
|
||||
|
||||
match get_country_from_coords(payload.lat, payload.lon).await {
|
||||
Ok(country) => {
|
||||
let mut countries = state.countries.lock().unwrap();
|
||||
countries.insert(payload.key.clone(), country.clone());
|
||||
info!(
|
||||
key = %payload.key,
|
||||
country = %country,
|
||||
"Stored device country"
|
||||
);
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
Err(status) => {
|
||||
warn!("Geocoding failed with status: {}", status);
|
||||
Err(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /get/location ─────────────────────────────────────────────────────────
|
||||
#[instrument(skip(state, payload))]
|
||||
async fn get_location(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<GetLocation>,
|
||||
) -> Result<Json<CountryResponse>, StatusCode> {
|
||||
info!(key = %payload.key, "Requesting stored location");
|
||||
|
||||
let countries = state.countries.lock().unwrap();
|
||||
if let Some(country) = countries.get(&payload.key) {
|
||||
info!(key = %payload.key, country = %country, "Found stored country");
|
||||
Ok(Json(CountryResponse {
|
||||
country: country.clone(),
|
||||
}))
|
||||
} else {
|
||||
warn!(key = %payload.key, "Device key not found");
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Load .env file if present (great for local development)
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
// Initialize structured logging
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(Level::INFO) // Set to DEBUG for more details
|
||||
.with_target(true)
|
||||
.with_thread_names(false)
|
||||
.pretty()
|
||||
.init();
|
||||
|
||||
// Load API keys from environment variable
|
||||
let api_keys_str = std::env::var("API_KEYS")
|
||||
.expect("API_KEYS environment variable is required");
|
||||
|
||||
let valid_api_keys: HashSet<String> = if api_keys_str.trim().is_empty() {
|
||||
HashSet::new()
|
||||
} else {
|
||||
api_keys_str
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
};
|
||||
|
||||
if valid_api_keys.is_empty() {
|
||||
warn!("No API keys configured – all requests will be rejected!");
|
||||
} else {
|
||||
info!("Loaded {} API key(s) from API_KEYS", valid_api_keys.len());
|
||||
}
|
||||
|
||||
let state = AppState {
|
||||
countries: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_keys: Arc::new(valid_api_keys),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/location", put(update_location))
|
||||
.route("/get/location", get(get_location))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
api_key_middleware,
|
||||
))
|
||||
.with_state(state);
|
||||
|
||||
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
info!("Country Location Server starting");
|
||||
info!("Listening on http://{}", addr);
|
||||
info!("Using BigDataCloud for reverse geocoding");
|
||||
warn!("Remember to update the User-Agent email in get_country_from_coords()!");
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user