avirn-server 0.1.0

HTTP server and OSINT adapters for Avirn.
Documentation
//! `avirn-server`: HTTP server that runs OSINT checks and serves the static site.

use axum::{
    extract::{Query, State},
    response::Json,
    routing::get,
    Router,
};
use avirn_core::{correlate, Input, Report};
use avirn_server::username_sites::{Catalog, CatalogView, UsernameSites};
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

struct AppState {
    registry: avirn_core::Registry,
    website_dir: PathBuf,
    username_catalog: Arc<Catalog>,
}

#[derive(Deserialize)]
struct CheckQuery {
    q: String,
    #[serde(rename = "type")]
    kind: Option<String>,
}

#[tokio::main]
async fn main() {
    tracing_subscriber::registry()
        .with(tracing_subscriber::fmt::layer().compact())
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "avirn_server=debug,tower_http=debug".into()),
        )
        .init();

    let mut registry = avirn_server::adapters::default_registry();
    let username_catalog = Arc::new(Catalog::load().unwrap_or_else(|e| {
        panic!("failed to load username site catalog: {e}");
    }));
    tracing::info!(
        sites = username_catalog.sites.len(),
        "loaded username site catalog"
    );
    registry.register(UsernameSites::new(username_catalog.clone()));

    let website_dir = std::env::var("AVIRN_WEBSITE_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("website"));
    let state = Arc::new(AppState {
        registry,
        website_dir,
        username_catalog,
    });

    let app = Router::new()
        .route("/api/health", get(health))
        .route("/api/check", get(check_handler))
        .route("/api/username-sites", get(username_sites_handler))
        .fallback_service(ServeDir::new(&state.website_dir))
        .layer(CorsLayer::new().allow_origin(Any).allow_methods(Any))
        .layer(TraceLayer::new_for_http())
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .expect("failed to bind");

    tracing::info!("avirn-server listening on http://{}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

async fn health() -> &'static str {
    "ok"
}

async fn username_sites_handler(State(state): State<Arc<AppState>>) -> Json<CatalogView> {
    Json(state.username_catalog.by_category())
}

async fn check_handler(
    State(state): State<Arc<AppState>>,
    Query(query): Query<CheckQuery>,
) -> Result<Json<Report>, String> {
    let input = parse_input(&query.q, query.kind.as_deref().unwrap_or("username"))?;
    let findings = state
        .registry
        .run(&input)
        .await
        .map_err(|e| e.to_string())?;
    let report = correlate(input, findings);
    Ok(Json(report))
}

fn parse_input(value: &str, kind: &str) -> Result<Input, String> {
    match kind {
        "username" => Ok(Input::Username(value.to_string())),
        "email" => Ok(Input::Email(value.to_string())),
        "phone" => Ok(Input::Phone(value.to_string())),
        "url" | "website" | "marketplace" | "dating" => Ok(Input::Url(value.to_string())),
        "image" => Ok(Input::Image(value.to_string())),
        "message" | "eml" => Ok(Input::Message(value.to_string())),
        _ => Err(format!("unknown input type: {}", kind)),
    }
}