use std::collections::HashMap;
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
};
use cloudillo_core::{
abac::{SubjectAccessLevel, relationship_level},
extract::{IdTag, OptionalAuth, OptionalRequestId},
file_access::{self, ScopeCheck},
};
use cloudillo_types::{
auth_adapter::AuthCtx,
meta_adapter::{
SEARCH_MAX_CONTENT_TYPES, SEARCH_MAX_LIMIT, SEARCH_MAX_OFFSET, SEARCH_MAX_TAGS,
SearchMatch, SearchOptions, SearchRow,
},
types::{ApiResponse, TokenScope, serialize_timestamp_iso},
};
use serde::{Deserialize, Serialize};
use crate::{
indexer::{OBJ_DOC, OBJ_FILE},
objects::{OBJ_ACTION, OBJ_PROFILE},
prelude::*,
};
const DEFAULT_LIMIT: u32 = 20;
const MAX_QUERY_CHARS: usize = 256;
const MAX_FILTER_ENTRY_CHARS: usize = 128;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchQuery {
pub q: String,
pub r#type: Option<String>,
pub file_id: Option<String>,
pub content_type: Option<String>,
pub tags: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchHit {
pub obj_tp: char,
pub obj_id: Box<str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub part_id: Option<Box<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub part_kind: Option<Box<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_part: Option<Box<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor_id: Option<Box<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub app_id: Option<Box<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nav_param: Option<Box<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<Box<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Box<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snippet: Option<Box<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snippet_matches: Option<Box<[SearchMatch]>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Box<[Box<str>]>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_tag: Option<Box<str>>,
#[serde(serialize_with = "serialize_timestamp_iso")]
pub updated_at: Timestamp,
pub score: f64,
}
pub async fn get_search(
State(app): State<App>,
tn_id: TnId,
IdTag(tenant_id_tag): IdTag,
OptionalAuth(maybe_auth): OptionalAuth,
OptionalRequestId(req_id): OptionalRequestId,
Query(q): Query<SearchQuery>,
) -> ClResult<(StatusCode, Json<ApiResponse<Vec<SearchHit>>>)> {
let limit = q.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, SEARCH_MAX_LIMIT);
let offset = q.offset.unwrap_or(0).min(SEARCH_MAX_OFFSET);
let authenticated = maybe_auth.is_some();
let auth = maybe_auth.unwrap_or_else(|| AuthCtx {
tn_id,
id_tag: "guest".into(),
roles: vec![].into(),
scope: None,
anonymous: true,
});
if q.q.chars().count() > MAX_QUERY_CHARS {
return Err(Error::ValidationError("Search query too long".into()));
}
let content_type =
csv_filter(q.content_type.as_deref(), SEARCH_MAX_CONTENT_TYPES, "contentType")?;
let tags = csv_filter(q.tags.as_deref(), SEARCH_MAX_TAGS, "tags")?;
let mut opts = SearchOptions {
q: q.q,
obj_tp: obj_tp_filter(q.r#type.as_deref()),
file_id: q.file_id,
content_type,
tags,
limit,
offset,
fts_cl: !crate::store_text(&app, tn_id).await,
..Default::default()
};
let subject = auth.id_tag.as_ref();
let rels = app.meta_adapter.get_relationships(tn_id, &[subject]).await?;
let (following, connected) = rels.get(subject).copied().unwrap_or((false, false));
let level =
subject_level(auth.scope.as_deref(), subject, tenant_id_tag.as_ref(), connected, following);
opts.visible_levels = level.visible_levels().map(<[char]>::to_vec);
opts.viewer_id_tag =
(!is_anonymous_share(auth.scope.as_deref(), subject, tenant_id_tag.as_ref()))
.then(|| subject.to_owned());
if !authenticated {
opts.obj_tp = Some(guest_obj_tp(opts.obj_tp.take()));
}
if let Some(scope) = auth.scope.as_deref() {
let Some(TokenScope::File { file_id, .. }) = TokenScope::parse(scope) else {
return Err(Error::PermissionDenied);
};
opts.scope_file_id = Some(file_id.clone());
opts.scope_grant_file_id = Some(file_id.clone().into());
opts.obj_tp = Some(scope_obj_tp(opts.obj_tp.take()));
}
let scope = auth.scope.as_deref();
let fetched = app.meta_adapter.search(tn_id, &opts).await?;
let fetched_len = fetched.len();
let total = match total_from_page(offset, fetched_len, limit) {
Some(total) => total,
None => app.meta_adapter.count_search(tn_id, &opts).await?,
};
let rows: Vec<SearchRow> = fetched
.into_iter()
.filter(|row| {
!matches!(row.obj_tp, OBJ_FILE | OBJ_DOC)
|| !matches!(
file_access::check_scope_allows_file(
scope,
&row.obj_id,
row.root_id.as_deref()
),
ScopeCheck::Denied
)
})
.collect();
if rows.len() < fetched_len {
warn!(
tn_id = %tn_id,
dropped = fetched_len - rows.len(),
"Search scope post-check dropped rows the SQL prefilter admitted"
);
}
let nav_params = read_nav_params(&app, tn_id, &rows).await;
let hits: Vec<SearchHit> = rows.into_iter().map(|row| to_hit(row, &nav_params)).collect();
let total = usize::try_from(total).unwrap_or(0);
let response = ApiResponse::with_pagination(hits, offset as usize, limit as usize, total)
.with_req_id(req_id.unwrap_or_default());
Ok((StatusCode::OK, Json(response)))
}
fn total_from_page(offset: u32, len: usize, limit: u32) -> Option<i64> {
(offset == 0 && len < usize::try_from(limit).unwrap_or(usize::MAX))
.then(|| i64::try_from(len).unwrap_or(i64::MAX))
}
fn is_anonymous_share(scope: Option<&str>, subject: &str, tenant_id_tag: &str) -> bool {
scope.is_some() && subject == tenant_id_tag
}
fn subject_level(
scope: Option<&str>,
subject: &str,
tenant_id_tag: &str,
connected: bool,
following: bool,
) -> SubjectAccessLevel {
if is_anonymous_share(scope, subject, tenant_id_tag) {
return SubjectAccessLevel::Public;
}
let is_real_auth = !subject.is_empty() && subject != "guest";
relationship_level(subject == tenant_id_tag, connected, following, is_real_auth)
}
async fn read_nav_params(
app: &App,
tn_id: TnId,
rows: &[SearchRow],
) -> HashMap<Box<str>, Option<Box<str>>> {
let mut out: HashMap<Box<str>, Option<Box<str>>> = HashMap::new();
for row in rows.iter().filter(|r| !r.part_id.is_empty()) {
let Some(ct) = row.content_type.as_deref() else { continue };
if out.contains_key(ct) {
continue;
}
let nav_param = cloudillo_core::doc_format::resolve(app, tn_id, ct)
.await
.inspect_err(|e| {
warn!(content_type = ct, error = %e, "Cannot read doc format for nav param");
})
.ok()
.flatten()
.and_then(|f| f.nav_param);
out.insert(ct.into(), nav_param);
}
out
}
fn to_hit(row: SearchRow, nav_params: &HashMap<Box<str>, Option<Box<str>>>) -> SearchHit {
let content_type = row.content_type;
let nav_param = match (&content_type, row.part_id.is_empty()) {
(Some(ct), false) => nav_params.get(ct.as_ref()).cloned().flatten(),
_ => None,
};
let kind = row.part_kind.as_deref();
let part_id: Option<Box<str>> =
(!row.part_id.is_empty()).then(|| strip_kind(&row.part_id, kind).into());
let parent_part: Option<Box<str>> =
row.parent_part.as_deref().map(|p| strip_kind(p, kind).into());
SearchHit {
obj_tp: row.obj_tp,
obj_id: row.obj_id,
part_id,
part_kind: row.part_kind,
parent_part,
anchor_id: row.anchor_id,
app_id: content_type.as_deref().and_then(app_id_of).map(Into::into),
nav_param,
content_type,
title: row.title,
snippet: row.snippet,
snippet_matches: row.snippet_matches,
tags: row.tags.as_deref().map(split_tags),
owner_tag: row.owner_tag,
updated_at: row.updated_at,
score: -row.score,
}
}
fn app_id_of(content_type: &str) -> Option<&str> {
content_type.strip_prefix("cloudillo/").filter(|s| !s.is_empty())
}
fn strip_kind<'a>(part: &'a str, kind: Option<&str>) -> &'a str {
kind.and_then(|k| part.strip_prefix(k)?.strip_prefix('/')).unwrap_or(part)
}
fn parse_types(raw: &str) -> Vec<char> {
raw.split(',')
.map(str::trim)
.filter_map(|t| match t {
"file" => Some('F'),
"doc" => Some('D'),
"action" => Some('A'),
"profile" => Some('P'),
_ => None,
})
.collect()
}
fn obj_tp_filter(raw: Option<&str>) -> Option<Vec<char>> {
raw.map(str::trim).filter(|t| !t.is_empty()).map(parse_types)
}
fn guest_obj_tp(requested: Option<Vec<char>>) -> Vec<char> {
match requested {
Some(tps) => tps.into_iter().filter(|tp| *tp != OBJ_PROFILE).collect(),
None => vec![OBJ_FILE, OBJ_DOC, OBJ_ACTION],
}
}
fn scope_obj_tp(requested: Option<Vec<char>>) -> Vec<char> {
match requested {
Some(tps) => tps.into_iter().filter(|tp| matches!(*tp, OBJ_FILE | OBJ_DOC)).collect(),
None => vec![OBJ_FILE, OBJ_DOC],
}
}
fn csv_filter(raw: Option<&str>, max: usize, what: &str) -> ClResult<Option<Vec<String>>> {
let Some(values) = raw.map(split_csv) else { return Ok(None) };
if values.len() > max {
return Err(Error::ValidationError(format!("Too many {what} values (max {max})")));
}
if values.iter().any(|v| v.chars().count() > MAX_FILTER_ENTRY_CHARS) {
return Err(Error::ValidationError(format!("A {what} value is too long")));
}
Ok((!values.is_empty()).then_some(values))
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn split_tags(raw: &str) -> Box<[Box<str>]> {
raw.split_whitespace().map(Into::into).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn type_names_map_to_obj_tp_codes() {
assert_eq!(parse_types("file,doc"), vec!['F', 'D']);
assert_eq!(parse_types(" action , profile "), vec!['A', 'P']);
}
#[test]
fn unknown_type_names_are_dropped_not_rejected() {
assert_eq!(parse_types("doc,quantum"), vec!['D']);
assert!(parse_types("quantum").is_empty());
}
#[test]
fn an_all_unknown_type_filter_matches_nothing_rather_than_everything() {
assert_eq!(obj_tp_filter(Some("quantum")), Some(vec![]));
assert_eq!(obj_tp_filter(Some("quantum,warp")), Some(vec![]));
assert_eq!(obj_tp_filter(Some("")), None);
assert_eq!(obj_tp_filter(Some(" ")), None);
assert_eq!(obj_tp_filter(None), None);
assert_eq!(obj_tp_filter(Some("doc,quantum")), Some(vec!['D']));
}
#[test]
fn the_second_fts_scan_is_skipped_only_for_a_short_first_page() {
assert_eq!(total_from_page(0, 3, 20), Some(3));
assert_eq!(total_from_page(0, 0, 20), Some(0));
assert_eq!(total_from_page(0, 19, 20), Some(19));
assert_eq!(total_from_page(0, 20, 20), None);
assert_eq!(total_from_page(20, 3, 20), None);
assert_eq!(total_from_page(20, 0, 20), None);
assert_eq!(total_from_page(1, 0, 20), None);
}
#[test]
fn a_guest_never_sees_profiles() {
assert_eq!(guest_obj_tp(None), vec![OBJ_FILE, OBJ_DOC, OBJ_ACTION]);
assert_eq!(guest_obj_tp(Some(vec!['P'])), Vec::<char>::new());
assert_eq!(guest_obj_tp(Some(vec!['F', 'P'])), vec!['F']);
assert_eq!(guest_obj_tp(Some(vec![])), Vec::<char>::new());
}
#[test]
fn a_file_scope_intersects_the_type_filter() {
assert_eq!(scope_obj_tp(None), vec![OBJ_FILE, OBJ_DOC]);
assert_eq!(scope_obj_tp(Some(vec!['A'])), Vec::<char>::new());
assert_eq!(scope_obj_tp(Some(vec!['F'])), vec!['F']);
assert_eq!(scope_obj_tp(Some(vec!['F', 'A', 'D'])), vec!['F', 'D']);
assert_eq!(scope_obj_tp(Some(vec![])), Vec::<char>::new());
}
#[test]
fn a_scoped_token_is_never_the_owner() {
let tenant = "alice.example.com";
assert_eq!(
subject_level(Some("file:f1~x:R"), tenant, tenant, false, false),
SubjectAccessLevel::Public
);
assert_eq!(subject_level(None, tenant, tenant, false, false), SubjectAccessLevel::Owner);
}
#[test]
fn an_anonymous_share_is_not_handed_the_tenants_own_tag() {
let tenant = "alice.example.com";
assert!(is_anonymous_share(Some("file:f1~x:R"), tenant, tenant));
assert!(!is_anonymous_share(Some("file:f1~x:R"), "bob.example.com", tenant));
assert!(!is_anonymous_share(None, tenant, tenant));
assert!(!is_anonymous_share(None, "bob.example.com", tenant));
}
#[test]
fn the_anonymity_test_and_the_visibility_level_agree() {
let tenant = "alice.example.com";
for scope in [None, Some("file:f1~x:R")] {
for subject in [tenant, "bob.example.com"] {
if is_anonymous_share(scope, subject, tenant) {
assert_eq!(
subject_level(scope, subject, tenant, false, false),
SubjectAccessLevel::Public
);
}
}
}
}
#[test]
fn a_scope_leaves_the_relationship_levels_alone() {
let tenant = "alice.example.com";
assert_eq!(
subject_level(Some("file:f1~x:R"), "bob.example.com", tenant, true, false),
SubjectAccessLevel::Connected
);
assert_eq!(
subject_level(Some("file:f1~x:R"), "bob.example.com", tenant, false, true),
SubjectAccessLevel::Follower
);
assert_eq!(subject_level(None, "guest", tenant, false, false), SubjectAccessLevel::Public);
assert_eq!(
subject_level(None, "bob.example.com", tenant, false, false),
SubjectAccessLevel::Verified
);
}
#[test]
fn an_over_long_filter_list_is_rejected() {
let tags = (0..=SEARCH_MAX_TAGS).map(|i| format!("t{i}")).collect::<Vec<_>>().join(",");
assert!(matches!(
csv_filter(Some(&tags), SEARCH_MAX_TAGS, "tags"),
Err(Error::ValidationError(_))
));
let tags = (0..SEARCH_MAX_TAGS).map(|i| format!("t{i}")).collect::<Vec<_>>().join(",");
assert_eq!(
csv_filter(Some(&tags), SEARCH_MAX_TAGS, "tags").expect("ok").map(|v| v.len()),
Some(SEARCH_MAX_TAGS)
);
}
#[test]
fn an_over_long_filter_entry_is_rejected() {
let long = "x".repeat(MAX_FILTER_ENTRY_CHARS + 1);
assert!(matches!(
csv_filter(Some(&long), SEARCH_MAX_TAGS, "tags"),
Err(Error::ValidationError(_))
));
}
#[test]
fn an_empty_filter_list_is_no_filter() {
assert_eq!(csv_filter(None, SEARCH_MAX_TAGS, "tags").expect("ok"), None);
assert_eq!(csv_filter(Some(" , "), SEARCH_MAX_TAGS, "tags").expect("ok"), None);
}
#[test]
fn app_id_is_parsed_only_from_the_cloudillo_namespace() {
assert_eq!(app_id_of("cloudillo/notillo"), Some("notillo"));
assert_eq!(app_id_of("application/pdf"), None);
assert_eq!(app_id_of("cloudillo/"), None);
}
#[test]
fn the_kind_prefix_comes_off_the_wire_value() {
assert_eq!(strip_kind("p/page1", Some("p")), "page1");
assert_eq!(strip_kind("page1", Some("p")), "page1");
assert_eq!(strip_kind("p/page1", None), "p/page1");
}
}