This commit is contained in:
2026-01-06 19:39:09 +00:00
commit e15d88ef99
14 changed files with 4545 additions and 0 deletions

19
.direnv/bin/nix-direnv-reload Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -e
if [[ ! -d "/home/mrfluffy/Documents/projects/rust/whereAmI" ]]; then
echo "Cannot find source directory; Did you move it?"
echo "(Looking for "/home/mrfluffy/Documents/projects/rust/whereAmI")"
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
exit 1
fi
# rebuild the cache forcefully
_nix_direnv_force_reload=1 direnv exec "/home/mrfluffy/Documents/projects/rust/whereAmI" true
# Update the mtime for .envrc.
# This will cause direnv to reload again - but without re-building.
touch "/home/mrfluffy/Documents/projects/rust/whereAmI/.envrc"
# Also update the timestamp of whatever profile_rc we have.
# This makes sure that we know we are up to date.
touch -r "/home/mrfluffy/Documents/projects/rust/whereAmI/.envrc" "/home/mrfluffy/Documents/projects/rust/whereAmI/.direnv"/*.rc

View File

@@ -0,0 +1 @@
/nix/store/0yj36irhwn225ywy1saz0gf5wr2ciz50-source

View File

@@ -0,0 +1 @@
/nix/store/g1rkrcba88bmgmjc2lrnwcala1w2yblq-source

View File

@@ -0,0 +1 @@
/nix/store/p0h1gvdli8k29651567l38qx7sxmkm5w-source

View File

@@ -0,0 +1 @@
/nix/store/s1ra3mlx2r37qxrm8w9438a3gwaws1mg-source

View File

@@ -0,0 +1 @@
/nix/store/43vi36d27viiyg22q566927b7divdx8f-nix-shell-env

File diff suppressed because it is too large Load Diff

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

1927
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Normal file
View File

@@ -0,0 +1,15 @@
[package]
name = "whereAmI"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.7", features = ["json"] }
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
reqwest = { version = "0.12", features = ["json"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
tower-http = { version = "0.5", features = ["add-extension"] } # needed for ConnectInfo
chrono = "0.4.42"
dotenvy = "0.15"

63
flake.lock generated Normal file
View File

@@ -0,0 +1,63 @@
{
"nodes": {
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1767250179,
"narHash": "sha256-PnQdWvPZqHp+7yaHWDFX3NYSKaOy0fjkwpR+rIQC7AY=",
"rev": "a3eaf682db8800962943a77ab77c0aae966f9825",
"revCount": 2511,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/nix-community/fenix/0.1.2511%2Brev-a3eaf682db8800962943a77ab77c0aae966f9825/019b78a8-f9ad-7faf-9a11-350b6ae3fcd9/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/nix-community/fenix/0.1.%2A"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1767640445,
"narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=",
"rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5",
"revCount": 922875,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.922875%2Brev-9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5/019b9446-bef1-749d-8068-2eb76ae32808/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/NixOS/nixpkgs/0.1.%2A"
}
},
"root": {
"inputs": {
"fenix": "fenix",
"nixpkgs": "nixpkgs"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1767191410,
"narHash": "sha256-cCZGjubgDWmstvFkS6eAw2qk2ihgWkycw55u2dtLd70=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "a9026e6d5068172bf5a0d52a260bb290961d1cb4",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

103
flake.nix Normal file
View File

@@ -0,0 +1,103 @@
{
description = "A Nix-flake-based Rust development environment with build and run support";
inputs = {
nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1.*"; # unstable
fenix = {
url = "https://flakehub.com/f/nix-community/fenix/0.1.*";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, fenix }:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forEachSupportedSystem = f:
nixpkgs.lib.genAttrs supportedSystems (system:
f {
pkgs = import nixpkgs {
inherit system;
overlays = [ self.overlays.default ];
};
}
);
in
{
overlays.default = final: prev: {
rustToolchain =
with fenix.packages.${prev.stdenv.hostPlatform.system};
combine (with stable; [
cargo
rustc
clippy
rustfmt
rust-src
]);
};
packages = forEachSupportedSystem ({ pkgs }: {
default = pkgs.rustPlatform.buildRustPackage {
pname = "whereAmI";
version = "0.1.0";
# Keep Cargo.lock even if gitignored
src = pkgs.lib.cleanSourceWith {
src = ./.;
filter = path: type:
let
name = pkgs.lib.baseNameOf path;
in
name == "Cargo.lock"
|| pkgs.lib.cleanSourceFilter path type;
};
cargoLock = {
lockFile = ./Cargo.lock;
};
nativeBuildInputs = [
pkgs.pkg-config
];
buildInputs = [
pkgs.openssl
];
meta = with pkgs.lib; {
description = "A simple Rust program to show current location info";
mainProgram = "whereAmI";
license = licenses.mit;
maintainers = [ ];
};
};
});
devShells = forEachSupportedSystem ({ pkgs }: {
default = pkgs.mkShell {
packages = with pkgs; [
rustToolchain
openssl
pkg-config
cargo-deny
cargo-edit
cargo-watch
rust-analyzer
];
env = {
# Needed for rust-analyzer stdlib discovery
RUST_SRC_PATH =
"${pkgs.rustToolchain}/lib/rustlib/src/rust/library";
};
};
});
};
}

257
src/main.rs Normal file
View 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();
}