Undelete: deleted files, calendar events, contacts and Sieve scripts are kept and restored where they were (UD-1, UD-8 to UD-11)
Files, events and contacts are noted when deleted for good and archived by the unindex task when retention is on; Sieve scripts are archived at deletion. A restore goes back to its folder, calendar or address book if it still exists, takes a free " (restored)" name, comes back inactive for scripts, and is refused over quota with the item left archived. Acceptance tests 6, 8 and 12.
This commit is contained in:
@@ -137,6 +137,8 @@ pub enum Extra {
|
||||
parent_id: Option<u32>,
|
||||
name: String,
|
||||
media_type: Option<String>,
|
||||
#[serde(default)]
|
||||
size: u32,
|
||||
},
|
||||
CalendarEvent {
|
||||
calendar_ids: Vec<u32>,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Deleted files, calendar events and contacts (UD-1, a deliberate
|
||||
//! extension: upstream archives only email).
|
||||
//!
|
||||
//! The code that deletes them for good (over JMAP and WebDAV alike) builds
|
||||
//! its changes without reaching the settings, so it notes every deletion:
|
||||
//! the content (a calendar or contact's text, or a file's blob) and where
|
||||
//! the item lived. The task that then removes the item from the search index
|
||||
//! archives the note if archiving is on, or drops it.
|
||||
|
||||
use crate::undelete::{
|
||||
data::{Extra, Json},
|
||||
records,
|
||||
};
|
||||
use registry::{
|
||||
schema::structs::{ArchivedCalendarEvent, ArchivedContactCard, ArchivedFileNode, ArchivedItem},
|
||||
types::datetime::UTCDateTime,
|
||||
};
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||
use store::{
|
||||
RegistryStore, Serialize, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass, now},
|
||||
};
|
||||
use types::{blob::BlobId, blob_hash::BlobHash, id::Id};
|
||||
|
||||
/// The kinds noted here, as stored in the note's key.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum Kind {
|
||||
File = 0,
|
||||
CalendarEvent = 1,
|
||||
ContactCard = 2,
|
||||
}
|
||||
|
||||
/// A deleted item, noted at deletion.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
pub struct Note {
|
||||
pub deleted_at: u64,
|
||||
pub created_at: i64,
|
||||
/// A calendar event or contact's text.
|
||||
pub content: Option<String>,
|
||||
/// A file's content, already a blob.
|
||||
pub blob_hash: Option<Vec<u8>>,
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
fn note_class(kind: Kind, account_id: u32, document_id: u32) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(11);
|
||||
key.extend_from_slice(b"Ug");
|
||||
key.push(kind as u8);
|
||||
key.extend_from_slice(&account_id.to_be_bytes());
|
||||
key.extend_from_slice(&document_id.to_be_bytes());
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: store::SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
/// Notes an item deleted for good (UD-1).
|
||||
pub fn note(
|
||||
batch: &mut BatchBuilder,
|
||||
kind: Kind,
|
||||
account_id: u32,
|
||||
document_id: u32,
|
||||
mut note: Note,
|
||||
) -> trc::Result<()> {
|
||||
note.deleted_at = now();
|
||||
batch.set(
|
||||
note_class(kind, account_id, document_id),
|
||||
Json(¬e).serialize()?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Takes an item's note, if it has one.
|
||||
pub async fn take(
|
||||
data: &Store,
|
||||
kind: Kind,
|
||||
account_id: u32,
|
||||
document_id: u32,
|
||||
) -> trc::Result<Option<Note>> {
|
||||
let class = note_class(kind, account_id, document_id);
|
||||
let Some(Json(note)) = data
|
||||
.get_value::<Json<Note>>(ValueKey::from(class.clone()))
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(class);
|
||||
data.write(batch.build_all()).await?;
|
||||
Ok(Some(note))
|
||||
}
|
||||
|
||||
/// The value of the first line starting with `name` (as `NAME:` or
|
||||
/// `NAME;params:`) in iCalendar or vCard text, unfolded.
|
||||
fn property(text: &str, name: &str) -> Option<String> {
|
||||
let mut lines = text.split("\r\n").flat_map(|l| l.split('\n')).peekable();
|
||||
while let Some(line) = lines.next() {
|
||||
let Some(rest) = line
|
||||
.get(..name.len())
|
||||
.filter(|head| head.eq_ignore_ascii_case(name))
|
||||
.map(|_| &line[name.len()..])
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !(rest.starts_with(':') || rest.starts_with(';')) {
|
||||
continue;
|
||||
}
|
||||
let mut value = rest.split_once(':')?.1.to_string();
|
||||
while let Some(next) = lines.peek() {
|
||||
if let Some(continued) = next.strip_prefix(' ').or_else(|| next.strip_prefix('\t')) {
|
||||
value.push_str(continued);
|
||||
lines.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Some(value.replace("\\,", ",").replace("\\;", ";").replace("\\n", " "));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// An iCalendar date or date-time (`20260918`, `20260918T100000Z`) as a
|
||||
/// Unix timestamp, read as UTC.
|
||||
fn ical_time(value: &str) -> Option<i64> {
|
||||
let digits = value.trim().trim_end_matches('Z');
|
||||
let (date, time) = digits.split_once('T').unwrap_or((digits, "000000"));
|
||||
if date.len() != 8 || time.len() < 6 {
|
||||
return None;
|
||||
}
|
||||
let n = |s: &str| s.parse::<i64>().ok();
|
||||
let (y, m, d) = (n(&date[..4])?, n(&date[4..6])?, n(&date[6..8])?);
|
||||
let (hh, mm, ss) = (n(&time[..2])?, n(&time[2..4])?, n(&time[4..6])?);
|
||||
// Days from the civil date (Howard Hinnant's algorithm)
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146097 + doe - 719468;
|
||||
Some(days * 86_400 + hh * 3600 + mm * 60 + ss)
|
||||
}
|
||||
|
||||
/// Archives a noted item: holds its kept copy (`blob_hash`) until the
|
||||
/// deadline and writes its record (UD-1, UD-4, UD-5).
|
||||
pub async fn archive(
|
||||
data: &Store,
|
||||
registry: &RegistryStore,
|
||||
account_id: u32,
|
||||
note: Note,
|
||||
blob_hash: BlobHash,
|
||||
retention: u64,
|
||||
) -> trc::Result<Id> {
|
||||
let archived_at = now();
|
||||
let common = (
|
||||
types::id::Id::from(account_id),
|
||||
UTCDateTime::from_timestamp(archived_at as i64),
|
||||
UTCDateTime::from_timestamp((archived_at + retention) as i64),
|
||||
BlobId::new(blob_hash, Default::default()),
|
||||
UTCDateTime::from_timestamp(note.created_at),
|
||||
);
|
||||
let text = note.content.as_deref().unwrap_or_default();
|
||||
let item = match ¬e.extra {
|
||||
Extra::FileNode { name, .. } => ArchivedItem::FileNode(ArchivedFileNode {
|
||||
name: name.clone(),
|
||||
created_at: common.4,
|
||||
account_id: common.0,
|
||||
archived_at: common.1,
|
||||
archived_until: common.2,
|
||||
blob_id: common.3,
|
||||
}),
|
||||
Extra::CalendarEvent { .. } => ArchivedItem::CalendarEvent(ArchivedCalendarEvent {
|
||||
title: property(text, "SUMMARY").unwrap_or_default(),
|
||||
start_time: property(text, "DTSTART")
|
||||
.and_then(|v| ical_time(&v))
|
||||
.map(UTCDateTime::from_timestamp),
|
||||
created_at: common.4,
|
||||
account_id: common.0,
|
||||
archived_at: common.1,
|
||||
archived_until: common.2,
|
||||
blob_id: common.3,
|
||||
}),
|
||||
Extra::ContactCard { .. } => ArchivedItem::ContactCard(ArchivedContactCard {
|
||||
name: property(text, "FN"),
|
||||
created_at: common.4,
|
||||
account_id: common.0,
|
||||
archived_at: common.1,
|
||||
archived_until: common.2,
|
||||
blob_id: common.3,
|
||||
}),
|
||||
Extra::Email { .. } | Extra::SieveScript { .. } => {
|
||||
return Err(trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Not a groupware item"));
|
||||
}
|
||||
};
|
||||
records::insert(data, registry, &item, ¬e.extra).await
|
||||
}
|
||||
|
||||
/// The names a restored item tries, in order: the original, then with
|
||||
/// ` (restored)`, then numbered (UD-8).
|
||||
pub fn candidates(name: &str) -> impl Iterator<Item = String> + '_ {
|
||||
let (stem, ext) = match name.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")),
|
||||
_ => (name, String::new()),
|
||||
};
|
||||
std::iter::once(name.to_string())
|
||||
.chain(std::iter::once(format!("{stem} (restored){ext}")))
|
||||
.chain((2u32..).map(move |n| format!("{stem} (restored {n}){ext}")))
|
||||
}
|
||||
|
||||
/// The first free name for a restored item (UD-8).
|
||||
pub fn free_name(name: &str, is_taken: impl Fn(&str) -> bool) -> String {
|
||||
candidates(name).find(|candidate| !is_taken(candidate)).unwrap()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_ical_and_vcard() {
|
||||
let ics = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Team\r\n lunch\r\nDTSTART;TZID=X:20260918T120000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
assert_eq!(property(ics, "SUMMARY").as_deref(), Some("Team lunch"));
|
||||
assert_eq!(
|
||||
property(ics, "DTSTART").and_then(|v| ical_time(&v)),
|
||||
Some(1_789_732_800)
|
||||
);
|
||||
let vcf = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Jane Doe\r\nEND:VCARD\r\n";
|
||||
assert_eq!(property(vcf, "FN").as_deref(), Some("Jane Doe"));
|
||||
assert_eq!(property(vcf, "N"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_names() {
|
||||
assert_eq!(free_name("a.txt", |_| false), "a.txt");
|
||||
assert_eq!(free_name("a.txt", |n| n == "a.txt"), "a (restored).txt");
|
||||
assert_eq!(
|
||||
free_name("a.txt", |n| n == "a.txt" || n == "a (restored).txt"),
|
||||
"a (restored 2).txt"
|
||||
);
|
||||
assert_eq!(free_name("script", |n| n == "script"), "script (restored)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Archives a Sieve script deleted for good (UD-1). Its content is already
|
||||
/// a blob, held from now until the deadline.
|
||||
pub async fn archive_sieve(
|
||||
data: &Store,
|
||||
registry: &RegistryStore,
|
||||
account_id: u32,
|
||||
name: &str,
|
||||
content: String,
|
||||
blob_hash: BlobHash,
|
||||
retention: u64,
|
||||
) -> trc::Result<Id> {
|
||||
let archived_at = now();
|
||||
let item = ArchivedItem::SieveScript(registry::schema::structs::ArchivedSieveScript {
|
||||
name: name.to_string(),
|
||||
created_at: UTCDateTime::from_timestamp(archived_at as i64),
|
||||
content,
|
||||
account_id: types::id::Id::from(account_id),
|
||||
archived_at: UTCDateTime::from_timestamp(archived_at as i64),
|
||||
archived_until: UTCDateTime::from_timestamp((archived_at + retention) as i64),
|
||||
blob_id: BlobId::new(blob_hash, Default::default()),
|
||||
});
|
||||
records::insert(
|
||||
data,
|
||||
registry,
|
||||
&item,
|
||||
&Extra::SieveScript {
|
||||
name: name.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -15,5 +15,6 @@
|
||||
|
||||
pub mod data;
|
||||
pub mod email;
|
||||
pub mod groupware;
|
||||
pub mod records;
|
||||
pub mod settings;
|
||||
|
||||
Reference in New Issue
Block a user