use crate::mcp::{CheckBatch, CheckComment, Verdicts, at};
use anyhow::{Context, bail};
use axum::extract::rejection::JsonRejection;
use axum::extract::{DefaultBodyLimit, Request, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use replysieve::{API_KEY_ENV, Checker};
use serde_json::json;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Instant;
pub const TOKEN_ENV: &str = "REPLYSIEVE_TOKEN";
const MAX_BODY: usize = 1024 * 1024;
const MAX_BATCH: usize = 100;
struct App {
checker: Checker,
token: Option<String>,
}
type Shared = State<Arc<App>>;
fn error(status: StatusCode, message: impl Into<String>) -> Response {
(status, Json(json!({ "error": message.into() }))).into_response()
}
fn rejected(e: JsonRejection) -> Response {
error(e.status(), e.body_text())
}
async fn healthz() -> Response {
Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") })).into_response()
}
async fn check(State(app): Shared, body: Result<Json<CheckComment>, JsonRejection>) -> Response {
let p = match body {
Ok(Json(p)) => p,
Err(e) => return rejected(e),
};
match at(&app.checker, p.level)
.check(&p.comment(), &p.recent)
.await
{
Ok(v) => Json(v).into_response(),
Err(e) => error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
}
}
async fn check_batch(
State(app): Shared,
body: Result<Json<CheckBatch>, JsonRejection>,
) -> Response {
let p = match body {
Ok(Json(p)) => p,
Err(e) => return rejected(e),
};
if p.comments.len() > MAX_BATCH {
return error(
StatusCode::UNPROCESSABLE_ENTITY,
format!(
"a batch holds at most {MAX_BATCH} comments, got {}",
p.comments.len()
),
);
}
match at(&app.checker, p.level)
.check_batch(&p.comments, &p.recent)
.await
{
Ok(verdicts) => Json(Verdicts { verdicts }).into_response(),
Err(e) => error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
}
}
fn same(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0, |acc, (x, y)| acc | (x ^ y)) == 0
}
async fn auth(State(app): Shared, req: Request, next: Next) -> Response {
if let Some(token) = &app.token {
let sent = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
if !sent.is_some_and(|s| same(s.as_bytes(), token.as_bytes())) {
let mut resp = error(StatusCode::UNAUTHORIZED, "missing or wrong bearer token");
resp.headers_mut()
.insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
return resp;
}
}
next.run(req).await
}
async fn log_request(req: Request, next: Next) -> Response {
let (method, path) = (req.method().clone(), req.uri().path().to_string());
let started = Instant::now();
let resp = next.run(req).await;
log::info!(
"{method} {path} {} in {} ms",
resp.status().as_u16(),
started.elapsed().as_millis()
);
resp
}
fn router(app: Arc<App>) -> Router {
let api = Router::new()
.route("/v1/check", post(check))
.route("/v1/check/batch", post(check_batch))
.route_layer(middleware::from_fn_with_state(app.clone(), auth));
Router::new()
.route("/healthz", get(healthz))
.merge(api)
.fallback(|| async { error(StatusCode::NOT_FOUND, "no such endpoint") })
.method_not_allowed_fallback(|| async {
error(StatusCode::METHOD_NOT_ALLOWED, "method not allowed")
})
.layer(DefaultBodyLimit::max(MAX_BODY))
.layer(middleware::from_fn(log_request))
.with_state(app)
}
#[cfg(unix)]
fn shutdown() -> anyhow::Result<impl Future<Output = ()>> {
use tokio::signal::unix::{SignalKind, signal};
let mut term = signal(SignalKind::terminate()).context("cannot handle SIGTERM")?;
let mut int = signal(SignalKind::interrupt()).context("cannot handle SIGINT")?;
Ok(async move {
tokio::select! {
_ = term.recv() => {}
_ = int.recv() => {}
}
log::info!("shutting down");
})
}
#[cfg(not(unix))]
fn shutdown() -> anyhow::Result<impl Future<Output = ()>> {
Ok(async {
tokio::signal::ctrl_c().await.ok();
log::info!("shutting down");
})
}
pub async fn serve(
checker: Checker,
bind: SocketAddr,
insecure_no_auth: bool,
) -> anyhow::Result<()> {
let has_key = std::env::var(API_KEY_ENV).is_ok_and(|k| !k.trim().is_empty());
if !has_key {
bail!("{}", replysieve::Error::MissingApiKey);
}
let token = std::env::var(TOKEN_ENV)
.ok()
.filter(|t| !t.trim().is_empty());
if token.is_none() && !bind.ip().is_loopback() && !insecure_no_auth {
bail!(
"refusing to listen on {bind} without {TOKEN_ENV}: anyone who can reach it could spend your \
OpenRouter credits. Set {TOKEN_ENV} to a long random string, or pass --insecure-no-auth \
if something in front of replysieve already authenticates callers"
);
}
let shutdown = shutdown()?;
let listener = tokio::net::TcpListener::bind(bind)
.await
.with_context(|| format!("cannot listen on {bind}"))?;
eprintln!("replysieve: listening on http://{}", listener.local_addr()?);
if token.is_none() {
eprintln!("replysieve: {TOKEN_ENV} is not set, so requests are not authenticated");
}
let app = Arc::new(App { checker, token });
axum::serve(listener, router(app))
.with_graceful_shutdown(shutdown)
.await?;
Ok(())
}