//! FolderSync, FolderCreate, FolderUpdate, FolderDelete, GetHierarchy. use std::collections::BTreeMap; use anyhow::Result; use sqlx::Row; use super::state::{self, HierarchyState, SyncKey, HIERARCHY}; use super::{Ctx, Reply}; use crate::backend::BFolder; use crate::db; use crate::wbxml::Element; pub async fn short_id(ctx: &Ctx, backend_id: &str) -> Result { Ok(db::folder_short_id(&ctx.app.db, ctx.user(), backend_id) .await? .to_string()) } /// Resolve a device-visible folder id to the backend folder. pub async fn resolve(ctx: &mut Ctx, short: &str) -> Result> { let Some(bid) = db::folder_backend_id(&ctx.app.db, ctx.user(), short).await? else { return Ok(None); }; ctx.sess.folder(&bid).await } pub async fn current_hierarchy(ctx: &mut Ctx) -> Result<(HierarchyState, Vec<(String, BFolder)>)> { let folders = ctx.sess.folders().await?; let mut st = HierarchyState::default(); let mut list = Vec::new(); let mut ids: BTreeMap = BTreeMap::new(); for f in &folders { let sid = short_id(ctx, &f.id).await?; ids.insert(f.id.clone(), sid); } for f in folders { let sid = ids[&f.id].clone(); let parent = f .parent .as_ref() .and_then(|p| ids.get(p).cloned()) .unwrap_or_else(|| "0".to_string()); st.folders .insert(sid.clone(), (parent, f.name.clone(), f.ftype)); list.push((sid, f)); } Ok((st, list)) } fn folder_el(tag: &str, sid: &str, parent: &str, name: &str, ftype: u8) -> Element { Element::new(tag) .with_text("FolderHierarchy:ServerId", sid) .with_text("FolderHierarchy:ParentId", parent) .with_text("FolderHierarchy:DisplayName", name) .with_text("FolderHierarchy:Type", ftype.to_string()) } fn fs_status(root: &str, status: u32) -> Reply { Reply::Wbxml(Element::new(root).with_text("FolderHierarchy:Status", status.to_string())) } pub async fn folder_sync(ctx: &mut Ctx, req: Option) -> Result { let Some(req) = req else { return Ok(fs_status("FolderHierarchy:FolderSync", 10)); }; let key_s = req .child_text("FolderHierarchy:SyncKey") .unwrap_or_default(); let Some(key) = SyncKey::parse(&key_s) else { ctx.status = "invalid synckey".into(); return Ok(fs_status("FolderHierarchy:FolderSync", 9)); }; let old: HierarchyState = match &key { SyncKey::Initial => { // full resync: forget every collection state of this device db::device_reset_states(&ctx.app.db, ctx.dev.id).await?; HierarchyState::default() } k => match state::load(ctx, HIERARCHY, k).await? { Some(s) => s, None => { ctx.status = "unknown synckey".into(); return Ok(fs_status("FolderHierarchy:FolderSync", 9)); } }, }; let (cur, _) = current_hierarchy(ctx).await?; let mut adds = Vec::new(); let mut updates = Vec::new(); let mut deletes = Vec::new(); for (sid, v) in &cur.folders { match old.folders.get(sid) { None => adds.push((sid.clone(), v.clone())), Some(o) if o != v => updates.push((sid.clone(), v.clone())), _ => {} } } for sid in old.folders.keys() { if !cur.folders.contains_key(sid) { deletes.push(sid.clone()); } } // collections the device still references but which never existed here (Z-Push leftovers) let stale: Vec = ctx .app .stale_take(ctx.dev.id) .into_iter() .filter(|s| !cur.folders.contains_key(s) && !deletes.contains(s)) .collect(); if !stale.is_empty() { tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, folders = %stale.join(","), "removing stale collections from device"); deletes.extend(stale); } // parents before children adds.sort_by_key(|(sid, (p, _, _))| (depth(&cur, sid), p.clone())); let changed = !(adds.is_empty() && updates.is_empty() && deletes.is_empty()); let new_key = match (&key, changed) { (SyncKey::Key { .. }, false) => key_s.clone(), (SyncKey::Key { uuid, counter }, true) => { state::save(ctx, HIERARCHY, uuid, counter + 1, &cur).await?; state::make_key(uuid, counter + 1) } (SyncKey::Initial, _) => { let u = state::new_uuid(); state::save(ctx, HIERARCHY, &u, 1, &cur).await?; state::make_key(&u, 1) } }; for sid in &deletes { db::state_delete_collection(&ctx.app.db, ctx.dev.id, sid).await?; } let mut changes = Element::new("FolderHierarchy:Changes").with_text( "FolderHierarchy:Count", (adds.len() + updates.len() + deletes.len()).to_string(), ); for (sid, (p, n, t)) in &updates { changes.push(folder_el("FolderHierarchy:Update", sid, p, n, *t)); } for sid in &deletes { changes.push( Element::new("FolderHierarchy:Delete") .with_text("FolderHierarchy:ServerId", sid.clone()), ); } for (sid, (p, n, t)) in &adds { changes.push(folder_el("FolderHierarchy:Add", sid, p, n, *t)); } ctx.items_out = (adds.len() + updates.len() + deletes.len()) as u64; Ok(Reply::Wbxml( Element::new("FolderHierarchy:FolderSync") .with_text("FolderHierarchy:Status", "1") .with_text("FolderHierarchy:SyncKey", new_key) .with(changes), )) } fn depth(st: &HierarchyState, sid: &str) -> usize { let mut d = 0; let mut cur = sid.to_string(); while let Some((p, _, _)) = st.folders.get(&cur) { if p == "0" || d > 32 { break; } d += 1; cur = p.clone(); } d } pub async fn folder_change(ctx: &mut Ctx, req: Option) -> Result { let cmd = ctx.req.cmd.clone(); let root = format!("FolderHierarchy:{cmd}"); let Some(req) = req else { return Ok(fs_status(&root, 10)); }; let key_s = req .child_text("FolderHierarchy:SyncKey") .unwrap_or_default(); let Some(key @ SyncKey::Key { .. }) = SyncKey::parse(&key_s) else { return Ok(fs_status(&root, 9)); }; let SyncKey::Key { uuid, counter } = &key else { unreachable!() }; let Some(mut st): Option = state::load(ctx, HIERARCHY, &key).await? else { return Ok(fs_status(&root, 9)); }; let name = req .child_text("FolderHierarchy:DisplayName") .unwrap_or_default(); let parent_sid = req .child_text("FolderHierarchy:ParentId") .unwrap_or_else(|| "0".into()); let parent = if parent_sid == "0" { None } else { resolve(ctx, &parent_sid).await? }; if parent_sid != "0" && parent.is_none() { return Ok(fs_status(&root, 5)); } let res: Result = async { match cmd.as_str() { "FolderCreate" => { if name.trim().is_empty() || name.len() > 256 { return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "10")); } if st .folders .values() .any(|(p, n, _)| p == &parent_sid && n.eq_ignore_ascii_case(&name)) { return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "2")); } let ftype: u8 = req.child_u32("FolderHierarchy:Type").unwrap_or(12) as u8; let f = ctx .sess .create_folder(parent.as_ref(), name.trim(), ftype) .await?; let sid = short_id(ctx, &f.id).await?; st.folders .insert(sid.clone(), (parent_sid.clone(), f.name.clone(), f.ftype)); state::save(ctx, HIERARCHY, uuid, counter + 1, &st).await?; db::audit(&ctx.app.db, ctx.user(), "folder_create", &f.id, &ctx.ip, "").await; Ok(Element::new(&root) .with_text("FolderHierarchy:Status", "1") .with_text( "FolderHierarchy:SyncKey", state::make_key(uuid, counter + 1), ) .with_text("FolderHierarchy:ServerId", sid)) } "FolderUpdate" | "FolderDelete" => { let sid = req .child_text("FolderHierarchy:ServerId") .unwrap_or_default(); let Some(folder) = resolve(ctx, &sid).await? else { return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "4")); }; if cmd == "FolderDelete" { if folder.ftype != crate::backend::FT_USER_MAIL && !matches!(folder.ftype, 13..=15) { return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "3")); } ctx.sess.delete_folder(&folder).await?; st.folders.remove(&sid); db::state_delete_collection(&ctx.app.db, ctx.dev.id, &sid).await?; db::audit( &ctx.app.db, ctx.user(), "folder_delete", &folder.id, &ctx.ip, "", ) .await; } else { if folder.ftype != crate::backend::FT_USER_MAIL && !matches!(folder.ftype, 13..=15) { return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "3")); } let nf = ctx .sess .rename_folder(&folder, parent.as_ref(), name.trim()) .await?; if nf.id != folder.id { rename_mapping(ctx, &folder.id, &nf.id).await?; } st.folders .insert(sid.clone(), (parent_sid.clone(), nf.name.clone(), nf.ftype)); db::audit( &ctx.app.db, ctx.user(), "folder_update", &nf.id, &ctx.ip, "", ) .await; } state::save(ctx, HIERARCHY, uuid, counter + 1, &st).await?; Ok(Element::new(&root) .with_text("FolderHierarchy:Status", "1") .with_text( "FolderHierarchy:SyncKey", state::make_key(uuid, counter + 1), )) } _ => Ok(Element::new(&root).with_text("FolderHierarchy:Status", "10")), } } .await; match res { Ok(e) => Ok(Reply::Wbxml(e)), Err(e) => { tracing::warn!(user = %ctx.user(), "{cmd} failed: {e:#}"); ctx.status = format!("error: {e}"); let m = e.to_string().to_ascii_lowercase(); let code = if m.contains("alreadyexists") || m.contains("already exists") { 2 } else { 6 }; Ok(fs_status(&root, code)) } } } /// After an IMAP rename, move the short-id mapping of the folder and all its children. async fn rename_mapping(ctx: &Ctx, old: &str, new: &str) -> Result<()> { let rows = sqlx::query("SELECT backend_id FROM folder_map WHERE username = ?") .bind(ctx.user()) .fetch_all(&ctx.app.db) .await?; for r in rows { let bid: String = r.get(0); if bid == old { db::folder_rename_backend(&ctx.app.db, ctx.user(), &bid, new).await?; } else if let Some(rest) = bid.strip_prefix(old) { if rest.starts_with(['/', '.']) { db::folder_rename_backend(&ctx.app.db, ctx.user(), &bid, &format!("{new}{rest}")) .await?; } } } Ok(()) } /// Legacy AS 1.0/2.0 GetHierarchy. pub async fn get_hierarchy(ctx: &mut Ctx) -> Result { let (st, _) = current_hierarchy(ctx).await?; let mut root = Element::new("FolderHierarchy:Folders"); for (sid, (p, n, t)) in st.folders { root.push(folder_el("FolderHierarchy:Folder", &sid, &p, &n, t)); } Ok(Reply::Wbxml(root)) }