use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use cloudillo_core::{
abac, doc_format,
extract::{Auth, IdTag, OptionalRequestId},
};
use cloudillo_types::{
auth_adapter::AuthCtx,
meta_adapter::{DocFormat, UpsertDocFormat},
types::ApiResponse,
};
use serde::{Deserialize, Serialize};
use crate::prelude::*;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PutDocFormat {
pub publisher_tag: String,
pub app_name: String,
pub format_version: Option<i64>,
pub version: Option<String>,
pub store_tp: Option<String>,
pub nav_param: Option<String>,
pub search: Option<serde_json::Value>,
pub x: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DocFormatEntry {
#[serde(flatten)]
format: DocFormat,
source: &'static str,
}
pub async fn list_doc_formats(
State(app): State<App>,
tn_id: TnId,
Auth(auth): Auth,
IdTag(tenant_id_tag): IdTag,
OptionalRequestId(req_id): OptionalRequestId,
) -> ClResult<(StatusCode, Json<ApiResponse<Vec<DocFormatEntry>>>)> {
require_format_reader(&auth, &tenant_id_tag)?;
let formats = doc_format::resolve_list(&app, tn_id)
.await?
.into_iter()
.map(|(format, source)| DocFormatEntry { format, source: source.as_str() })
.collect();
Ok((StatusCode::OK, Json(ApiResponse::new(formats).with_req_id(req_id.unwrap_or_default()))))
}
pub async fn put_doc_format(
State(app): State<App>,
tn_id: TnId,
Auth(auth): Auth,
IdTag(tenant_id_tag): IdTag,
OptionalRequestId(req_id): OptionalRequestId,
Path(content_type): Path<String>,
Json(body): Json<PutDocFormat>,
) -> ClResult<(StatusCode, Json<ApiResponse<Option<DocFormat>>>)> {
require_tenant_admin(&auth, &tenant_id_tag)?;
if content_type.is_empty() || content_type.len() > 128 {
return Err(Error::ValidationError("Invalid content type".into()));
}
validate_format_version(body.format_version)?;
if let Some(search) = &body.search {
crate::rules::IndexRules::parse(search)?;
}
let existing = app.meta_adapter.read_doc_format(tn_id, &content_type).await?;
if existing.is_none()
&& let Some(bundled) = app.bundled_apps.get(&content_type)
&& same_as_bundled(bundled, &body)
{
debug!(content_type, "Doc format registration matches the bundled default; ignoring");
return Ok((
StatusCode::OK,
Json(ApiResponse::new(Some(bundled.clone())).with_req_id(req_id.unwrap_or_default())),
));
}
check_claim(&auth, &content_type, existing.as_ref(), &body)?;
match gate(existing.as_ref(), &body) {
GateDecision::Unchanged => {
return Ok((
StatusCode::OK,
Json(ApiResponse::new(existing).with_req_id(req_id.unwrap_or_default())),
));
}
GateDecision::Stale => {
warn!(
content_type,
stored = ?existing.as_ref().and_then(|e| e.format_version),
submitted = ?body.format_version,
app = %format!("{}/{}", body.publisher_tag, body.app_name),
"Ignored a doc format registration older than the stored one"
);
return Ok((
StatusCode::OK,
Json(ApiResponse::new(existing).with_req_id(req_id.unwrap_or_default())),
));
}
GateDecision::WriteSameVersion => {
warn!(
content_type,
format_version = ?body.format_version,
app = %format!("{}/{}", body.publisher_tag, body.app_name),
"Doc format rules changed without a formatVersion bump — two builds \
sharing a version will re-index this content type against each other"
);
}
GateDecision::Write => {}
}
let rules_changed = existing.as_ref().and_then(|e| e.search.as_ref()) != body.search.as_ref();
app.meta_adapter
.upsert_doc_format(
tn_id,
&UpsertDocFormat {
content_type: &content_type,
publisher_tag: &body.publisher_tag,
app_name: &body.app_name,
format_version: body.format_version,
store_tp: body.store_tp.as_deref(),
nav_param: body.nav_param.as_deref(),
search: body.search.as_ref(),
x: body.x.as_ref(),
},
)
.await?;
doc_format::invalidate(&app, tn_id, &content_type);
if rules_changed {
crate::reindex::schedule_content_type(&app, tn_id, &content_type).await?;
app.meta_adapter
.delete_deep_search_by_content_type(tn_id, &content_type)
.await?;
}
let stored = app.meta_adapter.read_doc_format(tn_id, &content_type).await?;
Ok((StatusCode::OK, Json(ApiResponse::new(stored).with_req_id(req_id.unwrap_or_default()))))
}
pub async fn delete_doc_format(
State(app): State<App>,
tn_id: TnId,
Auth(auth): Auth,
IdTag(tenant_id_tag): IdTag,
OptionalRequestId(req_id): OptionalRequestId,
Path(content_type): Path<String>,
) -> ClResult<(StatusCode, Json<ApiResponse<()>>)> {
require_tenant_admin(&auth, &tenant_id_tag)?;
if app.meta_adapter.read_doc_format(tn_id, &content_type).await?.is_none() {
if app.bundled_apps.get(&content_type).is_some() {
return Ok((
StatusCode::OK,
Json(ApiResponse::new(()).with_req_id(req_id.unwrap_or_default())),
));
}
return Err(Error::NotFound);
}
app.meta_adapter.delete_doc_format(tn_id, &content_type).await?;
doc_format::invalidate(&app, tn_id, &content_type);
crate::reindex::schedule_content_type(&app, tn_id, &content_type).await?;
app.meta_adapter
.delete_deep_search_by_content_type(tn_id, &content_type)
.await?;
Ok((StatusCode::OK, Json(ApiResponse::new(()).with_req_id(req_id.unwrap_or_default()))))
}
fn require_tenant_admin(auth: &AuthCtx, tenant_id_tag: &str) -> ClResult<()> {
if abac::is_admin(auth) || &*auth.id_tag == tenant_id_tag {
return Ok(());
}
Err(Error::PermissionDenied)
}
fn require_format_reader(auth: &AuthCtx, tenant_id_tag: &str) -> ClResult<()> {
if abac::is_admin(auth)
|| &*auth.id_tag == tenant_id_tag
|| cloudillo_core::roles::is_leader(&auth.roles)
{
return Ok(());
}
Err(Error::PermissionDenied)
}
fn check_claim(
auth: &AuthCtx,
content_type: &str,
existing: Option<&DocFormat>,
body: &PutDocFormat,
) -> ClResult<()> {
if abac::is_admin(auth) {
return Ok(());
}
let Some(existing) = existing else { return Ok(()) };
if claimed_by(existing, &body.publisher_tag, &body.app_name) {
return Ok(());
}
warn!(
content_type,
claimant = %format!("{}/{}", existing.publisher_tag, existing.app_name),
challenger = %format!("{}/{}", body.publisher_tag, body.app_name),
"Rejected doc format claim by a different app"
);
Err(Error::PermissionDenied)
}
fn claimed_by(existing: &DocFormat, publisher_tag: &str, app_name: &str) -> bool {
&*existing.publisher_tag == publisher_tag && &*existing.app_name == app_name
}
fn same_as_bundled(bundled: &DocFormat, body: &PutDocFormat) -> bool {
*bundled.publisher_tag == body.publisher_tag
&& *bundled.app_name == body.app_name
&& bundled.format_version == body.format_version
&& bundled.store_tp.as_deref() == body.store_tp.as_deref()
&& bundled.nav_param.as_deref() == body.nav_param.as_deref()
&& bundled.search.as_ref() == body.search.as_ref()
&& body.x.is_none()
}
fn same_content(existing: &DocFormat, body: &PutDocFormat) -> bool {
*existing.publisher_tag == body.publisher_tag
&& *existing.app_name == body.app_name
&& existing.store_tp.as_deref() == body.store_tp.as_deref()
&& existing.nav_param.as_deref() == body.nav_param.as_deref()
&& existing.search.as_ref() == body.search.as_ref()
&& existing.x.as_ref() == body.x.as_ref()
}
const FORMAT_VERSION_MAX: i64 = 999_999_999;
fn validate_format_version(format_version: Option<i64>) -> ClResult<()> {
match format_version {
Some(v) if !(0..=FORMAT_VERSION_MAX).contains(&v) => {
Err(Error::ValidationError("Invalid formatVersion".into()))
}
_ => Ok(()),
}
}
#[derive(Debug, PartialEq, Eq)]
enum GateDecision {
Write,
WriteSameVersion,
Unchanged,
Stale,
}
fn gate(existing: Option<&DocFormat>, body: &PutDocFormat) -> GateDecision {
let Some(existing) = existing else { return GateDecision::Write };
let Some(stored) = existing.format_version else { return GateDecision::Write };
let Some(submitted) = body.format_version else { return GateDecision::Stale };
if submitted < stored {
return GateDecision::Stale;
}
if submitted > stored {
return GateDecision::Write;
}
if same_content(existing, body) {
GateDecision::Unchanged
} else {
GateDecision::WriteSameVersion
}
}
#[cfg(test)]
mod tests {
use super::*;
fn auth(id_tag: &str, roles: &[&str]) -> AuthCtx {
AuthCtx {
tn_id: TnId(1),
id_tag: id_tag.into(),
roles: roles.iter().map(|r| (*r).into()).collect(),
scope: None,
anonymous: false,
}
}
#[test]
fn reading_formats_is_one_step_looser_than_writing_them() {
let cases = [
("the tenant owner", auth("alice.example", &[]), true, true),
("a site admin", auth("root.example", &["SADM"]), true, true),
("a community leader", auth("bob.example", &["leader"]), true, false),
("a contributor", auth("carol.example", &["contributor"]), false, false),
("a federated visitor", auth("mallory.example", &[]), false, false),
];
for (who, ctx, may_read, may_write) in cases {
let read = require_format_reader(&ctx, "alice.example");
let write = require_tenant_admin(&ctx, "alice.example");
assert_eq!(read.is_ok(), may_read, "{who} read: {read:?}");
assert_eq!(write.is_ok(), may_write, "{who} write: {write:?}");
if !may_read {
assert!(matches!(read, Err(Error::PermissionDenied)), "{who}: {read:?}");
}
if !may_write {
assert!(matches!(write, Err(Error::PermissionDenied)), "{who}: {write:?}");
}
}
}
fn rules(title: &str) -> serde_json::Value {
serde_json::json!({ "v": 1, "parts": [{ "kind": "p", "title": [title] }] })
}
fn stored(format_version: Option<i64>, search: Option<serde_json::Value>) -> DocFormat {
DocFormat {
content_type: "cloudillo/notillo".into(),
publisher_tag: "cloudillo.org".into(),
app_name: "notillo".into(),
format_version,
store_tp: Some("RTDB".into()),
nav_param: Some("nav".into()),
search,
x: None,
updated_at: Timestamp(0),
}
}
fn put(format_version: Option<i64>, search: Option<serde_json::Value>) -> PutDocFormat {
PutDocFormat {
publisher_tag: "cloudillo.org".into(),
app_name: "notillo".into(),
format_version,
version: None,
store_tp: Some("RTDB".into()),
nav_param: Some("nav".into()),
search,
x: None,
}
}
#[test]
fn the_write_gate_orders_registrations_by_version() {
use GateDecision::{Stale, Unchanged, Write, WriteSameVersion};
let (v0, v1) = (Some(1_000_000), Some(1_001_000));
let ti = || Some(rules("ti"));
let tj = || Some(rules("tj"));
let cases: Vec<(&str, Option<DocFormat>, PutDocFormat, GateDecision)> = vec![
("a first registration has nothing to order against", None, put(v0, ti()), Write),
(
"a NULL stored version carries no ordering",
Some(stored(None, ti())),
put(v0, ti()),
Write,
),
(
"a caller stating no version cannot outrank one that did",
Some(stored(v0, ti())),
put(None, ti()),
Stale,
),
("an older registration is ignored", Some(stored(v1, ti())), put(v0, ti()), Stale),
("a newer registration writes", Some(stored(v0, ti())), put(v1, tj()), Write),
(
"the same version restating the same rules writes nothing",
Some(stored(v0, ti())),
put(v0, ti()),
Unchanged,
),
(
"the same version with different rules still writes",
Some(stored(v0, ti())),
put(v0, tj()),
WriteSameVersion,
),
];
for (why, existing, body, expected) in cases {
assert_eq!(gate(existing.as_ref(), &body), expected, "{why}");
}
}
#[test]
fn the_same_version_with_a_changed_non_rule_field_still_writes() {
let v = Some(1_000_000);
let existing = stored(v, Some(rules("ti")));
let nav = PutDocFormat { nav_param: Some("page".into()), ..put(v, Some(rules("ti"))) };
assert_eq!(gate(Some(&existing), &nav), GateDecision::WriteSameVersion);
let store = PutDocFormat { store_tp: Some("CRDT".into()), ..put(v, Some(rules("ti"))) };
assert_eq!(gate(Some(&existing), &store), GateDecision::WriteSameVersion);
let x = PutDocFormat {
x: Some(serde_json::json!({ "icon": "note" })),
..put(v, Some(rules("ti")))
};
assert_eq!(gate(Some(&existing), &x), GateDecision::WriteSameVersion);
let app = PutDocFormat { app_name: "notillo2".into(), ..put(v, Some(rules("ti"))) };
assert_eq!(gate(Some(&existing), &app), GateDecision::WriteSameVersion);
}
#[test]
fn a_registration_restating_the_bundled_default_writes_nothing() {
let bundled = stored(Some(1_000_000), Some(rules("ti")));
assert!(same_as_bundled(&bundled, &put(Some(1_000_000), Some(rules("ti")))));
}
#[test]
fn anything_the_bundle_does_not_already_say_still_writes() {
let bundled = stored(Some(1_000_000), Some(rules("ti")));
assert!(!same_as_bundled(&bundled, &put(Some(1_000_000), Some(rules("tj")))));
assert!(!same_as_bundled(&bundled, &put(Some(1_001_000), Some(rules("ti")))));
let mut other_app = put(Some(1_000_000), Some(rules("ti")));
other_app.app_name = "otherillo".into();
assert!(!same_as_bundled(&bundled, &other_app));
let mut with_x = put(Some(1_000_000), Some(rules("ti")));
with_x.x = Some(serde_json::json!({ "k": 1 }));
assert!(!same_as_bundled(&bundled, &with_x));
let mut no_nav = put(Some(1_000_000), Some(rules("ti")));
no_nav.nav_param = None;
assert!(!same_as_bundled(&bundled, &no_nav));
}
#[test]
fn a_bundled_entry_does_not_block_a_tenants_own_claim() {
let owner = auth("alice.example", &[]);
let mut challenger = put(Some(1_000_000), Some(rules("tj")));
challenger.publisher_tag = "other.example".into();
challenger.app_name = "otherillo".into();
assert!(check_claim(&owner, "cloudillo/notillo", None, &challenger).is_ok());
}
#[test]
fn the_encoding_bounds_are_accepted_and_anything_outside_them_is_not() {
assert!(validate_format_version(None).is_ok());
assert!(validate_format_version(Some(0)).is_ok());
assert!(validate_format_version(Some(999_999_999)).is_ok());
assert!(matches!(validate_format_version(Some(-1)), Err(Error::ValidationError(_))));
assert!(matches!(
validate_format_version(Some(1_000_000_000)),
Err(Error::ValidationError(_))
));
}
}