use serde_json::Value;
use trust_tasks_rs::TrustTask;
use trust_tasks_rs::specs::rooms::keys::{label::v0_1 as label_spec, list::v0_2 as list_spec};
use vti_common::acl::Capability;
use vti_common::error::AppError;
use crate::auth::AuthClaims;
use crate::operations::{room_groups, room_member};
use crate::server::AppState;
use super::helpers::{
TRANSPORT_TRUST_TASK, TrustTaskOutcome, app_error_to_reject, malformed_request_response,
parse_payload, reject_declared, reject_with, success_response,
};
pub(super) async fn handle_label(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::RoomOpen,
"naming a room this VTA holds keys for",
)
.await
{
return r;
}
if let Err(r) = refuse_delegate(state, auth, &doc).await {
return r;
}
let req: label_spec::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let change = match change_of(&req) {
Ok(c) => c,
Err(reason) => return malformed_request_response(reason),
};
let room_id = req.room_id.as_str();
let member_did = req.member.as_str();
let member = match room_member::acting_as(state, auth, room_id, Some(member_did)).await {
Ok(Some(m)) => m,
Ok(None) => return not_held(&doc),
Err(e) => return app_error_to_reject(&doc, e),
};
let outcome =
match room_groups::set_label(&state.room_groups_ks, &member, &change, chrono::Utc::now())
.await
{
Ok(o) => o,
Err(e) => return app_error_to_reject(&doc, e),
};
let (label, changed) = match outcome {
room_groups::LabelOutcome::Applied { label, changed } => (label, changed),
room_groups::LabelOutcome::NotHeld => return not_held(&doc),
};
audit_label(state, auth, room_id, member_did, changed).await;
let mut payload = serde_json::json!({
"roomId": room_id,
"member": member_did,
"changed": changed,
});
if let Some(label) = label {
payload["personal"] = match serde_json::to_value(&label) {
Ok(v) => v,
Err(e) => return app_error_to_reject(&doc, AppError::Internal(e.to_string())),
};
}
match serde_json::from_value::<label_spec::Response>(payload) {
Ok(response) => success_response(&doc, response),
Err(e) => app_error_to_reject(
&doc,
AppError::Internal(format!("the label response does not fit its schema: {e}")),
),
}
}
fn change_of(req: &label_spec::Payload) -> Result<room_groups::LabelChange, String> {
fn detail<T>(
given: Option<T>,
remove: Option<bool>,
name: &str,
) -> Result<room_groups::LabelDetail<T>, String> {
match (given, remove) {
(Some(_), Some(true)) => Err(format!(
"`{name}` is both given and removed; a request does one or the other"
)),
(Some(v), _) => Ok(room_groups::LabelDetail::Set(v)),
(None, Some(true)) => Ok(room_groups::LabelDetail::Remove),
(None, _) => Ok(room_groups::LabelDetail::Unchanged),
}
}
let change = room_groups::LabelChange {
label: detail(
req.label.as_ref().map(|v| v.to_string()),
req.remove_label,
"label",
)?,
description: detail(
req.description.as_ref().map(|v| v.to_string()),
req.remove_description,
"description",
)?,
notes: detail(
req.notes.as_ref().map(|v| v.to_string()),
req.remove_notes,
"notes",
)?,
tags: detail(
req.tags
.as_ref()
.map(|t| t.iter().map(|tag| tag.to_string()).collect::<Vec<_>>()),
req.remove_tags,
"tags",
)?,
};
let names_nothing = [
matches!(change.label, room_groups::LabelDetail::Unchanged),
matches!(change.description, room_groups::LabelDetail::Unchanged),
matches!(change.notes, room_groups::LabelDetail::Unchanged),
matches!(change.tags, room_groups::LabelDetail::Unchanged),
]
.iter()
.all(|u| *u);
if names_nothing {
return Err(
"the request names no change: give a detail (`label`, `description`, `notes`, \
`tags`) or remove one (`removeLabel`, …); `ext` is not a change"
.into(),
);
}
Ok(change)
}
fn not_held(doc: &TrustTask<Value>) -> TrustTaskOutcome {
reject_declared(
doc,
label_spec::error_codes::NOT_HELD,
"No group state for this room is held for a member you act as.",
)
}
async fn refuse_delegate(
state: &AppState,
auth: &AuthClaims,
doc: &TrustTask<Value>,
) -> Result<(), TrustTaskOutcome> {
let refuse = || {
reject_with(
doc,
trust_tasks_rs::RejectReason::PermissionDenied {
reason: "a label is the member's: a delegate acts in a room under its own \
identifier and does not read or write what the member calls it"
.into(),
},
)
};
match super::room_act::is_delegate(state, auth).await {
Ok(false) => Ok(()),
Ok(true) => Err(refuse()),
Err(e) => {
tracing::error!(error = %e, did = %auth.did, "could not read the caller's ACL entry; refusing");
Err(refuse())
}
}
}
async fn audit_label(
state: &AppState,
auth: &AuthClaims,
room_id: &str,
member: &str,
changed: bool,
) {
let detail = format!(
"member {member}; {}",
if changed { "changed" } else { "unchanged" }
);
if let Err(e) = crate::audit::record_with_detail(
&state.audit_sink,
"rooms.keys.label",
&auth.did,
Some(room_id),
"success",
Some(TRANSPORT_TRUST_TASK),
None,
Some(&detail),
)
.await
{
tracing::error!(error = %e, "failed to record a room-label audit entry");
}
}
pub(super) async fn handle_list_v0_2(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::RoomOpen,
"listing the rooms this VTA holds keys for",
)
.await
{
return r;
}
let req: list_spec::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let include_personal = req.include_personal == Some(true);
let delegate = match super::room_act::is_delegate(state, auth).await {
Ok(d) => d,
Err(e) => {
tracing::error!(error = %e, did = %auth.did, "could not read the caller's ACL entry; listing nothing");
true
}
};
let members = if delegate {
Vec::new()
} else {
match room_member::held_by_caller(state, auth).await {
Ok(m) => m,
Err(e) => return app_error_to_reject(&doc, e),
}
};
let mut rooms = Vec::with_capacity(members.len());
for m in &members {
let held = match room_groups::held_room(&state.room_groups_ks, m).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, e),
};
let mut entry = serde_json::json!({
"roomId": held.room_id,
"member": m.persona(),
"epoch": held.epoch,
"earliestReadableEpoch": held.earliest_readable_epoch,
});
if include_personal {
match room_groups::personal_label(&state.room_groups_ks, m).await {
Ok(Some(label)) => {
entry["personal"] = match serde_json::to_value(&label) {
Ok(v) => v,
Err(e) => {
return app_error_to_reject(&doc, AppError::Internal(e.to_string()));
}
};
}
Ok(None) => {}
Err(e) => return app_error_to_reject(&doc, e),
}
}
rooms.push((entry, super::room_group::held_by(state, auth, m).await));
}
rooms.sort_by(|(a, _), (b, _)| {
a["roomId"]
.as_str()
.cmp(&b["roomId"].as_str())
.then_with(|| a["member"].as_str().cmp(&b["member"].as_str()))
});
let (rooms, held): (Vec<_>, Vec<_>) = rooms.into_iter().unzip();
super::room_group::record(state, "rooms.keys.list", auth, "").await;
let mut payload = serde_json::json!({ "rooms": rooms });
if !held.is_empty() {
payload["ext"] = serde_json::json!({ room_member::MEMBER_EXT: { "held": held } });
}
match serde_json::from_value::<list_spec::Response>(payload) {
Ok(response) => success_response(&doc, response),
Err(e) => app_error_to_reject(
&doc,
AppError::Internal(format!("the list response does not fit its schema: {e}")),
),
}
}
#[cfg(test)]
#[path = "room_label_tests.rs"]
mod tests;