use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::Value;
use trust_tasks_rs::specs::rooms::blobs::chunk::v0_1 as download_chunk;
use trust_tasks_rs::specs::rooms::blobs::get::v0_1 as blob_get;
use trust_tasks_rs::specs::rooms::blobs::get::v0_2 as blob_get_v2;
use trust_tasks_rs::specs::rooms::blobs::upload::{
abort::v0_1 as upload_abort, begin::v0_1 as upload_begin, chunk::v0_1 as upload_chunk,
commit::v0_1 as upload_commit,
};
use trust_tasks_rs::specs::rooms::blobs::upload::{
abort::v0_2 as upload_abort_v2, begin::v0_2 as upload_begin_v2, chunk::v0_2 as upload_chunk_v2,
commit::v0_2 as upload_commit_v2,
};
use trust_tasks_rs::specs::rooms::info::v0_1 as rooms_info;
use trust_tasks_rs::specs::rooms::records::{
get::v0_2 as get_v2, list::v0_2 as list_v2, put::v0_2 as put_v2,
};
use trust_tasks_rs::specs::rooms::usage::v0_1 as rooms_usage;
use trust_tasks_rs::{RejectReason, TrustTask};
use vti_common::error::AppError;
use vti_common::store::Store;
use vti_rooms::authz::{self, Action, AuthorizedAction};
use vti_rooms::blob_tasks::{self, Refusal, TaskResult};
use vti_rooms::blobs::{BlobHost, UploadCaller};
use vti_rooms::wire::{AuthorityPresentation, GetRecordBody, ListRecordsBody, PutRecordBody};
use vti_rooms::{Room, storage};
use super::handlers::{RoomCtx, charged, now, presenter_and_verifier};
use super::storage_configs::RoomStorage;
use crate::config::RoomFilesConfig;
use crate::routing::trust_task_admission::RoomTraffic;
use crate::store::keyspaces;
use crate::trust_tasks::helpers::{
TrustTaskOutcome, app_error_to_reject, declared_code, reject_with, reject_with_code,
success_response,
};
pub async fn open_room_storage(
store: &Store,
data_dir: &Path,
config: Option<&RoomFilesConfig>,
community_did: Option<&str>,
) -> Result<Arc<RoomStorage>, AppError> {
let Some(config) = config else {
return RoomStorage::open(
store.keyspace(keyspaces::STORAGE_CONFIGS)?,
&store.keyspace(keyspaces::ROOMS)?,
None,
Vec::new(),
community_did,
)
.await;
};
let cwd = std::env::current_dir().map_err(AppError::Io)?;
let seed_root = absolute_dir(
config
.blob_dir
.clone()
.unwrap_or_else(|| data_dir.join("room-blobs")),
&cwd,
)
.await?;
let bases = if config.local_storage_bases.is_empty() {
vec![absolute_dir(data_dir.join("room-storage"), &cwd).await?]
} else {
config.local_storage_bases.clone()
};
RoomStorage::open(
store.keyspace(keyspaces::STORAGE_CONFIGS)?,
&store.keyspace(keyspaces::ROOMS)?,
Some(seed_root),
bases,
community_did,
)
.await
}
async fn absolute_dir(path: PathBuf, cwd: &Path) -> Result<PathBuf, AppError> {
if path.is_absolute() {
return Ok(path);
}
let joined = cwd.join(path);
tokio::fs::create_dir_all(&joined)
.await
.map_err(AppError::Io)?;
std::fs::canonicalize(&joined).map_err(AppError::Io)
}
pub fn open_blob_host(
store: &Store,
data_dir: &Path,
config: Option<&RoomFilesConfig>,
storage: Arc<RoomStorage>,
) -> Result<BlobHost, AppError> {
vti_rooms::observe::set_hook(crate::admin_events::notify_rooms);
let limits = match config {
Some(c) => c.limits(),
None => vti_rooms::blobs::HostLimits {
files_enabled: false,
..RoomFilesConfig::default().limits()
},
};
Ok(BlobHost::new(
store.keyspace(keyspaces::ROOM_BLOBS)?,
store.keyspace(keyspaces::ROOM_BLOB_TRANSFERS)?,
data_dir.join("room-blob-staging"),
storage,
limits,
)
.with_direct_transfer(config.map(|c| c.direct_transfer).unwrap_or_default()))
}
pub(crate) fn spawn_sweeper(
files: Arc<BlobHost>,
storage: Arc<RoomStorage>,
interval_secs: u64,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) {
let interval = std::time::Duration::from_secs(interval_secs.max(1));
tokio::spawn(async move {
loop {
tokio::select! {
_ = shutdown.changed() => return,
_ = tokio::time::sleep(interval) => {}
}
match files.sweep().await {
Ok(stats) if stats.collected + stats.released + stats.forgotten > 0 => {
tracing::info!(
collected = stats.collected,
released = stats.released,
forgotten = stats.forgotten,
"room files swept"
);
}
Ok(_) => {}
Err(e) => tracing::warn!(error = %e, "room file sweep failed"),
}
let settled = match files.config_holdings().await {
Ok(holdings) => storage.settle_draining(&holdings).await,
Err(e) => Err(e),
};
if let Err(e) = settled {
tracing::warn!(error = %e, "draining storage configs not settled");
}
}
});
}
pub(super) fn refused<P>(doc: &TrustTask<P>, refusal: Refusal) -> TrustTaskOutcome {
match refusal {
Refusal::Declared {
code,
message,
details,
} => reject_with_code(doc, declared_code(code.code), message, details),
Refusal::Malformed(reason) => reject_with(doc, RejectReason::MalformedRequest { reason }),
Refusal::Unavailable { retry_after_secs } => reject_with(
doc,
RejectReason::Unavailable {
retry_after: Some(
chrono::Utc::now() + chrono::Duration::seconds(retry_after_secs as i64),
),
},
),
Refusal::TaskFailed(reason) => reject_with(
doc,
RejectReason::TaskFailed {
reason,
details: None,
},
),
Refusal::App(e) => app_error_to_reject(doc, &e),
Refusal::Internal(reason) => reject_with(doc, RejectReason::InternalError { reason }),
}
}
pub(super) fn render<P>(doc: &TrustTask<P>, result: TaskResult) -> TrustTaskOutcome {
match result {
Ok(body) => success_response(doc, body),
Err(r) => refused(doc, r),
}
}
fn payload_json<P: serde::Serialize>(doc: &TrustTask<P>) -> Result<Value, TrustTaskOutcome> {
serde_json::to_value(&doc.payload)
.map_err(|e| app_error_to_reject(doc, &AppError::Serialization(e)))
}
async fn authorize<P>(
doc: &TrustTask<P>,
ctx: &RoomCtx,
payload: &Value,
action: Action,
) -> Result<(Room, AuthorizedAction), TrustTaskOutcome> {
let presentation: AuthorityPresentation =
serde_json::from_value(payload["presentation"].clone()).map_err(|e| {
reject_with(
doc,
RejectReason::MalformedRequest {
reason: format!("presentation: {e}"),
},
)
})?;
let room_id = payload["roomId"].as_str().unwrap_or_default();
let room = storage::get_room(&ctx.state.rooms_ks, room_id)
.await
.map_err(|e| app_error_to_reject(doc, &e))?;
let (presenter, verifier) = presenter_and_verifier(&ctx.state, &ctx.presenter);
let authorized = authz::authorize(
&room,
&presentation,
action,
&presenter,
now(),
&verifier,
&ctx.state.room_revocations_ks,
)
.await
.map_err(|e| app_error_to_reject(doc, &e))?;
charged(&room.room_id, &presenter, RoomTraffic::Operation)?;
Ok((room, authorized))
}
async fn held_step<P>(
doc: &TrustTask<P>,
ctx: &RoomCtx,
payload: &Value,
) -> Result<Option<vti_rooms::revocation::gate::Shared>, TrustTaskOutcome> {
vti_rooms::revocation_tasks::hold_transfer_step(
&ctx.state.room_files,
&ctx.state.rooms_ks,
&ctx.state.room_revocations_ks,
payload,
)
.await
.map_err(|r| refused(doc, r))
}
pub(crate) async fn handle_revocations(
doc: TrustTask<Value>,
ctx: RoomCtx,
put: bool,
) -> TrustTaskOutcome {
let payload = doc.payload.clone();
let room_id = payload["roomId"].as_str().unwrap_or_default();
let room = match storage::get_room(&ctx.state.rooms_ks, room_id).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, &e),
};
let (presenter, verifier) = presenter_and_verifier(&ctx.state, &ctx.presenter);
let (result, operation) = if put {
(
vti_rooms::revocation_tasks::put(
&room,
&presenter,
&payload,
&verifier,
&ctx.state.room_revocations_ks,
)
.await,
vti_rooms::audit::RoomOperation::PutRevocations,
)
} else {
(
vti_rooms::revocation_tasks::get(
&room,
&presenter,
&payload,
&verifier,
&ctx.state.room_revocations_ks,
)
.await,
vti_rooms::audit::RoomOperation::GetRevocations,
)
};
match result {
Ok((body, authorized)) => {
super::handlers::audit_room(&ctx.state, &room, &authorized, operation, None).await;
success_response(&doc, body)
}
Err(r) => refused(&doc, r),
}
}
async fn charge_transfer(
ctx: &RoomCtx,
payload: &Value,
id_member: &str,
traffic: RoomTraffic,
) -> Result<(), TrustTaskOutcome> {
let Some(id) = payload[id_member].as_str() else {
return Ok(());
};
match ctx.state.room_files.transfer_room(&ctx.presenter, id).await {
Some(room_id) => charged(&room_id, &ctx.presenter, traffic),
None => Ok(()),
}
}
async fn charge_upload_v2(
ctx: &RoomCtx,
payload: &Value,
traffic: RoomTraffic,
) -> Result<(), TrustTaskOutcome> {
let Some(id) = payload["uploadId"].as_str() else {
return Ok(());
};
match ctx
.state
.room_files
.upload_owner_room(UploadCaller::V2(&ctx.presenter), id)
.await
{
Some((room_id, owner)) => charged(&room_id, &owner, traffic),
None => Ok(()),
}
}
fn as_v0_1<P, B: serde::de::DeserializeOwned>(
doc: &TrustTask<P>,
payload: Value,
) -> Result<B, TrustTaskOutcome> {
serde_json::from_value(blob_tasks::as_v0_1(payload)).map_err(|e| {
reject_with(
doc,
RejectReason::MalformedRequest {
reason: e.to_string(),
},
)
})
}
pub(crate) async fn handle_put_record_v2(
doc: TrustTask<put_v2::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let payload = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
let blobs = match blob_tasks::put_v2_blobs(&payload) {
Ok(b) => b,
Err(r) => return refused(&doc, r),
};
let req: PutRecordBody = match as_v0_1(&doc, payload) {
Ok(r) => r,
Err(o) => return o,
};
super::handlers::put_record(&doc, &ctx, req, blobs).await
}
pub(crate) async fn handle_get_record_v2(
doc: TrustTask<get_v2::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let req: GetRecordBody = match payload_json(&doc).and_then(|p| as_v0_1(&doc, p)) {
Ok(r) => r,
Err(o) => return o,
};
super::handlers::get_record(&doc, &ctx, req, true).await
}
pub(crate) async fn handle_list_records_v2(
doc: TrustTask<list_v2::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let req: ListRecordsBody = match payload_json(&doc).and_then(|p| as_v0_1(&doc, p)) {
Ok(r) => r,
Err(o) => return o,
};
super::handlers::list_records(&doc, &ctx, req, true).await
}
pub(crate) async fn handle_upload_begin(
doc: TrustTask<upload_begin::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let payload = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
let (room, authorized) = match authorize(&doc, &ctx, &payload, Action::Write).await {
Ok(r) => r,
Err(o) => return o,
};
render(
&doc,
blob_tasks::begin(
&ctx.state.room_files,
&room,
&ctx.presenter,
&authorized,
&payload,
)
.await,
)
}
pub(crate) async fn handle_upload_chunk(
doc: TrustTask<upload_chunk::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let p = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
if let Err(o) = charge_transfer(&ctx, &p, "uploadId", RoomTraffic::Transfer).await {
return o;
}
let _gate = match held_step(&doc, &ctx, &p).await {
Ok(g) => g,
Err(o) => return o,
};
render(
&doc,
blob_tasks::upload_chunk(&ctx.state.room_files, &ctx.presenter, &p).await,
)
}
pub(crate) async fn handle_upload_commit(
doc: TrustTask<upload_commit::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let p = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
if let Err(o) = charge_transfer(&ctx, &p, "uploadId", RoomTraffic::Operation).await {
return o;
}
let _gate = match held_step(&doc, &ctx, &p).await {
Ok(g) => g,
Err(o) => return o,
};
render(
&doc,
blob_tasks::commit(&ctx.state.room_files, &ctx.presenter, &p).await,
)
}
pub(crate) async fn handle_upload_abort(
doc: TrustTask<upload_abort::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let p = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
if let Err(o) = charge_transfer(&ctx, &p, "uploadId", RoomTraffic::Operation).await {
return o;
}
render(
&doc,
blob_tasks::abort(&ctx.state.room_files, &ctx.presenter, &p).await,
)
}
pub(crate) async fn handle_blob_get(
doc: TrustTask<blob_get::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let payload = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
let (room, authorized) = match authorize(&doc, &ctx, &payload, Action::Read).await {
Ok(r) => r,
Err(o) => return o,
};
render(
&doc,
blob_tasks::get(
&ctx.state.room_files,
&room,
&ctx.presenter,
&authorized,
&payload,
)
.await,
)
}
pub(crate) async fn handle_download_chunk(
doc: TrustTask<download_chunk::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let p = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
if let Err(o) = charge_transfer(&ctx, &p, "downloadId", RoomTraffic::Transfer).await {
return o;
}
let _gate = match held_step(&doc, &ctx, &p).await {
Ok(g) => g,
Err(o) => return o,
};
render(
&doc,
blob_tasks::download_chunk(&ctx.state.room_files, &ctx.presenter, &p).await,
)
}
pub(crate) async fn handle_upload_begin_v2(
doc: TrustTask<upload_begin_v2::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let payload = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
let (room, authorized) = match authorize(&doc, &ctx, &payload, Action::Write).await {
Ok(r) => r,
Err(o) => return o,
};
render(
&doc,
blob_tasks::begin_v2(
&ctx.state.room_files,
&room,
&ctx.presenter,
&authorized,
&payload,
)
.await,
)
}
pub(crate) async fn handle_upload_chunk_v2(
doc: TrustTask<upload_chunk_v2::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let p = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
if let Err(o) = charge_upload_v2(&ctx, &p, RoomTraffic::Transfer).await {
return o;
}
let _gate = match held_step(&doc, &ctx, &p).await {
Ok(g) => g,
Err(o) => return o,
};
render(
&doc,
blob_tasks::upload_chunk_v2(&ctx.state.room_files, &ctx.presenter, &p).await,
)
}
pub(crate) async fn handle_upload_commit_v2(
doc: TrustTask<upload_commit_v2::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let p = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
if let Err(o) = charge_upload_v2(&ctx, &p, RoomTraffic::Operation).await {
return o;
}
let _gate = match held_step(&doc, &ctx, &p).await {
Ok(g) => g,
Err(o) => return o,
};
render(
&doc,
blob_tasks::commit_v2(&ctx.state.room_files, &ctx.presenter, &p).await,
)
}
pub(crate) async fn handle_upload_abort_v2(
doc: TrustTask<upload_abort_v2::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let p = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
if let Err(o) = charge_upload_v2(&ctx, &p, RoomTraffic::Operation).await {
return o;
}
render(
&doc,
blob_tasks::abort_v2(&ctx.state.room_files, &ctx.presenter, &p).await,
)
}
pub(crate) async fn handle_blob_get_v2(
doc: TrustTask<blob_get_v2::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let payload = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
let (room, authorized) = match authorize(&doc, &ctx, &payload, Action::Read).await {
Ok(r) => r,
Err(o) => return o,
};
render(
&doc,
blob_tasks::get_v2(
&ctx.state.room_files,
&room,
&ctx.presenter,
&authorized,
&payload,
)
.await,
)
}
pub(crate) async fn handle_usage(
doc: TrustTask<rooms_usage::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let payload = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
let (room, authorized) = match authorize(&doc, &ctx, &payload, Action::Admin).await {
Ok(r) => r,
Err(o) => return o,
};
render(
&doc,
blob_tasks::usage(&ctx.state.room_files, &room, &authorized, &payload).await,
)
}
pub(crate) async fn handle_info(
doc: TrustTask<rooms_info::Payload>,
ctx: RoomCtx,
) -> TrustTaskOutcome {
let payload = match payload_json(&doc) {
Ok(p) => p,
Err(o) => return o,
};
let (room, authorized) = match authorize(&doc, &ctx, &payload, Action::Read).await {
Ok(r) => r,
Err(o) => return o,
};
render(
&doc,
blob_tasks::info(&ctx.state.room_files, &room, &authorized, &payload).await,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn without_the_section_nothing_is_created_and_files_are_off() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&vti_common::config::StoreConfig {
data_dir: dir.path().join("store"),
})
.unwrap();
let unwritable = Path::new("/nonexistent/vtc-data");
let storage = open_room_storage(&store, unwritable, None, Some("did:key:zCommunity"))
.await
.expect("opens with room files off");
assert!(
storage.configs().await.unwrap().is_empty(),
"nothing seeded"
);
let host = open_blob_host(&store, unwritable, None, storage).unwrap();
assert!(!host.files_enabled());
}
#[tokio::test]
async fn a_relative_blob_dir_is_resolved_against_the_working_directory() {
let cwd = tempfile::tempdir().unwrap();
let resolved = absolute_dir(PathBuf::from("data/vtc/room-blobs"), cwd.path())
.await
.unwrap();
assert!(resolved.is_absolute(), "{}", resolved.display());
assert_eq!(
resolved,
std::fs::canonicalize(cwd.path())
.unwrap()
.join("data/vtc/room-blobs")
);
assert!(resolved.is_dir());
let with_dots = absolute_dir(PathBuf::from("data/../data/./x"), cwd.path())
.await
.unwrap();
assert!(
!with_dots.components().any(|c| matches!(
c,
std::path::Component::ParentDir | std::path::Component::CurDir
)),
"{}",
with_dots.display()
);
}
#[tokio::test]
async fn an_absolute_blob_dir_is_kept_as_given() {
let p = PathBuf::from("/srv/vtc/room-blobs");
assert_eq!(
absolute_dir(p.clone(), Path::new("/elsewhere"))
.await
.unwrap(),
p
);
}
#[tokio::test]
async fn storage_opens_over_a_relative_data_dir() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&vti_common::config::StoreConfig {
data_dir: dir.path().join("store"),
})
.unwrap();
let rel = PathBuf::from(format!("target/relative-data-{}", uuid::Uuid::new_v4()));
let storage = open_room_storage(
&store,
&rel,
Some(&RoomFilesConfig::default()),
Some("did:key:zCommunity"),
)
.await;
let _ = std::fs::remove_dir_all(&rel);
let storage = storage.expect("a relative data directory opens");
let local = storage
.config(super::super::storage_configs::SEED_CONFIG_ID)
.await
.unwrap()
.unwrap();
assert!(
local["settings"]["root"].as_str().unwrap().starts_with('/'),
"{local}"
);
}
}