pub mod pairing;
pub mod routes;
pub mod sse_ticket;
use axum::http::HeaderValue;
use axum::{
extract::{DefaultBodyLimit, Request},
middleware::{self, Next},
response::Response,
routing::{get, patch, post},
Router,
};
use routes::{
admin_client_session_history, admin_client_sessions, admin_ilink_qr_stream,
admin_ilink_qr_stream_ticket, admin_ilink_relogin, admin_ilink_status,
};
use std::net::SocketAddr;
use std::sync::Arc;
use tower::limit::ConcurrencyLimitLayer;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
pub fn parse_origins(s: &str) -> Vec<HeaderValue> {
s.split(',')
.map(|o| o.trim())
.filter(|o| !o.is_empty())
.map(|o| {
assert!(
o.starts_with("http://") || o.starts_with("https://"),
"CORS origin {o:?} is invalid: must start with http:// or https:// (without scheme)"
);
HeaderValue::from_str(o)
.unwrap_or_else(|_| panic!("CORS origin {o:?} is not a valid header value"))
})
.collect()
}
pub fn build_cors_layer() -> CorsLayer {
match std::env::var("ILINK_CORS_ORIGINS")
.ok()
.filter(|v| !v.trim().is_empty())
{
None => CorsLayer::permissive(),
Some(origins_str) => {
let origins = parse_origins(&origins_str);
CorsLayer::new()
.allow_origin(origins)
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any)
}
}
}
use crate::hub::HubState;
use pairing::*;
use routes::*;
const SENDMESSAGE_MAX_CONCURRENCY: usize = 64;
async fn admin_audit_log(req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
if matches!(method.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
let ip_str = req
.extensions()
.get::<axum::extract::ConnectInfo<SocketAddr>>()
.map(|ci| ci.0.ip().to_string());
if let Some(ip) = ip_str {
tracing::info!(ip = %ip, %method, %path, "admin API call");
} else {
tracing::info!(%method, %path, "admin API call");
}
}
next.run(req).await
}
pub fn build_router(state: Arc<HubState>) -> Router {
let bot_cors = CorsLayer::permissive();
let bot_api = Router::new()
.route(
"/ilink/bot/get_bot_qrcode",
get(get_bot_qrcode).post(get_bot_qrcode_post),
)
.route("/ilink/bot/get_qrcode_status", get(get_qrcode_status))
.route("/ilink/bot/getupdates", post(getupdates))
.route(
"/ilink/bot/sendmessage",
post(sendmessage).layer(
tower::ServiceBuilder::new()
.layer(DefaultBodyLimit::max(4 * 1024 * 1024))
.layer(ConcurrencyLimitLayer::new(SENDMESSAGE_MAX_CONCURRENCY)),
),
)
.route("/ilink/bot/sendtyping", post(sendtyping))
.route("/ilink/bot/getconfig", post(getconfig))
.route("/ilink/bot/getuploadurl", post(getuploadurl))
.layer(bot_cors);
let admin_api = Router::new()
.route("/hub/register", post(register))
.route("/hub/clients", get(admin_clients))
.route(
"/hub/clients/{name}",
patch(admin_update_client).delete(admin_delete_client),
)
.route("/hub/clients/{name}/sessions", get(admin_client_sessions))
.route(
"/hub/clients/{name}/sessions/{session}/history",
get(admin_client_session_history),
)
.route("/hub/ui", get(admin_ui))
.route("/hub/pair/{code}", get(pair_page))
.route("/hub/pair/{code}/confirm", post(pair_confirm))
.route("/hub/ilink/status", get(admin_ilink_status))
.route("/hub/ilink/relogin", post(admin_ilink_relogin))
.route(
"/hub/ilink/qr-stream-ticket",
post(admin_ilink_qr_stream_ticket),
)
.route("/hub/ilink/qr-stream", get(admin_ilink_qr_stream))
.route("/metrics", get(metrics))
.route("/health", get(|| async { "ok" }))
.layer(middleware::from_fn(admin_audit_log));
Router::new()
.merge(bot_api)
.merge(admin_api)
.layer(TraceLayer::new_for_http())
.layer(DefaultBodyLimit::max(256 * 1024))
.with_state(state)
}