341 lines
12 KiB
Rust
341 lines
12 KiB
Rust
//! 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<String> {
|
|
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<Option<BFolder>> {
|
|
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<String, String> = 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<Element>) -> Result<Reply> {
|
|
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<String> = 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<Element>) -> Result<Reply> {
|
|
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<HierarchyState> = 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<Element> = 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<Reply> {
|
|
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))
|
|
}
|