use std::env;
use std::sync::OnceLock;
use std::time::Duration;
use bytes::Bytes;
use http::request::Parts;
use http::{HeaderValue, Method, Request};
use http_body_util::{BodyExt, Full};
use hyper_rustls::HttpsConnector;
use hyper_util::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::TokioExecutor;
use crate::errors::StackureError;
use crate::types::{MagicLinkResponse, Session, User};
use crate::validation::{is_uuid, validate_email, validate_uuid};
const DEFAULT_BASE_URL: &str = "https://stackure.com";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
const MAX_RETRIES: u32 = 1;
const RETRY_DELAY: Duration = Duration::from_millis(500);
pub const SESSION_COOKIE: &str = "session";
pub const TOKEN_PARAM: &str = "session_token";
#[must_use]
pub fn base_url() -> String {
env::var("STACKURE_BASE_URL")
.ok()
.filter(|v| !v.is_empty())
.map_or_else(
|| DEFAULT_BASE_URL.to_string(),
|v| v.trim_end_matches('/').to_string(),
)
}
fn app_secret() -> Result<String, StackureError> {
env::var("STACKURE_APP_SECRET")
.ok()
.filter(|v| !v.is_empty())
.ok_or_else(|| StackureError::Validation("STACKURE_APP_SECRET is not set".into()))
}
fn app_id() -> Result<String, StackureError> {
let v = env::var("STACKURE_APP_ID")
.ok()
.filter(|v| !v.is_empty())
.ok_or_else(|| StackureError::Validation("STACKURE_APP_ID is not set".into()))?;
validate_uuid(&v, "STACKURE_APP_ID")?;
Ok(v)
}
pub(crate) fn origin() -> String {
base_url()
.parse::<http::Uri>()
.ok()
.and_then(|u| Some(format!("{}://{}", u.scheme_str()?, u.authority()?)))
.unwrap_or_default()
}
type HttpsClient = Client<HttpsConnector<HttpConnector>, Full<Bytes>>;
fn client() -> Result<&'static HttpsClient, StackureError> {
static CLIENT: OnceLock<Result<HttpsClient, String>> = OnceLock::new();
CLIENT
.get_or_init(|| {
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()
.map_err(|e| format!("no native root certificates found: {e}"))?
.https_or_http()
.enable_http1()
.build();
Ok(Client::builder(TokioExecutor::new()).build(https))
})
.as_ref()
.map_err(|e| StackureError::Network(e.clone()))
}
#[derive(Default)]
struct CallOpts<'a> {
body: Option<Vec<u8>>,
query: Option<String>,
token: &'a str,
bearer: &'a str,
with_secret: bool,
status_only: bool,
ua: &'a str,
ip: &'a str,
}
async fn send_once(
client: &HttpsClient,
method: &Method,
url: &str,
o: &CallOpts<'_>,
secret: &str,
) -> Result<(u16, Bytes), StackureError> {
let mut builder = Request::builder().method(method).uri(url);
if o.body.is_some() {
builder = builder.header("content-type", "application/json");
} else if method == Method::POST {
builder = builder.header("content-length", "0");
}
if !secret.is_empty() {
builder = builder.header("x-app-secret", secret);
}
let cookie = if o.token.is_empty() {
String::new()
} else {
format!("{SESSION_COOKIE}={}", o.token)
};
let bearer = if o.bearer.is_empty() {
String::new()
} else {
format!("Bearer {}", o.bearer)
};
for (name, value) in [
("authorization", bearer.as_str()),
("user-agent", o.ua),
("x-forwarded-for", o.ip),
("cookie", cookie.as_str()),
] {
if let Some(Ok(v)) = (!value.is_empty()).then(|| HeaderValue::from_str(value)) {
builder = builder.header(name, v);
}
}
let body = Full::new(Bytes::from(o.body.clone().unwrap_or_default()));
let req = builder
.body(body)
.map_err(|e| StackureError::Network(format!("failed to create request: {e}")))?;
let response = client
.request(req)
.await
.map_err(|e| StackureError::Network(format!("network request failed: {e}")))?;
let status = response.status().as_u16();
if o.status_only {
return Ok((status, Bytes::new()));
}
let bytes = response
.into_body()
.collect()
.await
.map_err(|_| StackureError::Network("failed to read response body".into()))?
.to_bytes();
Ok((status, bytes))
}
async fn send(method: &Method, path: &str, o: &CallOpts<'_>) -> Result<Bytes, StackureError> {
let deadline = tokio::time::Instant::now() + REQUEST_TIMEOUT;
let secret = if o.bearer.is_empty() || o.with_secret {
app_secret()?
} else {
String::new()
};
let client = client()?;
let url = match &o.query {
Some(q) => format!("{}{path}?{q}", base_url()),
None => format!("{}{path}", base_url()),
};
let mut attempt = 0;
loop {
let res = tokio::time::timeout_at(deadline, send_once(client, method, &url, o, &secret))
.await
.map_err(|_| {
StackureError::Timeout(format!(
"request timed out after {}s",
REQUEST_TIMEOUT.as_secs()
))
})?;
let retry = attempt < MAX_RETRIES
&& deadline.saturating_duration_since(tokio::time::Instant::now()) > RETRY_DELAY;
match res {
Ok((status, bytes)) if status < 500 || !retry => {
return check_status(status, &bytes).map(|()| bytes);
}
Err(e) if !retry => return Err(e),
_ => {}
}
attempt += 1;
tokio::time::sleep(RETRY_DELAY).await;
}
}
async fn request(
method: &Method,
path: &str,
o: CallOpts<'_>,
) -> Result<serde_json::Value, StackureError> {
let bytes = send(method, path, &o).await?;
serde_json::from_str(&String::from_utf8_lossy(&bytes))
.map_err(|_| StackureError::Network("invalid JSON response from server".into()))
}
fn check_status(status: u16, bytes: &[u8]) -> Result<(), StackureError> {
if (200..300).contains(&status) {
return Ok(());
}
let text = String::from_utf8_lossy(bytes);
let body = if text.is_empty() {
"unknown error"
} else {
&text
};
Err(match status {
401 => StackureError::Auth(body.to_string()),
403 => StackureError::Forbidden(body.to_string()),
_ => StackureError::Network(format!("api error ({status}): {body}")),
})
}
#[must_use]
pub fn client_ip(parts: &Parts) -> String {
let forwarded = parts
.headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(str::trim)
.filter(|first| !first.is_empty());
if let Some(first) = forwarded {
return first.to_string();
}
peer_addr(parts).map_or_else(String::new, |a| a.ip().to_string())
}
#[cfg(feature = "axum")]
fn peer_addr(parts: &Parts) -> Option<std::net::SocketAddr> {
parts
.extensions
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|info| info.0)
.or_else(|| parts.extensions.get::<std::net::SocketAddr>().copied())
}
#[cfg(not(feature = "axum"))]
fn peer_addr(parts: &Parts) -> Option<std::net::SocketAddr> {
parts.extensions.get::<std::net::SocketAddr>().copied()
}
#[must_use]
pub fn cookie(parts: &Parts, name: &str) -> String {
parts
.headers
.get("cookie")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.split(';')
.find_map(|part| {
let (key, value) = part.split_once('=')?;
(key.trim() == name).then(|| value.trim().trim_matches('"').to_string())
})
.unwrap_or_default()
}
fn bearer(parts: &Parts) -> &str {
parts
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split_once(' '))
.filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer"))
.map(|(_, token)| token.trim())
.filter(|token| is_uuid(token))
.unwrap_or_default()
}
#[must_use]
pub fn form_value(encoded: &str, name: &str) -> String {
encoded
.split('&')
.find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(percent_decode(key) == name).then(|| percent_decode(value))
})
.unwrap_or_default()
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let decoded = std::str::from_utf8(&bytes[i + 1..i + 3])
.ok()
.and_then(|h| u8::from_str_radix(h, 16).ok());
if let Some(b) = decoded {
out.push(b);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn percent_encode(s: &str) -> String {
use std::fmt::Write as _;
s.bytes().fold(String::new(), |mut out, b| {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
out.push(char::from(b));
} else {
let _ = write!(out, "%{b:02X}");
}
out
})
}
pub async fn send_magic_link(email: &str) -> Result<MagicLinkResponse, StackureError> {
validate_email(email)?;
let body = serde_json::json!({ "user_email": email, "app_id": app_id()? });
let data = request(
&Method::POST,
"/api/public/auth/magic-link/send",
CallOpts {
body: Some(serde_json::to_vec(&body).unwrap_or_default()),
..CallOpts::default()
},
)
.await?;
serde_json::from_value(data)
.map_err(|_| StackureError::Network("unexpected API response format".into()))
}
pub async fn validate_session(parts: &Parts) -> Result<Session, StackureError> {
validate_token(&cookie(parts, SESSION_COOKIE), parts).await
}
pub async fn validate_token(token: &str, parts: &Parts) -> Result<Session, StackureError> {
let app_id = app_id()?;
if !is_uuid(token) {
return Ok(Session {
sign_in_url: format!("{}/sign-in/magic-link?app_id={app_id}", base_url()),
..Session::default()
});
}
let data = request(
&Method::GET,
"/api/public/auth/session/validate",
CallOpts {
query: Some(format!("app_id={app_id}")),
token,
ua: parts
.headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or_default(),
ip: &client_ip(parts),
..CallOpts::default()
},
)
.await?;
serde_json::from_value(data)
.map_err(|_| StackureError::Network("unexpected API response format".into()))
}
#[derive(Default, serde::Deserialize)]
#[serde(default)]
pub(crate) struct McpSession {
pub(crate) authenticated: bool,
pub(crate) user: Option<User>,
pub(crate) www_authenticate: String,
}
pub(crate) async fn validate_mcp(url: &str, parts: &Parts) -> Result<McpSession, StackureError> {
let app_id = app_id()?;
let data = request(
&Method::GET,
"/api/public/auth/session/validate",
CallOpts {
query: Some(format!("app_id={app_id}&mcp={}", percent_encode(url))),
bearer: bearer(parts),
with_secret: true,
ua: parts
.headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or_default(),
ip: &client_ip(parts),
..CallOpts::default()
},
)
.await?;
serde_json::from_value(data)
.map_err(|_| StackureError::Network("unexpected API response format".into()))
}
pub(crate) async fn sign_out(parts: &Parts) -> Result<(), StackureError> {
let token = cookie(parts, SESSION_COOKIE);
if !is_uuid(&token) {
return Ok(());
}
let ua = parts
.headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
let ip = client_ip(parts);
tokio::spawn(async move {
send(
&Method::POST,
"/api/public/auth/sign-out",
&CallOpts {
bearer: &token,
status_only: true,
ua: &ua,
ip: &ip,
..CallOpts::default()
},
)
.await
.map(drop)
})
.await
.unwrap_or_else(|e| Err(StackureError::Network(format!("sign-out task failed: {e}"))))
}