mod format;
mod links;
mod listing;
mod render;
use crate::authz::KbAccess;
use crate::middleware::extract_request_id;
use crate::state::AppState;
use axum::extract::rejection::PathRejection;
use axum::extract::{Path, Request, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use format::{ABSENT, civil_date, http_date, human_size};
use listing::{BROWSE_MAX_KEYS, DirectoryListing, is_present, read_directory};
use notedthat_core::{ObjectMeta, ObjectPath, Principal, Verb};
use render::{Crumb, PageView, RowKind, RowView, display_text, escape_html, page};
const SITE_NAME: &str = "notedthat";
pub(super) async fn browse_root(State(state): State<AppState>, mut req: Request) -> Response {
if !req.uri().path().ends_with('/') {
return redirect(StatusCode::PERMANENT_REDIRECT, &links::root_href());
}
let request_id = extract_request_id(&req);
let Ok(principal) = state
.authenticator
.resolve(req.headers(), notedthat_core::Schemes::Bearer)
.await
else {
return unauthorized(&request_id);
};
req.extensions_mut().insert(principal.clone());
let visible: Vec<&String> = state
.declared_kbs
.keys()
.filter(|slug| crate::authz::visible_in_listing(&state, slug, &principal))
.collect();
let rows: Vec<RowView> = visible
.iter()
.map(|slug| {
let listable = state
.access_policies
.get(slug.as_str())
.is_some_and(|policy| policy.grants_any(&principal, Verb::List));
RowView {
label: format!("{}/", display_text(slug)),
href: listable.then(|| escape_html(&links::directory_href(slug, ""))),
size: ABSENT.to_string(),
modified: ABSENT.to_string(),
modified_title: None,
kind: RowKind::Folder,
}
})
.collect();
let summary = if rows.is_empty() {
"Nothing is published here.".to_string()
} else {
format!(
"{} {}",
rows.len(),
plural(rows.len(), "knowledge base", "knowledge bases")
)
};
html_ok(&page(&PageView {
title: SITE_NAME.to_string(),
crumbs: vec![Crumb {
label: escape_html(SITE_NAME),
href: escape_html(&links::root_href()),
}],
rows,
summary,
notice: None,
footnote: None,
}))
}
pub(super) async fn browse_path(
State(state): State<AppState>,
path: Result<Path<String>, PathRejection>,
mut req: Request,
) -> Response {
let request_id = extract_request_id(&req);
let Ok(Path(captured)) = path else {
return not_found(&request_id);
};
let Ok(principal) = state
.authenticator
.resolve(req.headers(), notedthat_core::Schemes::Bearer)
.await
else {
return unauthorized(&request_id);
};
req.extensions_mut().insert(principal.clone());
let has_trailing_slash = req.uri().path().ends_with('/');
let (kb_slug, remainder) = match captured.split_once('/') {
Some((slug, rest)) => (slug, rest),
None => (captured.as_str(), ""),
};
let Ok(access) = KbAccess::resolve(&state, kb_slug, &req) else {
return not_found(&request_id);
};
if !crate::authz::visible_in_listing(&state, kb_slug, &principal) {
return denied(&principal, &request_id);
}
if !has_trailing_slash && !remainder.is_empty() {
return resolve_ambiguous(&state, &access, kb_slug, remainder, &request_id).await;
}
if !has_trailing_slash {
return redirect(
StatusCode::TEMPORARY_REDIRECT,
&links::directory_href(kb_slug, ""),
);
}
let prefix = remainder;
if !prefix.is_empty() {
let trimmed = prefix.trim_end_matches('/');
if ObjectPath::try_from_str(trimmed).is_err() {
return not_found(&request_id);
}
if notedthat_core::is_internal_path(trimmed) {
return not_found(&request_id);
}
}
if !access.policy_grants_any(Verb::List) {
return denied(&principal, &request_id);
}
render_directory(&state, &access, kb_slug, prefix, &principal, &request_id).await
}
async fn resolve_ambiguous(
state: &AppState,
access: &KbAccess,
kb_slug: &str,
key: &str,
request_id: &str,
) -> Response {
let Ok(path) = ObjectPath::try_from_str(key) else {
return not_found(request_id);
};
if notedthat_core::is_internal_path(path.as_str()) {
return not_found(request_id);
}
if access.allows(Verb::Read, path.as_str())
&& state
.storage
.head_object(
access.kb(),
&path,
notedthat_core::ConditionalHeaders::default(),
)
.await
.is_ok()
{
return redirect(
StatusCode::SEE_OTHER,
&links::object_href(kb_slug, path.as_str()),
);
}
let folder_prefix = format!("{}/", path.as_str());
if access.policy_grants_any(Verb::List) {
match read_directory(state.storage.as_ref(), access.kb(), &folder_prefix, access).await {
Ok(listing) if is_present(&listing) => {
return redirect(
StatusCode::TEMPORARY_REDIRECT,
&links::directory_href(kb_slug, &folder_prefix),
);
}
Ok(_) => {}
Err(_) => return server_error(request_id),
}
}
not_found(request_id)
}
async fn render_directory(
state: &AppState,
access: &KbAccess,
kb_slug: &str,
prefix: &str,
principal: &Principal,
request_id: &str,
) -> Response {
let Ok(listing) = read_directory(state.storage.as_ref(), access.kb(), prefix, access).await
else {
return server_error(request_id);
};
if !prefix.is_empty() && !is_present(&listing) {
return denied(principal, request_id);
}
let rows = directory_rows(access, kb_slug, prefix, &listing);
let restricted = rows
.iter()
.filter(|row| row.kind == RowKind::RestrictedObject)
.count();
let folders = listing.rollup.folders.len();
let objects = listing.rollup.files.len();
html_ok(&page(&PageView {
title: format!("{kb_slug}/{prefix}"),
crumbs: crumbs(kb_slug, prefix),
rows,
summary: format!(
"{folders} {}, {objects} {}",
plural(folders, "folder", "folders"),
plural(objects, "object", "objects")
),
notice: listing.truncated.then(|| truncation_notice(&listing)),
footnote: (restricted > 0).then(|| {
format!(
"{restricted} {} listed but not readable.",
plural(restricted, "object is", "objects are")
)
}),
}))
}
fn directory_rows(
access: &KbAccess,
kb_slug: &str,
prefix: &str,
listing: &DirectoryListing,
) -> Vec<RowView> {
let mut rows =
Vec::with_capacity(listing.rollup.folders.len() + listing.rollup.files.len() + 1);
rows.push(RowView {
label: "../".to_string(),
href: Some(escape_html(&links::parent_href(kb_slug, prefix))),
size: String::new(),
modified: String::new(),
modified_title: None,
kind: RowKind::Parent,
});
for folder in &listing.rollup.folders {
rows.push(RowView {
label: format!("{}/", display_text(folder)),
href: Some(escape_html(&links::directory_href(
kb_slug,
&format!("{prefix}{folder}/"),
))),
size: ABSENT.to_string(),
modified: ABSENT.to_string(),
modified_title: None,
kind: RowKind::Folder,
});
}
for (name, meta) in &listing.rollup.files {
rows.push(object_row(access, kb_slug, name, meta));
}
rows
}
fn object_row(access: &KbAccess, kb_slug: &str, name: &str, meta: &ObjectMeta) -> RowView {
let readable = access.allows(Verb::Read, &meta.key);
let (modified, modified_title) = match meta.last_modified.and_then(civil_date) {
Some(date) => (
date,
meta.last_modified
.and_then(http_date)
.map(|full| escape_html(&full)),
),
None => (ABSENT.to_string(), None),
};
RowView {
label: display_text(name),
href: readable.then(|| escape_html(&links::object_href(kb_slug, &meta.key))),
size: human_size(meta.size),
modified,
modified_title,
kind: if readable {
RowKind::Object
} else {
RowKind::RestrictedObject
},
}
}
fn crumbs(kb_slug: &str, prefix: &str) -> Vec<Crumb> {
let mut crumbs = vec![
Crumb {
label: escape_html(SITE_NAME),
href: escape_html(&links::root_href()),
},
Crumb {
label: display_text(kb_slug),
href: escape_html(&links::directory_href(kb_slug, "")),
},
];
let mut walked = String::new();
for segment in prefix.split('/').filter(|segment| !segment.is_empty()) {
walked.push_str(segment);
walked.push('/');
crumbs.push(Crumb {
label: display_text(segment),
href: escape_html(&links::directory_href(kb_slug, &walked)),
});
}
crumbs
}
fn truncation_notice(listing: &DirectoryListing) -> String {
match &listing.last_key {
Some(last) => format!(
"Listing truncated at {BROWSE_MAX_KEYS} objects. Entries after \
<code>{}</code> are not shown — open a subfolder to reach them.",
display_text(last)
),
None => format!("Listing truncated at {BROWSE_MAX_KEYS} objects."),
}
}
fn plural<'a>(count: usize, one: &'a str, many: &'a str) -> &'a str {
if count == 1 { one } else { many }
}
fn redirect(status: StatusCode, location: &str) -> Response {
let mut response = status.into_response();
if let Ok(value) = HeaderValue::from_str(location) {
response.headers_mut().insert(header::LOCATION, value);
}
browse_headers(&mut response);
response
}
fn html_ok(body: &str) -> Response {
let mut response = (StatusCode::OK, body.to_string()).into_response();
browse_headers(&mut response);
response
}
fn html_error(status: StatusCode, heading: &str, detail: &str, request_id: &str) -> Response {
let mut response = (status, render::error_page(heading, detail, request_id)).into_response();
browse_headers(&mut response);
response
}
fn not_found(request_id: &str) -> Response {
html_error(
StatusCode::NOT_FOUND,
"Not found",
"There is nothing to show at this address.",
request_id,
)
}
fn unauthorized(request_id: &str) -> Response {
html_error(
StatusCode::UNAUTHORIZED,
"Unauthorized",
"The credentials supplied with this request were not accepted.",
request_id,
)
}
fn server_error(request_id: &str) -> Response {
html_error(
StatusCode::BAD_GATEWAY,
"Unavailable",
"The storage backend could not be reached.",
request_id,
)
}
fn denied(principal: &Principal, request_id: &str) -> Response {
match principal {
Principal::Anyone => not_found(request_id),
Principal::SignedIn(_) => html_error(
StatusCode::FORBIDDEN,
"Forbidden",
"This knowledge base's access rules do not grant you this listing.",
request_id,
),
}
}
fn browse_headers(response: &mut Response) {
let headers = response.headers_mut();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(header::VARY, HeaderValue::from_static("authorization"));
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; \
form-action 'none'; frame-ancestors 'none'",
),
);
}