use crate::authz::{KbAccess, ScanScope, effective_prefix, visible_in_listing};
use crate::error::{ApiError, ApiErrorResponse};
use crate::middleware::extract_request_id;
use crate::state::AppState;
use axum::Json;
use axum::extract::{Path, Query, Request, State};
use axum::response::IntoResponse;
use notedthat_core::{KbDetails, KbSlug, KeyFilter, ListResponse, Storage, StorageError, Verb};
use serde::{Deserialize, Serialize};
const LIST_SCAN_PAGE: u32 = 1000;
const LIST_SCAN_MAX_CALLS: usize = 20;
#[derive(Deserialize)]
pub(super) struct ListQuery {
prefix: Option<String>,
limit: Option<u32>,
cursor: Option<String>,
}
#[derive(Serialize)]
struct KbListEntry {
kb_slug: String,
display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
impl KbListEntry {
fn new(kb_slug: &str, details: Option<&KbDetails>) -> Self {
Self {
kb_slug: kb_slug.to_string(),
display_name: details.map_or_else(|| kb_slug.to_string(), |d| d.display_name.clone()),
description: details.and_then(|d| d.description.clone()),
}
}
}
#[derive(Serialize)]
struct KbListResponse {
knowledgebases: Vec<KbListEntry>,
}
pub(super) async fn list_kbs(
State(state): State<AppState>,
req: Request,
) -> Result<impl IntoResponse, ApiErrorResponse> {
let principal = crate::middleware::principal(&req);
let knowledgebases: Vec<KbListEntry> = state
.declared_kbs
.keys()
.filter(|slug| visible_in_listing(&state, slug, &principal))
.map(|slug| KbListEntry::new(slug, state.kb_details.get(slug)))
.collect();
if knowledgebases.is_empty() && principal.is_anonymous() {
return Err(ApiErrorResponse {
error: ApiError::Unauthorized,
request_id: extract_request_id(&req),
});
}
Ok(Json(KbListResponse { knowledgebases }))
}
pub(super) async fn list_objects(
State(state): State<AppState>,
Path(kb_slug): Path<String>,
Query(q): Query<ListQuery>,
req: Request,
) -> Result<impl IntoResponse, ApiErrorResponse> {
let request_id = extract_request_id(&req);
let err = |error: ApiError| ApiErrorResponse {
error,
request_id: request_id.clone(),
};
let access = KbAccess::resolve(&state, &kb_slug, &req).map_err(&err)?;
access.require_any(Verb::List).map_err(&err)?;
let limit = q.limit.filter(|&limit| limit > 0).unwrap_or(100).min(1000);
let result = list_filtered_objects(
state.storage.as_ref(),
access.kb(),
q.prefix.as_deref(),
limit,
q.cursor.as_deref(),
&access.filter(Verb::List),
)
.await
.map_err(|error| err(ApiError::from(error)))?;
Ok(Json(serde_json::json!({
"objects": result.objects,
"truncated": result.truncated,
"next_cursor": result.next_cursor,
})))
}
async fn list_filtered_objects(
storage: &dyn Storage,
kb: &KbSlug,
prefix: Option<&str>,
limit: u32,
cursor: Option<&str>,
filter: &KeyFilter<'_>,
) -> Result<ListResponse, StorageError> {
if filter.is_allow_all() {
return storage.list_objects(kb, prefix, limit, cursor).await;
}
if filter.is_deny_all() {
return Ok(empty_page());
}
let ScanScope::From(scan_prefix) = effective_prefix(prefix, filter.literal_prefix_hint())
else {
return Ok(empty_page());
};
let mut objects = Vec::new();
let mut page_cursor = cursor.map(str::to_string);
for _ in 0..LIST_SCAN_MAX_CALLS {
let page = storage
.list_objects(
kb,
scan_prefix.as_deref(),
LIST_SCAN_PAGE,
page_cursor.as_deref(),
)
.await?;
let mut consumed = 0_usize;
let mut filled = false;
for object in &page.objects {
consumed += 1;
if filter.allows(&object.key) {
objects.push(object.clone());
if objects.len() >= limit as usize {
filled = true;
break;
}
}
}
if filled {
if consumed < page.objects.len() {
let exact = storage
.list_objects(
kb,
scan_prefix.as_deref(),
u32::try_from(consumed).unwrap_or(LIST_SCAN_PAGE),
page_cursor.as_deref(),
)
.await?;
return Ok(ListResponse {
objects,
truncated: exact.truncated,
next_cursor: exact.next_cursor,
});
}
return Ok(ListResponse {
objects,
truncated: page.truncated,
next_cursor: page.next_cursor,
});
}
let Some(next_cursor) = page.next_cursor else {
return Ok(ListResponse {
objects,
truncated: false,
next_cursor: None,
});
};
if page_cursor.as_deref() == Some(next_cursor.as_str()) {
return Err(StorageError::BackendUnavailable {
message: "storage returned a non-advancing cursor".into(),
});
}
page_cursor = Some(next_cursor);
}
Ok(ListResponse {
objects,
truncated: true,
next_cursor: page_cursor,
})
}
fn empty_page() -> ListResponse {
ListResponse {
objects: Vec::new(),
truncated: false,
next_cursor: None,
}
}