Files
ActivePush/src/eas/folders.rs
T
2026-11-05 12:52:32 +01:00

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))
}