first commit
This commit is contained in:
commit
19dfc51360
85 files changed
+25504
No files matched your search
@@ -0,0 +1,340 @@
|
||||
//! 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))
|
||||
}
|
||||
Reference in new issue
Block a user