use axum::extract::{Request, State};
use axum::http::{StatusCode, Uri};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use boatramp_core::deploy::DeployStore;
use boatramp_core::project::{ProjectRef, DEFAULT_PROJECT};
pub const PROJECT_SCOPED_FAMILIES: &[&str] = &["sites", "functions", "compute", "workflows"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectContext(pub String);
impl ProjectContext {
pub fn default_project() -> Self {
Self(DEFAULT_PROJECT.to_string())
}
pub fn as_ref(&self) -> ProjectRef<'_> {
ProjectRef::new(&self.0)
}
}
impl Default for ProjectContext {
fn default() -> Self {
Self::default_project()
}
}
#[derive(Debug, Clone)]
pub struct OriginalPath(pub String);
struct Scope {
project: String,
rewrite: Option<String>,
}
fn scope_of(path: &str) -> Scope {
let Some((proj, sub)) = boatramp_core::authz::project_api_path(path) else {
return Scope {
project: DEFAULT_PROJECT.to_string(),
rewrite: None,
};
};
if proj.is_empty() {
return Scope {
project: DEFAULT_PROJECT.to_string(),
rewrite: None,
};
}
if sub.is_empty() {
return Scope {
project: proj.to_string(),
rewrite: None,
};
}
let family = sub.split('/').next().unwrap_or("");
let rewrite = PROJECT_SCOPED_FAMILIES
.contains(&family)
.then(|| format!("/api/{sub}"));
Scope {
project: proj.to_string(),
rewrite,
}
}
pub async fn project_scope(mut request: Request, next: Next) -> Response {
let scope = scope_of(request.uri().path());
request
.extensions_mut()
.insert(ProjectContext(scope.project));
if let Some(new_path) = scope.rewrite {
let original = request.uri().path().to_string();
rewrite_path(request.uri_mut(), &new_path);
request.extensions_mut().insert(OriginalPath(original));
}
next.run(request).await
}
pub async fn require_project_exists(
State(deploy): State<DeployStore>,
request: Request,
next: Next,
) -> Response {
let rewritten = request.extensions().get::<OriginalPath>().is_some();
let project = request
.extensions()
.get::<ProjectContext>()
.map(|p| p.0.clone())
.unwrap_or_else(|| DEFAULT_PROJECT.to_string());
if rewritten && project != DEFAULT_PROJECT {
match deploy.project_exists(&project).await {
Ok(true) => {}
Ok(false) => {
return (
StatusCode::NOT_FOUND,
format!("no project `{project}`; create it first (`boatramp project create {project}`)\n"),
)
.into_response();
}
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("project lookup failed: {err}\n"),
)
.into_response();
}
}
}
next.run(request).await
}
fn rewrite_path(uri: &mut Uri, new_path: &str) {
let mut parts = uri.clone().into_parts();
let pq = match uri.query() {
Some(q) => format!("{new_path}?{q}"),
None => new_path.to_string(),
};
if let Ok(path_and_query) = pq.parse() {
parts.path_and_query = Some(path_and_query);
if let Ok(rebuilt) = Uri::from_parts(parts) {
*uri = rebuilt;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_and_non_api_paths_default_and_are_not_rewritten() {
for p in ["/api/sites/blog/config", "/healthz", "/api/tokens"] {
let s = scope_of(p);
assert_eq!(s.project, "default");
assert!(s.rewrite.is_none());
}
}
#[test]
fn project_resource_paths_rewrite_to_the_global_handler() {
let s = scope_of("/api/projects/acme/sites/blog/config");
assert_eq!(s.project, "acme");
assert_eq!(s.rewrite.as_deref(), Some("/api/sites/blog/config"));
let s = scope_of("/api/projects/acme/functions/resize/versions");
assert_eq!(s.project, "acme");
assert_eq!(s.rewrite.as_deref(), Some("/api/functions/resize/versions"));
let s = scope_of("/api/projects/acme/compute/api");
assert_eq!(s.rewrite.as_deref(), Some("/api/compute/api"));
}
#[test]
fn non_whitelisted_project_subpaths_are_never_rewritten() {
for sub in [
"tokens",
"authz/policy",
"daemon/config",
"prune",
"blobs/abc",
] {
let s = scope_of(&format!("/api/projects/acme/{sub}"));
assert_eq!(s.project, "acme");
assert!(s.rewrite.is_none(), "{sub} must not rewrite");
}
}
#[test]
fn project_entity_paths_carry_the_tenant_without_rewrite() {
let s = scope_of("/api/projects/acme");
assert_eq!(s.project, "acme");
assert!(s.rewrite.is_none());
let s = scope_of("/api/projects");
assert_eq!(s.project, "default");
assert!(s.rewrite.is_none());
}
#[test]
fn a_malformed_empty_project_segment_is_default_and_never_rewrites() {
let s = scope_of("/api/projects//sites/blog/config");
assert_eq!(s.project, "default");
assert!(s.rewrite.is_none());
let s = scope_of("/api/projects/");
assert_eq!(s.project, "default");
assert!(s.rewrite.is_none());
}
#[test]
fn scope_of_and_authz_resolve_the_same_project_segment() {
use boatramp_core::authz::project_api_path;
for path in [
"/api/projects/acme/sites/blog/config",
"/api/projects/acme",
"/api/projects/acme/tokens",
"/api/projects//sites/blog", "/api/projects/",
"/api/sites/blog", "/healthz",
] {
let mw = scope_of(path).project;
match project_api_path(path) {
Some((proj, _)) if !proj.is_empty() => assert_eq!(mw, proj, "{path}"),
_ => assert_eq!(mw, "default", "{path}"),
}
}
}
}