use std::string::String;
use std::sync::Arc;
use std::vec::Vec;
use core::fmt;
use axum08::extract::{Request, State};
use axum08::http::{HeaderMap, Method, header};
use axum08::middleware::Next;
use axum08::response::{IntoResponse, Redirect, Response};
use super::SignedIn;
use crate::Auth;
use crate::types::AuthError;
pub fn is_public(path: &str) -> bool {
path.starts_with("/api/auth/") || path.starts_with("/register/")
}
type Predicate = dyn Fn(&str) -> bool + Send + Sync;
#[derive(Clone)]
pub struct RequireSession {
auth: Auth,
sign_in_path: Arc<str>,
public: Option<Arc<Predicate>>,
}
impl RequireSession {
pub fn new(auth: Auth, sign_in_path: &str) -> Self {
Self {
auth,
sign_in_path: Arc::from(sign_in_path),
public: None,
}
}
pub fn public(mut self, public: impl Fn(&str) -> bool + Send + Sync + 'static) -> Self {
self.public = Some(Arc::new(public));
self
}
fn is_public(&self, path: &str) -> bool {
is_public(path)
|| path == &*self.sign_in_path
|| self.public.as_ref().is_some_and(|public| public(path))
}
fn sign_in_url(&self, target: &str) -> String {
let (path, query) = target.split_once('?').unwrap_or((target, ""));
let mut error = None;
let mut rest = Vec::new();
for pair in query.split('&').filter(|pair| !pair.is_empty()) {
match pair.strip_prefix("error=") {
Some(value) if error.is_none() => error = Some(value),
_ => rest.push(pair),
}
}
let mut url = String::from(&*self.sign_in_path);
let mut separator = '?';
if let Some(error) = error {
url.push(separator);
url.push_str("error=");
url.push_str(error);
separator = '&';
}
let own_next = match rest.as_slice() {
[pair] if path == "/" => pair.strip_prefix("next="),
_ => None,
};
if let Some(next) = own_next {
url.push(separator);
url.push_str("next=");
url.push_str(next);
} else if path != "/" || !rest.is_empty() {
let mut next = String::from(path);
if !rest.is_empty() {
next.push('?');
next.push_str(&rest.join("&"));
}
url.push(separator);
url.push_str("next=");
encode_into(&mut url, &next);
}
url
}
}
impl fmt::Debug for RequireSession {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RequireSession")
.field("sign_in_path", &self.sign_in_path)
.field("public", &self.public.is_some())
.finish_non_exhaustive()
}
}
pub async fn require_session(
State(gate): State<RequireSession>,
request: Request,
next: Next,
) -> Response {
if gate.is_public(request.uri().path()) {
return next.run(request).await;
}
let (mut parts, body) = request.into_parts();
match SignedIn::load(&mut parts, &gate.auth).await {
Ok(Some(..)) => next.run(Request::from_parts(parts, body)).await,
Ok(None) => {
if is_page_request(&parts.method, parts.uri.path(), &parts.headers) {
let target = parts.uri.path_and_query().map_or("/", |p| p.as_str());
Redirect::to(&gate.sign_in_url(target)).into_response()
} else {
AuthError::Unauthorized.into_response()
}
}
Err(error) => error.into_response(),
}
}
fn is_page_request(method: &Method, path: &str, headers: &HeaderMap) -> bool {
(method == Method::GET || method == Method::HEAD)
&& !path.starts_with("/api/")
&& headers
.get_all(header::ACCEPT)
.iter()
.filter_map(|v| v.to_str().ok())
.any(|v| v.contains("text/html"))
}
pub(super) fn encode_into(out: &mut String, value: &str) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
for &b in value.as_bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') {
out.push(char::from(b));
} else {
out.push('%');
out.push(char::from(HEX[usize::from(b >> 4)]));
out.push(char::from(HEX[usize::from(b & 0xf)]));
}
}
}
#[cfg(test)]
pub(super) fn sign_in_url(sign_in_path: &str, target: &str) -> String {
let auth = Auth::builder(crate::MemoryStore::new(), crate::generate_session_key()).build();
RequireSession::new(auth, sign_in_path).sign_in_url(target)
}