first commit
This commit is contained in:
commit
19dfc51360
85 files changed
+25504
No files matched your search
+1254
File diff suppressed because it is too large.
Load diff
+149
@@ -0,0 +1,149 @@
|
||||
//! Shared application state.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use rand::RngCore;
|
||||
|
||||
use crate::backend::dav::DavClient;
|
||||
use crate::backend::{ImapPool, Session};
|
||||
use crate::config::Settings;
|
||||
use crate::db::{self, Db};
|
||||
use crate::logging::LogRing;
|
||||
use crate::metrics::{AuthGuard, Metrics};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Boot {
|
||||
pub data_dir: PathBuf,
|
||||
pub eas_listen: SocketAddr,
|
||||
pub admin_listen: SocketAddr,
|
||||
pub allow_remote_admin: bool,
|
||||
pub admin_tls: Option<(String, String)>,
|
||||
pub eas_tls: Option<(String, String)>,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub db: Db,
|
||||
pub boot: Boot,
|
||||
settings: ArcSwap<Settings>,
|
||||
pub settings_rev: AtomicI64,
|
||||
pub metrics: Metrics,
|
||||
pub auth: AuthGuard,
|
||||
pub pool: Arc<ImapPool>,
|
||||
pub logs: Arc<LogRing>,
|
||||
pub started: Instant,
|
||||
pub started_ts: i64,
|
||||
http: Mutex<(i64, Option<reqwest::Client>, Option<reqwest::Client>)>,
|
||||
/// Random per-process key for HMACs (credential cache keys).
|
||||
pub secret: [u8; 32],
|
||||
loops: Mutex<std::collections::HashMap<String, (u32, i64)>>,
|
||||
pub shutdown: tokio_util::sync::CancellationToken,
|
||||
/// Collection ids a device still uses but that do not exist here (e.g. left over from
|
||||
/// Z-Push). They are sent as FolderHierarchy Delete on the next FolderSync.
|
||||
stale: Mutex<std::collections::HashMap<i64, std::collections::BTreeSet<String>>>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub async fn new(boot: Boot, logs: Arc<LogRing>) -> anyhow::Result<Arc<App>> {
|
||||
let db = db::open(&boot.data_dir).await?;
|
||||
let (settings, rev) = db::load_settings(&db).await?;
|
||||
if let Err(e) = settings.validate() {
|
||||
tracing::warn!("stored settings do not validate ({e}); running with them anyway");
|
||||
}
|
||||
logs.set_cap(settings.logging.ring_size);
|
||||
let mut secret = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut secret);
|
||||
Ok(Arc::new(App {
|
||||
db,
|
||||
boot,
|
||||
settings: ArcSwap::from_pointee(settings),
|
||||
settings_rev: AtomicI64::new(rev),
|
||||
metrics: Metrics::new(),
|
||||
auth: AuthGuard::default(),
|
||||
pool: Arc::new(ImapPool::new(secret)),
|
||||
logs,
|
||||
started: Instant::now(),
|
||||
started_ts: crate::util::now(),
|
||||
http: Mutex::new((-1, None, None)),
|
||||
secret,
|
||||
loops: Mutex::new(std::collections::HashMap::new()),
|
||||
shutdown: tokio_util::sync::CancellationToken::new(),
|
||||
stale: Mutex::new(std::collections::HashMap::new()),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> Arc<Settings> {
|
||||
self.settings.load_full()
|
||||
}
|
||||
|
||||
pub async fn update_settings(&self, s: Settings) -> anyhow::Result<i64> {
|
||||
let rev = db::save_settings(&self.db, &s).await?;
|
||||
self.logs.set_cap(s.logging.ring_size);
|
||||
self.settings.store(Arc::new(s));
|
||||
self.settings_rev.store(rev, Ordering::SeqCst);
|
||||
// backend endpoints may have changed
|
||||
self.pool.clear();
|
||||
self.auth.clear_cache();
|
||||
Ok(rev)
|
||||
}
|
||||
|
||||
fn http_clients(&self, s: &Settings) -> (Option<reqwest::Client>, Option<reqwest::Client>) {
|
||||
let rev = self.settings_rev.load(Ordering::SeqCst);
|
||||
let mut g = self.http.lock().unwrap();
|
||||
if g.0 != rev {
|
||||
let cal = s
|
||||
.caldav
|
||||
.enabled
|
||||
.then(|| DavClient::build_http(s.caldav.verify_tls, s.caldav.timeout_secs).ok())
|
||||
.flatten();
|
||||
let card = s
|
||||
.carddav
|
||||
.enabled
|
||||
.then(|| DavClient::build_http(s.carddav.verify_tls, s.carddav.timeout_secs).ok())
|
||||
.flatten();
|
||||
*g = (rev, cal, card);
|
||||
}
|
||||
(g.1.clone(), g.2.clone())
|
||||
}
|
||||
|
||||
pub fn stale_add(&self, device: i64, collection: &str) {
|
||||
if collection.is_empty() || collection.len() > 64 {
|
||||
return;
|
||||
}
|
||||
let mut g = self.stale.lock().unwrap();
|
||||
let set = g.entry(device).or_default();
|
||||
if set.len() < 500 {
|
||||
set.insert(collection.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stale_take(&self, device: i64) -> Vec<String> {
|
||||
self.stale
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&device)
|
||||
.map(|s| s.into_iter().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Count how often a (device, collection, old sync key) was retried within the last hour.
|
||||
pub fn loop_hit(&self, key: &str) -> u32 {
|
||||
let now = crate::util::now();
|
||||
let mut g = self.loops.lock().unwrap();
|
||||
g.retain(|_, (_, t)| *t > now - 3600);
|
||||
let e = g.entry(key.to_string()).or_insert((0, now));
|
||||
e.0 += 1;
|
||||
e.1 = now;
|
||||
e.0
|
||||
}
|
||||
|
||||
pub fn session(&self, login: &str, password: &str) -> Session {
|
||||
let s = self.settings();
|
||||
let (cal, card) = self.http_clients(&s);
|
||||
Session::new(s, self.pool.clone(), cal, card, login, password)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Autodiscover for mobile devices (MobileSync schema) - replaces z-push/autodiscover.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{ConnectInfo, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::backend::AuthResult;
|
||||
use crate::eas::{basic_auth, client_ip, normalize_login, user_allowed};
|
||||
use crate::metrics::Event;
|
||||
use crate::util::{now, xml_escape};
|
||||
use crate::xmltree;
|
||||
|
||||
const MOBILESYNC: &str =
|
||||
"http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006";
|
||||
|
||||
fn xml_response(status: StatusCode, body: String) -> Response {
|
||||
let mut r = (status, body).into_response();
|
||||
r.headers_mut().insert(
|
||||
"Content-Type",
|
||||
HeaderValue::from_static("text/xml; charset=utf-8"),
|
||||
);
|
||||
r
|
||||
}
|
||||
|
||||
fn error_xml(code: u32, msg: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
|
||||
<Response>
|
||||
<Error Time="{}" Id="1">
|
||||
<ErrorCode>{code}</ErrorCode>
|
||||
<Message>{}</Message>
|
||||
<DebugData />
|
||||
</Error>
|
||||
</Response>
|
||||
</Autodiscover>"#,
|
||||
chrono::Utc::now().format("%H:%M:%S%.6f"),
|
||||
xml_escape(msg)
|
||||
)
|
||||
}
|
||||
|
||||
fn unauthorized() -> Response {
|
||||
let mut r = (StatusCode::UNAUTHORIZED, "Access denied").into_response();
|
||||
r.headers_mut().insert(
|
||||
"WWW-Authenticate",
|
||||
HeaderValue::from_static("Basic realm=\"ActiveSync\""),
|
||||
);
|
||||
r
|
||||
}
|
||||
|
||||
fn safe_host(h: &str) -> Option<&str> {
|
||||
(!h.is_empty()
|
||||
&& h.len() < 256
|
||||
&& h.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')))
|
||||
.then_some(h)
|
||||
}
|
||||
|
||||
pub async fn handler(
|
||||
State(app): State<Arc<App>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> Response {
|
||||
let s = app.settings();
|
||||
let ip = client_ip(peer, &headers, &s).to_string();
|
||||
let started = std::time::Instant::now();
|
||||
app.metrics
|
||||
.c
|
||||
.autodiscover
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if !s.autodiscover.enabled {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
if method != Method::POST && method != Method::GET {
|
||||
return StatusCode::METHOD_NOT_ALLOWED.into_response();
|
||||
}
|
||||
let body = match http_body_util::Limited::new(body, 64 * 1024)
|
||||
.collect()
|
||||
.await
|
||||
{
|
||||
Ok(b) => b.to_bytes(),
|
||||
Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(),
|
||||
};
|
||||
// requested address + schema
|
||||
let (mut email, schema) = if body.is_empty() {
|
||||
(String::new(), MOBILESYNC.to_string())
|
||||
} else {
|
||||
match xmltree::parse(&body) {
|
||||
Ok(x) => (
|
||||
x.text_of("EMailAddress").unwrap_or_default(),
|
||||
x.text_of("AcceptableResponseSchema").unwrap_or_default(),
|
||||
),
|
||||
Err(_) => return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")),
|
||||
}
|
||||
};
|
||||
if schema != MOBILESYNC {
|
||||
tracing::info!(ip = %ip, schema = %schema, "autodiscover: unsupported response schema");
|
||||
return xml_response(StatusCode::OK, error_xml(600, "Invalid Request"));
|
||||
}
|
||||
|
||||
// authentication
|
||||
let creds = basic_auth(&headers, &s);
|
||||
let login = match (&creds, s.autodiscover.require_auth) {
|
||||
(None, true) => return unauthorized(),
|
||||
(Some((l, _)), _) => l.clone(),
|
||||
(None, false) => match normalize_login(&email, &s) {
|
||||
Some(l) => l,
|
||||
None => return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")),
|
||||
},
|
||||
};
|
||||
let ip_key = format!("ip:{ip}");
|
||||
let user_key = format!("user:{login}");
|
||||
if app.auth.locked_until(&ip_key).is_some() || app.auth.locked_until(&user_key).is_some() {
|
||||
return (StatusCode::TOO_MANY_REQUESTS, "Too many failed logins").into_response();
|
||||
}
|
||||
if !user_allowed(&login, &s) {
|
||||
return unauthorized();
|
||||
}
|
||||
if let Some((l, p)) = &creds {
|
||||
let key = app.pool.key(l, p);
|
||||
if !app.auth.is_cached(&key) {
|
||||
let mut sess = app.session(l, p);
|
||||
match sess.authenticate().await {
|
||||
Ok(AuthResult::Ok) => {
|
||||
app.auth.success(&user_key);
|
||||
app.auth.cache_ok(key, s.security.auth_cache_secs);
|
||||
sess.close().await;
|
||||
}
|
||||
Ok(AuthResult::Denied) => {
|
||||
app.metrics.auth_failure();
|
||||
if s.security.log_auth_failures {
|
||||
tracing::warn!(target: "active_push::auth", "authentication failed user={l} ip={ip} service=autodiscover");
|
||||
}
|
||||
let (m, w, lo) = (
|
||||
s.security.auth_fail_max,
|
||||
s.security.auth_fail_window_secs,
|
||||
s.security.lockout_secs,
|
||||
);
|
||||
app.auth.fail(&ip_key, m * 3, w, lo);
|
||||
app.auth.fail(&user_key, m, w, lo);
|
||||
return unauthorized();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("autodiscover: backend unavailable: {e:#}");
|
||||
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if email.is_empty() || !email.contains('@') {
|
||||
email = crate::backend::primary_email(&s, &login);
|
||||
}
|
||||
let url = if !s.autodiscover.server_url.is_empty() {
|
||||
s.autodiscover.server_url.clone()
|
||||
} else {
|
||||
let host = headers
|
||||
.get("x-forwarded-host")
|
||||
.or_else(|| headers.get("host"))
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(safe_host)
|
||||
.unwrap_or("localhost");
|
||||
format!("https://{host}/Microsoft-Server-ActiveSync")
|
||||
};
|
||||
let display = email.clone();
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<Autodiscover xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
|
||||
<Response xmlns="{MOBILESYNC}">
|
||||
<Culture>en:en</Culture>
|
||||
<User>
|
||||
<DisplayName>{}</DisplayName>
|
||||
<EMailAddress>{}</EMailAddress>
|
||||
</User>
|
||||
<Action>
|
||||
<Settings>
|
||||
<Server>
|
||||
<Type>MobileSync</Type>
|
||||
<Url>{}</Url>
|
||||
<Name>{}</Name>
|
||||
</Server>
|
||||
</Settings>
|
||||
</Action>
|
||||
</Response>
|
||||
</Autodiscover>"#,
|
||||
xml_escape(&display),
|
||||
xml_escape(&email),
|
||||
xml_escape(&url),
|
||||
xml_escape(&url)
|
||||
);
|
||||
tracing::info!(user = %login, ip = %ip, "autodiscover answered");
|
||||
app.metrics.record(Event {
|
||||
ts: now(),
|
||||
user: login,
|
||||
device_id: String::new(),
|
||||
device_type: "Autodiscover".into(),
|
||||
command: "Autodiscover".into(),
|
||||
ip,
|
||||
http: 200,
|
||||
status: "ok".into(),
|
||||
ms: started.elapsed().as_millis() as u64,
|
||||
bytes_in: body.len() as u64,
|
||||
bytes_out: xml.len() as u64,
|
||||
items_out: 0,
|
||||
items_in: 0,
|
||||
});
|
||||
xml_response(StatusCode::OK, xml)
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
//! Minimal CalDAV / CardDAV client (RFC 4791 / RFC 6352).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
|
||||
use crate::util::xml_escape;
|
||||
use crate::xmltree;
|
||||
|
||||
const PATH_SEG: &AsciiSet = &CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'"')
|
||||
.add(b'#')
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b'?')
|
||||
.add(b'`')
|
||||
.add(b'{')
|
||||
.add(b'}')
|
||||
.add(b'/')
|
||||
.add(b'%')
|
||||
.add(b'\\');
|
||||
|
||||
pub fn encode_segment(s: &str) -> String {
|
||||
utf8_percent_encode(s, PATH_SEG).to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DavKind {
|
||||
Calendar,
|
||||
AddressBook,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Collection {
|
||||
pub href: String,
|
||||
pub display_name: String,
|
||||
pub ctag: String,
|
||||
pub sync_token: String,
|
||||
pub supports_events: bool,
|
||||
pub supports_todos: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Resource {
|
||||
pub href: String,
|
||||
pub etag: String,
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DavClient {
|
||||
http: Client,
|
||||
base: url::Url,
|
||||
user: String,
|
||||
pass: String,
|
||||
}
|
||||
|
||||
impl DavClient {
|
||||
pub fn build_http(verify: bool, timeout_secs: u64) -> Result<Client> {
|
||||
let mut b = Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs.max(5)))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::limited(3))
|
||||
.user_agent(concat!("ActivePush/", env!("CARGO_PKG_VERSION")))
|
||||
.pool_idle_timeout(Duration::from_secs(60));
|
||||
if !verify {
|
||||
b = b.danger_accept_invalid_certs(true);
|
||||
}
|
||||
Ok(b.build()?)
|
||||
}
|
||||
|
||||
pub fn new(http: Client, base_url: &str, user: &str, pass: &str) -> Result<Self> {
|
||||
let base = url::Url::parse(base_url).context("invalid DAV base URL")?;
|
||||
Ok(DavClient {
|
||||
http,
|
||||
base,
|
||||
user: user.to_string(),
|
||||
pass: pass.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn url(&self, href: &str) -> Result<url::Url> {
|
||||
let u = self.base.join(href)?;
|
||||
// never follow hrefs to other hosts (SSRF protection)
|
||||
if u.host_str() != self.base.host_str()
|
||||
|| u.port_or_known_default() != self.base.port_or_known_default()
|
||||
{
|
||||
bail!("DAV href points to a different host");
|
||||
}
|
||||
Ok(u)
|
||||
}
|
||||
|
||||
async fn request(
|
||||
&self,
|
||||
method: &str,
|
||||
href: &str,
|
||||
depth: Option<&str>,
|
||||
body: Option<String>,
|
||||
content_type: &str,
|
||||
extra: &[(&str, String)],
|
||||
) -> Result<(StatusCode, reqwest::header::HeaderMap, String)> {
|
||||
let m = Method::from_bytes(method.as_bytes())?;
|
||||
let mut rb = self
|
||||
.http
|
||||
.request(m, self.url(href)?)
|
||||
.basic_auth(&self.user, Some(&self.pass));
|
||||
if let Some(d) = depth {
|
||||
rb = rb.header("Depth", d);
|
||||
}
|
||||
for (k, v) in extra {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
if let Some(b) = body {
|
||||
rb = rb.header("Content-Type", content_type).body(b);
|
||||
}
|
||||
let resp = rb
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("DAV {method} {href}"))?;
|
||||
let status = resp.status();
|
||||
let headers = resp.headers().clone();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
bail!("DAV authentication failed");
|
||||
}
|
||||
Ok((status, headers, text))
|
||||
}
|
||||
|
||||
pub async fn check(&self, home: &str) -> Result<()> {
|
||||
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>"#;
|
||||
let (st, _, _) = self
|
||||
.request(
|
||||
"PROPFIND",
|
||||
home,
|
||||
Some("0"),
|
||||
Some(body.into()),
|
||||
"application/xml; charset=utf-8",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
if !st.is_success() && st.as_u16() != 207 {
|
||||
bail!("DAV home {home} returned {st}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover collections below a home path.
|
||||
pub async fn collections(&self, home: &str, kind: DavKind) -> Result<Vec<Collection>> {
|
||||
let body = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop><d:resourcetype/><d:displayname/><cs:getctag/><d:sync-token/><c:supported-calendar-component-set/></d:prop>
|
||||
</d:propfind>"#;
|
||||
let (st, _, text) = self
|
||||
.request(
|
||||
"PROPFIND",
|
||||
home,
|
||||
Some("1"),
|
||||
Some(body.into()),
|
||||
"application/xml; charset=utf-8",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
if st.as_u16() != 207 {
|
||||
bail!("PROPFIND {home} returned {st}");
|
||||
}
|
||||
let doc = xmltree::parse(text.as_bytes())?;
|
||||
let mut out = Vec::new();
|
||||
for resp in doc.children_named("response") {
|
||||
let href = resp.text_of("href").unwrap_or_default();
|
||||
let Some(rt) = resp.find("resourcetype") else {
|
||||
continue;
|
||||
};
|
||||
let is_cal = rt.child("calendar").is_some();
|
||||
let is_book = rt.child("addressbook").is_some();
|
||||
if (kind == DavKind::Calendar && !is_cal) || (kind == DavKind::AddressBook && !is_book)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (mut ev, mut todo) = (true, true);
|
||||
if let Some(set) = resp.find("supported-calendar-component-set") {
|
||||
let comps: Vec<String> = set
|
||||
.children_named("comp")
|
||||
.filter_map(|c| c.attr("name").map(|n| n.to_ascii_uppercase()))
|
||||
.collect();
|
||||
if !comps.is_empty() {
|
||||
ev = comps.iter().any(|c| c == "VEVENT");
|
||||
todo = comps.iter().any(|c| c == "VTODO");
|
||||
}
|
||||
}
|
||||
let name = resp
|
||||
.text_of("displayname")
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| last_segment(&href));
|
||||
out.push(Collection {
|
||||
href: normalize_dir(&href),
|
||||
display_name: name,
|
||||
ctag: resp.text_of("getctag").unwrap_or_default(),
|
||||
sync_token: resp.text_of("sync-token").unwrap_or_default(),
|
||||
supports_events: ev,
|
||||
supports_todos: todo,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Cheap change marker for a collection (ctag / sync-token / etag list hash fallback).
|
||||
pub async fn ctag(&self, href: &str) -> Result<String> {
|
||||
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"><d:prop><cs:getctag/><d:sync-token/></d:prop></d:propfind>"#;
|
||||
let (st, _, text) = self
|
||||
.request(
|
||||
"PROPFIND",
|
||||
href,
|
||||
Some("0"),
|
||||
Some(body.into()),
|
||||
"application/xml; charset=utf-8",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
if st.as_u16() != 207 {
|
||||
bail!("PROPFIND {href} returned {st}");
|
||||
}
|
||||
let doc = xmltree::parse(text.as_bytes())?;
|
||||
let tag = doc.text_of("getctag").filter(|s| !s.is_empty());
|
||||
let tok = doc.text_of("sync-token").filter(|s| !s.is_empty());
|
||||
Ok(tag.or(tok).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// List resources (href + etag). For calendars a component filter and optional
|
||||
/// time-range start (UTC, compact format) are applied server side.
|
||||
pub async fn list(
|
||||
&self,
|
||||
href: &str,
|
||||
comp: Option<&str>,
|
||||
since: Option<&str>,
|
||||
) -> Result<Vec<Resource>> {
|
||||
let text = if let Some(comp) = comp {
|
||||
let range = since
|
||||
.map(|s| format!("<c:time-range start=\"{}\"/>", xml_escape(s)))
|
||||
.unwrap_or_default();
|
||||
let body = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop><d:getetag/></d:prop>
|
||||
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="{comp}">{range}</c:comp-filter></c:comp-filter></c:filter>
|
||||
</c:calendar-query>"#
|
||||
);
|
||||
let (st, _, text) = self
|
||||
.request(
|
||||
"REPORT",
|
||||
href,
|
||||
Some("1"),
|
||||
Some(body),
|
||||
"application/xml; charset=utf-8",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
if st.as_u16() != 207 {
|
||||
bail!("calendar-query {href} returned {st}");
|
||||
}
|
||||
text
|
||||
} else {
|
||||
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
|
||||
let (st, _, text) = self
|
||||
.request(
|
||||
"PROPFIND",
|
||||
href,
|
||||
Some("1"),
|
||||
Some(body.into()),
|
||||
"application/xml; charset=utf-8",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
if st.as_u16() != 207 {
|
||||
bail!("PROPFIND {href} returned {st}");
|
||||
}
|
||||
text
|
||||
};
|
||||
let doc = xmltree::parse(text.as_bytes())?;
|
||||
let base = normalize_dir(href);
|
||||
let mut out = Vec::new();
|
||||
for resp in doc.children_named("response") {
|
||||
let h = resp.text_of("href").unwrap_or_default();
|
||||
if normalize_dir(&h) == base || h.ends_with('/') {
|
||||
continue;
|
||||
}
|
||||
if resp
|
||||
.find("resourcetype")
|
||||
.map(|r| !r.children.is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let ok = resp.children_named("propstat").any(|p| {
|
||||
p.text_of("status")
|
||||
.map(|s| s.contains(" 200"))
|
||||
.unwrap_or(true)
|
||||
&& p.find("getetag").is_some()
|
||||
});
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
out.push(Resource {
|
||||
href: h,
|
||||
etag: resp.text_of("getetag").unwrap_or_default(),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Fetch several resources at once (calendar-multiget / addressbook-multiget).
|
||||
pub async fn multiget(
|
||||
&self,
|
||||
collection: &str,
|
||||
hrefs: &[String],
|
||||
kind: DavKind,
|
||||
) -> Result<Vec<Resource>> {
|
||||
if hrefs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut hs = String::new();
|
||||
for h in hrefs {
|
||||
hs.push_str(&format!("<d:href>{}</d:href>", xml_escape(h)));
|
||||
}
|
||||
let body = match kind {
|
||||
DavKind::Calendar => format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?><c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop>{hs}</c:calendar-multiget>"#
|
||||
),
|
||||
DavKind::AddressBook => format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?><c:addressbook-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><c:address-data/></d:prop>{hs}</c:addressbook-multiget>"#
|
||||
),
|
||||
};
|
||||
let (st, _, text) = self
|
||||
.request(
|
||||
"REPORT",
|
||||
collection,
|
||||
Some("1"),
|
||||
Some(body),
|
||||
"application/xml; charset=utf-8",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
if st.as_u16() != 207 {
|
||||
// fallback: individual GETs
|
||||
let mut out = Vec::new();
|
||||
for h in hrefs {
|
||||
if let Ok(r) = self.get(h).await {
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let doc = xmltree::parse(text.as_bytes())?;
|
||||
let mut out = Vec::new();
|
||||
for resp in doc.children_named("response") {
|
||||
let data = resp
|
||||
.text_of("calendar-data")
|
||||
.or_else(|| resp.text_of("address-data"))
|
||||
.filter(|s| !s.is_empty());
|
||||
if data.is_none() {
|
||||
continue;
|
||||
}
|
||||
out.push(Resource {
|
||||
href: resp.text_of("href").unwrap_or_default(),
|
||||
etag: resp.text_of("getetag").unwrap_or_default(),
|
||||
data,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn get(&self, href: &str) -> Result<Resource> {
|
||||
let (st, headers, text) = self.request("GET", href, None, None, "", &[]).await?;
|
||||
if st == StatusCode::NOT_FOUND {
|
||||
bail!("not found");
|
||||
}
|
||||
if !st.is_success() {
|
||||
bail!("GET {href} returned {st}");
|
||||
}
|
||||
Ok(Resource {
|
||||
href: href.to_string(),
|
||||
etag: headers
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
data: Some(text),
|
||||
})
|
||||
}
|
||||
|
||||
/// PUT a resource. `if_match` = Some(etag) for updates, None for create (If-None-Match: *).
|
||||
pub async fn put(
|
||||
&self,
|
||||
href: &str,
|
||||
body: String,
|
||||
content_type: &str,
|
||||
if_match: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let extra = match if_match {
|
||||
Some(e) if !e.is_empty() => vec![("If-Match", e.to_string())],
|
||||
Some(_) => vec![],
|
||||
None => vec![("If-None-Match", "*".to_string())],
|
||||
};
|
||||
let (st, headers, text) = self
|
||||
.request("PUT", href, None, Some(body), content_type, &extra)
|
||||
.await?;
|
||||
if st == StatusCode::PRECONDITION_FAILED {
|
||||
bail!("conflict: resource changed on server");
|
||||
}
|
||||
if !st.is_success() {
|
||||
bail!(
|
||||
"PUT {href} returned {st}: {}",
|
||||
crate::util::truncate_bytes(&text, 200)
|
||||
);
|
||||
}
|
||||
let etag = headers
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
match etag {
|
||||
Some(e) => Ok(e),
|
||||
None => Ok(self.get(href).await.map(|r| r.etag).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete(&self, href: &str, if_match: Option<&str>) -> Result<()> {
|
||||
let extra = if_match
|
||||
.filter(|e| !e.is_empty())
|
||||
.map(|e| vec![("If-Match", e.to_string())])
|
||||
.unwrap_or_default();
|
||||
let (st, _, _) = self.request("DELETE", href, None, None, "", &extra).await?;
|
||||
if st.is_success() || st == StatusCode::NOT_FOUND {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("DELETE {href} returned {st}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn make_collection(
|
||||
&self,
|
||||
href: &str,
|
||||
name: &str,
|
||||
kind: DavKind,
|
||||
todo: bool,
|
||||
) -> Result<()> {
|
||||
let n = xml_escape(name);
|
||||
let (method, body) = match kind {
|
||||
DavKind::Calendar => (
|
||||
"MKCALENDAR",
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?><c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:set><d:prop><d:displayname>{n}</d:displayname><c:supported-calendar-component-set><c:comp name="{}"/></c:supported-calendar-component-set></d:prop></d:set></c:mkcalendar>"#,
|
||||
if todo { "VTODO" } else { "VEVENT" }
|
||||
),
|
||||
),
|
||||
DavKind::AddressBook => (
|
||||
"MKCOL",
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?><d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:set><d:prop><d:resourcetype><d:collection/><c:addressbook/></d:resourcetype><d:displayname>{n}</d:displayname></d:prop></d:set></d:mkcol>"#
|
||||
),
|
||||
),
|
||||
};
|
||||
let (st, _, text) = self
|
||||
.request(
|
||||
method,
|
||||
href,
|
||||
None,
|
||||
Some(body),
|
||||
"application/xml; charset=utf-8",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
if !st.is_success() {
|
||||
bail!(
|
||||
"{method} {href} returned {st}: {}",
|
||||
crate::util::truncate_bytes(&text, 200)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rename(&self, href: &str, name: &str) -> Result<()> {
|
||||
let body = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?><d:propertyupdate xmlns:d="DAV:"><d:set><d:prop><d:displayname>{}</d:displayname></d:prop></d:set></d:propertyupdate>"#,
|
||||
xml_escape(name)
|
||||
);
|
||||
let (st, _, _) = self
|
||||
.request(
|
||||
"PROPPATCH",
|
||||
href,
|
||||
None,
|
||||
Some(body),
|
||||
"application/xml; charset=utf-8",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
if !(st.is_success() || st.as_u16() == 207) {
|
||||
bail!("PROPPATCH {href} returned {st}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// addressbook-query text match on FN / EMAIL (GAL search).
|
||||
pub async fn search_cards(&self, href: &str, text: &str) -> Result<Vec<Resource>> {
|
||||
let t = xml_escape(text);
|
||||
let body = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<c:addressbook-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
|
||||
<d:prop><d:getetag/><c:address-data/></d:prop>
|
||||
<c:filter test="anyof">
|
||||
<c:prop-filter name="FN"><c:text-match collation="i;unicode-casemap" match-type="contains">{t}</c:text-match></c:prop-filter>
|
||||
<c:prop-filter name="EMAIL"><c:text-match collation="i;unicode-casemap" match-type="contains">{t}</c:text-match></c:prop-filter>
|
||||
<c:prop-filter name="N"><c:text-match collation="i;unicode-casemap" match-type="contains">{t}</c:text-match></c:prop-filter>
|
||||
</c:filter>
|
||||
</c:addressbook-query>"#
|
||||
);
|
||||
let (st, _, resp) = self
|
||||
.request(
|
||||
"REPORT",
|
||||
href,
|
||||
Some("1"),
|
||||
Some(body),
|
||||
"application/xml; charset=utf-8",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
if st.as_u16() != 207 {
|
||||
bail!("addressbook-query returned {st}");
|
||||
}
|
||||
let doc = xmltree::parse(resp.as_bytes())?;
|
||||
let mut out = Vec::new();
|
||||
for r in doc.children_named("response") {
|
||||
if let Some(d) = r.text_of("address-data").filter(|s| !s.is_empty()) {
|
||||
out.push(Resource {
|
||||
href: r.text_of("href").unwrap_or_default(),
|
||||
etag: r.text_of("getetag").unwrap_or_default(),
|
||||
data: Some(d),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_dir(h: &str) -> String {
|
||||
let d = percent_decode_str(h).decode_utf8_lossy().into_owned();
|
||||
if d.ends_with('/') {
|
||||
d
|
||||
} else {
|
||||
format!("{d}/")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn last_segment(h: &str) -> String {
|
||||
let d = percent_decode_str(h.trim_end_matches('/'))
|
||||
.decode_utf8_lossy()
|
||||
.into_owned();
|
||||
d.rsplit('/').next().unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
/// Re-encode a decoded collection path for use in URLs.
|
||||
pub fn encode_path(path: &str) -> String {
|
||||
path.split('/')
|
||||
.map(encode_segment)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn paths() {
|
||||
assert_eq!(normalize_dir("/a/b%20c"), "/a/b c/");
|
||||
assert_eq!(last_segment("/a/b/x%40y.ics"), "x@y.ics");
|
||||
assert_eq!(encode_path("/a b/c@d/"), "/a%20b/c@d/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Generic iCalendar (RFC 5545) / vCard (RFC 6350, 2.1/3.0 tolerant) content-line model.
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Property {
|
||||
pub name: String,
|
||||
pub params: Vec<(String, String)>,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl Property {
|
||||
pub fn new(name: &str, value: impl Into<String>) -> Self {
|
||||
Property {
|
||||
name: name.to_ascii_uppercase(),
|
||||
params: vec![],
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(name: &str, value: &str) -> Self {
|
||||
Property::new(name, escape_text(value))
|
||||
}
|
||||
|
||||
pub fn with_param(mut self, k: &str, v: &str) -> Self {
|
||||
self.params.push((k.to_ascii_uppercase(), v.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn param(&self, k: &str) -> Option<&str> {
|
||||
self.params
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(k))
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// All TYPE values (handles `TYPE=a,b`, repeated TYPE and vCard 2.1 bare params).
|
||||
pub fn types(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for (k, v) in &self.params {
|
||||
if k.eq_ignore_ascii_case("TYPE") {
|
||||
out.extend(v.split(',').map(|s| s.trim().to_ascii_lowercase()));
|
||||
} else if v.is_empty() {
|
||||
out.push(k.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn text_value(&self) -> String {
|
||||
unescape_text(&self.value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Component {
|
||||
pub name: String,
|
||||
pub props: Vec<Property>,
|
||||
pub children: Vec<Component>,
|
||||
}
|
||||
|
||||
impl Component {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Component {
|
||||
name: name.to_ascii_uppercase(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prop(&self, name: &str) -> Option<&Property> {
|
||||
self.props
|
||||
.iter()
|
||||
.find(|p| p.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
pub fn props_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Property> + 'a {
|
||||
self.props
|
||||
.iter()
|
||||
.filter(move |p| p.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
pub fn text(&self, name: &str) -> Option<String> {
|
||||
self.prop(name)
|
||||
.map(|p| p.text_value())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, name: &str) {
|
||||
self.props.retain(|p| !p.name.eq_ignore_ascii_case(name));
|
||||
}
|
||||
|
||||
pub fn set(&mut self, p: Property) {
|
||||
self.remove(&p.name.clone());
|
||||
self.props.push(p);
|
||||
}
|
||||
|
||||
pub fn add(&mut self, p: Property) {
|
||||
self.props.push(p);
|
||||
}
|
||||
|
||||
pub fn child(&self, name: &str) -> Option<&Component> {
|
||||
self.children
|
||||
.iter()
|
||||
.find(|c| c.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Component> + 'a {
|
||||
self.children
|
||||
.iter()
|
||||
.filter(move |c| c.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut out = String::new();
|
||||
self.write(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn write(&self, out: &mut String) {
|
||||
fold_line(out, &format!("BEGIN:{}", self.name));
|
||||
for p in &self.props {
|
||||
let mut line = p.name.clone();
|
||||
for (k, v) in &p.params {
|
||||
line.push(';');
|
||||
line.push_str(k);
|
||||
line.push('=');
|
||||
if v.contains([':', ';', ',']) && !v.starts_with('"') {
|
||||
line.push('"');
|
||||
line.push_str(&v.replace('"', "'"));
|
||||
line.push('"');
|
||||
} else {
|
||||
line.push_str(v);
|
||||
}
|
||||
}
|
||||
line.push(':');
|
||||
line.push_str(&p.value);
|
||||
fold_line(out, &line);
|
||||
}
|
||||
for c in &self.children {
|
||||
c.write(out);
|
||||
}
|
||||
fold_line(out, &format!("END:{}", self.name));
|
||||
}
|
||||
}
|
||||
|
||||
fn fold_line(out: &mut String, line: &str) {
|
||||
// fold at 75 octets on char boundaries
|
||||
let line = line.replace(['\r', '\n'], "");
|
||||
let mut count = 0;
|
||||
for c in line.chars() {
|
||||
let l = c.len_utf8();
|
||||
if count + l > 75 {
|
||||
out.push_str("\r\n ");
|
||||
count = 1;
|
||||
}
|
||||
out.push(c);
|
||||
count += l;
|
||||
}
|
||||
out.push_str("\r\n");
|
||||
}
|
||||
|
||||
/// Parse an iCalendar/vCard stream. Returns the top-level components.
|
||||
pub fn parse(input: &str) -> Vec<Component> {
|
||||
// unfold
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for raw in input.split('\n') {
|
||||
let raw = raw.strip_suffix('\r').unwrap_or(raw);
|
||||
if (raw.starts_with(' ') || raw.starts_with('\t')) && !lines.is_empty() {
|
||||
lines.last_mut().unwrap().push_str(&raw[1..]);
|
||||
} else if !raw.is_empty() {
|
||||
lines.push(raw.to_string());
|
||||
}
|
||||
}
|
||||
// vCard 2.1 quoted-printable soft line breaks
|
||||
let mut merged: Vec<String> = Vec::new();
|
||||
for l in lines {
|
||||
if let Some(last) = merged.last_mut() {
|
||||
if last.to_ascii_uppercase().contains("QUOTED-PRINTABLE") && last.ends_with('=') {
|
||||
last.pop();
|
||||
last.push_str(&l);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
merged.push(l);
|
||||
}
|
||||
|
||||
let mut stack: Vec<Component> = vec![Component::new("ROOT")];
|
||||
for line in merged {
|
||||
let Some(p) = parse_line(&line) else { continue };
|
||||
if p.name == "BEGIN" {
|
||||
if stack.len() > 32 {
|
||||
break;
|
||||
}
|
||||
stack.push(Component::new(&p.value));
|
||||
} else if p.name == "END" {
|
||||
if stack.len() > 1 {
|
||||
let c = stack.pop().unwrap();
|
||||
stack.last_mut().unwrap().children.push(c);
|
||||
}
|
||||
} else if let Some(top) = stack.last_mut() {
|
||||
if top.props.len() < 10_000 {
|
||||
top.props.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
while stack.len() > 1 {
|
||||
let c = stack.pop().unwrap();
|
||||
stack.last_mut().unwrap().children.push(c);
|
||||
}
|
||||
stack.pop().map(|r| r.children).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn parse_line(line: &str) -> Option<Property> {
|
||||
// find the first ':' not inside quotes
|
||||
let mut in_q = false;
|
||||
let mut colon = None;
|
||||
for (i, c) in line.char_indices() {
|
||||
match c {
|
||||
'"' => in_q = !in_q,
|
||||
':' if !in_q => {
|
||||
colon = Some(i);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let colon = colon?;
|
||||
let head = &line[..colon];
|
||||
let mut value = line[colon + 1..].to_string();
|
||||
let mut parts = split_unquoted(head, ';').into_iter();
|
||||
let mut name = parts.next()?.trim().to_ascii_uppercase();
|
||||
// strip vCard group prefix (item1.EMAIL)
|
||||
if let Some((_, n)) = name.split_once('.') {
|
||||
name = n.to_string();
|
||||
}
|
||||
let mut params = Vec::new();
|
||||
for p in parts {
|
||||
match p.split_once('=') {
|
||||
Some((k, v)) => params.push((
|
||||
k.trim().to_ascii_uppercase(),
|
||||
v.trim().trim_matches('"').to_string(),
|
||||
)),
|
||||
None => params.push((p.trim().to_ascii_uppercase(), String::new())),
|
||||
}
|
||||
}
|
||||
let qp = params.iter().any(|(k, v)| {
|
||||
(k == "ENCODING" && v.eq_ignore_ascii_case("QUOTED-PRINTABLE")) || k == "QUOTED-PRINTABLE"
|
||||
});
|
||||
if qp {
|
||||
value = decode_qp(&value);
|
||||
params.retain(|(k, v)| {
|
||||
!(k == "ENCODING" && v.eq_ignore_ascii_case("QUOTED-PRINTABLE"))
|
||||
&& k != "QUOTED-PRINTABLE"
|
||||
});
|
||||
}
|
||||
Some(Property {
|
||||
name,
|
||||
params,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
fn split_unquoted(s: &str, sep: char) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut in_q = false;
|
||||
for c in s.chars() {
|
||||
if c == '"' {
|
||||
in_q = !in_q;
|
||||
}
|
||||
if c == sep && !in_q {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
} else {
|
||||
cur.push(c);
|
||||
}
|
||||
}
|
||||
out.push(cur);
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_qp(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'=' && i + 2 < b.len() + 1 {
|
||||
if let (Some(h), Some(l)) = (b.get(i + 1), b.get(i + 2)) {
|
||||
if let Ok(v) = u8::from_str_radix(&format!("{}{}", *h as char, *l as char), 16) {
|
||||
out.push(v);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(b[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
pub fn escape_text(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
';' => out.push_str("\\;"),
|
||||
',' => out.push_str("\\,"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => {}
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn unescape_text(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut it = s.chars();
|
||||
while let Some(c) = it.next() {
|
||||
if c == '\\' {
|
||||
match it.next() {
|
||||
Some('n') | Some('N') => out.push('\n'),
|
||||
Some(o) => out.push(o),
|
||||
None => {}
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a structured value (N, ADR) on unescaped `;`.
|
||||
pub fn split_structured(s: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut esc = false;
|
||||
for c in s.chars() {
|
||||
if esc {
|
||||
cur.push('\\');
|
||||
cur.push(c);
|
||||
esc = false;
|
||||
} else if c == '\\' {
|
||||
esc = true;
|
||||
} else if c == ';' {
|
||||
out.push(unescape_text(&std::mem::take(&mut cur)));
|
||||
} else {
|
||||
cur.push(c);
|
||||
}
|
||||
}
|
||||
out.push(unescape_text(&cur));
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a list value (CATEGORIES) on unescaped `,`.
|
||||
pub fn split_list(s: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut esc = false;
|
||||
for c in s.chars() {
|
||||
if esc {
|
||||
cur.push('\\');
|
||||
cur.push(c);
|
||||
esc = false;
|
||||
} else if c == '\\' {
|
||||
esc = true;
|
||||
} else if c == ',' {
|
||||
out.push(unescape_text(&std::mem::take(&mut cur)));
|
||||
} else {
|
||||
cur.push(c);
|
||||
}
|
||||
}
|
||||
out.push(unescape_text(&cur));
|
||||
out.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_and_roundtrip() {
|
||||
let src = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:abc\r\nSUMMARY:Spotkanie\\, ważne\r\nDESCRIPTION:Linia 1\\nLinia 2 bardzo długa linia, która musi zostać zawinięta bo przekracza 75 bajtów w UTF-8 ąęś\r\nDTSTART;TZID=Europe/Warsaw:20261105T100000\r\nATTENDEE;CN=\"Kowalski, Jan\";PARTSTAT=ACCEPTED:mailto:jan@x.pl\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
let c = parse(src);
|
||||
assert_eq!(c.len(), 1);
|
||||
let ev = c[0].child("VEVENT").unwrap();
|
||||
assert_eq!(ev.text("SUMMARY").unwrap(), "Spotkanie, ważne");
|
||||
assert_eq!(
|
||||
ev.prop("DTSTART").unwrap().param("TZID"),
|
||||
Some("Europe/Warsaw")
|
||||
);
|
||||
assert_eq!(
|
||||
ev.prop("ATTENDEE").unwrap().param("CN"),
|
||||
Some("Kowalski, Jan")
|
||||
);
|
||||
let again = parse(&c[0].serialize());
|
||||
assert_eq!(again[0], c[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vcard21() {
|
||||
let src = "BEGIN:VCARD\nVERSION:2.1\nitem1.TEL;CELL;PREF:+48 600\nN;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:Kowalsk=C4=85;Anna;;;\nEND:VCARD\n";
|
||||
let c = parse(src);
|
||||
let tel = c[0].prop("TEL").unwrap();
|
||||
assert!(tel.types().contains(&"cell".to_string()));
|
||||
assert_eq!(
|
||||
split_structured(&c[0].prop("N").unwrap().value)[0],
|
||||
"Kowalską"
|
||||
);
|
||||
}
|
||||
}
|
||||
+1072
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,247 @@
|
||||
//! Small MIME builder used for SmartReply / SmartForward composition.
|
||||
|
||||
use base64::Engine;
|
||||
use rand::Rng;
|
||||
|
||||
pub struct Attachment {
|
||||
pub name: String,
|
||||
pub content_type: String,
|
||||
pub data: Vec<u8>,
|
||||
pub content_id: Option<String>,
|
||||
pub inline: bool,
|
||||
}
|
||||
|
||||
fn boundary() -> String {
|
||||
let n: u64 = rand::thread_rng().gen();
|
||||
format!("=_ap_{n:016x}")
|
||||
}
|
||||
|
||||
fn b64_lines(data: &[u8]) -> String {
|
||||
let enc = base64::engine::general_purpose::STANDARD.encode(data);
|
||||
let mut out = String::with_capacity(enc.len() + enc.len() / 76 * 2 + 2);
|
||||
for chunk in enc.as_bytes().chunks(76) {
|
||||
out.push_str(std::str::from_utf8(chunk).unwrap_or(""));
|
||||
out.push_str("\r\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// RFC 2047 encoded-word for header values / filenames when needed.
|
||||
pub fn encode_word(s: &str) -> String {
|
||||
if s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) && !s.contains("=?") {
|
||||
return s.to_string();
|
||||
}
|
||||
format!(
|
||||
"=?UTF-8?B?{}?=",
|
||||
base64::engine::general_purpose::STANDARD.encode(s.as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
fn quote_param(s: &str) -> String {
|
||||
let e = encode_word(s);
|
||||
format!("\"{}\"", e.replace(['"', '\\', '\r', '\n'], "_"))
|
||||
}
|
||||
|
||||
/// Build a complete message from header lines (already folded/encoded), bodies and attachments.
|
||||
pub fn build(
|
||||
headers: &[(String, String)],
|
||||
text: Option<&str>,
|
||||
html: Option<&str>,
|
||||
atts: &[Attachment],
|
||||
) -> Vec<u8> {
|
||||
let mut out = String::new();
|
||||
for (k, v) in headers {
|
||||
let v = v.replace(['\r', '\n'], " ");
|
||||
out.push_str(&format!("{k}: {v}\r\n"));
|
||||
}
|
||||
out.push_str("MIME-Version: 1.0\r\n");
|
||||
|
||||
let text_part = |t: &str| {
|
||||
format!(
|
||||
"Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{}",
|
||||
b64_lines(t.replace("\r\n", "\n").replace('\n', "\r\n").as_bytes())
|
||||
)
|
||||
};
|
||||
let html_part = |h: &str| {
|
||||
format!(
|
||||
"Content-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{}",
|
||||
b64_lines(h.as_bytes())
|
||||
)
|
||||
};
|
||||
let body = match (text, html) {
|
||||
(Some(t), Some(h)) => {
|
||||
let b = boundary();
|
||||
format!(
|
||||
"Content-Type: multipart/alternative; boundary=\"{b}\"\r\n\r\n--{b}\r\n{}--{b}\r\n{}--{b}--\r\n",
|
||||
text_part(t),
|
||||
html_part(h)
|
||||
)
|
||||
}
|
||||
(Some(t), None) => text_part(t),
|
||||
(None, Some(h)) => html_part(h),
|
||||
(None, None) => text_part(""),
|
||||
};
|
||||
if atts.is_empty() {
|
||||
out.push_str(&body);
|
||||
return out.into_bytes();
|
||||
}
|
||||
let b = boundary();
|
||||
out.push_str(&format!(
|
||||
"Content-Type: multipart/mixed; boundary=\"{b}\"\r\n\r\nThis is a multi-part message in MIME format.\r\n--{b}\r\n{body}"
|
||||
));
|
||||
for a in atts {
|
||||
let ct = if a.content_type.contains('/') {
|
||||
a.content_type.clone()
|
||||
} else {
|
||||
"application/octet-stream".into()
|
||||
};
|
||||
out.push_str(&format!("\r\n--{b}\r\n"));
|
||||
if ct.eq_ignore_ascii_case("message/rfc822") {
|
||||
out.push_str(&format!(
|
||||
"Content-Type: message/rfc822; name={}\r\nContent-Disposition: attachment; filename={}\r\n\r\n",
|
||||
quote_param(&a.name),
|
||||
quote_param(&a.name)
|
||||
));
|
||||
out.push_str(&String::from_utf8_lossy(&a.data));
|
||||
continue;
|
||||
}
|
||||
out.push_str(&format!(
|
||||
"Content-Type: {ct}; name={}\r\n",
|
||||
quote_param(&a.name)
|
||||
));
|
||||
out.push_str("Content-Transfer-Encoding: base64\r\n");
|
||||
if let Some(cid) = &a.content_id {
|
||||
out.push_str(&format!(
|
||||
"Content-ID: <{}>\r\n",
|
||||
cid.trim_matches(['<', '>'])
|
||||
));
|
||||
}
|
||||
out.push_str(&format!(
|
||||
"Content-Disposition: {}; filename={}\r\n\r\n",
|
||||
if a.inline { "inline" } else { "attachment" },
|
||||
quote_param(&a.name)
|
||||
));
|
||||
out.push_str(&b64_lines(&a.data));
|
||||
}
|
||||
out.push_str(&format!("--{b}--\r\n"));
|
||||
out.into_bytes()
|
||||
}
|
||||
|
||||
/// Split a raw message into (header block, body) at the first empty line.
|
||||
pub fn split_headers(raw: &[u8]) -> (&[u8], &[u8]) {
|
||||
for (i, w) in raw.windows(4).enumerate() {
|
||||
if w == b"\r\n\r\n" {
|
||||
return (&raw[..i + 2], &raw[i + 4..]);
|
||||
}
|
||||
}
|
||||
for (i, w) in raw.windows(2).enumerate() {
|
||||
if w == b"\n\n" {
|
||||
return (&raw[..i + 1], &raw[i + 2..]);
|
||||
}
|
||||
}
|
||||
(raw, &[])
|
||||
}
|
||||
|
||||
/// Unfolded raw header lines (name, value).
|
||||
pub fn header_lines(block: &[u8]) -> Vec<(String, String)> {
|
||||
let text = String::from_utf8_lossy(block);
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for line in text.split('\n') {
|
||||
let line = line.trim_end_matches('\r');
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if line.starts_with([' ', '\t']) {
|
||||
if let Some(last) = out.last_mut() {
|
||||
last.1.push(' ');
|
||||
last.1.push_str(line.trim());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once(':') {
|
||||
out.push((k.trim().to_string(), v.trim().to_string()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Remove headers (case-insensitive) from a raw message, keeping the body untouched.
|
||||
pub fn strip_headers(raw: &[u8], names: &[&str]) -> Vec<u8> {
|
||||
let (h, body) = split_headers(raw);
|
||||
let text = String::from_utf8_lossy(h);
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
let mut skipping = false;
|
||||
for line in text.split_inclusive('\n') {
|
||||
if line.starts_with([' ', '\t']) {
|
||||
if !skipping {
|
||||
out.push_str(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let name = line.split(':').next().unwrap_or("").trim();
|
||||
skipping = names.iter().any(|n| n.eq_ignore_ascii_case(name));
|
||||
if !skipping {
|
||||
out.push_str(line);
|
||||
}
|
||||
}
|
||||
let mut v = out.into_bytes();
|
||||
if !v.ends_with(b"\n") {
|
||||
v.extend_from_slice(b"\r\n");
|
||||
}
|
||||
v.extend_from_slice(b"\r\n");
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
|
||||
/// Prepend header lines to a raw message.
|
||||
pub fn prepend_headers(raw: &[u8], headers: &[(String, String)]) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(raw.len() + 256);
|
||||
for (k, val) in headers {
|
||||
v.extend_from_slice(format!("{k}: {}\r\n", val.replace(['\r', '\n'], " ")).as_bytes());
|
||||
}
|
||||
v.extend_from_slice(raw);
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_and_parse() {
|
||||
let raw = build(
|
||||
&[
|
||||
("Subject".into(), encode_word("Zażółć")),
|
||||
("To".into(), "a@b.pl".into()),
|
||||
],
|
||||
Some("tekst"),
|
||||
Some("<p>tekst</p>"),
|
||||
&[Attachment {
|
||||
name: "plik ą.txt".into(),
|
||||
content_type: "text/plain".into(),
|
||||
data: b"abc".to_vec(),
|
||||
content_id: None,
|
||||
inline: false,
|
||||
}],
|
||||
);
|
||||
let m = mail_parser::MessageParser::default().parse(&raw).unwrap();
|
||||
assert_eq!(m.subject(), Some("Zażółć"));
|
||||
assert_eq!(m.body_text(0).unwrap(), "tekst");
|
||||
assert_eq!(m.attachment_count(), 1);
|
||||
use mail_parser::MimeHeaders;
|
||||
assert_eq!(
|
||||
m.attachment(0).unwrap().attachment_name(),
|
||||
Some("plik ą.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip() {
|
||||
let raw = b"From: a\r\nBcc: x@y,\r\n z@w\r\nSubject: s\r\n\r\nbody";
|
||||
let s = strip_headers(raw, &["bcc"]);
|
||||
assert_eq!(
|
||||
String::from_utf8(s).unwrap(),
|
||||
"From: a\r\nSubject: s\r\n\r\nbody"
|
||||
);
|
||||
}
|
||||
}
|
||||
+1174
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,145 @@
|
||||
//! Outgoing mail: SMTP submission (lettre) or local sendmail binary.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use lettre::address::Envelope;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
use lettre::{Address, AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::config::{SendMethod, SmtpAuth, SmtpSettings, TlsMode};
|
||||
|
||||
pub async fn send(
|
||||
cfg: &SmtpSettings,
|
||||
login: &str,
|
||||
password: &str,
|
||||
from: &str,
|
||||
recipients: &[String],
|
||||
raw: &[u8],
|
||||
) -> Result<()> {
|
||||
if recipients.is_empty() {
|
||||
bail!("message has no recipients");
|
||||
}
|
||||
if raw.len() as u64 > cfg.max_message_mb * 1024 * 1024 {
|
||||
bail!("message too large");
|
||||
}
|
||||
let from_addr: Address = from
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("invalid sender address {from}"))?;
|
||||
let mut to = Vec::with_capacity(recipients.len());
|
||||
for r in recipients {
|
||||
to.push(
|
||||
r.parse::<Address>()
|
||||
.map_err(|_| anyhow!("invalid recipient address {r}"))?,
|
||||
);
|
||||
}
|
||||
match cfg.method {
|
||||
SendMethod::Smtp => {
|
||||
let tls = |host: &str| -> Result<TlsParameters> {
|
||||
Ok(TlsParameters::builder(host.to_string())
|
||||
.dangerous_accept_invalid_certs(!cfg.verify_tls)
|
||||
.dangerous_accept_invalid_hostnames(!cfg.verify_tls)
|
||||
.build_rustls()?)
|
||||
};
|
||||
let mut b = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host)
|
||||
.port(cfg.port)
|
||||
.timeout(Some(Duration::from_secs(cfg.timeout_secs.max(5))));
|
||||
b = match cfg.security {
|
||||
TlsMode::None => b.tls(Tls::None),
|
||||
TlsMode::StartTls => b.tls(Tls::Required(tls(&cfg.host)?)),
|
||||
TlsMode::Tls => b.tls(Tls::Wrapper(tls(&cfg.host)?)),
|
||||
};
|
||||
b = match cfg.auth {
|
||||
SmtpAuth::None => b,
|
||||
SmtpAuth::UserCredentials => {
|
||||
b.credentials(Credentials::new(login.to_string(), password.to_string()))
|
||||
}
|
||||
SmtpAuth::Static => {
|
||||
b.credentials(Credentials::new(cfg.username.clone(), cfg.password.clone()))
|
||||
}
|
||||
};
|
||||
let transport = b.build();
|
||||
let env = Envelope::new(Some(from_addr), to).context("invalid envelope")?;
|
||||
transport
|
||||
.send_raw(&env, raw)
|
||||
.await
|
||||
.map_err(|e| anyhow!("SMTP: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
SendMethod::Sendmail => {
|
||||
let mut cmd = tokio::process::Command::new(&cfg.sendmail_path);
|
||||
cmd.arg("-i").arg("-f").arg(&from_addr).arg("--");
|
||||
for t in &to {
|
||||
cmd.arg(t);
|
||||
}
|
||||
cmd.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child = cmd.spawn().context("spawn sendmail")?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(raw).await?;
|
||||
stdin.shutdown().await.ok();
|
||||
}
|
||||
let out = tokio::time::timeout(
|
||||
Duration::from_secs(cfg.timeout_secs.max(5)),
|
||||
child.wait_with_output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow!("sendmail timeout"))??;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"sendmail failed ({}): {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check connectivity (and optionally authentication) for the panel "test" button.
|
||||
pub async fn test(cfg: &SmtpSettings, login: &str, password: &str) -> Result<String> {
|
||||
match cfg.method {
|
||||
SendMethod::Sendmail => {
|
||||
if std::path::Path::new(&cfg.sendmail_path).exists() {
|
||||
Ok(format!("{} exists", cfg.sendmail_path))
|
||||
} else {
|
||||
bail!("{} not found", cfg.sendmail_path)
|
||||
}
|
||||
}
|
||||
SendMethod::Smtp => {
|
||||
let tls = TlsParameters::builder(cfg.host.clone())
|
||||
.dangerous_accept_invalid_certs(!cfg.verify_tls)
|
||||
.dangerous_accept_invalid_hostnames(!cfg.verify_tls)
|
||||
.build_rustls()?;
|
||||
let mut b = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host)
|
||||
.port(cfg.port)
|
||||
.timeout(Some(Duration::from_secs(10)));
|
||||
b = match cfg.security {
|
||||
TlsMode::None => b.tls(Tls::None),
|
||||
TlsMode::StartTls => b.tls(Tls::Required(tls)),
|
||||
TlsMode::Tls => b.tls(Tls::Wrapper(tls)),
|
||||
};
|
||||
match cfg.auth {
|
||||
SmtpAuth::UserCredentials if !login.is_empty() => {
|
||||
b = b.credentials(Credentials::new(login.into(), password.into()))
|
||||
}
|
||||
SmtpAuth::Static => {
|
||||
b = b.credentials(Credentials::new(cfg.username.clone(), cfg.password.clone()))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let t: AsyncSmtpTransport<Tokio1Executor> = b.build();
|
||||
let ok = t.test_connection().await.map_err(|e| anyhow!("{e}"))?;
|
||||
if ok {
|
||||
Ok(format!("SMTP {}:{} OK", cfg.host, cfg.port))
|
||||
} else {
|
||||
bail!("SMTP connection test failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
//! Time zone helpers: EAS TIME_ZONE_INFORMATION blobs <-> IANA zones, date formats, iCal dates.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use base64::Engine;
|
||||
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, Offset, TimeZone, Timelike, Utc};
|
||||
use chrono_tz::Tz;
|
||||
|
||||
use super::ical::{Component, Property};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct SysTime {
|
||||
pub year: u16,
|
||||
pub month: u16,
|
||||
pub dow: u16,
|
||||
pub day: u16,
|
||||
pub hour: u16,
|
||||
pub minute: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct TzRule {
|
||||
pub bias: i32,
|
||||
pub std_date: SysTime,
|
||||
pub std_bias: i32,
|
||||
pub dst_date: SysTime,
|
||||
pub dst_bias: i32,
|
||||
}
|
||||
|
||||
fn offset_min(tz: &Tz, t: DateTime<Utc>) -> i32 {
|
||||
t.with_timezone(tz).offset().fix().local_minus_utc() / 60
|
||||
}
|
||||
|
||||
fn sys_from_local(local: NaiveDateTime) -> SysTime {
|
||||
let day = local.day();
|
||||
let dim = days_in_month(local.year(), local.month());
|
||||
let week = if day + 7 > dim { 5 } else { (day - 1) / 7 + 1 };
|
||||
SysTime {
|
||||
year: 0,
|
||||
month: local.month() as u16,
|
||||
dow: local.weekday().num_days_from_sunday() as u16,
|
||||
day: week as u16,
|
||||
hour: local.hour() as u16,
|
||||
minute: local.minute() as u16,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn days_in_month(y: i32, m: u32) -> u32 {
|
||||
let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
|
||||
NaiveDate::from_ymd_opt(ny, nm, 1)
|
||||
.and_then(|d| d.pred_opt())
|
||||
.map(|d| d.day())
|
||||
.unwrap_or(30)
|
||||
}
|
||||
|
||||
/// Compute the Windows-style rule for a zone in a given year.
|
||||
pub fn rule_for(tz: &Tz, year: i32) -> TzRule {
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, i32), TzRule>>> = OnceLock::new();
|
||||
let key = (tz.name().to_string(), year);
|
||||
if let Some(r) = CACHE
|
||||
.get_or_init(Default::default)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&key)
|
||||
{
|
||||
return *r;
|
||||
}
|
||||
let start = Utc.with_ymd_and_hms(year, 1, 1, 0, 0, 0).unwrap();
|
||||
let mut transitions = Vec::new();
|
||||
let mut prev = offset_min(tz, start);
|
||||
let mut min_off = prev;
|
||||
let mut max_off = prev;
|
||||
let mut t = start;
|
||||
// hourly scan, then refine to the minute
|
||||
for _ in 0..(366 * 24) {
|
||||
let n = t + chrono::Duration::hours(1);
|
||||
let o = offset_min(tz, n);
|
||||
if o != prev {
|
||||
let mut lo = t;
|
||||
let mut hi = n;
|
||||
while (hi - lo).num_minutes() > 1 {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if offset_min(tz, mid) == prev {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
transitions.push((hi, prev, o));
|
||||
prev = o;
|
||||
}
|
||||
min_off = min_off.min(o);
|
||||
max_off = max_off.max(o);
|
||||
t = n;
|
||||
if t.year() > year {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut rule = TzRule {
|
||||
bias: -min_off,
|
||||
..Default::default()
|
||||
};
|
||||
if max_off != min_off && transitions.len() >= 2 {
|
||||
rule.dst_bias = -(max_off - min_off);
|
||||
for (instant, from, to) in transitions {
|
||||
if to > from {
|
||||
// into daylight: expressed in local standard time
|
||||
let local = (instant + chrono::Duration::minutes(from as i64)).naive_utc();
|
||||
rule.dst_date = sys_from_local(local);
|
||||
} else {
|
||||
// into standard: expressed in local daylight time
|
||||
let local = (instant + chrono::Duration::minutes(from as i64)).naive_utc();
|
||||
rule.std_date = sys_from_local(local);
|
||||
}
|
||||
}
|
||||
}
|
||||
CACHE
|
||||
.get_or_init(Default::default)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key, rule);
|
||||
rule
|
||||
}
|
||||
|
||||
fn write_sys(out: &mut Vec<u8>, s: &SysTime) {
|
||||
for v in [s.year, s.month, s.dow, s.day, s.hour, s.minute, 0, 0] {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn write_name(out: &mut Vec<u8>, name: &str) {
|
||||
let mut units: Vec<u16> = name.encode_utf16().take(31).collect();
|
||||
units.resize(32, 0);
|
||||
for u in units {
|
||||
out.extend_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blob_for(tz: &Tz, year: i32) -> String {
|
||||
let r = rule_for(tz, year);
|
||||
let mut out = Vec::with_capacity(172);
|
||||
out.extend_from_slice(&r.bias.to_le_bytes());
|
||||
write_name(&mut out, tz.name());
|
||||
write_sys(&mut out, &r.std_date);
|
||||
out.extend_from_slice(&r.std_bias.to_le_bytes());
|
||||
write_name(&mut out, tz.name());
|
||||
write_sys(&mut out, &r.dst_date);
|
||||
out.extend_from_slice(&r.dst_bias.to_le_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(out)
|
||||
}
|
||||
|
||||
pub fn parse_blob(b64: &str) -> Option<TzRule> {
|
||||
let b = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64.trim())
|
||||
.ok()?;
|
||||
if b.len() < 172 {
|
||||
return None;
|
||||
}
|
||||
let i32_at = |o: usize| i32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
|
||||
let u16_at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
|
||||
let sys_at = |o: usize| SysTime {
|
||||
year: u16_at(o),
|
||||
month: u16_at(o + 2),
|
||||
dow: u16_at(o + 4),
|
||||
day: u16_at(o + 6),
|
||||
hour: u16_at(o + 8),
|
||||
minute: u16_at(o + 10),
|
||||
};
|
||||
Some(TzRule {
|
||||
bias: i32_at(0),
|
||||
std_date: sys_at(68),
|
||||
std_bias: i32_at(84),
|
||||
dst_date: sys_at(152),
|
||||
dst_bias: i32_at(168),
|
||||
})
|
||||
}
|
||||
|
||||
fn rules_match(a: &TzRule, b: &TzRule) -> bool {
|
||||
let norm = |s: &SysTime| (s.month, s.dow, s.day, s.hour);
|
||||
a.bias == b.bias
|
||||
&& (a.dst_date.month == 0) == (b.dst_date.month == 0)
|
||||
&& (a.dst_date.month == 0
|
||||
|| (norm(&a.std_date) == norm(&b.std_date)
|
||||
&& norm(&a.dst_date) == norm(&b.dst_date)
|
||||
&& a.dst_bias == b.dst_bias))
|
||||
}
|
||||
|
||||
/// Map a device-supplied blob to an IANA zone (preferring `preferred`, then common zones).
|
||||
pub fn tz_from_blob(b64: &str, preferred: Tz) -> Tz {
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, Tz), Tz>>> = OnceLock::new();
|
||||
let key = (b64.to_string(), preferred);
|
||||
if let Some(t) = CACHE
|
||||
.get_or_init(Default::default)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&key)
|
||||
{
|
||||
return *t;
|
||||
}
|
||||
let Some(rule) = parse_blob(b64) else {
|
||||
return preferred;
|
||||
};
|
||||
let year = Utc::now().year();
|
||||
const COMMON: &[&str] = &[
|
||||
"Europe/Warsaw",
|
||||
"Europe/Berlin",
|
||||
"Europe/London",
|
||||
"Europe/Kiev",
|
||||
"Europe/Moscow",
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Los_Angeles",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Shanghai",
|
||||
"Australia/Sydney",
|
||||
];
|
||||
let found = if rules_match(&rule_for(&preferred, year), &rule) {
|
||||
preferred
|
||||
} else if rule.bias == 0 && rule.dst_date.month == 0 {
|
||||
chrono_tz::UTC
|
||||
} else {
|
||||
COMMON
|
||||
.iter()
|
||||
.filter_map(|n| n.parse::<Tz>().ok())
|
||||
.chain(
|
||||
chrono_tz::TZ_VARIANTS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|z| !z.name().starts_with("Etc/") || rule.dst_date.month == 0),
|
||||
)
|
||||
.find(|z| rules_match(&rule_for(z, year), &rule))
|
||||
.unwrap_or(preferred)
|
||||
};
|
||||
{
|
||||
let mut c = CACHE.get_or_init(Default::default).lock().unwrap();
|
||||
if c.len() > 2000 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, found);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ EAS date formats
|
||||
|
||||
pub fn fmt_compact(d: &DateTime<Utc>) -> String {
|
||||
d.format("%Y%m%dT%H%M%SZ").to_string()
|
||||
}
|
||||
|
||||
pub fn fmt_iso(d: &DateTime<Utc>) -> String {
|
||||
d.format("%Y-%m-%dT%H:%M:%S.000Z").to_string()
|
||||
}
|
||||
|
||||
pub fn parse_eas_date(s: &str) -> Option<DateTime<Utc>> {
|
||||
let s = s.trim();
|
||||
for f in [
|
||||
"%Y%m%dT%H%M%SZ",
|
||||
"%Y-%m-%dT%H:%M:%S%.fZ",
|
||||
"%Y-%m-%dT%H:%M:%SZ",
|
||||
"%Y%m%dT%H%M%S",
|
||||
] {
|
||||
if let Ok(n) = NaiveDateTime::parse_from_str(s, f) {
|
||||
return Some(Utc.from_utc_datetime(&n));
|
||||
}
|
||||
}
|
||||
DateTime::parse_from_rfc3339(s)
|
||||
.ok()
|
||||
.map(|d| d.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ iCalendar dates
|
||||
|
||||
/// Resolve a TZID parameter to an IANA zone.
|
||||
pub fn resolve_tzid(tzid: &str, default: Tz) -> Tz {
|
||||
let t = tzid.trim().trim_matches('"');
|
||||
if let Ok(z) = t.parse::<Tz>() {
|
||||
return z;
|
||||
}
|
||||
// /mozilla.org/20050126_1/Europe/Warsaw, /freeassociation.sourceforge.net/Tzfile/Europe/Warsaw
|
||||
let segs: Vec<&str> = t.split('/').filter(|s| !s.is_empty()).collect();
|
||||
for n in [3usize, 2, 1] {
|
||||
if segs.len() >= n {
|
||||
if let Ok(z) = segs[segs.len() - n..].join("/").parse::<Tz>() {
|
||||
return z;
|
||||
}
|
||||
}
|
||||
}
|
||||
let windows = [
|
||||
("Central European Standard Time", "Europe/Warsaw"),
|
||||
("Central Europe Standard Time", "Europe/Budapest"),
|
||||
("W. Europe Standard Time", "Europe/Berlin"),
|
||||
("Romance Standard Time", "Europe/Paris"),
|
||||
("GMT Standard Time", "Europe/London"),
|
||||
("Greenwich Standard Time", "Atlantic/Reykjavik"),
|
||||
("E. Europe Standard Time", "Europe/Chisinau"),
|
||||
("FLE Standard Time", "Europe/Kiev"),
|
||||
("GTB Standard Time", "Europe/Bucharest"),
|
||||
("Russian Standard Time", "Europe/Moscow"),
|
||||
("Eastern Standard Time", "America/New_York"),
|
||||
("Central Standard Time", "America/Chicago"),
|
||||
("Mountain Standard Time", "America/Denver"),
|
||||
("Pacific Standard Time", "America/Los_Angeles"),
|
||||
("UTC", "UTC"),
|
||||
("Coordinated Universal Time", "UTC"),
|
||||
("China Standard Time", "Asia/Shanghai"),
|
||||
("Tokyo Standard Time", "Asia/Tokyo"),
|
||||
("India Standard Time", "Asia/Kolkata"),
|
||||
("AUS Eastern Standard Time", "Australia/Sydney"),
|
||||
];
|
||||
for (w, i) in windows {
|
||||
if t.eq_ignore_ascii_case(w) {
|
||||
return i.parse().unwrap_or(default);
|
||||
}
|
||||
}
|
||||
default
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct IcalTime {
|
||||
pub utc: DateTime<Utc>,
|
||||
pub all_day: bool,
|
||||
pub tz: Option<Tz>,
|
||||
pub date: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
pub fn parse_ical_time(p: &Property, default: Tz) -> Option<IcalTime> {
|
||||
let v = p.value.trim();
|
||||
let is_date = p
|
||||
.param("VALUE")
|
||||
.map(|x| x.eq_ignore_ascii_case("DATE"))
|
||||
.unwrap_or(false)
|
||||
|| (v.len() == 8 && v.chars().all(|c| c.is_ascii_digit()));
|
||||
if is_date {
|
||||
let d = NaiveDate::parse_from_str(&v[..8.min(v.len())], "%Y%m%d").ok()?;
|
||||
let local = d.and_hms_opt(0, 0, 0)?;
|
||||
let utc = default
|
||||
.from_local_datetime(&local)
|
||||
.earliest()
|
||||
.map(|x| x.with_timezone(&Utc))?;
|
||||
return Some(IcalTime {
|
||||
utc,
|
||||
all_day: true,
|
||||
tz: None,
|
||||
date: Some(d),
|
||||
});
|
||||
}
|
||||
if let Some(stripped) = v.strip_suffix('Z') {
|
||||
let n = NaiveDateTime::parse_from_str(stripped, "%Y%m%dT%H%M%S").ok()?;
|
||||
return Some(IcalTime {
|
||||
utc: Utc.from_utc_datetime(&n),
|
||||
all_day: false,
|
||||
tz: None,
|
||||
date: None,
|
||||
});
|
||||
}
|
||||
let n = NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%S").ok()?;
|
||||
let tz = p
|
||||
.param("TZID")
|
||||
.map(|t| resolve_tzid(t, default))
|
||||
.unwrap_or(default);
|
||||
let utc = tz
|
||||
.from_local_datetime(&n)
|
||||
.earliest()
|
||||
.or_else(|| {
|
||||
tz.from_local_datetime(&(n + chrono::Duration::hours(1)))
|
||||
.earliest()
|
||||
})
|
||||
.map(|x| x.with_timezone(&Utc))?;
|
||||
Some(IcalTime {
|
||||
utc,
|
||||
all_day: false,
|
||||
tz: Some(tz),
|
||||
date: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ical_utc(name: &str, d: &DateTime<Utc>) -> Property {
|
||||
Property::new(name, d.format("%Y%m%dT%H%M%SZ").to_string())
|
||||
}
|
||||
|
||||
pub fn ical_local(name: &str, d: &DateTime<Utc>, tz: &Tz) -> Property {
|
||||
Property::new(
|
||||
name,
|
||||
d.with_timezone(tz).format("%Y%m%dT%H%M%S").to_string(),
|
||||
)
|
||||
.with_param("TZID", tz.name())
|
||||
}
|
||||
|
||||
pub fn ical_date(name: &str, d: NaiveDate) -> Property {
|
||||
Property::new(name, d.format("%Y%m%d").to_string()).with_param("VALUE", "DATE")
|
||||
}
|
||||
|
||||
/// Build a VTIMEZONE for `tz` using the rules of the current year.
|
||||
pub fn vtimezone(tz: &Tz) -> Component {
|
||||
let mut c = Component::new("VTIMEZONE");
|
||||
c.add(Property::new("TZID", tz.name()));
|
||||
let year = Utc::now().year();
|
||||
let r = rule_for(tz, year);
|
||||
let fmt_off = |m: i32| {
|
||||
let s = if m < 0 { '-' } else { '+' };
|
||||
format!("{s}{:02}{:02}", m.abs() / 60, m.abs() % 60)
|
||||
};
|
||||
let std_off = -r.bias;
|
||||
if r.dst_date.month == 0 {
|
||||
let mut s = Component::new("STANDARD");
|
||||
s.add(Property::new("DTSTART", "19700101T000000"));
|
||||
s.add(Property::new("TZOFFSETFROM", fmt_off(std_off)));
|
||||
s.add(Property::new("TZOFFSETTO", fmt_off(std_off)));
|
||||
c.children.push(s);
|
||||
return c;
|
||||
}
|
||||
let dst_off = std_off - r.dst_bias;
|
||||
let byday = |st: &SysTime| {
|
||||
let wd = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"][st.dow as usize % 7];
|
||||
let n = if st.day >= 5 {
|
||||
"-1".to_string()
|
||||
} else {
|
||||
st.day.to_string()
|
||||
};
|
||||
format!("FREQ=YEARLY;BYMONTH={};BYDAY={n}{wd}", st.month)
|
||||
};
|
||||
let mut d = Component::new("DAYLIGHT");
|
||||
d.add(Property::new(
|
||||
"DTSTART",
|
||||
format!("19700101T{:02}{:02}00", r.dst_date.hour, r.dst_date.minute),
|
||||
));
|
||||
d.add(Property::new("RRULE", byday(&r.dst_date)));
|
||||
d.add(Property::new("TZOFFSETFROM", fmt_off(std_off)));
|
||||
d.add(Property::new("TZOFFSETTO", fmt_off(dst_off)));
|
||||
let mut s = Component::new("STANDARD");
|
||||
s.add(Property::new(
|
||||
"DTSTART",
|
||||
format!("19700101T{:02}{:02}00", r.std_date.hour, r.std_date.minute),
|
||||
));
|
||||
s.add(Property::new("RRULE", byday(&r.std_date)));
|
||||
s.add(Property::new("TZOFFSETFROM", fmt_off(dst_off)));
|
||||
s.add(Property::new("TZOFFSETTO", fmt_off(std_off)));
|
||||
c.children.push(d);
|
||||
c.children.push(s);
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn warsaw_rule() {
|
||||
let r = rule_for(&chrono_tz::Europe::Warsaw, 2026);
|
||||
assert_eq!(r.bias, -60);
|
||||
assert_eq!(r.dst_bias, -60);
|
||||
assert_eq!(
|
||||
(
|
||||
r.dst_date.month,
|
||||
r.dst_date.day,
|
||||
r.dst_date.dow,
|
||||
r.dst_date.hour
|
||||
),
|
||||
(3, 5, 0, 2)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
r.std_date.month,
|
||||
r.std_date.day,
|
||||
r.std_date.dow,
|
||||
r.std_date.hour
|
||||
),
|
||||
(10, 5, 0, 3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_roundtrip() {
|
||||
let b = blob_for(&chrono_tz::America::New_York, 2026);
|
||||
let tz = tz_from_blob(&b, chrono_tz::Europe::Warsaw);
|
||||
let r1 = rule_for(&tz, 2026);
|
||||
assert_eq!(r1, rule_for(&chrono_tz::America::New_York, 2026));
|
||||
let w = blob_for(&chrono_tz::Europe::Warsaw, 2026);
|
||||
assert_eq!(
|
||||
tz_from_blob(&w, chrono_tz::Europe::Warsaw),
|
||||
chrono_tz::Europe::Warsaw
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ical_times() {
|
||||
let p = Property::new("DTSTART", "20260704T100000")
|
||||
.with_param("TZID", "/mozilla.org/x/Europe/Warsaw");
|
||||
let t = parse_ical_time(&p, chrono_tz::UTC).unwrap();
|
||||
assert_eq!(fmt_compact(&t.utc), "20260704T080000Z");
|
||||
let d = parse_ical_time(
|
||||
&Property::new("DTSTART", "20260704").with_param("VALUE", "DATE"),
|
||||
chrono_tz::Europe::Warsaw,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(d.all_day);
|
||||
assert_eq!(fmt_compact(&d.utc), "20260703T220000Z");
|
||||
}
|
||||
}
|
||||
+764
@@ -0,0 +1,764 @@
|
||||
//! Runtime settings. Persisted as JSON in SQLite, editable from the admin panel and hot-applied.
|
||||
//! Defaults mirror Z-Push `config.php`, `backend/*/config.php` and `policies.ini`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TlsMode {
|
||||
None,
|
||||
#[default]
|
||||
StartTls,
|
||||
Tls,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoginFormat {
|
||||
/// Use the login exactly as the device sent it (after stripping a Windows `DOMAIN\`).
|
||||
#[default]
|
||||
AsGiven,
|
||||
/// Use only the local part (`user` from `user@example.com`) - Z-Push USE_FULLEMAIL_FOR_LOGIN=false.
|
||||
LocalPart,
|
||||
/// Always use a full address; append `default_domain` when missing.
|
||||
FullEmail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
#[derive(Default)]
|
||||
pub struct Settings {
|
||||
pub general: General,
|
||||
pub imap: ImapSettings,
|
||||
pub smtp: SmtpSettings,
|
||||
pub caldav: DavSettings,
|
||||
pub carddav: CardDavSettings,
|
||||
pub sync: SyncSettings,
|
||||
pub ping: PingSettings,
|
||||
pub provisioning: Provisioning,
|
||||
pub autodiscover: Autodiscover,
|
||||
pub security: Security,
|
||||
pub logging: Logging,
|
||||
pub panel: Panel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct General {
|
||||
/// Shown in the panel and used for autodiscover display name.
|
||||
pub server_name: String,
|
||||
/// IANA zone used for all-day events and recurring items when the device gives no zone.
|
||||
pub default_timezone: String,
|
||||
pub login_format: LoginFormat,
|
||||
/// Domain appended for FullEmail login and used for `{domain}` in templates.
|
||||
pub default_domain: String,
|
||||
/// If non-empty, only these domains may log in (lowercase).
|
||||
pub allowed_domains: Vec<String>,
|
||||
/// Template for the user's primary e-mail address. Placeholders: {login} {local} {domain}.
|
||||
pub email_template: String,
|
||||
/// Highest ActiveSync protocol version announced to devices.
|
||||
pub max_protocol: String,
|
||||
}
|
||||
|
||||
impl Default for General {
|
||||
fn default() -> Self {
|
||||
General {
|
||||
server_name: "ActivePush".into(),
|
||||
default_timezone: "Europe/Warsaw".into(),
|
||||
login_format: LoginFormat::AsGiven,
|
||||
default_domain: String::new(),
|
||||
allowed_domains: vec![],
|
||||
email_template: "{login}".into(),
|
||||
max_protocol: "14.1".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ImapSettings {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub security: TlsMode,
|
||||
pub verify_tls: bool,
|
||||
/// Optional namespace prefix (e.g. "INBOX.") - Z-Push IMAP_FOLDER_PREFIX.
|
||||
pub folder_prefix: String,
|
||||
pub folder_inbox: String,
|
||||
pub folder_sent: String,
|
||||
pub folder_drafts: String,
|
||||
pub folder_trash: String,
|
||||
pub folder_spam: String,
|
||||
pub folder_archive: String,
|
||||
/// Prefer RFC 6154 SPECIAL-USE flags over the names above.
|
||||
pub use_special_use: bool,
|
||||
/// Folders hidden from devices (exact names or `prefix*`).
|
||||
pub excluded_folders: Vec<String>,
|
||||
/// Mark messages \Seen before moving them to Trash - IMAP_AUTOSEEN_ON_DELETE.
|
||||
pub autoseen_on_delete: bool,
|
||||
/// Inline (true) or attached (false) forward body - IMAP_INLINE_FORWARD.
|
||||
pub inline_forward: bool,
|
||||
pub timeout_secs: u64,
|
||||
/// Keep authenticated connections for reuse between requests of the same user.
|
||||
pub pool_idle_secs: u64,
|
||||
pub pool_max_per_user: usize,
|
||||
}
|
||||
|
||||
impl Default for ImapSettings {
|
||||
fn default() -> Self {
|
||||
ImapSettings {
|
||||
host: "127.0.0.1".into(),
|
||||
port: 143,
|
||||
security: TlsMode::None,
|
||||
verify_tls: true,
|
||||
folder_prefix: String::new(),
|
||||
folder_inbox: "INBOX".into(),
|
||||
folder_sent: "Sent".into(),
|
||||
folder_drafts: "Drafts".into(),
|
||||
folder_trash: "Trash".into(),
|
||||
folder_spam: "Junk".into(),
|
||||
folder_archive: "Archive".into(),
|
||||
use_special_use: true,
|
||||
excluded_folders: vec![],
|
||||
autoseen_on_delete: false,
|
||||
inline_forward: true,
|
||||
timeout_secs: 60,
|
||||
pool_idle_secs: 90,
|
||||
pool_max_per_user: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SendMethod {
|
||||
#[default]
|
||||
Smtp,
|
||||
Sendmail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SmtpAuth {
|
||||
None,
|
||||
/// Authenticate with the device user's own credentials (recommended with submission port).
|
||||
#[default]
|
||||
UserCredentials,
|
||||
/// Authenticate with a fixed relay account.
|
||||
Static,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SmtpSettings {
|
||||
pub method: SendMethod,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub security: TlsMode,
|
||||
pub verify_tls: bool,
|
||||
pub auth: SmtpAuth,
|
||||
pub username: String,
|
||||
/// Secret - never returned by the API.
|
||||
pub password: String,
|
||||
pub sendmail_path: String,
|
||||
pub timeout_secs: u64,
|
||||
/// Store a copy of sent mail in the Sent folder when the device asks for it.
|
||||
pub save_in_sent: bool,
|
||||
/// Reject messages whose From is not one of the user's addresses (anti-spoofing).
|
||||
pub enforce_from: bool,
|
||||
pub max_message_mb: u64,
|
||||
}
|
||||
|
||||
impl Default for SmtpSettings {
|
||||
fn default() -> Self {
|
||||
SmtpSettings {
|
||||
method: SendMethod::Smtp,
|
||||
host: "127.0.0.1".into(),
|
||||
port: 587,
|
||||
security: TlsMode::StartTls,
|
||||
verify_tls: false,
|
||||
auth: SmtpAuth::UserCredentials,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
sendmail_path: "/usr/sbin/sendmail".into(),
|
||||
timeout_secs: 60,
|
||||
save_in_sent: true,
|
||||
enforce_from: true,
|
||||
max_message_mb: 50,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DavSettings {
|
||||
pub enabled: bool,
|
||||
/// Base URL of the server, e.g. `https://dav.example.com`.
|
||||
pub base_url: String,
|
||||
/// Path to the user's calendar home. Placeholders: %u login, %l local part, %d domain.
|
||||
pub path: String,
|
||||
pub verify_tls: bool,
|
||||
/// Only collections whose path matches this regex (empty = all).
|
||||
pub include_regex: String,
|
||||
pub exclude_regex: String,
|
||||
pub timeout_secs: u64,
|
||||
/// Name of the default calendar (path segment); empty = first found.
|
||||
pub default_collection: String,
|
||||
}
|
||||
|
||||
impl Default for DavSettings {
|
||||
fn default() -> Self {
|
||||
DavSettings {
|
||||
enabled: false,
|
||||
base_url: "https://127.0.0.1".into(),
|
||||
path: "/dav/calendars/%u/".into(),
|
||||
verify_tls: true,
|
||||
include_regex: String::new(),
|
||||
exclude_regex: String::new(),
|
||||
timeout_secs: 60,
|
||||
default_collection: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CardDavSettings {
|
||||
pub enabled: bool,
|
||||
pub base_url: String,
|
||||
pub path: String,
|
||||
pub verify_tls: bool,
|
||||
pub include_regex: String,
|
||||
pub exclude_regex: String,
|
||||
pub timeout_secs: u64,
|
||||
pub default_collection: String,
|
||||
/// Global address list collection (GAL search); empty = search the user's books.
|
||||
pub gal_path: String,
|
||||
pub gal_min_length: usize,
|
||||
pub max_picture_kb: usize,
|
||||
}
|
||||
|
||||
impl Default for CardDavSettings {
|
||||
fn default() -> Self {
|
||||
CardDavSettings {
|
||||
enabled: false,
|
||||
base_url: "https://127.0.0.1".into(),
|
||||
path: "/dav/addressbooks/%u/".into(),
|
||||
verify_tls: true,
|
||||
include_regex: String::new(),
|
||||
exclude_regex: String::new(),
|
||||
timeout_secs: 60,
|
||||
default_collection: String::new(),
|
||||
gal_path: String::new(),
|
||||
gal_min_length: 3,
|
||||
max_picture_kb: 5120,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SyncSettings {
|
||||
/// SYNC_MAX_ITEMS - upper bound for WindowSize.
|
||||
pub max_items: u32,
|
||||
/// SYNC_FILTERTIME_MAX - 0 = no limit, otherwise EAS filter type 1..7.
|
||||
pub filter_time_max: u8,
|
||||
/// SYNC_CONFLICT_DEFAULT: true = server wins (overwrite PIM), false = client wins.
|
||||
pub server_wins: bool,
|
||||
pub max_attachment_mb: u64,
|
||||
pub max_request_mb: u64,
|
||||
/// Hanging Sync (Wait / HeartbeatInterval) bounds in seconds.
|
||||
pub heartbeat_min: u64,
|
||||
pub heartbeat_max: u64,
|
||||
/// Detect a device retrying the same SyncKey and shrink its window (loop detection).
|
||||
pub loop_detection: bool,
|
||||
/// Max seconds a Search waits - SEARCH_WAIT.
|
||||
pub search_wait_secs: u64,
|
||||
/// SEARCH_MAXRESULTS
|
||||
pub search_max_results: u32,
|
||||
/// Retry-After for 503 responses - RETRY_AFTER_DELAY.
|
||||
pub retry_after_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for SyncSettings {
|
||||
fn default() -> Self {
|
||||
SyncSettings {
|
||||
max_items: 512,
|
||||
filter_time_max: 0,
|
||||
server_wins: true,
|
||||
max_attachment_mb: 50,
|
||||
max_request_mb: 60,
|
||||
heartbeat_min: 60,
|
||||
heartbeat_max: 3540,
|
||||
loop_detection: true,
|
||||
search_wait_secs: 10,
|
||||
search_max_results: 50,
|
||||
retry_after_secs: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PingSettings {
|
||||
/// PING_INTERVAL - how often backend changes are checked while a Ping hangs.
|
||||
pub check_interval_secs: u64,
|
||||
/// PING_LOWER_BOUND_LIFETIME / PING_HIGHER_BOUND_LIFETIME.
|
||||
pub lifetime_min: u64,
|
||||
pub lifetime_max: u64,
|
||||
pub max_folders: usize,
|
||||
}
|
||||
|
||||
impl Default for PingSettings {
|
||||
fn default() -> Self {
|
||||
PingSettings {
|
||||
check_interval_secs: 30,
|
||||
lifetime_min: 60,
|
||||
lifetime_max: 3540,
|
||||
max_folders: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Provisioning {
|
||||
pub enabled: bool,
|
||||
/// LOOSE_PROVISIONING - allow devices that never provision.
|
||||
pub loose: bool,
|
||||
pub default_policy: String,
|
||||
pub policies: Vec<Policy>,
|
||||
/// Per-user policy assignment: (username, policy name).
|
||||
pub user_policies: Vec<UserPolicy>,
|
||||
}
|
||||
|
||||
impl Default for Provisioning {
|
||||
fn default() -> Self {
|
||||
Provisioning {
|
||||
enabled: true,
|
||||
loose: false,
|
||||
default_policy: "default".into(),
|
||||
policies: vec![Policy::default()],
|
||||
user_policies: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UserPolicy {
|
||||
pub username: String,
|
||||
pub policy: String,
|
||||
}
|
||||
|
||||
/// All fields from Z-Push `policies.ini`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Policy {
|
||||
pub name: String,
|
||||
pub devpwenabled: bool,
|
||||
pub alphanumpwreq: bool,
|
||||
pub devencenabled: bool,
|
||||
pub attenabled: bool,
|
||||
pub mindevpwlenngth: u32,
|
||||
pub maxinacttimedevlock: u32,
|
||||
pub maxdevpwfailedattempts: u32,
|
||||
/// -1 = unlimited
|
||||
pub maxattsize: i64,
|
||||
pub allowsimpledevpw: bool,
|
||||
pub devpwexpiration: u32,
|
||||
pub devpwhistory: u32,
|
||||
pub allowstoragecard: bool,
|
||||
pub allowcam: bool,
|
||||
pub reqdevenc: bool,
|
||||
pub allowunsignedapps: bool,
|
||||
pub allowunsignedinstallationpackages: bool,
|
||||
pub mindevcomplexchars: u32,
|
||||
pub allowwifi: bool,
|
||||
pub allowtextmessaging: bool,
|
||||
pub allowpopimapemail: bool,
|
||||
/// 0 disabled, 1 handsfree only, 2 allowed
|
||||
pub allowbluetooth: u32,
|
||||
pub allowirda: bool,
|
||||
pub reqmansyncroam: bool,
|
||||
pub allowdesktopsync: bool,
|
||||
pub maxcalagefilter: u32,
|
||||
pub allowhtmlemail: bool,
|
||||
pub maxemailagefilter: u32,
|
||||
pub maxemailbodytruncsize: i64,
|
||||
pub maxemailhtmlbodytruncsize: i64,
|
||||
pub reqsignedsmimemessages: bool,
|
||||
pub reqencsmimemessages: bool,
|
||||
pub reqsignedsmimealgorithm: u32,
|
||||
pub reqencsmimealgorithm: u32,
|
||||
pub allowsmimeencalgneg: u32,
|
||||
pub allowsmimesoftcerts: bool,
|
||||
pub allowbrowser: bool,
|
||||
pub allowconsumeremail: bool,
|
||||
pub allowremotedesktop: bool,
|
||||
pub allowinternetsharing: bool,
|
||||
pub pwrecoveryenabled: bool,
|
||||
}
|
||||
|
||||
impl Default for Policy {
|
||||
fn default() -> Self {
|
||||
Policy {
|
||||
name: "default".into(),
|
||||
devpwenabled: false,
|
||||
alphanumpwreq: false,
|
||||
devencenabled: false,
|
||||
attenabled: true,
|
||||
mindevpwlenngth: 4,
|
||||
maxinacttimedevlock: 900,
|
||||
maxdevpwfailedattempts: 8,
|
||||
maxattsize: -1,
|
||||
allowsimpledevpw: true,
|
||||
devpwexpiration: 0,
|
||||
devpwhistory: 0,
|
||||
allowstoragecard: true,
|
||||
allowcam: true,
|
||||
reqdevenc: false,
|
||||
allowunsignedapps: true,
|
||||
allowunsignedinstallationpackages: true,
|
||||
mindevcomplexchars: 3,
|
||||
allowwifi: true,
|
||||
allowtextmessaging: true,
|
||||
allowpopimapemail: true,
|
||||
allowbluetooth: 2,
|
||||
allowirda: true,
|
||||
reqmansyncroam: false,
|
||||
allowdesktopsync: true,
|
||||
maxcalagefilter: 0,
|
||||
allowhtmlemail: true,
|
||||
maxemailagefilter: 0,
|
||||
maxemailbodytruncsize: -1,
|
||||
maxemailhtmlbodytruncsize: -1,
|
||||
reqsignedsmimemessages: false,
|
||||
reqencsmimemessages: false,
|
||||
reqsignedsmimealgorithm: 0,
|
||||
reqencsmimealgorithm: 0,
|
||||
allowsmimeencalgneg: 2,
|
||||
allowsmimesoftcerts: true,
|
||||
allowbrowser: true,
|
||||
allowconsumeremail: true,
|
||||
allowremotedesktop: true,
|
||||
allowinternetsharing: true,
|
||||
pwrecoveryenabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Autodiscover {
|
||||
pub enabled: bool,
|
||||
/// Public ActiveSync URL handed to devices; empty = derived from the request Host.
|
||||
pub server_url: String,
|
||||
/// Validate credentials against IMAP before answering (recommended).
|
||||
pub require_auth: bool,
|
||||
}
|
||||
|
||||
impl Default for Autodiscover {
|
||||
fn default() -> Self {
|
||||
Autodiscover {
|
||||
enabled: true,
|
||||
server_url: String::new(),
|
||||
require_auth: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Security {
|
||||
/// Failed logins per IP / per user before lockout.
|
||||
pub auth_fail_max: u32,
|
||||
pub auth_fail_window_secs: u64,
|
||||
pub lockout_secs: u64,
|
||||
/// Proxies whose forwarded-for header is trusted (CIDR). Default: loopback.
|
||||
pub trusted_proxies: Vec<String>,
|
||||
pub real_ip_header: String,
|
||||
/// If non-empty, only these client networks may use ActiveSync (CIDR).
|
||||
pub eas_allowed_networks: Vec<String>,
|
||||
/// Users that may never sync (lowercase logins).
|
||||
pub blocked_users: Vec<String>,
|
||||
/// If non-empty, only these users may sync.
|
||||
pub allowed_users: Vec<String>,
|
||||
/// New devices need administrator approval before they can sync.
|
||||
pub require_device_approval: bool,
|
||||
/// Device types (case-insensitive substring of DeviceType or User-Agent) that are refused.
|
||||
pub blocked_device_types: Vec<String>,
|
||||
/// LOGAUTHFAIL - log failed logins (fail2ban friendly line).
|
||||
pub log_auth_failures: bool,
|
||||
/// Successful credentials are cached (HMAC only, never plaintext) for this many seconds.
|
||||
pub auth_cache_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for Security {
|
||||
fn default() -> Self {
|
||||
Security {
|
||||
auth_fail_max: 8,
|
||||
auth_fail_window_secs: 600,
|
||||
lockout_secs: 900,
|
||||
trusted_proxies: vec!["127.0.0.1/32".into(), "::1/128".into()],
|
||||
real_ip_header: "X-Forwarded-For".into(),
|
||||
eas_allowed_networks: vec![],
|
||||
blocked_users: vec![],
|
||||
allowed_users: vec![],
|
||||
require_device_approval: false,
|
||||
blocked_device_types: vec![],
|
||||
log_auth_failures: true,
|
||||
auth_cache_secs: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Logging {
|
||||
/// Log decoded WBXML of requests and responses (debug only; contains mail data!).
|
||||
pub log_wbxml: bool,
|
||||
/// Users with verbose (WBXML) logging regardless of global setting - LOGUSERLEVEL.
|
||||
pub debug_users: Vec<String>,
|
||||
/// Lines kept in memory for the panel log viewer.
|
||||
pub ring_size: usize,
|
||||
/// Days of hourly statistics kept in the database.
|
||||
pub stats_retention_days: u32,
|
||||
/// Days of audit log kept.
|
||||
pub audit_retention_days: u32,
|
||||
}
|
||||
|
||||
impl Default for Logging {
|
||||
fn default() -> Self {
|
||||
Logging {
|
||||
log_wbxml: false,
|
||||
debug_users: vec![],
|
||||
ring_size: 2000,
|
||||
stats_retention_days: 90,
|
||||
audit_retention_days: 365,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Panel {
|
||||
pub session_ttl_minutes: u64,
|
||||
/// Bearer token for /metrics (Prometheus). Empty = metrics endpoint disabled. Secret.
|
||||
pub prometheus_token: String,
|
||||
/// Remove devices not seen for this many days (0 = never) - like `z-push-admin -a fixstates`.
|
||||
pub device_inactive_days: u32,
|
||||
/// If non-empty, the panel only answers these client networks (CIDR).
|
||||
pub allowed_networks: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Panel {
|
||||
fn default() -> Self {
|
||||
Panel {
|
||||
session_ttl_minutes: 480,
|
||||
prometheus_token: String::new(),
|
||||
device_inactive_days: 180,
|
||||
allowed_networks: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON pointers of secret values. They are blanked on read and kept when an update sends "".
|
||||
pub const SECRET_FIELDS: &[&str] = &["/smtp/password", "/panel/prometheus_token"];
|
||||
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.imap.host.trim().is_empty() {
|
||||
return Err("IMAP host is required".into());
|
||||
}
|
||||
if self.imap.port == 0 {
|
||||
return Err("IMAP port is invalid".into());
|
||||
}
|
||||
if self
|
||||
.general
|
||||
.default_timezone
|
||||
.parse::<chrono_tz::Tz>()
|
||||
.is_err()
|
||||
{
|
||||
return Err(format!(
|
||||
"Unknown time zone '{}'",
|
||||
self.general.default_timezone
|
||||
));
|
||||
}
|
||||
if !["2.5", "12.0", "12.1", "14.0", "14.1"].contains(&self.general.max_protocol.as_str()) {
|
||||
return Err("max_protocol must be one of 2.5, 12.0, 12.1, 14.0, 14.1".into());
|
||||
}
|
||||
for (name, d) in [
|
||||
("CalDAV", &self.caldav.base_url),
|
||||
("CardDAV", &self.carddav.base_url),
|
||||
] {
|
||||
if !d.is_empty() && url::Url::parse(d).is_err() {
|
||||
return Err(format!("{name} base URL is invalid"));
|
||||
}
|
||||
}
|
||||
for r in [
|
||||
&self.caldav.include_regex,
|
||||
&self.caldav.exclude_regex,
|
||||
&self.carddav.include_regex,
|
||||
&self.carddav.exclude_regex,
|
||||
] {
|
||||
if !r.is_empty() && regex::Regex::new(r).is_err() {
|
||||
return Err(format!("Invalid regular expression: {r}"));
|
||||
}
|
||||
}
|
||||
for n in self
|
||||
.security
|
||||
.trusted_proxies
|
||||
.iter()
|
||||
.chain(self.security.eas_allowed_networks.iter())
|
||||
.chain(self.panel.allowed_networks.iter())
|
||||
{
|
||||
if crate::util::Cidr::parse(n).is_none() {
|
||||
return Err(format!(
|
||||
"Invalid network '{n}' (expected CIDR, e.g. 10.0.0.0/8)"
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.provisioning.policies.is_empty() {
|
||||
return Err("At least one policy is required".into());
|
||||
}
|
||||
if !self
|
||||
.provisioning
|
||||
.policies
|
||||
.iter()
|
||||
.any(|p| p.name == self.provisioning.default_policy)
|
||||
{
|
||||
return Err("Default policy does not exist".into());
|
||||
}
|
||||
if self.sync.max_items == 0 || self.sync.max_items > 2000 {
|
||||
return Err("sync.max_items must be 1..2000".into());
|
||||
}
|
||||
if self.ping.check_interval_secs < 5 {
|
||||
return Err("ping.check_interval_secs must be >= 5".into());
|
||||
}
|
||||
if self.ping.lifetime_min > self.ping.lifetime_max {
|
||||
return Err("ping lifetime min > max".into());
|
||||
}
|
||||
if self.smtp.method == SendMethod::Sendmail
|
||||
&& !std::path::Path::new(&self.smtp.sendmail_path).is_absolute()
|
||||
{
|
||||
return Err("sendmail_path must be absolute".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn policy_for(&self, user: &str) -> &Policy {
|
||||
let name = self
|
||||
.provisioning
|
||||
.user_policies
|
||||
.iter()
|
||||
.find(|u| u.username.eq_ignore_ascii_case(user))
|
||||
.map(|u| u.policy.as_str())
|
||||
.unwrap_or(&self.provisioning.default_policy);
|
||||
self.provisioning
|
||||
.policies
|
||||
.iter()
|
||||
.find(|p| p.name == name)
|
||||
.or_else(|| self.provisioning.policies.first())
|
||||
.expect("validated: at least one policy")
|
||||
}
|
||||
|
||||
pub fn timezone(&self) -> chrono_tz::Tz {
|
||||
self.general
|
||||
.default_timezone
|
||||
.parse()
|
||||
.unwrap_or(chrono_tz::Europe::Warsaw)
|
||||
}
|
||||
|
||||
/// Remove secrets for display.
|
||||
pub fn redacted(&self) -> serde_json::Value {
|
||||
let mut v = serde_json::to_value(self).unwrap_or_default();
|
||||
for p in SECRET_FIELDS {
|
||||
if let Some(x) = v.pointer_mut(p) {
|
||||
let set = x.as_str().map(|s| !s.is_empty()).unwrap_or(false);
|
||||
*x = serde_json::Value::String(if set { "__SET__".into() } else { String::new() });
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Merge an update from the panel: secret fields that are "__SET__" or missing keep the old value.
|
||||
pub fn merge_update(&self, incoming: serde_json::Value) -> Result<Settings, String> {
|
||||
let old = serde_json::to_value(self).map_err(|e| e.to_string())?;
|
||||
// deep-merge: a partial document only changes the keys it contains
|
||||
let mut incoming = {
|
||||
let mut base = old.clone();
|
||||
json_merge(&mut base, incoming);
|
||||
base
|
||||
};
|
||||
for p in SECRET_FIELDS {
|
||||
let keep = match incoming.pointer(p) {
|
||||
None => true,
|
||||
Some(v) => v.as_str() == Some("__SET__"),
|
||||
};
|
||||
if keep {
|
||||
if let (Some(dst), Some(src)) = (incoming.pointer_mut(p), old.pointer(p)) {
|
||||
*dst = src.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
let s: Settings =
|
||||
serde_json::from_value(incoming).map_err(|e| format!("Invalid settings: {e}"))?;
|
||||
s.validate()?;
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
fn json_merge(base: &mut serde_json::Value, patch: serde_json::Value) {
|
||||
match (base, patch) {
|
||||
(serde_json::Value::Object(b), serde_json::Value::Object(p)) => {
|
||||
for (k, v) in p {
|
||||
json_merge(b.entry(k).or_insert(serde_json::Value::Null), v);
|
||||
}
|
||||
}
|
||||
(b, p) => *b = p,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expand_user_template(tpl: &str, login: &str, default_domain: &str) -> String {
|
||||
let (local, domain) = match login.split_once('@') {
|
||||
Some((l, d)) => (l.to_string(), d.to_string()),
|
||||
None => (login.to_string(), default_domain.to_string()),
|
||||
};
|
||||
tpl.replace("%u", login)
|
||||
.replace("%l", &local)
|
||||
.replace("%d", &domain)
|
||||
.replace("{login}", login)
|
||||
.replace("{local}", &local)
|
||||
.replace("{domain}", &domain)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_validate() {
|
||||
Settings::default().validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_kept() {
|
||||
let mut s = Settings::default();
|
||||
s.smtp.password = "secret".into();
|
||||
let red = s.redacted();
|
||||
assert_eq!(red.pointer("/smtp/password").unwrap(), "__SET__");
|
||||
let merged = s.merge_update(red).unwrap();
|
||||
assert_eq!(merged.smtp.password, "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template() {
|
||||
assert_eq!(
|
||||
expand_user_template("/dav/%u/", "a@b.pl", "x"),
|
||||
"/dav/a@b.pl/"
|
||||
);
|
||||
assert_eq!(
|
||||
expand_user_template("{local}@{domain}", "jan", "firma.pl"),
|
||||
"jan@firma.pl"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
//! SQLite persistence (WAL). All queries are parameterised.
|
||||
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
|
||||
use sqlx::{FromRow, Row, SqlitePool};
|
||||
|
||||
use crate::config::Settings;
|
||||
use crate::util::now;
|
||||
|
||||
pub type Db = SqlitePool;
|
||||
|
||||
pub async fn open(data_dir: &Path) -> Result<Db> {
|
||||
std::fs::create_dir_all(data_dir).with_context(|| format!("create {}", data_dir.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(data_dir, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
let path = data_dir.join("active-push.db");
|
||||
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))?
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal)
|
||||
.foreign_keys(true)
|
||||
.busy_timeout(Duration::from_secs(15))
|
||||
// new databases release freed pages incrementally (see housekeeping)
|
||||
.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(8)
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.with_context(|| format!("open database {}", path.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
for f in ["active-push.db", "active-push.db-wal", "active-push.db-shm"] {
|
||||
let _ =
|
||||
std::fs::set_permissions(data_dir.join(f), std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
}
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ settings
|
||||
|
||||
pub async fn load_settings(db: &Db) -> Result<(Settings, i64)> {
|
||||
let row = sqlx::query("SELECT json, revision FROM settings WHERE id = 1")
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
match row {
|
||||
Some(r) => {
|
||||
let json: String = r.get(0);
|
||||
let rev: i64 = r.get(1);
|
||||
let s: Settings = serde_json::from_str(&json).context("stored settings are invalid")?;
|
||||
Ok((s, rev))
|
||||
}
|
||||
None => {
|
||||
let s = Settings::default();
|
||||
save_settings(db, &s).await?;
|
||||
Ok((s, 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_settings(db: &Db, s: &Settings) -> Result<i64> {
|
||||
let json = serde_json::to_string(s)?;
|
||||
let r = sqlx::query(
|
||||
"INSERT INTO settings (id, json, revision, updated_at) VALUES (1, ?, 1, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET json = excluded.json, revision = settings.revision + 1, updated_at = excluded.updated_at
|
||||
RETURNING revision",
|
||||
)
|
||||
.bind(json)
|
||||
.bind(now())
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
Ok(r.get(0))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ devices
|
||||
|
||||
#[derive(Debug, Clone, FromRow, Serialize, Default)]
|
||||
pub struct Device {
|
||||
pub id: i64,
|
||||
pub device_id: String,
|
||||
pub username: String,
|
||||
pub device_type: String,
|
||||
pub user_agent: String,
|
||||
pub model: String,
|
||||
pub imei: String,
|
||||
pub friendly_name: String,
|
||||
pub os: String,
|
||||
pub os_language: String,
|
||||
pub phone_number: String,
|
||||
pub mobile_operator: String,
|
||||
pub as_version: String,
|
||||
pub policy_key: i64,
|
||||
pub policy_temp_key: i64,
|
||||
pub policy_name: String,
|
||||
pub policy_hash: String,
|
||||
pub wipe_status: i64,
|
||||
pub wipe_requested_at: Option<i64>,
|
||||
pub wipe_requested_by: Option<String>,
|
||||
pub wipe_completed_at: Option<i64>,
|
||||
pub blocked: i64,
|
||||
pub approved: i64,
|
||||
pub first_seen: i64,
|
||||
pub last_seen: i64,
|
||||
pub last_ip: String,
|
||||
pub last_command: String,
|
||||
pub requests: i64,
|
||||
#[serde(skip)]
|
||||
pub recovery_password: String,
|
||||
#[serde(skip)]
|
||||
pub ping_folders: String,
|
||||
pub ping_lifetime: i64,
|
||||
#[serde(skip)]
|
||||
pub last_sync_request: String,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// Wipe status values (Z-Push SYNC_PROVISION_RWSTATUS_*).
|
||||
pub const WIPE_NA: i64 = 0;
|
||||
pub const WIPE_OK: i64 = 1;
|
||||
pub const WIPE_PENDING: i64 = 2;
|
||||
pub const WIPE_REQUESTED: i64 = 4;
|
||||
pub const WIPE_WIPED: i64 = 8;
|
||||
pub const WIPE_ACCOUNT_PENDING: i64 = 16;
|
||||
pub const WIPE_ACCOUNT_REQUESTED: i64 = 32;
|
||||
pub const WIPE_ACCOUNT_WIPED: i64 = 64;
|
||||
|
||||
pub async fn device_get_or_create(
|
||||
db: &Db,
|
||||
device_id: &str,
|
||||
username: &str,
|
||||
device_type: &str,
|
||||
user_agent: &str,
|
||||
approved_default: bool,
|
||||
) -> Result<(Device, bool)> {
|
||||
if let Some(d) = device_get(db, device_id, username).await? {
|
||||
return Ok((d, false));
|
||||
}
|
||||
let ts = now();
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO devices (device_id, username, device_type, user_agent, first_seen, last_seen, approved)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(device_id)
|
||||
.bind(username)
|
||||
.bind(device_type)
|
||||
.bind(user_agent)
|
||||
.bind(ts)
|
||||
.bind(ts)
|
||||
.bind(approved_default as i64)
|
||||
.execute(db)
|
||||
.await?;
|
||||
let d = device_get(db, device_id, username)
|
||||
.await?
|
||||
.context("device insert failed")?;
|
||||
Ok((d, true))
|
||||
}
|
||||
|
||||
pub async fn device_get(db: &Db, device_id: &str, username: &str) -> Result<Option<Device>> {
|
||||
Ok(
|
||||
sqlx::query_as::<_, Device>("SELECT * FROM devices WHERE device_id = ? AND username = ?")
|
||||
.bind(device_id)
|
||||
.bind(username)
|
||||
.fetch_optional(db)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn device_by_id(db: &Db, id: i64) -> Result<Option<Device>> {
|
||||
Ok(
|
||||
sqlx::query_as::<_, Device>("SELECT * FROM devices WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(db)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn device_list(db: &Db) -> Result<Vec<Device>> {
|
||||
Ok(
|
||||
sqlx::query_as::<_, Device>("SELECT * FROM devices ORDER BY last_seen DESC")
|
||||
.fetch_all(db)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn device_touch(
|
||||
db: &Db,
|
||||
id: i64,
|
||||
ip: &str,
|
||||
command: &str,
|
||||
as_version: &str,
|
||||
user_agent: &str,
|
||||
device_type: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE devices SET last_seen = ?, last_ip = ?, last_command = ?, requests = requests + 1,
|
||||
as_version = CASE WHEN ? = '' THEN as_version ELSE ? END,
|
||||
user_agent = CASE WHEN ? = '' THEN user_agent ELSE ? END,
|
||||
device_type = CASE WHEN ? = '' THEN device_type ELSE ? END
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(now())
|
||||
.bind(ip)
|
||||
.bind(command)
|
||||
.bind(as_version)
|
||||
.bind(as_version)
|
||||
.bind(user_agent)
|
||||
.bind(user_agent)
|
||||
.bind(device_type)
|
||||
.bind(device_type)
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct DeviceInfo {
|
||||
pub model: Option<String>,
|
||||
pub imei: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub os: Option<String>,
|
||||
pub os_language: Option<String>,
|
||||
pub phone_number: Option<String>,
|
||||
pub mobile_operator: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn device_set_info(db: &Db, id: i64, i: &DeviceInfo) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE devices SET model = COALESCE(?, model), imei = COALESCE(?, imei), friendly_name = COALESCE(?, friendly_name),
|
||||
os = COALESCE(?, os), os_language = COALESCE(?, os_language), phone_number = COALESCE(?, phone_number),
|
||||
mobile_operator = COALESCE(?, mobile_operator), user_agent = COALESCE(?, user_agent) WHERE id = ?",
|
||||
)
|
||||
.bind(&i.model)
|
||||
.bind(&i.imei)
|
||||
.bind(&i.friendly_name)
|
||||
.bind(&i.os)
|
||||
.bind(&i.os_language)
|
||||
.bind(&i.phone_number)
|
||||
.bind(&i.mobile_operator)
|
||||
.bind(&i.user_agent)
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn device_set_field(
|
||||
db: &Db,
|
||||
id: i64,
|
||||
field: DeviceField,
|
||||
value: Option<i64>,
|
||||
text: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let col = match field {
|
||||
DeviceField::PolicyKey => "policy_key",
|
||||
DeviceField::PolicyTempKey => "policy_temp_key",
|
||||
DeviceField::WipeStatus => "wipe_status",
|
||||
DeviceField::Blocked => "blocked",
|
||||
DeviceField::Approved => "approved",
|
||||
DeviceField::PingLifetime => "ping_lifetime",
|
||||
DeviceField::PolicyName => "policy_name",
|
||||
DeviceField::PolicyHash => "policy_hash",
|
||||
DeviceField::RecoveryPassword => "recovery_password",
|
||||
DeviceField::PingFolders => "ping_folders",
|
||||
DeviceField::LastSyncRequest => "last_sync_request",
|
||||
DeviceField::Notes => "notes",
|
||||
};
|
||||
let sql = format!("UPDATE devices SET {col} = ? WHERE id = ?");
|
||||
let q = sqlx::query(&sql);
|
||||
let q = match (value, text) {
|
||||
(Some(v), _) => q.bind(v),
|
||||
(None, Some(t)) => q.bind(t.to_string()),
|
||||
(None, None) => q.bind(Option::<String>::None),
|
||||
};
|
||||
q.bind(id).execute(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum DeviceField {
|
||||
PolicyKey,
|
||||
PolicyTempKey,
|
||||
WipeStatus,
|
||||
Blocked,
|
||||
Approved,
|
||||
PingLifetime,
|
||||
PolicyName,
|
||||
PolicyHash,
|
||||
RecoveryPassword,
|
||||
PingFolders,
|
||||
LastSyncRequest,
|
||||
Notes,
|
||||
}
|
||||
|
||||
pub async fn device_request_wipe(db: &Db, id: i64, by: &str, account_only: bool) -> Result<()> {
|
||||
let st = if account_only {
|
||||
WIPE_ACCOUNT_PENDING
|
||||
} else {
|
||||
WIPE_PENDING
|
||||
};
|
||||
sqlx::query("UPDATE devices SET wipe_status = ?, wipe_requested_at = ?, wipe_requested_by = ?, wipe_completed_at = NULL WHERE id = ?")
|
||||
.bind(st)
|
||||
.bind(now())
|
||||
.bind(by)
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn device_wipe_done(db: &Db, id: i64, status: i64) -> Result<()> {
|
||||
sqlx::query("UPDATE devices SET wipe_status = ?, wipe_completed_at = ? WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(now())
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn device_delete(db: &Db, id: i64) -> Result<()> {
|
||||
sqlx::query("DELETE FROM devices WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn device_reset_states(db: &Db, id: i64) -> Result<()> {
|
||||
sqlx::query("DELETE FROM sync_states WHERE device_rowid = ?")
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM folder_stats WHERE device_rowid = ?")
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query("UPDATE devices SET ping_folders = '', last_sync_request = '' WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn devices_cleanup_inactive(db: &Db, days: u32) -> Result<u64> {
|
||||
if days == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let cutoff = now() - days as i64 * 86400;
|
||||
let r = sqlx::query(
|
||||
"DELETE FROM devices WHERE last_seen < ? AND wipe_status NOT IN (2, 4, 16, 32)",
|
||||
)
|
||||
.bind(cutoff)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(r.rows_affected())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ states
|
||||
|
||||
pub async fn state_get(
|
||||
db: &Db,
|
||||
dev: i64,
|
||||
collection: &str,
|
||||
uuid: &str,
|
||||
counter: i64,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let r = sqlx::query(
|
||||
"SELECT data FROM sync_states WHERE device_rowid = ? AND collection = ? AND uuid = ? AND counter = ?",
|
||||
)
|
||||
.bind(dev)
|
||||
.bind(collection)
|
||||
.bind(uuid)
|
||||
.bind(counter)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(r.map(|r| r.get(0)))
|
||||
}
|
||||
|
||||
/// Store state `counter` and drop everything older than `counter - 1`
|
||||
/// (the previous state is kept so a device may retry a lost response).
|
||||
pub async fn state_put(
|
||||
db: &Db,
|
||||
dev: i64,
|
||||
collection: &str,
|
||||
uuid: &str,
|
||||
counter: i64,
|
||||
data: &[u8],
|
||||
) -> Result<()> {
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO sync_states (device_rowid, collection, uuid, counter, data, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(dev)
|
||||
.bind(collection)
|
||||
.bind(uuid)
|
||||
.bind(counter)
|
||||
.bind(data)
|
||||
.bind(now())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM sync_states WHERE device_rowid = ? AND collection = ? AND (uuid != ? OR counter < ?)",
|
||||
)
|
||||
.bind(dev)
|
||||
.bind(collection)
|
||||
.bind(uuid)
|
||||
.bind(counter - 1)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn state_delete_collection(db: &Db, dev: i64, collection: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM sync_states WHERE device_rowid = ? AND collection = ?")
|
||||
.bind(dev)
|
||||
.bind(collection)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM folder_stats WHERE device_rowid = ? AND collection = ?")
|
||||
.bind(dev)
|
||||
.bind(collection)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Latest state per collection for a device (used by Ping/GetItemEstimate when no key is given).
|
||||
pub async fn state_latest(
|
||||
db: &Db,
|
||||
dev: i64,
|
||||
collection: &str,
|
||||
) -> Result<Option<(String, i64, Vec<u8>)>> {
|
||||
let r = sqlx::query(
|
||||
"SELECT uuid, counter, data FROM sync_states WHERE device_rowid = ? AND collection = ? ORDER BY created_at DESC, counter DESC LIMIT 1",
|
||||
)
|
||||
.bind(dev)
|
||||
.bind(collection)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(r.map(|r| (r.get(0), r.get(1), r.get(2))))
|
||||
}
|
||||
|
||||
pub async fn state_collections(db: &Db, dev: i64) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query("SELECT DISTINCT collection FROM sync_states WHERE device_rowid = ?")
|
||||
.bind(dev)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|r| r.get(0)).collect())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ folder / item maps
|
||||
|
||||
pub async fn folder_short_id(db: &Db, user: &str, backend_id: &str) -> Result<i64> {
|
||||
if let Some(r) =
|
||||
sqlx::query("SELECT short_id FROM folder_map WHERE username = ? AND backend_id = ?")
|
||||
.bind(user)
|
||||
.bind(backend_id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
{
|
||||
return Ok(r.get(0));
|
||||
}
|
||||
let r = sqlx::query(
|
||||
"INSERT INTO folder_map (username, backend_id, short_id)
|
||||
VALUES (?, ?, (SELECT COALESCE(MAX(short_id), 0) + 1 FROM folder_map WHERE username = ?))
|
||||
ON CONFLICT(username, backend_id) DO UPDATE SET short_id = folder_map.short_id
|
||||
RETURNING short_id",
|
||||
)
|
||||
.bind(user)
|
||||
.bind(backend_id)
|
||||
.bind(user)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
Ok(r.get(0))
|
||||
}
|
||||
|
||||
pub async fn folder_backend_id(db: &Db, user: &str, short_id: &str) -> Result<Option<String>> {
|
||||
let Ok(n) = short_id.parse::<i64>() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let r = sqlx::query("SELECT backend_id FROM folder_map WHERE username = ? AND short_id = ?")
|
||||
.bind(user)
|
||||
.bind(n)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(r.map(|r| r.get(0)))
|
||||
}
|
||||
|
||||
pub async fn folder_rename_backend(db: &Db, user: &str, old: &str, new: &str) -> Result<()> {
|
||||
// a stale mapping of a previously deleted folder with the new name must go
|
||||
sqlx::query("DELETE FROM folder_map WHERE username = ? AND backend_id = ?")
|
||||
.bind(user)
|
||||
.bind(new)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query("UPDATE folder_map SET backend_id = ? WHERE username = ? AND backend_id = ?")
|
||||
.bind(new)
|
||||
.bind(user)
|
||||
.bind(old)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn item_short(db: &Db, user: &str, folder: &str, long_id: &str) -> Result<i64> {
|
||||
if let Some(r) = sqlx::query(
|
||||
"SELECT short_id FROM item_map WHERE username = ? AND folder = ? AND long_id = ?",
|
||||
)
|
||||
.bind(user)
|
||||
.bind(folder)
|
||||
.bind(long_id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
{
|
||||
return Ok(r.get(0));
|
||||
}
|
||||
let r = sqlx::query(
|
||||
"INSERT INTO item_map (username, folder, long_id, short_id)
|
||||
VALUES (?, ?, ?, (SELECT COALESCE(MAX(short_id), 0) + 1 FROM item_map WHERE username = ? AND folder = ?))
|
||||
ON CONFLICT(username, folder, long_id) DO UPDATE SET short_id = item_map.short_id
|
||||
RETURNING short_id",
|
||||
)
|
||||
.bind(user)
|
||||
.bind(folder)
|
||||
.bind(long_id)
|
||||
.bind(user)
|
||||
.bind(folder)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
Ok(r.get(0))
|
||||
}
|
||||
|
||||
pub async fn item_long(db: &Db, user: &str, folder: &str, short: i64) -> Result<Option<String>> {
|
||||
let r = sqlx::query(
|
||||
"SELECT long_id FROM item_map WHERE username = ? AND folder = ? AND short_id = ?",
|
||||
)
|
||||
.bind(user)
|
||||
.bind(folder)
|
||||
.bind(short)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(r.map(|r| r.get(0)))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ folder stats
|
||||
|
||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||
pub struct FolderStat {
|
||||
pub collection: String,
|
||||
pub folder_name: String,
|
||||
pub folder_class: String,
|
||||
pub last_sync: i64,
|
||||
pub items_out: i64,
|
||||
pub items_in: i64,
|
||||
pub item_count: i64,
|
||||
pub last_status: i64,
|
||||
pub window_size: i64,
|
||||
pub loop_hits: i64,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn folder_stat_update(
|
||||
db: &Db,
|
||||
dev: i64,
|
||||
collection: &str,
|
||||
name: &str,
|
||||
class: &str,
|
||||
out: i64,
|
||||
inn: i64,
|
||||
count: i64,
|
||||
status: i64,
|
||||
window: i64,
|
||||
loop_hit: bool,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO folder_stats (device_rowid, collection, folder_name, folder_class, last_sync, items_out, items_in, item_count, last_status, window_size, loop_hits)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(device_rowid, collection) DO UPDATE SET
|
||||
folder_name = CASE WHEN excluded.folder_name = '' THEN folder_name ELSE excluded.folder_name END,
|
||||
folder_class = CASE WHEN excluded.folder_class = '' THEN folder_class ELSE excluded.folder_class END,
|
||||
last_sync = excluded.last_sync, items_out = items_out + excluded.items_out, items_in = items_in + excluded.items_in,
|
||||
item_count = excluded.item_count, last_status = excluded.last_status, window_size = excluded.window_size,
|
||||
loop_hits = loop_hits + excluded.loop_hits",
|
||||
)
|
||||
.bind(dev)
|
||||
.bind(collection)
|
||||
.bind(name)
|
||||
.bind(class)
|
||||
.bind(now())
|
||||
.bind(out)
|
||||
.bind(inn)
|
||||
.bind(count)
|
||||
.bind(status)
|
||||
.bind(window)
|
||||
.bind(loop_hit as i64)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn folder_stats(db: &Db, dev: i64) -> Result<Vec<FolderStat>> {
|
||||
Ok(sqlx::query_as::<_, FolderStat>(
|
||||
"SELECT collection, folder_name, folder_class, last_sync, items_out, items_in, item_count, last_status, window_size, loop_hits
|
||||
FROM folder_stats WHERE device_rowid = ? ORDER BY folder_class, folder_name",
|
||||
)
|
||||
.bind(dev)
|
||||
.fetch_all(db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ audit
|
||||
|
||||
pub async fn audit(db: &Db, actor: &str, action: &str, target: &str, ip: &str, details: &str) {
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO audit_log (ts, actor, action, target, ip, details) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(now())
|
||||
.bind(actor)
|
||||
.bind(action)
|
||||
.bind(target)
|
||||
.bind(ip)
|
||||
.bind(details)
|
||||
.execute(db)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||
pub struct AuditEntry {
|
||||
pub id: i64,
|
||||
pub ts: i64,
|
||||
pub actor: String,
|
||||
pub action: String,
|
||||
pub target: String,
|
||||
pub ip: String,
|
||||
pub details: String,
|
||||
}
|
||||
|
||||
pub async fn audit_list(db: &Db, limit: i64) -> Result<Vec<AuditEntry>> {
|
||||
Ok(
|
||||
sqlx::query_as::<_, AuditEntry>("SELECT * FROM audit_log ORDER BY id DESC LIMIT ?")
|
||||
.bind(limit)
|
||||
.fetch_all(db)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn housekeeping(db: &Db, s: &Settings) -> Result<()> {
|
||||
let ts = now();
|
||||
sqlx::query("DELETE FROM admin_sessions WHERE expires_at < ?")
|
||||
.bind(ts)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM setup_token WHERE expires_at < ?")
|
||||
.bind(ts)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM stats_hourly WHERE hour < ?")
|
||||
.bind(ts - s.logging.stats_retention_days as i64 * 86400)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM audit_log WHERE ts < ?")
|
||||
.bind(ts - s.logging.audit_retention_days as i64 * 86400)
|
||||
.execute(db)
|
||||
.await?;
|
||||
let removed = devices_cleanup_inactive(db, s.panel.device_inactive_days).await?;
|
||||
if removed > 0 {
|
||||
tracing::info!(removed, "removed inactive devices");
|
||||
}
|
||||
// id maps of accounts that no longer have any device
|
||||
sqlx::query(
|
||||
"DELETE FROM item_map WHERE username NOT IN (SELECT DISTINCT username FROM devices)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM folder_map WHERE username NOT IN (SELECT DISTINCT username FROM devices)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
// states of collections a device stopped syncing more than a year ago
|
||||
sqlx::query("DELETE FROM sync_states WHERE created_at < ?")
|
||||
.bind(ts - 400 * 86400)
|
||||
.execute(db)
|
||||
.await?;
|
||||
compact(db).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep the database files small: checkpoint/truncate the WAL and give free pages back.
|
||||
pub async fn compact(db: &Db) {
|
||||
let _ = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
.execute(db)
|
||||
.await;
|
||||
let mode: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let free: i64 = sqlx::query_scalar("PRAGMA freelist_count")
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let pages: i64 = sqlx::query_scalar("PRAGMA page_count")
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap_or(1);
|
||||
if mode == 0 {
|
||||
// databases created by 0.1.0: switch to incremental vacuum once (one full VACUUM)
|
||||
tracing::info!("converting database to incremental auto-vacuum");
|
||||
if let Ok(mut c) = db.acquire().await {
|
||||
let _ = sqlx::query("PRAGMA auto_vacuum = INCREMENTAL")
|
||||
.execute(&mut *c)
|
||||
.await;
|
||||
let _ = sqlx::query("VACUUM").execute(&mut *c).await;
|
||||
}
|
||||
} else if free > 256 && free * 5 > pages {
|
||||
let _ = sqlx::query("PRAGMA incremental_vacuum").execute(db).await;
|
||||
}
|
||||
let _ = sqlx::query("PRAGMA optimize").execute(db).await;
|
||||
let _ = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
.execute(db)
|
||||
.await;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,650 @@
|
||||
//! vCard <-> EAS Contacts (with lossless merge of unmapped vCard data).
|
||||
|
||||
use base64::Engine;
|
||||
use chrono::{NaiveDate, TimeZone, Utc};
|
||||
use chrono_tz::Tz;
|
||||
|
||||
use super::{categories, categories_element, incoming_body, pim_body, Options};
|
||||
use crate::backend::ical::{self, Component, Property};
|
||||
use crate::backend::tz;
|
||||
use crate::wbxml::Element;
|
||||
|
||||
/// Phone slots: (EAS tag, required types, excluded types)
|
||||
const PHONE_SLOTS: &[(&str, &[&str], &[&str])] = &[
|
||||
("Contacts:MobilePhoneNumber", &["cell"], &[]),
|
||||
("Contacts:BusinessFaxNumber", &["work", "fax"], &[]),
|
||||
("Contacts:HomeFaxNumber", &["fax"], &["work"]),
|
||||
("Contacts:PagerNumber", &["pager"], &[]),
|
||||
("Contacts:CarPhoneNumber", &["car"], &[]),
|
||||
(
|
||||
"Contacts:BusinessPhoneNumber",
|
||||
&["work"],
|
||||
&["fax", "cell", "pager"],
|
||||
),
|
||||
(
|
||||
"Contacts:Business2PhoneNumber",
|
||||
&["work"],
|
||||
&["fax", "cell", "pager"],
|
||||
),
|
||||
("Contacts:HomePhoneNumber", &["home"], &["fax", "cell"]),
|
||||
("Contacts:Home2PhoneNumber", &["home"], &["fax", "cell"]),
|
||||
("Contacts:RadioPhoneNumber", &["x-radio"], &[]),
|
||||
("Contacts:AssistantPhoneNumber", &["x-assistant"], &[]),
|
||||
("Contacts2:CompanyMainPhone", &["x-company"], &[]),
|
||||
("Contacts2:MMS", &["x-mms"], &[]),
|
||||
];
|
||||
|
||||
fn tel_types(slot: &str) -> &'static str {
|
||||
match slot {
|
||||
"Contacts:MobilePhoneNumber" => "CELL",
|
||||
"Contacts:BusinessFaxNumber" => "WORK,FAX",
|
||||
"Contacts:HomeFaxNumber" => "HOME,FAX",
|
||||
"Contacts:PagerNumber" => "PAGER",
|
||||
"Contacts:CarPhoneNumber" => "CAR",
|
||||
"Contacts:BusinessPhoneNumber" | "Contacts:Business2PhoneNumber" => "WORK,VOICE",
|
||||
"Contacts:HomePhoneNumber" | "Contacts:Home2PhoneNumber" => "HOME,VOICE",
|
||||
"Contacts:RadioPhoneNumber" => "X-RADIO",
|
||||
"Contacts:AssistantPhoneNumber" => "X-ASSISTANT",
|
||||
"Contacts2:CompanyMainPhone" => "X-COMPANY",
|
||||
"Contacts2:MMS" => "X-MMS",
|
||||
_ => "VOICE",
|
||||
}
|
||||
}
|
||||
|
||||
/// Assign TEL properties to EAS slots. Returns (slot -> prop index) and leftover indices.
|
||||
fn assign_tels(tels: &[&Property]) -> (Vec<(&'static str, usize)>, Vec<usize>) {
|
||||
let mut used = vec![false; tels.len()];
|
||||
let mut out = Vec::new();
|
||||
for (slot, req, exc) in PHONE_SLOTS {
|
||||
let pos = tels.iter().enumerate().position(|(i, p)| {
|
||||
if used[i] {
|
||||
return false;
|
||||
}
|
||||
let t = p.types();
|
||||
req.iter().all(|r| t.iter().any(|x| x == r))
|
||||
&& !exc.iter().any(|e| t.iter().any(|x| x == e))
|
||||
});
|
||||
if let Some(i) = pos {
|
||||
used[i] = true;
|
||||
out.push((*slot, i));
|
||||
}
|
||||
}
|
||||
// untyped / voice-only numbers go to free home/business/mobile slots
|
||||
for fallback in [
|
||||
"Contacts:HomePhoneNumber",
|
||||
"Contacts:BusinessPhoneNumber",
|
||||
"Contacts:MobilePhoneNumber",
|
||||
"Contacts:Home2PhoneNumber",
|
||||
"Contacts:Business2PhoneNumber",
|
||||
] {
|
||||
if out.iter().any(|(s, _)| *s == fallback) {
|
||||
continue;
|
||||
}
|
||||
if let Some(i) = (0..tels.len()).find(|i| {
|
||||
!used[*i] && {
|
||||
let t = tels[*i].types();
|
||||
t.is_empty()
|
||||
|| t.iter().all(|x| {
|
||||
x == "voice" || x == "pref" || x == "other" || x.starts_with("pref")
|
||||
})
|
||||
}
|
||||
}) {
|
||||
used[i] = true;
|
||||
out.push((fallback, i));
|
||||
}
|
||||
}
|
||||
let left = (0..tels.len()).filter(|i| !used[*i]).collect();
|
||||
(out, left)
|
||||
}
|
||||
|
||||
const ADR_SLOTS: &[(&str, &str)] = &[("Business", "work"), ("Home", "home"), ("Other", "other")];
|
||||
|
||||
fn assign_adrs(adrs: &[&Property]) -> (Vec<(&'static str, usize)>, Vec<usize>) {
|
||||
let mut used = vec![false; adrs.len()];
|
||||
let mut out = Vec::new();
|
||||
for (slot, typ) in ADR_SLOTS {
|
||||
if let Some(i) =
|
||||
(0..adrs.len()).find(|i| !used[*i] && adrs[*i].types().iter().any(|t| t == typ))
|
||||
{
|
||||
used[i] = true;
|
||||
out.push((*slot, i));
|
||||
}
|
||||
}
|
||||
// untyped address -> Other / Home
|
||||
for (slot, _) in [("Other", ""), ("Home", "")] {
|
||||
if out.iter().any(|(s, _)| *s == slot) {
|
||||
continue;
|
||||
}
|
||||
if let Some(i) = (0..adrs.len()).find(|i| {
|
||||
!used[*i]
|
||||
&& adrs[*i]
|
||||
.types()
|
||||
.iter()
|
||||
.all(|t| t == "pref" || t == "postal" || t == "parcel")
|
||||
}) {
|
||||
used[i] = true;
|
||||
out.push((slot, i));
|
||||
}
|
||||
}
|
||||
let left = (0..adrs.len()).filter(|i| !used[*i]).collect();
|
||||
(out, left)
|
||||
}
|
||||
|
||||
fn bday_to_eas(v: &str, dtz: &Tz) -> Option<String> {
|
||||
let v = v.trim();
|
||||
let d = if v.len() >= 10 && v.as_bytes()[4] == b'-' {
|
||||
NaiveDate::parse_from_str(&v[..10], "%Y-%m-%d").ok()
|
||||
} else if v.starts_with("--") {
|
||||
// --MMDD (no year) -> use 1604 like Apple/Outlook
|
||||
NaiveDate::parse_from_str(&format!("1604{}", &v[2..6.min(v.len())]), "%Y%m%d").ok()
|
||||
} else {
|
||||
NaiveDate::parse_from_str(&v[..8.min(v.len())], "%Y%m%d").ok()
|
||||
}?;
|
||||
let local = dtz
|
||||
.from_local_datetime(&d.and_hms_opt(0, 0, 0)?)
|
||||
.earliest()?;
|
||||
Some(tz::fmt_iso(&local.with_timezone(&Utc)))
|
||||
}
|
||||
|
||||
fn eas_to_date(s: &str, dtz: &Tz) -> Option<NaiveDate> {
|
||||
let t = tz::parse_eas_date(s)?;
|
||||
Some(
|
||||
(t + chrono::Duration::hours(12))
|
||||
.with_timezone(dtz)
|
||||
.date_naive(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn to_eas(vcf: &str, opts: &Options, ver: u32, dtz: Tz, max_picture: usize) -> Option<Element> {
|
||||
let cards = ical::parse(vcf);
|
||||
let c = cards.iter().find(|c| c.name == "VCARD")?;
|
||||
let mut app = Element::new("AirSync:ApplicationData");
|
||||
let get = |n: &str| c.text(n);
|
||||
if let Some(b) = get("ANNIVERSARY")
|
||||
.or_else(|| get("X-ANNIVERSARY"))
|
||||
.and_then(|v| bday_to_eas(&v, &dtz))
|
||||
{
|
||||
app.push_text("Contacts:Anniversary", b);
|
||||
}
|
||||
app.push_opt(
|
||||
"Contacts:AssistantName",
|
||||
get("X-ASSISTANT").or_else(|| get("X-MS-ASSISTANT")),
|
||||
);
|
||||
if let Some(b) = get("BDAY").and_then(|v| bday_to_eas(&v, &dtz)) {
|
||||
app.push_text("Contacts:Birthday", b);
|
||||
}
|
||||
let note = get("NOTE").unwrap_or_default();
|
||||
if let Some(b) = pim_body(¬e, opts, ver) {
|
||||
if !note.is_empty() {
|
||||
app.push(b);
|
||||
}
|
||||
} else if !note.is_empty() {
|
||||
app.push_text("Contacts:Body", note.clone());
|
||||
}
|
||||
// phones
|
||||
let tels: Vec<&Property> = c.props_named("TEL").collect();
|
||||
let (slots, _) = assign_tels(&tels);
|
||||
// addresses
|
||||
let adrs: Vec<&Property> = c.props_named("ADR").collect();
|
||||
let (aslots, _) = assign_adrs(&adrs);
|
||||
for (slot, i) in &aslots {
|
||||
let parts = ical::split_structured(&adrs[*i].value);
|
||||
let p = |n: usize| parts.get(n).cloned().unwrap_or_default();
|
||||
let street = [p(1), p(2)]
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
for (suffix, val) in [
|
||||
("City", p(3)),
|
||||
("Country", p(6)),
|
||||
("PostalCode", p(5)),
|
||||
("State", p(4)),
|
||||
("Street", street),
|
||||
] {
|
||||
if !val.is_empty() {
|
||||
app.push_text(&format!("Contacts:{slot}{suffix}"), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (slot, i) in &slots {
|
||||
if !slot.starts_with("Contacts2") {
|
||||
app.push_text(slot, tels[*i].text_value());
|
||||
}
|
||||
}
|
||||
let cats: Vec<String> = c
|
||||
.props_named("CATEGORIES")
|
||||
.flat_map(|p| ical::split_list(&p.value))
|
||||
.collect();
|
||||
if let Some(e) = categories_element("Contacts", &cats) {
|
||||
app.push(e);
|
||||
}
|
||||
if let Some(org) = c.prop("ORG") {
|
||||
let parts = ical::split_structured(&org.value);
|
||||
if let Some(n) = parts.first().filter(|s| !s.is_empty()) {
|
||||
app.push_text("Contacts:CompanyName", n.clone());
|
||||
}
|
||||
if let Some(d) = parts.get(1).filter(|s| !s.is_empty()) {
|
||||
app.push_text("Contacts:Department", d.clone());
|
||||
}
|
||||
}
|
||||
for (i, e) in c.props_named("EMAIL").take(3).enumerate() {
|
||||
app.push_text(&format!("Contacts:Email{}Address", i + 1), e.text_value());
|
||||
}
|
||||
let n = c
|
||||
.prop("N")
|
||||
.map(|n| ical::split_structured(&n.value))
|
||||
.unwrap_or_default();
|
||||
let np = |i: usize| n.get(i).cloned().filter(|s| !s.is_empty());
|
||||
let fileas = get("X-EAS-FILEAS")
|
||||
.or_else(|| get("FN"))
|
||||
.or_else(|| match (np(0), np(1)) {
|
||||
(Some(l), Some(f)) => Some(format!("{l}, {f}")),
|
||||
(Some(l), None) => Some(l),
|
||||
(None, Some(f)) => Some(f),
|
||||
_ => None,
|
||||
});
|
||||
app.push_opt("Contacts:FileAs", fileas);
|
||||
app.push_opt("Contacts:FirstName", np(1));
|
||||
app.push_opt("Contacts:JobTitle", get("TITLE"));
|
||||
app.push_opt("Contacts:LastName", np(0));
|
||||
app.push_opt("Contacts:MiddleName", np(2));
|
||||
app.push_opt("Contacts:OfficeLocation", get("X-OFFICE-LOCATION"));
|
||||
app.push_opt("Contacts:Spouse", get("X-SPOUSE"));
|
||||
app.push_opt("Contacts:Suffix", np(4));
|
||||
app.push_opt("Contacts:Title", np(3));
|
||||
app.push_opt("Contacts:WebPage", get("URL"));
|
||||
app.push_opt("Contacts:YomiCompanyName", get("X-PHONETIC-ORG"));
|
||||
app.push_opt("Contacts:YomiFirstName", get("X-PHONETIC-FIRST-NAME"));
|
||||
app.push_opt("Contacts:YomiLastName", get("X-PHONETIC-LAST-NAME"));
|
||||
if let Some(ph) = c.prop("PHOTO") {
|
||||
let v = ph.value.trim();
|
||||
let data = if let Some(idx) = v.find("base64,") {
|
||||
v[idx + 7..].to_string()
|
||||
} else if ph
|
||||
.param("ENCODING")
|
||||
.map(|e| e.eq_ignore_ascii_case("b") || e.eq_ignore_ascii_case("BASE64"))
|
||||
.unwrap_or(false)
|
||||
|| ph.params.iter().any(|(k, _)| k == "BASE64")
|
||||
{
|
||||
v.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let data: String = data.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
if !data.is_empty() && data.len() * 3 / 4 <= max_picture {
|
||||
app.push_text("Contacts:Picture", data);
|
||||
}
|
||||
}
|
||||
// Contacts2
|
||||
app.push_opt("Contacts2:CustomerId", get("X-CUSTOMER-ID"));
|
||||
app.push_opt("Contacts2:GovernmentId", get("X-GOVERNMENT-ID"));
|
||||
for (i, im) in c.props_named("IMPP").take(3).enumerate() {
|
||||
let tag = if i == 0 {
|
||||
"Contacts2:IMAddress".to_string()
|
||||
} else {
|
||||
format!("Contacts2:IMAddress{}", i + 1)
|
||||
};
|
||||
app.push_text(&tag, im.text_value());
|
||||
}
|
||||
app.push_opt("Contacts2:ManagerName", get("X-MANAGER"));
|
||||
for (slot, i) in &slots {
|
||||
if slot.starts_with("Contacts2") {
|
||||
app.push_text(slot, tels[*i].text_value());
|
||||
}
|
||||
}
|
||||
app.push_opt("Contacts2:AccountName", get("X-ACCOUNT-NAME"));
|
||||
app.push_opt("Contacts2:NickName", get("NICKNAME"));
|
||||
if ver >= 120 {
|
||||
app.push_text("AirSyncBase:NativeBodyType", "1");
|
||||
}
|
||||
Some(app)
|
||||
}
|
||||
|
||||
const MANAGED: &[&str] = &[
|
||||
"FN",
|
||||
"N",
|
||||
"ORG",
|
||||
"TITLE",
|
||||
"BDAY",
|
||||
"ANNIVERSARY",
|
||||
"X-ANNIVERSARY",
|
||||
"URL",
|
||||
"NOTE",
|
||||
"CATEGORIES",
|
||||
"NICKNAME",
|
||||
"X-SPOUSE",
|
||||
"X-ASSISTANT",
|
||||
"X-MS-ASSISTANT",
|
||||
"X-MANAGER",
|
||||
"X-OFFICE-LOCATION",
|
||||
"X-PHONETIC-ORG",
|
||||
"X-PHONETIC-FIRST-NAME",
|
||||
"X-PHONETIC-LAST-NAME",
|
||||
"X-CUSTOMER-ID",
|
||||
"X-GOVERNMENT-ID",
|
||||
"X-ACCOUNT-NAME",
|
||||
"X-EAS-FILEAS",
|
||||
"REV",
|
||||
];
|
||||
|
||||
pub fn from_eas(app: &Element, existing: Option<&str>, dtz: Tz) -> Option<String> {
|
||||
let mut card = existing
|
||||
.and_then(|e| ical::parse(e).into_iter().find(|c| c.name == "VCARD"))
|
||||
.unwrap_or_else(|| {
|
||||
let mut c = Component::new("VCARD");
|
||||
c.add(Property::new("VERSION", "3.0"));
|
||||
c.add(Property::new("PRODID", "-//ActivePush//ActiveSync//EN"));
|
||||
c.add(Property::text("UID", &uuid::Uuid::new_v4().to_string()));
|
||||
c
|
||||
});
|
||||
let v4 = card
|
||||
.text("VERSION")
|
||||
.map(|v| v.starts_with('4'))
|
||||
.unwrap_or(false);
|
||||
let g = |n: &str| app.child_text(n).filter(|s| !s.trim().is_empty());
|
||||
|
||||
// keep overflow EMAIL/TEL/ADR/IMPP that EAS cannot represent
|
||||
let old_emails: Vec<Property> = card.props_named("EMAIL").skip(3).cloned().collect();
|
||||
let old_tels: Vec<Property> = card.props_named("TEL").cloned().collect();
|
||||
let tel_refs: Vec<&Property> = old_tels.iter().collect();
|
||||
let (_, tel_left) = assign_tels(&tel_refs);
|
||||
let keep_tels: Vec<Property> = tel_left.iter().map(|i| old_tels[*i].clone()).collect();
|
||||
let old_adrs: Vec<Property> = card.props_named("ADR").cloned().collect();
|
||||
let adr_refs: Vec<&Property> = old_adrs.iter().collect();
|
||||
let (_, adr_left) = assign_adrs(&adr_refs);
|
||||
let keep_adrs: Vec<Property> = adr_left.iter().map(|i| old_adrs[*i].clone()).collect();
|
||||
let old_impp: Vec<Property> = card.props_named("IMPP").skip(3).cloned().collect();
|
||||
let old_photo = card.prop("PHOTO").cloned();
|
||||
|
||||
card.props.retain(|p| {
|
||||
!MANAGED.iter().any(|m| p.name.eq_ignore_ascii_case(m))
|
||||
&& !["EMAIL", "TEL", "ADR", "IMPP", "PHOTO"].contains(&p.name.as_str())
|
||||
});
|
||||
|
||||
let (first, last, middle, title, suffix) = (
|
||||
g("Contacts:FirstName").unwrap_or_default(),
|
||||
g("Contacts:LastName").unwrap_or_default(),
|
||||
g("Contacts:MiddleName").unwrap_or_default(),
|
||||
g("Contacts:Title").unwrap_or_default(),
|
||||
g("Contacts:Suffix").unwrap_or_default(),
|
||||
);
|
||||
let company = g("Contacts:CompanyName").unwrap_or_default();
|
||||
let fn_ = [
|
||||
title.as_str(),
|
||||
first.as_str(),
|
||||
middle.as_str(),
|
||||
last.as_str(),
|
||||
suffix.as_str(),
|
||||
]
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let fn_ = if fn_.is_empty() {
|
||||
g("Contacts:FileAs")
|
||||
.or_else(|| (!company.is_empty()).then(|| company.clone()))
|
||||
.or_else(|| g("Contacts:Email1Address"))
|
||||
.unwrap_or_else(|| "?".into())
|
||||
} else {
|
||||
fn_
|
||||
};
|
||||
card.add(Property::text("FN", &fn_));
|
||||
card.add(Property::new(
|
||||
"N",
|
||||
[&last, &first, &middle, &title, &suffix]
|
||||
.iter()
|
||||
.map(|s| ical::escape_text(s))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";"),
|
||||
));
|
||||
if let Some(fa) = g("Contacts:FileAs") {
|
||||
if fa != fn_ {
|
||||
card.add(Property::text("X-EAS-FILEAS", &fa));
|
||||
}
|
||||
}
|
||||
let dept = g("Contacts:Department").unwrap_or_default();
|
||||
if !company.is_empty() || !dept.is_empty() {
|
||||
card.add(Property::new(
|
||||
"ORG",
|
||||
format!(
|
||||
"{};{}",
|
||||
ical::escape_text(&company),
|
||||
ical::escape_text(&dept)
|
||||
),
|
||||
));
|
||||
}
|
||||
if let Some(t) = g("Contacts:JobTitle") {
|
||||
card.add(Property::text("TITLE", &t));
|
||||
}
|
||||
for i in 1..=3 {
|
||||
if let Some(e) = g(&format!("Contacts:Email{i}Address")) {
|
||||
// devices send `"Name" <addr>`
|
||||
let addr = e
|
||||
.rsplit_once('<')
|
||||
.map(|(_, a)| a.trim_end_matches('>').to_string())
|
||||
.unwrap_or(e)
|
||||
.trim()
|
||||
.to_string();
|
||||
let typ = match i {
|
||||
1 => "INTERNET,WORK",
|
||||
2 => "INTERNET,HOME",
|
||||
_ => "INTERNET,OTHER",
|
||||
};
|
||||
card.add(Property::text("EMAIL", &addr).with_param("TYPE", typ));
|
||||
}
|
||||
}
|
||||
card.props.extend(old_emails);
|
||||
for (slot, _, _) in PHONE_SLOTS {
|
||||
if let Some(n) = g(slot) {
|
||||
card.add(Property::text("TEL", &n).with_param("TYPE", tel_types(slot)));
|
||||
}
|
||||
}
|
||||
card.props.extend(keep_tels);
|
||||
for (slot, typ) in ADR_SLOTS {
|
||||
let f = |s: &str| g(&format!("Contacts:{slot}{s}")).unwrap_or_default();
|
||||
let (street, city, state, zip, country) = (
|
||||
f("Street"),
|
||||
f("City"),
|
||||
f("State"),
|
||||
f("PostalCode"),
|
||||
f("Country"),
|
||||
);
|
||||
if [&street, &city, &state, &zip, &country]
|
||||
.iter()
|
||||
.any(|s| !s.is_empty())
|
||||
{
|
||||
let v = ["", "", &street, &city, &state, &zip, &country]
|
||||
.iter()
|
||||
.map(|s| ical::escape_text(s))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
card.add(Property::new("ADR", v).with_param("TYPE", &typ.to_ascii_uppercase()));
|
||||
}
|
||||
}
|
||||
card.props.extend(keep_adrs);
|
||||
if let Some(b) = g("Contacts:Birthday").and_then(|s| eas_to_date(&s, &dtz)) {
|
||||
card.add(Property::new(
|
||||
"BDAY",
|
||||
b.format(if v4 { "%Y%m%d" } else { "%Y-%m-%d" }).to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(b) = g("Contacts:Anniversary").and_then(|s| eas_to_date(&s, &dtz)) {
|
||||
let name = if v4 { "ANNIVERSARY" } else { "X-ANNIVERSARY" };
|
||||
card.add(Property::new(
|
||||
name,
|
||||
b.format(if v4 { "%Y%m%d" } else { "%Y-%m-%d" }).to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(u) = g("Contacts:WebPage") {
|
||||
card.add(Property::new("URL", u));
|
||||
}
|
||||
if let Some(n) = incoming_body(app, "Contacts").filter(|s| !s.trim().is_empty()) {
|
||||
card.add(Property::text("NOTE", &n));
|
||||
}
|
||||
if let Some(c) = categories(app, "Contacts").filter(|c| !c.is_empty()) {
|
||||
card.add(Property::new(
|
||||
"CATEGORIES",
|
||||
c.iter()
|
||||
.map(|x| ical::escape_text(x))
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
));
|
||||
}
|
||||
for (tag, prop) in [
|
||||
("Contacts:AssistantName", "X-ASSISTANT"),
|
||||
("Contacts:Spouse", "X-SPOUSE"),
|
||||
("Contacts:OfficeLocation", "X-OFFICE-LOCATION"),
|
||||
("Contacts:YomiCompanyName", "X-PHONETIC-ORG"),
|
||||
("Contacts:YomiFirstName", "X-PHONETIC-FIRST-NAME"),
|
||||
("Contacts:YomiLastName", "X-PHONETIC-LAST-NAME"),
|
||||
("Contacts2:CustomerId", "X-CUSTOMER-ID"),
|
||||
("Contacts2:GovernmentId", "X-GOVERNMENT-ID"),
|
||||
("Contacts2:ManagerName", "X-MANAGER"),
|
||||
("Contacts2:AccountName", "X-ACCOUNT-NAME"),
|
||||
("Contacts2:NickName", "NICKNAME"),
|
||||
] {
|
||||
if let Some(v) = g(tag) {
|
||||
card.add(Property::text(prop, &v));
|
||||
}
|
||||
}
|
||||
for t in [
|
||||
"Contacts2:IMAddress",
|
||||
"Contacts2:IMAddress2",
|
||||
"Contacts2:IMAddress3",
|
||||
] {
|
||||
if let Some(v) = g(t) {
|
||||
card.add(Property::text("IMPP", &v));
|
||||
}
|
||||
}
|
||||
card.props.extend(old_impp);
|
||||
match app.child("Contacts:Picture") {
|
||||
Some(p) => {
|
||||
let data = p.text_value();
|
||||
if !data.is_empty()
|
||||
&& base64::engine::general_purpose::STANDARD
|
||||
.decode(data.trim())
|
||||
.is_ok()
|
||||
{
|
||||
if v4 {
|
||||
card.add(Property::new(
|
||||
"PHOTO",
|
||||
format!("data:image/jpeg;base64,{}", data.trim()),
|
||||
));
|
||||
} else {
|
||||
card.add(
|
||||
Property::new("PHOTO", data.trim().to_string())
|
||||
.with_param("ENCODING", "b")
|
||||
.with_param("TYPE", "JPEG"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Some(p) = old_photo {
|
||||
card.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
card.add(Property::new(
|
||||
"REV",
|
||||
Utc::now().format("%Y%m%dT%H%M%SZ").to_string(),
|
||||
));
|
||||
// VERSION must come first
|
||||
if let Some(pos) = card.props.iter().position(|p| p.name == "VERSION") {
|
||||
let v = card.props.remove(pos);
|
||||
card.props.insert(0, v);
|
||||
}
|
||||
Some(card.serialize())
|
||||
}
|
||||
|
||||
/// Short summary for GAL / ResolveRecipients.
|
||||
pub struct CardSummary {
|
||||
pub display_name: String,
|
||||
pub first: String,
|
||||
pub last: String,
|
||||
pub emails: Vec<String>,
|
||||
pub phone: String,
|
||||
pub mobile: String,
|
||||
pub company: String,
|
||||
pub title: String,
|
||||
pub office: String,
|
||||
}
|
||||
|
||||
pub fn summary(vcf: &str) -> Option<CardSummary> {
|
||||
let cards = ical::parse(vcf);
|
||||
let c = cards.iter().find(|c| c.name == "VCARD")?;
|
||||
let n = c
|
||||
.prop("N")
|
||||
.map(|n| ical::split_structured(&n.value))
|
||||
.unwrap_or_default();
|
||||
let tels: Vec<&Property> = c.props_named("TEL").collect();
|
||||
let (slots, _) = assign_tels(&tels);
|
||||
let slot = |s: &str| {
|
||||
slots
|
||||
.iter()
|
||||
.find(|(x, _)| *x == s)
|
||||
.map(|(_, i)| tels[*i].text_value())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
Some(CardSummary {
|
||||
display_name: c.text("FN").unwrap_or_default(),
|
||||
first: n.get(1).cloned().unwrap_or_default(),
|
||||
last: n.first().cloned().unwrap_or_default(),
|
||||
emails: c.props_named("EMAIL").map(|e| e.text_value()).collect(),
|
||||
phone: slot("Contacts:BusinessPhoneNumber"),
|
||||
mobile: slot("Contacts:MobilePhoneNumber"),
|
||||
company: c
|
||||
.prop("ORG")
|
||||
.map(|o| {
|
||||
ical::split_structured(&o.value)
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
title: c.text("TITLE").unwrap_or_default(),
|
||||
office: c.text("X-OFFICE-LOCATION").unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_lossless() {
|
||||
let v = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Jan Kowalski\r\nN:Kowalski;Jan;;;\r\nEMAIL;TYPE=WORK:a@x.pl\r\nEMAIL:b@x.pl\r\nEMAIL:c@x.pl\r\nEMAIL:d@x.pl\r\nTEL;TYPE=CELL:+48600\r\nTEL;TYPE=WORK:+4822\r\nTEL;TYPE=X-WEIRD:999\r\nADR;TYPE=HOME:;;Polna 1;Warszawa;;00-001;PL\r\nBDAY:1985-05-12\r\nX-SOCIAL:keep\r\nEND:VCARD\r\n";
|
||||
let app = to_eas(
|
||||
v,
|
||||
&Options::default(),
|
||||
141,
|
||||
chrono_tz::Europe::Warsaw,
|
||||
100000,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.child_text("Contacts:MobilePhoneNumber").unwrap(),
|
||||
"+48600"
|
||||
);
|
||||
assert_eq!(
|
||||
app.child_text("Contacts:BusinessPhoneNumber").unwrap(),
|
||||
"+4822"
|
||||
);
|
||||
assert_eq!(app.child_text("Contacts:HomeCity").unwrap(), "Warszawa");
|
||||
assert_eq!(
|
||||
app.child_text("Contacts:Birthday").unwrap(),
|
||||
"1985-05-11T22:00:00.000Z"
|
||||
);
|
||||
assert_eq!(app.child_text("Contacts:Email3Address").unwrap(), "c@x.pl");
|
||||
let back = from_eas(&app, Some(v), chrono_tz::Europe::Warsaw).unwrap();
|
||||
assert!(back.contains("d@x.pl"), "4th email kept");
|
||||
assert!(back.contains("X-WEIRD"), "unmapped phone kept");
|
||||
assert!(back.contains("X-SOCIAL:keep"));
|
||||
assert!(back.contains("BDAY:1985-05-12"));
|
||||
assert!(back.contains("UID:c1"));
|
||||
assert!(back.starts_with("BEGIN:VCARD\r\nVERSION:3.0"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
//! RFC 5322 message -> EAS Email ApplicationData.
|
||||
|
||||
use base64::Engine;
|
||||
use mail_parser::{MessageParser, MimeHeaders, PartType};
|
||||
|
||||
use super::{body_element, legacy_trunc_size, BodyPref, Options, BODY_HTML, BODY_MIME, BODY_TEXT};
|
||||
use crate::backend::ical;
|
||||
use crate::backend::imap::FetchedMessage;
|
||||
use crate::backend::tz;
|
||||
use crate::util::{html_to_text, text_to_html, truncate_bytes};
|
||||
use crate::wbxml::Element;
|
||||
|
||||
pub struct MailContext<'a> {
|
||||
pub collection: &'a str,
|
||||
pub ver: u32,
|
||||
pub opts: &'a Options,
|
||||
pub default_tz: chrono_tz::Tz,
|
||||
}
|
||||
|
||||
fn fmt_addr(name: Option<&str>, addr: Option<&str>) -> String {
|
||||
match (name.map(str::trim).filter(|n| !n.is_empty()), addr) {
|
||||
(Some(n), Some(a)) => format!("\"{}\" <{}>", n.replace('"', "'"), a),
|
||||
(None, Some(a)) => a.to_string(),
|
||||
(Some(n), None) => n.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn addr_list(a: Option<&mail_parser::Address>) -> Option<String> {
|
||||
let a = a?;
|
||||
let v: Vec<String> = a
|
||||
.iter()
|
||||
.map(|x| fmt_addr(x.name(), x.address()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
(!v.is_empty()).then(|| v.join(", "))
|
||||
}
|
||||
|
||||
fn display_names(a: Option<&mail_parser::Address>) -> Option<String> {
|
||||
let a = a?;
|
||||
let v: Vec<String> = a
|
||||
.iter()
|
||||
.map(|x| x.name().or(x.address()).unwrap_or("").to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
(!v.is_empty()).then(|| v.join("; "))
|
||||
}
|
||||
|
||||
pub fn file_reference(collection: &str, uid: u32, idx: usize) -> String {
|
||||
format!("{collection}:{uid}:{idx}")
|
||||
}
|
||||
|
||||
pub fn parse_file_reference(r: &str) -> Option<(String, u32, usize)> {
|
||||
let mut it = r.rsplitn(3, ':');
|
||||
let idx = it.next()?.parse().ok()?;
|
||||
let uid = it.next()?.parse().ok()?;
|
||||
let col = it.next()?.to_string();
|
||||
Some((col, uid, idx))
|
||||
}
|
||||
|
||||
/// Outlook compatible GlobalObjId wrapping an iCalendar UID.
|
||||
pub fn global_obj_id(uid: &str) -> String {
|
||||
let mut v =
|
||||
hex::decode("040000008200E00074C5B7101A82E0080000000000000000000000000000000000000000")
|
||||
.unwrap();
|
||||
if uid.len() <= 64 {
|
||||
v.extend_from_slice(&((13 + uid.len()) as u32).to_le_bytes());
|
||||
v.extend_from_slice(b"vCal-Uid");
|
||||
v.extend_from_slice(&1u32.to_le_bytes());
|
||||
v.extend_from_slice(uid.as_bytes());
|
||||
v.push(0);
|
||||
} else if let Ok(b) = hex::decode(uid) {
|
||||
v = b;
|
||||
}
|
||||
base64::engine::general_purpose::STANDARD.encode(v)
|
||||
}
|
||||
|
||||
pub fn uid_from_global_obj_id(b64: &str) -> Option<String> {
|
||||
let b = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64.trim())
|
||||
.ok()?;
|
||||
let pos = b
|
||||
.windows(8)
|
||||
.position(|w| w.eq_ignore_ascii_case(b"vCal-Uid"))?;
|
||||
if pos < 4 {
|
||||
return None;
|
||||
}
|
||||
let len = u32::from_le_bytes([b[pos - 4], b[pos - 3], b[pos - 2], b[pos - 1]]) as usize;
|
||||
let start = pos + 12;
|
||||
let end = (start + len.saturating_sub(13)).min(b.len());
|
||||
Some(String::from_utf8_lossy(&b[start..end]).into_owned())
|
||||
}
|
||||
|
||||
fn conversation_id(subject: &str) -> Vec<u8> {
|
||||
let s = subject.trim();
|
||||
let mut t = s;
|
||||
loop {
|
||||
let l = t.to_ascii_lowercase();
|
||||
let stripped = [
|
||||
"re:", "fw:", "fwd:", "aw:", "odp:", "pd:", "wg:", "sv:", "tr:",
|
||||
]
|
||||
.iter()
|
||||
.find(|p| l.starts_with(*p))
|
||||
.map(|p| t[p.len()..].trim_start());
|
||||
match stripped {
|
||||
Some(n) => t = n,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
use md5::{Digest, Md5};
|
||||
Md5::digest(t.to_lowercase().as_bytes()).to_vec()
|
||||
}
|
||||
|
||||
pub struct Converted {
|
||||
pub app: Element,
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
/// Convert a fetched message to ApplicationData.
|
||||
pub fn to_eas(m: &FetchedMessage, ctx: &MailContext) -> Converted {
|
||||
let parser = MessageParser::default();
|
||||
let msg = parser.parse(&m.body);
|
||||
let mut app = Element::new("AirSync:ApplicationData");
|
||||
let ver = ctx.ver;
|
||||
let Some(msg) = msg else {
|
||||
app.push_text("Email:Subject", "(unparseable message)");
|
||||
app.push_text("Email:Read", "0");
|
||||
app.push_text("Email:MessageClass", "IPM.Note");
|
||||
return Converted {
|
||||
app,
|
||||
size: m.body.len(),
|
||||
};
|
||||
};
|
||||
|
||||
let subject = msg.subject().unwrap_or("").to_string();
|
||||
app.push_opt("Email:To", addr_list(msg.to()));
|
||||
app.push_opt("Email:Cc", addr_list(msg.cc()));
|
||||
app.push_opt("Email:From", addr_list(msg.from()));
|
||||
app.push_text("Email:Subject", subject.clone());
|
||||
app.push_opt("Email:ReplyTo", addr_list(msg.reply_to()));
|
||||
let date = m
|
||||
.internal_date
|
||||
.or_else(|| {
|
||||
msg.date()
|
||||
.and_then(|d| chrono::DateTime::from_timestamp(d.to_timestamp(), 0))
|
||||
})
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
app.push_text("Email:DateReceived", tz::fmt_iso(&date));
|
||||
app.push_opt("Email:DisplayTo", display_names(msg.to()));
|
||||
app.push_text("Email:ThreadTopic", subject.clone());
|
||||
let importance = {
|
||||
let xp = msg
|
||||
.header_raw("X-Priority")
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let imp = msg
|
||||
.header_raw("Importance")
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if xp.starts_with('1') || xp.starts_with('2') || imp == "high" {
|
||||
2
|
||||
} else if xp.starts_with('4') || xp.starts_with('5') || imp == "low" {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
};
|
||||
app.push_text("Email:Importance", importance.to_string());
|
||||
let read = m.flags.iter().any(|f| f.eq_ignore_ascii_case("\\Seen"));
|
||||
app.push_text("Email:Read", if read { "1" } else { "0" });
|
||||
|
||||
// ---- attachments
|
||||
let mut atts = Vec::new();
|
||||
for (idx, part) in msg.attachments().enumerate() {
|
||||
let name = part
|
||||
.attachment_name()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
let ct = part.content_type();
|
||||
let ext = match ct.and_then(|c| c.subtype()).unwrap_or("bin") {
|
||||
"calendar" => "ics",
|
||||
"rfc822" => "eml",
|
||||
s => s,
|
||||
};
|
||||
format!("attachment{}.{}", idx + 1, ext)
|
||||
});
|
||||
let size = part.contents().len();
|
||||
let cid = part
|
||||
.content_id()
|
||||
.map(|c| c.trim_matches(['<', '>']).to_string());
|
||||
let inline = part
|
||||
.content_disposition()
|
||||
.map(|d| d.ctype().eq_ignore_ascii_case("inline"))
|
||||
.unwrap_or(false)
|
||||
&& cid.is_some();
|
||||
atts.push((idx, name, size, cid, inline, part.is_message()));
|
||||
}
|
||||
if !atts.is_empty() {
|
||||
if ver >= 120 {
|
||||
let mut a = Element::new("AirSyncBase:Attachments");
|
||||
for (idx, name, size, cid, inline, is_msg) in &atts {
|
||||
let mut e = Element::new("AirSyncBase:Attachment")
|
||||
.with_text("AirSyncBase:DisplayName", name.clone())
|
||||
.with_text(
|
||||
"AirSyncBase:FileReference",
|
||||
file_reference(ctx.collection, m.uid, *idx),
|
||||
)
|
||||
.with_text("AirSyncBase:Method", if *is_msg { "5" } else { "1" })
|
||||
.with_text("AirSyncBase:EstimatedDataSize", size.to_string());
|
||||
if let Some(c) = cid {
|
||||
e.push_text("AirSyncBase:ContentId", c.clone());
|
||||
}
|
||||
if *inline {
|
||||
e.push_text("AirSyncBase:IsInline", "1");
|
||||
}
|
||||
a.push(e);
|
||||
}
|
||||
app.push(a);
|
||||
} else {
|
||||
let mut a = Element::new("Email:Attachments");
|
||||
for (idx, name, size, _, _, _) in &atts {
|
||||
a.push(
|
||||
Element::new("Email:Attachment")
|
||||
.with_text("Email:AttMethod", "1")
|
||||
.with_text("Email:AttSize", size.to_string())
|
||||
.with_text("Email:DisplayName", name.clone())
|
||||
.with_text("Email:AttName", file_reference(ctx.collection, m.uid, *idx)),
|
||||
);
|
||||
}
|
||||
app.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- body
|
||||
let html = msg
|
||||
.body_html(0)
|
||||
.map(|s| s.into_owned())
|
||||
.filter(|_| msg.html_body_count() > 0 && has_real_html(&msg));
|
||||
let text = msg.body_text(0).map(|s| s.into_owned());
|
||||
let plain = text
|
||||
.clone()
|
||||
.unwrap_or_else(|| html.as_deref().map(html_to_text).unwrap_or_default());
|
||||
let ctype = msg
|
||||
.content_type()
|
||||
.map(|c| format!("{}/{}", c.ctype(), c.subtype().unwrap_or("")).to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
let is_smime = ctype.contains("signed") || ctype.contains("pkcs7");
|
||||
let native = if html.is_some() { BODY_HTML } else { BODY_TEXT };
|
||||
let opts = ctx.opts;
|
||||
if ver >= 120 {
|
||||
let want_mime = opts.wants(BODY_MIME).is_some()
|
||||
&& (opts.mime_support == 2 || (opts.mime_support == 1 && is_smime));
|
||||
let (typ, data): (u8, String) = if want_mime {
|
||||
(BODY_MIME, String::from_utf8_lossy(&m.body).into_owned())
|
||||
} else if opts.wants(BODY_HTML).is_some()
|
||||
&& (html.is_some() || opts.wants(BODY_TEXT).is_none())
|
||||
{
|
||||
(
|
||||
BODY_HTML,
|
||||
html.clone().unwrap_or_else(|| text_to_html(&plain)),
|
||||
)
|
||||
} else {
|
||||
(BODY_TEXT, plain.replace("\r\n", "\n").replace('\n', "\r\n"))
|
||||
};
|
||||
let pref = opts.wants(typ).cloned().unwrap_or(BodyPref {
|
||||
typ,
|
||||
..Default::default()
|
||||
});
|
||||
app.push(body_element(typ, &data, &pref, Some(&plain)));
|
||||
} else {
|
||||
// AS 2.5
|
||||
if opts.mime_support == 2 || (opts.mime_support == 1 && is_smime) {
|
||||
let raw = String::from_utf8_lossy(&m.body).into_owned();
|
||||
let limit = opts.mime_trunc.and_then(legacy_trunc_size);
|
||||
let t = limit.map(|l| l < raw.len()).unwrap_or(false);
|
||||
app.push_text("Email:MIMETruncated", if t { "1" } else { "0" });
|
||||
app.push_text("Email:MIMESize", raw.len().to_string());
|
||||
app.push_text(
|
||||
"Email:MIMEData",
|
||||
truncate_bytes(&raw, limit.unwrap_or(usize::MAX)),
|
||||
);
|
||||
} else {
|
||||
let limit = opts.legacy_trunc.and_then(legacy_trunc_size);
|
||||
let t = limit.map(|l| l < plain.len()).unwrap_or(false);
|
||||
app.push_text("Email:BodyTruncated", if t { "1" } else { "0" });
|
||||
app.push_text(
|
||||
"Email:Body",
|
||||
truncate_bytes(&plain, limit.unwrap_or(usize::MAX)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- class / meeting
|
||||
let mut class = if is_smime {
|
||||
if ctype.contains("signed") {
|
||||
"IPM.Note.SMIME.MultipartSigned"
|
||||
} else {
|
||||
"IPM.Note.SMIME"
|
||||
}
|
||||
.to_string()
|
||||
} else {
|
||||
"IPM.Note".to_string()
|
||||
};
|
||||
let mut meeting: Option<Element> = None;
|
||||
if let Some(ics) = find_calendar_part(&msg) {
|
||||
if let Some((cls, mr)) = meeting_request(&ics, ctx.default_tz) {
|
||||
class = cls;
|
||||
meeting = mr;
|
||||
}
|
||||
}
|
||||
app.push_text("Email:MessageClass", class);
|
||||
if let Some(mr) = meeting {
|
||||
app.push(mr);
|
||||
}
|
||||
app.push_text("Email:InternetCPID", "65001");
|
||||
if ver >= 120 {
|
||||
let flagged = m.flags.iter().any(|f| f.eq_ignore_ascii_case("\\Flagged"));
|
||||
if flagged {
|
||||
app.push(
|
||||
Element::new("Email:Flag")
|
||||
.with_text("Email:FlagStatus", "2")
|
||||
.with_text("Email:FlagType", "Flag for follow up"),
|
||||
);
|
||||
} else {
|
||||
app.push(Element::new("Email:Flag"));
|
||||
}
|
||||
app.push_text("Email:ContentClass", "urn:content-classes:message");
|
||||
app.push_text("AirSyncBase:NativeBodyType", native.to_string());
|
||||
}
|
||||
if ver >= 140 {
|
||||
app.push(Element::opaque(
|
||||
"Email2:ConversationId",
|
||||
conversation_id(&subject),
|
||||
));
|
||||
let mut idx = Vec::with_capacity(5);
|
||||
idx.push(0x01);
|
||||
idx.extend_from_slice(&(date.timestamp() as u32).to_be_bytes());
|
||||
app.push(Element::opaque("Email2:ConversationIndex", idx));
|
||||
let answered = m.flags.iter().any(|f| f.eq_ignore_ascii_case("\\Answered"));
|
||||
let forwarded = m
|
||||
.flags
|
||||
.iter()
|
||||
.any(|f| f.eq_ignore_ascii_case("$Forwarded") || f.eq_ignore_ascii_case("Forwarded"));
|
||||
if answered || forwarded {
|
||||
app.push_text("Email2:LastVerbExecuted", if forwarded { "3" } else { "1" });
|
||||
app.push_text("Email2:LastVerbExecutionTime", tz::fmt_iso(&date));
|
||||
}
|
||||
}
|
||||
Converted {
|
||||
app,
|
||||
size: m.body.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes-only payload (flags) for Sync Change commands.
|
||||
pub fn flags_change(flags: &[String], ver: u32) -> Element {
|
||||
let mut app = Element::new("AirSync:ApplicationData");
|
||||
let read = flags.iter().any(|f| f.eq_ignore_ascii_case("\\Seen"));
|
||||
app.push_text("Email:Read", if read { "1" } else { "0" });
|
||||
if ver >= 120 {
|
||||
if flags.iter().any(|f| f.eq_ignore_ascii_case("\\Flagged")) {
|
||||
app.push(
|
||||
Element::new("Email:Flag")
|
||||
.with_text("Email:FlagStatus", "2")
|
||||
.with_text("Email:FlagType", "Flag for follow up"),
|
||||
);
|
||||
} else {
|
||||
app.push(Element::new("Email:Flag"));
|
||||
}
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
pub fn flags_from_fingerprint(fp: &str) -> Vec<String> {
|
||||
let mut v = Vec::new();
|
||||
for c in fp.chars() {
|
||||
match c {
|
||||
'S' => v.push("\\Seen".to_string()),
|
||||
'F' => v.push("\\Flagged".to_string()),
|
||||
'A' => v.push("\\Answered".to_string()),
|
||||
'W' => v.push("$Forwarded".to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn has_real_html(msg: &mail_parser::Message) -> bool {
|
||||
msg.html_bodies()
|
||||
.any(|p| matches!(p.body, PartType::Html(_)))
|
||||
}
|
||||
|
||||
fn find_calendar_part(msg: &mail_parser::Message) -> Option<String> {
|
||||
for part in msg.parts.iter() {
|
||||
let ct = part.content_type();
|
||||
let is_cal = ct
|
||||
.map(|c| {
|
||||
c.ctype().eq_ignore_ascii_case("text")
|
||||
&& c.subtype()
|
||||
.map(|s| s.eq_ignore_ascii_case("calendar"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if is_cal {
|
||||
if let Some(t) = part.text_contents() {
|
||||
return Some(t.to_string());
|
||||
}
|
||||
return Some(String::from_utf8_lossy(part.contents()).into_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build Email:MeetingRequest from an iMIP calendar part.
|
||||
fn meeting_request(ics: &str, default_tz: chrono_tz::Tz) -> Option<(String, Option<Element>)> {
|
||||
let cals = ical::parse(ics);
|
||||
let cal = cals.first()?;
|
||||
let method = cal.text("METHOD").unwrap_or_default().to_ascii_uppercase();
|
||||
let ev = cal.child("VEVENT")?;
|
||||
let class = match method.as_str() {
|
||||
"REQUEST" => "IPM.Schedule.Meeting.Request",
|
||||
"CANCEL" => "IPM.Schedule.Meeting.Canceled",
|
||||
"REPLY" => {
|
||||
let st = ev
|
||||
.prop("ATTENDEE")
|
||||
.and_then(|a| a.param("PARTSTAT"))
|
||||
.unwrap_or("")
|
||||
.to_ascii_uppercase();
|
||||
match st.as_str() {
|
||||
"ACCEPTED" => "IPM.Schedule.Meeting.Resp.Pos",
|
||||
"TENTATIVE" => "IPM.Schedule.Meeting.Resp.Tent",
|
||||
"DECLINED" => "IPM.Schedule.Meeting.Resp.Neg",
|
||||
_ => "IPM.Note",
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if !(method == "REQUEST" || method == "CANCEL") {
|
||||
return Some((class.to_string(), None));
|
||||
}
|
||||
let start = ev
|
||||
.prop("DTSTART")
|
||||
.and_then(|p| tz::parse_ical_time(p, default_tz))?;
|
||||
let end = ev
|
||||
.prop("DTEND")
|
||||
.and_then(|p| tz::parse_ical_time(p, default_tz))
|
||||
.map(|t| t.utc)
|
||||
.unwrap_or(start.utc + chrono::Duration::hours(1));
|
||||
let mut mr = Element::new("Email:MeetingRequest");
|
||||
mr.push_text("Email:AllDayEvent", if start.all_day { "1" } else { "0" });
|
||||
mr.push_text("Email:StartTime", tz::fmt_iso(&start.utc));
|
||||
let stamp = ev
|
||||
.prop("DTSTAMP")
|
||||
.and_then(|p| tz::parse_ical_time(p, default_tz))
|
||||
.map(|t| t.utc)
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
mr.push_text("Email:DtStamp", tz::fmt_iso(&stamp));
|
||||
mr.push_text("Email:EndTime", tz::fmt_iso(&end));
|
||||
mr.push_text(
|
||||
"Email:InstanceType",
|
||||
if ev.prop("RRULE").is_some() { "1" } else { "0" },
|
||||
);
|
||||
mr.push_opt("Email:Location", ev.text("LOCATION"));
|
||||
if let Some(o) = ev.prop("ORGANIZER") {
|
||||
let addr = o
|
||||
.value
|
||||
.trim_start_matches("mailto:")
|
||||
.trim_start_matches("MAILTO:")
|
||||
.to_string();
|
||||
mr.push_text("Email:Organizer", fmt_addr(o.param("CN"), Some(&addr)));
|
||||
}
|
||||
if ev.child("VALARM").is_some() {
|
||||
mr.push_text("Email:Reminder", "15");
|
||||
}
|
||||
mr.push_text("Email:ResponseRequested", "1");
|
||||
let sens = match ev
|
||||
.text("CLASS")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase()
|
||||
.as_str()
|
||||
{
|
||||
"PRIVATE" => "2",
|
||||
"CONFIDENTIAL" => "3",
|
||||
_ => "0",
|
||||
};
|
||||
mr.push_text("Email:Sensitivity", sens);
|
||||
mr.push_text("Email:BusyStatus", "2");
|
||||
let tzone = start.tz.unwrap_or(default_tz);
|
||||
mr.push_text(
|
||||
"Email:TimeZone",
|
||||
tz::blob_for(&tzone, chrono::Datelike::year(&start.utc)),
|
||||
);
|
||||
if let Some(uid) = ev.text("UID") {
|
||||
mr.push_text("Email:GlobalObjId", global_obj_id(&uid));
|
||||
}
|
||||
mr.push_text("Email2:MeetingMessageType", "1");
|
||||
Some((class.to_string(), Some(mr)))
|
||||
}
|
||||
|
||||
/// Raw bytes of one attachment by index (as numbered in `to_eas`).
|
||||
pub fn attachment(raw: &[u8], idx: usize) -> Option<(String, String, Vec<u8>)> {
|
||||
let msg = MessageParser::default().parse(raw)?;
|
||||
let part = msg.attachments().nth(idx)?;
|
||||
let ct = part
|
||||
.content_type()
|
||||
.map(|c| format!("{}/{}", c.ctype(), c.subtype().unwrap_or("octet-stream")))
|
||||
.unwrap_or_else(|| "application/octet-stream".into());
|
||||
let name = part.attachment_name().unwrap_or("attachment").to_string();
|
||||
let data = if part.is_message() {
|
||||
// keep the embedded message raw
|
||||
raw.get(part.raw_body_offset() as usize..part.raw_end_offset() as usize)
|
||||
.map(|s| s.to_vec())
|
||||
.unwrap_or_else(|| part.contents().to_vec())
|
||||
} else {
|
||||
part.contents().to_vec()
|
||||
};
|
||||
Some((name, ct, data))
|
||||
}
|
||||
|
||||
/// Calendar part of a message (for MeetingResponse).
|
||||
pub fn calendar_of(raw: &[u8]) -> Option<String> {
|
||||
let msg = MessageParser::default().parse(raw)?;
|
||||
find_calendar_part(&msg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn goid_roundtrip() {
|
||||
let g = global_obj_id("abc-123@example.com");
|
||||
assert_eq!(uid_from_global_obj_id(&g).unwrap(), "abc-123@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_simple() {
|
||||
let raw = "From: Jan <jan@x.pl>\r\nTo: Anna <anna@x.pl>\r\nSubject: =?utf-8?q?Za=C5=BC=C3=B3=C5=82=C4=87?=\r\nDate: Thu, 05 Nov 2026 10:00:00 +0100\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nTreść wiadomości\r\n".as_bytes().to_vec();
|
||||
let m = FetchedMessage {
|
||||
uid: 7,
|
||||
flags: vec!["\\Seen".into()],
|
||||
internal_date: None,
|
||||
body: raw,
|
||||
size: 0,
|
||||
};
|
||||
let opts = Options {
|
||||
prefs: vec![BodyPref {
|
||||
typ: 1,
|
||||
trunc: Some(5),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let c = to_eas(
|
||||
&m,
|
||||
&MailContext {
|
||||
collection: "3",
|
||||
ver: 141,
|
||||
opts: &opts,
|
||||
default_tz: chrono_tz::UTC,
|
||||
},
|
||||
);
|
||||
assert_eq!(c.app.child_text("Email:Subject").unwrap(), "Zażółć");
|
||||
assert_eq!(c.app.child_text("Email:Read").unwrap(), "1");
|
||||
let body = c.app.child("AirSyncBase:Body").unwrap();
|
||||
assert_eq!(body.child_text("AirSyncBase:Truncated").unwrap(), "1");
|
||||
assert_eq!(body.child_text("AirSyncBase:Data").unwrap(), "Treś");
|
||||
assert!(c.app.child_text("Email:From").unwrap().contains("jan@x.pl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fileref() {
|
||||
assert_eq!(
|
||||
parse_file_reference("12:345:2"),
|
||||
Some(("12".into(), 345, 2))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Conversions between backend formats (RFC 5322, iCalendar, vCard) and EAS ApplicationData.
|
||||
|
||||
pub mod calendar;
|
||||
pub mod contact;
|
||||
pub mod email;
|
||||
pub mod task;
|
||||
|
||||
use crate::util::{html_to_text, text_to_html, truncate_bytes};
|
||||
use crate::wbxml::Element;
|
||||
|
||||
pub const BODY_TEXT: u8 = 1;
|
||||
pub const BODY_HTML: u8 = 2;
|
||||
pub const BODY_RTF: u8 = 3;
|
||||
pub const BODY_MIME: u8 = 4;
|
||||
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BodyPref {
|
||||
pub typ: u8,
|
||||
pub trunc: Option<u32>,
|
||||
pub all_or_none: bool,
|
||||
pub preview: Option<u32>,
|
||||
}
|
||||
|
||||
/// Options of a collection (or ItemOperations/Search fetch).
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Options {
|
||||
pub prefs: Vec<BodyPref>,
|
||||
pub mime_support: u8,
|
||||
/// AS 2.5 Truncation code (0..9)
|
||||
pub legacy_trunc: Option<u8>,
|
||||
pub mime_trunc: Option<u8>,
|
||||
pub filter_type: u8,
|
||||
pub conflict: Option<u8>,
|
||||
pub max_items: Option<u32>,
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub fn parse(opts: &Element) -> Options {
|
||||
let mut o = Options::default();
|
||||
for bp in opts.children_named("AirSyncBase:BodyPreference") {
|
||||
o.prefs.push(BodyPref {
|
||||
typ: bp.child_u32("AirSyncBase:Type").unwrap_or(1) as u8,
|
||||
trunc: bp.child_u32("AirSyncBase:TruncationSize"),
|
||||
all_or_none: bp.child_u32("AirSyncBase:AllOrNone").unwrap_or(0) == 1,
|
||||
preview: bp.child_u32("AirSyncBase:Preview"),
|
||||
});
|
||||
}
|
||||
o.mime_support = opts.child_u32("AirSync:MIMESupport").unwrap_or(0) as u8;
|
||||
o.legacy_trunc = opts.child_u32("AirSync:Truncation").map(|v| v as u8);
|
||||
o.mime_trunc = opts.child_u32("AirSync:MIMETruncation").map(|v| v as u8);
|
||||
o.filter_type = opts.child_u32("AirSync:FilterType").unwrap_or(0) as u8;
|
||||
o.conflict = opts.child_u32("AirSync:Conflict").map(|v| v as u8);
|
||||
o.max_items = opts.child_u32("AirSync:MaxItems");
|
||||
o
|
||||
}
|
||||
|
||||
pub fn wants(&self, t: u8) -> Option<&BodyPref> {
|
||||
self.prefs.iter().find(|p| p.typ == t)
|
||||
}
|
||||
}
|
||||
|
||||
/// AS 2.5 truncation codes to byte sizes.
|
||||
pub fn legacy_trunc_size(code: u8) -> Option<usize> {
|
||||
match code {
|
||||
0 => Some(0),
|
||||
1 => Some(4096),
|
||||
2 => Some(5120),
|
||||
3 => Some(7168),
|
||||
4 => Some(10240),
|
||||
5 => Some(20480),
|
||||
6 => Some(51200),
|
||||
7 => Some(102400),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an AirSyncBase:Body element for PIM items (calendar/contacts/tasks), which store
|
||||
/// plain text natively.
|
||||
pub fn pim_body(text: &str, opts: &Options, ver: u32) -> Option<Element> {
|
||||
if ver < 120 {
|
||||
return None;
|
||||
}
|
||||
let want_html = opts.wants(BODY_HTML).is_some() && opts.wants(BODY_TEXT).is_none();
|
||||
let pref = opts
|
||||
.wants(if want_html { BODY_HTML } else { BODY_TEXT })
|
||||
.cloned()
|
||||
.unwrap_or(BodyPref {
|
||||
typ: BODY_TEXT,
|
||||
..Default::default()
|
||||
});
|
||||
let data = if pref.typ == BODY_HTML {
|
||||
text_to_html(text)
|
||||
} else {
|
||||
text.replace("\r\n", "\n").replace('\n', "\r\n")
|
||||
};
|
||||
Some(body_element(pref.typ, &data, &pref, Some(text)))
|
||||
}
|
||||
|
||||
/// Create AirSyncBase:Body with truncation / preview handling.
|
||||
pub fn body_element(
|
||||
typ: u8,
|
||||
data: &str,
|
||||
pref: &BodyPref,
|
||||
plain_for_preview: Option<&str>,
|
||||
) -> Element {
|
||||
let full = data.len();
|
||||
let mut body = Element::new("AirSyncBase:Body").with_text("AirSyncBase:Type", typ.to_string());
|
||||
let truncated = pref.trunc.map(|t| (t as usize) < full).unwrap_or(false);
|
||||
body.push_text("AirSyncBase:EstimatedDataSize", full.to_string());
|
||||
if truncated {
|
||||
body.push_text("AirSyncBase:Truncated", "1");
|
||||
if !pref.all_or_none {
|
||||
let t = pref.trunc.unwrap_or(0) as usize;
|
||||
body.push_text("AirSyncBase:Data", truncate_bytes(data, t));
|
||||
}
|
||||
} else if full > 0 {
|
||||
body.push_text("AirSyncBase:Data", data);
|
||||
}
|
||||
if let Some(p) = pref.preview.filter(|p| *p > 0) {
|
||||
let src = plain_for_preview.map(|s| s.to_string()).unwrap_or_else(|| {
|
||||
if typ == BODY_HTML {
|
||||
html_to_text(data)
|
||||
} else {
|
||||
data.to_string()
|
||||
}
|
||||
});
|
||||
let flat: String = src.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
body.push_text(
|
||||
"AirSyncBase:Preview",
|
||||
truncate_bytes(&flat, p.min(255) as usize),
|
||||
);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// Extract plain text from a client supplied body (AirSyncBase:Body or legacy fields).
|
||||
pub fn incoming_body(app: &Element, legacy_ns: &str) -> Option<String> {
|
||||
if let Some(b) = app.child("AirSyncBase:Body") {
|
||||
let typ = b.child_u32("AirSyncBase:Type").unwrap_or(1);
|
||||
let data = b.child_text("AirSyncBase:Data")?;
|
||||
return Some(if typ == 2_u32 {
|
||||
html_to_text(&data)
|
||||
} else {
|
||||
data.replace("\r\n", "\n")
|
||||
});
|
||||
}
|
||||
app.child_text(&format!("{legacy_ns}:Body"))
|
||||
.map(|s| s.replace("\r\n", "\n"))
|
||||
}
|
||||
|
||||
pub fn categories(app: &Element, ns: &str) -> Option<Vec<String>> {
|
||||
app.child(&format!("{ns}:Categories")).map(|c| {
|
||||
c.children_named(&format!("{ns}:Category"))
|
||||
.map(|x| x.text_value())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn categories_element(ns: &str, cats: &[String]) -> Option<Element> {
|
||||
if cats.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut c = Element::new(&format!("{ns}:Categories"));
|
||||
for x in cats {
|
||||
c.push_text(&format!("{ns}:Category"), x.clone());
|
||||
}
|
||||
Some(c)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! iCalendar VTODO <-> EAS Tasks.
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
|
||||
use chrono_tz::Tz;
|
||||
|
||||
use super::calendar::{eas_to_rrule, masters_and_overrides, rrule_to_eas};
|
||||
use super::{categories, categories_element, incoming_body, pim_body, Options};
|
||||
use crate::backend::ical::{self, Component, Property};
|
||||
use crate::backend::tz;
|
||||
use crate::wbxml::Element;
|
||||
|
||||
/// "Local" EAS task dates are local wall-clock times written with a Z suffix.
|
||||
fn local_as_utc(d: &DateTime<Utc>, tzone: &Tz) -> String {
|
||||
tz::fmt_iso(&Utc.from_utc_datetime(&d.with_timezone(tzone).naive_local()))
|
||||
}
|
||||
|
||||
fn utc_from_local_field(s: &str, tzone: &Tz) -> Option<DateTime<Utc>> {
|
||||
let fake = tz::parse_eas_date(s)?;
|
||||
let n: NaiveDateTime = fake.naive_utc();
|
||||
tzone
|
||||
.from_local_datetime(&n)
|
||||
.earliest()
|
||||
.map(|x| x.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
pub fn to_eas(ics: &str, opts: &Options, ver: u32, dtz: Tz) -> Option<Element> {
|
||||
let cals = ical::parse(ics);
|
||||
let cal = cals.iter().find(|c| c.name == "VCALENDAR")?;
|
||||
let (master, _) = masters_and_overrides(cal, "VTODO");
|
||||
let t = master?;
|
||||
let mut app = Element::new("AirSync:ApplicationData");
|
||||
let desc = t.text("DESCRIPTION").unwrap_or_default();
|
||||
if let Some(b) = pim_body(&desc, opts, ver) {
|
||||
app.push(b);
|
||||
} else if ver < 120 && !desc.is_empty() {
|
||||
app.push_text("Tasks:Body", desc.clone());
|
||||
}
|
||||
let cats: Vec<String> = t
|
||||
.props_named("CATEGORIES")
|
||||
.flat_map(|p| ical::split_list(&p.value))
|
||||
.collect();
|
||||
if let Some(c) = categories_element("Tasks", &cats) {
|
||||
app.push(c);
|
||||
}
|
||||
let completed = t
|
||||
.text("STATUS")
|
||||
.map(|s| s.eq_ignore_ascii_case("COMPLETED"))
|
||||
.unwrap_or(false)
|
||||
|| t.prop("COMPLETED").is_some();
|
||||
app.push_text("Tasks:Complete", if completed { "1" } else { "0" });
|
||||
if completed {
|
||||
let c = t
|
||||
.prop("COMPLETED")
|
||||
.and_then(|p| tz::parse_ical_time(p, dtz))
|
||||
.map(|x| x.utc)
|
||||
.unwrap_or_else(Utc::now);
|
||||
app.push_text("Tasks:DateCompleted", tz::fmt_iso(&c));
|
||||
}
|
||||
let due = t.prop("DUE").and_then(|p| tz::parse_ical_time(p, dtz));
|
||||
if let Some(d) = &due {
|
||||
app.push_text("Tasks:DueDate", local_as_utc(&d.utc, &d.tz.unwrap_or(dtz)));
|
||||
app.push_text("Tasks:UtcDueDate", tz::fmt_iso(&d.utc));
|
||||
}
|
||||
let prio: u32 = t.text("PRIORITY").and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||
let imp = match prio {
|
||||
1..=4 => 2,
|
||||
6..=9 => 0,
|
||||
_ => 1,
|
||||
};
|
||||
app.push_text("Tasks:Importance", imp.to_string());
|
||||
let start = t.prop("DTSTART").and_then(|p| tz::parse_ical_time(p, dtz));
|
||||
if let (Some(rr), Some(s)) = (t.prop("RRULE"), &start) {
|
||||
if let Some(mut rec) =
|
||||
rrule_to_eas(&rr.value, &s.utc, &s.tz.unwrap_or(dtz), "Tasks", s.all_day)
|
||||
{
|
||||
rec.push_text("Tasks:Start", local_as_utc(&s.utc, &s.tz.unwrap_or(dtz)));
|
||||
app.push(rec);
|
||||
}
|
||||
}
|
||||
// reminder
|
||||
let mut reminder: Option<DateTime<Utc>> = None;
|
||||
if let Some(a) = t.child("VALARM") {
|
||||
if let Some(tr) = a.prop("TRIGGER") {
|
||||
if tr
|
||||
.param("VALUE")
|
||||
.map(|v| v.eq_ignore_ascii_case("DATE-TIME"))
|
||||
.unwrap_or(false)
|
||||
|| tr.value.ends_with('Z')
|
||||
{
|
||||
reminder = tz::parse_eas_date(&tr.value);
|
||||
} else if let Some(m) = super::calendar::reminder_minutes(&t) {
|
||||
let base = if tr
|
||||
.param("RELATED")
|
||||
.map(|r| r.eq_ignore_ascii_case("END"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
due.as_ref().map(|d| d.utc)
|
||||
} else {
|
||||
start
|
||||
.as_ref()
|
||||
.map(|s| s.utc)
|
||||
.or(due.as_ref().map(|d| d.utc))
|
||||
};
|
||||
reminder = base.map(|b| b - chrono::Duration::minutes(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
app.push_text(
|
||||
"Tasks:ReminderSet",
|
||||
if reminder.is_some() { "1" } else { "0" },
|
||||
);
|
||||
if let Some(r) = reminder {
|
||||
app.push_text("Tasks:ReminderTime", tz::fmt_iso(&r));
|
||||
}
|
||||
let sens = match t
|
||||
.text("CLASS")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase()
|
||||
.as_str()
|
||||
{
|
||||
"PRIVATE" => "2",
|
||||
"CONFIDENTIAL" => "3",
|
||||
_ => "0",
|
||||
};
|
||||
app.push_text("Tasks:Sensitivity", sens);
|
||||
if let Some(s) = &start {
|
||||
app.push_text(
|
||||
"Tasks:StartDate",
|
||||
local_as_utc(&s.utc, &s.tz.unwrap_or(dtz)),
|
||||
);
|
||||
app.push_text("Tasks:UtcStartDate", tz::fmt_iso(&s.utc));
|
||||
}
|
||||
app.push_text("Tasks:Subject", t.text("SUMMARY").unwrap_or_default());
|
||||
Some(app)
|
||||
}
|
||||
|
||||
const MANAGED: &[&str] = &[
|
||||
"SUMMARY",
|
||||
"DESCRIPTION",
|
||||
"STATUS",
|
||||
"COMPLETED",
|
||||
"PERCENT-COMPLETE",
|
||||
"DUE",
|
||||
"DTSTART",
|
||||
"PRIORITY",
|
||||
"CLASS",
|
||||
"CATEGORIES",
|
||||
"RRULE",
|
||||
"DTSTAMP",
|
||||
"LAST-MODIFIED",
|
||||
"UID",
|
||||
];
|
||||
|
||||
pub fn from_eas(app: &Element, existing: Option<&str>, dtz: Tz) -> Option<String> {
|
||||
let mut cal = existing
|
||||
.and_then(|e| ical::parse(e).into_iter().find(|c| c.name == "VCALENDAR"))
|
||||
.unwrap_or_else(|| {
|
||||
let mut c = Component::new("VCALENDAR");
|
||||
c.add(Property::new("VERSION", "2.0"));
|
||||
c.add(Property::new("PRODID", "-//ActivePush//ActiveSync//EN"));
|
||||
c
|
||||
});
|
||||
let (old, _) = masters_and_overrides(&cal, "VTODO");
|
||||
let mut t = old.clone().unwrap_or_else(|| Component::new("VTODO"));
|
||||
let uid = old
|
||||
.as_ref()
|
||||
.and_then(|o| o.text("UID"))
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
t.props
|
||||
.retain(|p| !MANAGED.iter().any(|m| p.name.eq_ignore_ascii_case(m)));
|
||||
t.props.insert(0, Property::text("UID", &uid));
|
||||
t.add(tz::ical_utc("DTSTAMP", &Utc::now()));
|
||||
t.add(tz::ical_utc("LAST-MODIFIED", &Utc::now()));
|
||||
if let Some(s) = app.child_text("Tasks:Subject") {
|
||||
t.add(Property::text("SUMMARY", &s));
|
||||
}
|
||||
if let Some(b) = incoming_body(app, "Tasks").filter(|b| !b.trim().is_empty()) {
|
||||
t.add(Property::text("DESCRIPTION", &b));
|
||||
} else if !app.has("AirSyncBase:Body") {
|
||||
if let Some(d) = old.as_ref().and_then(|o| o.prop("DESCRIPTION")) {
|
||||
t.add(d.clone());
|
||||
}
|
||||
}
|
||||
let complete = app.child_u32("Tasks:Complete").unwrap_or(0) == 1;
|
||||
if complete {
|
||||
t.add(Property::new("STATUS", "COMPLETED"));
|
||||
t.add(Property::new("PERCENT-COMPLETE", "100"));
|
||||
let c = app
|
||||
.child_text("Tasks:DateCompleted")
|
||||
.and_then(|s| tz::parse_eas_date(&s))
|
||||
.unwrap_or_else(Utc::now);
|
||||
t.add(tz::ical_utc("COMPLETED", &c));
|
||||
} else {
|
||||
t.add(Property::new("STATUS", "NEEDS-ACTION"));
|
||||
}
|
||||
let due = app
|
||||
.child_text("Tasks:UtcDueDate")
|
||||
.and_then(|s| tz::parse_eas_date(&s))
|
||||
.or_else(|| {
|
||||
app.child_text("Tasks:DueDate")
|
||||
.and_then(|s| utc_from_local_field(&s, &dtz))
|
||||
});
|
||||
if let Some(d) = due {
|
||||
t.add(tz::ical_utc("DUE", &d));
|
||||
}
|
||||
let start = app
|
||||
.child_text("Tasks:UtcStartDate")
|
||||
.and_then(|s| tz::parse_eas_date(&s))
|
||||
.or_else(|| {
|
||||
app.child_text("Tasks:StartDate")
|
||||
.and_then(|s| utc_from_local_field(&s, &dtz))
|
||||
});
|
||||
if let Some(s) = start {
|
||||
t.add(tz::ical_utc("DTSTART", &s));
|
||||
}
|
||||
let prio = match app.child_u32("Tasks:Importance").unwrap_or(1) {
|
||||
2 => "1",
|
||||
0 => "9",
|
||||
_ => "5",
|
||||
};
|
||||
t.add(Property::new("PRIORITY", prio));
|
||||
if let Some(s) = app.child_text("Tasks:Sensitivity") {
|
||||
t.add(Property::new(
|
||||
"CLASS",
|
||||
match s.as_str() {
|
||||
"2" => "PRIVATE",
|
||||
"3" => "CONFIDENTIAL",
|
||||
_ => "PUBLIC",
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(cats) = categories(app, "Tasks").filter(|c| !c.is_empty()) {
|
||||
t.add(Property::new(
|
||||
"CATEGORIES",
|
||||
cats.iter()
|
||||
.map(|c| ical::escape_text(c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
));
|
||||
}
|
||||
if let Some(rec) = app.child("Tasks:Recurrence") {
|
||||
if let Some(rr) = eas_to_rrule(rec, "Tasks", false, &dtz) {
|
||||
t.add(Property::new("RRULE", rr));
|
||||
if start.is_none() {
|
||||
if let Some(s) = rec
|
||||
.child_text("Tasks:Start")
|
||||
.and_then(|s| utc_from_local_field(&s, &dtz))
|
||||
{
|
||||
t.add(tz::ical_utc("DTSTART", &s));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.children.retain(|c| c.name != "VALARM");
|
||||
if app.child_u32("Tasks:ReminderSet").unwrap_or(0) == 1 {
|
||||
if let Some(r) = app
|
||||
.child_text("Tasks:ReminderTime")
|
||||
.and_then(|s| tz::parse_eas_date(&s))
|
||||
{
|
||||
let mut a = Component::new("VALARM");
|
||||
a.add(Property::new("ACTION", "DISPLAY"));
|
||||
a.add(Property::text(
|
||||
"DESCRIPTION",
|
||||
&t.text("SUMMARY").unwrap_or_else(|| "Reminder".into()),
|
||||
));
|
||||
a.add(tz::ical_utc("TRIGGER", &r).with_param("VALUE", "DATE-TIME"));
|
||||
t.children.push(a);
|
||||
}
|
||||
}
|
||||
cal.children.retain(|c| c.name != "VTODO");
|
||||
cal.children.push(t);
|
||||
Some(cal.serialize())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t1\r\nSUMMARY:Kup mleko\r\nDUE:20261106T120000Z\r\nPRIORITY:1\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
|
||||
let app = to_eas(ics, &Options::default(), 141, chrono_tz::Europe::Warsaw).unwrap();
|
||||
assert_eq!(
|
||||
app.child_text("Tasks:UtcDueDate").unwrap(),
|
||||
"2026-11-06T12:00:00.000Z"
|
||||
);
|
||||
assert_eq!(
|
||||
app.child_text("Tasks:DueDate").unwrap(),
|
||||
"2026-11-06T13:00:00.000Z"
|
||||
);
|
||||
assert_eq!(app.child_text("Tasks:Importance").unwrap(), "2");
|
||||
let mut app2 = app.clone();
|
||||
app2.children.retain(|c| !c.is("Tasks:Complete"));
|
||||
app2.push_text("Tasks:Complete", "1");
|
||||
let back = from_eas(&app2, Some(ics), chrono_tz::Europe::Warsaw).unwrap();
|
||||
assert!(back.contains("STATUS:COMPLETED"));
|
||||
assert!(back.contains("UID:t1"));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! ItemOperations and GetAttachment.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::Engine;
|
||||
|
||||
use super::convert::{email, Options};
|
||||
use super::folders::resolve;
|
||||
use super::sync::{convert_raw, parse_server_id};
|
||||
use super::{Ctx, Reply};
|
||||
use crate::backend::Kind;
|
||||
use crate::wbxml::{self, Element};
|
||||
|
||||
/// Load one attachment by FileReference. Returns (name, content type, bytes).
|
||||
async fn load_attachment(ctx: &mut Ctx, fref: &str) -> Result<(String, String, Vec<u8>)> {
|
||||
let (col, uid, idx) =
|
||||
email::parse_file_reference(fref).ok_or_else(|| anyhow!("invalid file reference"))?;
|
||||
let folder = resolve(ctx, &col)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("not found"))?;
|
||||
if folder.kind() != Kind::Mail {
|
||||
return Err(anyhow!("not found"));
|
||||
}
|
||||
let m = ctx
|
||||
.sess
|
||||
.fetch_mail(folder.path(), uid)
|
||||
.await
|
||||
.map_err(|_| anyhow!("not found"))?;
|
||||
email::attachment(&m.body, idx).ok_or_else(|| anyhow!("not found"))
|
||||
}
|
||||
|
||||
fn attachments_allowed(ctx: &Ctx) -> bool {
|
||||
!ctx.s.provisioning.enabled || ctx.s.policy_for(ctx.user()).attenabled
|
||||
}
|
||||
|
||||
fn max_att(ctx: &Ctx) -> usize {
|
||||
let mut m = (ctx.s.sync.max_attachment_mb * 1024 * 1024) as usize;
|
||||
if ctx.s.provisioning.enabled {
|
||||
let p = ctx.s.policy_for(ctx.user()).maxattsize;
|
||||
if p > 0 {
|
||||
m = m.min(p as usize);
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
pub async fn get_attachment(ctx: &mut Ctx) -> Result<Reply> {
|
||||
let name = ctx.req.attachment_name.clone().unwrap_or_default();
|
||||
if !attachments_allowed(ctx) {
|
||||
return Ok(Reply::Status(axum::http::StatusCode::FORBIDDEN));
|
||||
}
|
||||
match load_attachment(ctx, &name).await {
|
||||
Ok((_, _, data)) if data.len() > max_att(ctx) => {
|
||||
Ok(Reply::Status(axum::http::StatusCode::PAYLOAD_TOO_LARGE))
|
||||
}
|
||||
Ok((_, _ct, data)) => {
|
||||
ctx.items_out += 1;
|
||||
Ok(Reply::Raw("application/octet-stream", data))
|
||||
}
|
||||
Err(e) => {
|
||||
ctx.status = format!("attachment: {e}");
|
||||
Ok(Reply::Status(axum::http::StatusCode::NOT_FOUND))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_range(r: &str, total: usize) -> Option<(usize, usize)> {
|
||||
let (a, b) = r.split_once('-')?;
|
||||
let a: usize = a.trim().parse().ok()?;
|
||||
let b: usize = b.trim().parse().ok()?;
|
||||
if a > b || a >= total.max(1) {
|
||||
return None;
|
||||
}
|
||||
Some((a, b.min(total.saturating_sub(1))))
|
||||
}
|
||||
|
||||
pub async fn item_operations(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||
let Some(req) = req else {
|
||||
return Ok(Reply::Wbxml(
|
||||
Element::new("ItemOperations:ItemOperations").with_text("ItemOperations:Status", "2"),
|
||||
));
|
||||
};
|
||||
let multipart = ctx.req.accept_multipart;
|
||||
let mut parts: Vec<Vec<u8>> = Vec::new();
|
||||
let mut response = Element::new("ItemOperations:Response");
|
||||
for op in &req.children {
|
||||
match op.tag() {
|
||||
"Fetch" => {
|
||||
let mut f = Element::new("ItemOperations:Fetch");
|
||||
let opts = op
|
||||
.child("ItemOperations:Options")
|
||||
.map(Options::parse)
|
||||
.unwrap_or_default();
|
||||
let range = op
|
||||
.child("ItemOperations:Options")
|
||||
.and_then(|o| o.child_text("ItemOperations:Range"));
|
||||
if let Some(fref) = op.child_text("AirSyncBase:FileReference") {
|
||||
if !attachments_allowed(ctx) {
|
||||
f.push_text("ItemOperations:Status", "16");
|
||||
f.push_text("AirSyncBase:FileReference", fref);
|
||||
response.push(f);
|
||||
continue;
|
||||
}
|
||||
match load_attachment(ctx, &fref).await {
|
||||
Ok((_, ct, data)) if data.len() > max_att(ctx) => {
|
||||
let _ = ct;
|
||||
f.push_text("ItemOperations:Status", "11");
|
||||
f.push_text("AirSyncBase:FileReference", fref);
|
||||
}
|
||||
Ok((_, ct, data)) => {
|
||||
let total = data.len();
|
||||
let (slice, rng) =
|
||||
match range.as_deref().and_then(|r| parse_range(r, total)) {
|
||||
Some((a, b)) => (
|
||||
&data[a..=b.min(total.saturating_sub(1))],
|
||||
Some(format!("{a}-{b}")),
|
||||
),
|
||||
None if range.is_some() && total > 0 => {
|
||||
f.push_text("ItemOperations:Status", "8");
|
||||
f.push_text("AirSyncBase:FileReference", fref);
|
||||
response.push(f);
|
||||
continue;
|
||||
}
|
||||
None => (&data[..], None),
|
||||
};
|
||||
f.push_text("ItemOperations:Status", "1");
|
||||
f.push_text("AirSyncBase:FileReference", fref);
|
||||
let mut props = Element::new("ItemOperations:Properties");
|
||||
props.push_text("AirSyncBase:ContentType", ct);
|
||||
if let Some(r) = rng {
|
||||
props.push_text("ItemOperations:Range", r);
|
||||
props.push_text("ItemOperations:Total", total.to_string());
|
||||
}
|
||||
if multipart {
|
||||
parts.push(slice.to_vec());
|
||||
props.push_text("ItemOperations:Part", parts.len().to_string());
|
||||
} else {
|
||||
props.push_text(
|
||||
"ItemOperations:Data",
|
||||
base64::engine::general_purpose::STANDARD.encode(slice),
|
||||
);
|
||||
}
|
||||
f.push(props);
|
||||
ctx.items_out += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::info!(user = %ctx.user(), "attachment fetch failed: {e:#}");
|
||||
f.push_text(
|
||||
"ItemOperations:Status",
|
||||
if e.to_string().contains("invalid") {
|
||||
"15"
|
||||
} else {
|
||||
"6"
|
||||
},
|
||||
);
|
||||
f.push_text("AirSyncBase:FileReference", fref);
|
||||
}
|
||||
}
|
||||
response.push(f);
|
||||
continue;
|
||||
}
|
||||
// item fetch (by CollectionId + ServerId, or LongId from Search)
|
||||
let (col, sid) = match (
|
||||
op.child_text("AirSync:CollectionId"),
|
||||
op.child_text("AirSync:ServerId"),
|
||||
op.child_text("Search:LongId"),
|
||||
) {
|
||||
(Some(c), Some(s), _) => (c, s),
|
||||
(_, _, Some(l)) => (super::sync::collection_of(&l).to_string(), l),
|
||||
_ => {
|
||||
f.push_text("ItemOperations:Status", "2");
|
||||
response.push(f);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let res: Result<Element> = async {
|
||||
let folder = resolve(ctx, &col)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("not found"))?;
|
||||
let id = parse_server_id(ctx, &sid, &folder.id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("not found"))?;
|
||||
let raw = ctx
|
||||
.sess
|
||||
.fetch(&folder, std::slice::from_ref(&id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("not found"))?;
|
||||
let app = convert_raw(ctx, &col, folder.kind(), &raw, &opts)
|
||||
.ok_or_else(|| anyhow!("conversion failed"))?;
|
||||
Ok(app)
|
||||
}
|
||||
.await;
|
||||
match res {
|
||||
Ok(app) => {
|
||||
f.push_text("ItemOperations:Status", "1");
|
||||
if op.has("Search:LongId") {
|
||||
f.push_text("Search:LongId", sid.clone());
|
||||
} else {
|
||||
f.push_text("AirSync:CollectionId", col.clone());
|
||||
f.push_text("AirSync:ServerId", sid.clone());
|
||||
}
|
||||
let mut props = Element::new("ItemOperations:Properties");
|
||||
props.children = app.children;
|
||||
f.push(props);
|
||||
ctx.items_out += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
f.push_text(
|
||||
"ItemOperations:Status",
|
||||
if e.to_string().contains("not found") {
|
||||
"6"
|
||||
} else {
|
||||
"3"
|
||||
},
|
||||
);
|
||||
f.push_text("AirSync:CollectionId", col);
|
||||
f.push_text("AirSync:ServerId", sid);
|
||||
}
|
||||
}
|
||||
response.push(f);
|
||||
}
|
||||
"EmptyFolderContents" => {
|
||||
let col = op.child_text("AirSync:CollectionId").unwrap_or_default();
|
||||
let mut e = Element::new("ItemOperations:EmptyFolderContents");
|
||||
let res: Result<()> = async {
|
||||
let folder = resolve(ctx, &col)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("not found"))?;
|
||||
match folder.kind() {
|
||||
Kind::Mail => ctx.sess.mail_empty(&folder).await,
|
||||
_ => {
|
||||
let l = ctx.sess.list(&folder, None).await?;
|
||||
for (id, _) in l.items {
|
||||
ctx.sess.dav_delete(&folder, &id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
.await;
|
||||
e.push_text("ItemOperations:Status", if res.is_ok() { "1" } else { "3" });
|
||||
e.push_text("AirSync:CollectionId", col);
|
||||
response.push(e);
|
||||
}
|
||||
"Move" => {
|
||||
let mut m = Element::new("ItemOperations:Move");
|
||||
m.push_text("ItemOperations:Status", "2");
|
||||
if let Some(c) = op.child("ItemOperations:ConversationId") {
|
||||
m.push(c.clone());
|
||||
}
|
||||
response.push(m);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let root = Element::new("ItemOperations:ItemOperations")
|
||||
.with_text("ItemOperations:Status", "1")
|
||||
.with(response);
|
||||
if multipart {
|
||||
let wb = wbxml::encode(&root);
|
||||
let mut all = vec![wb];
|
||||
all.extend(parts);
|
||||
let n = all.len();
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&(n as i32).to_le_bytes());
|
||||
let mut offset = 4 + n * 8;
|
||||
for p in &all {
|
||||
body.extend_from_slice(&(offset as i32).to_le_bytes());
|
||||
body.extend_from_slice(&(p.len() as i32).to_le_bytes());
|
||||
offset += p.len();
|
||||
}
|
||||
for p in all {
|
||||
body.extend_from_slice(&p);
|
||||
}
|
||||
return Ok(Reply::Raw("application/vnd.ms-sync.multipart", body));
|
||||
}
|
||||
Ok(Reply::Wbxml(root))
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
//! SendMail, SmartReply, SmartForward, MeetingResponse.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use axum::http::StatusCode;
|
||||
use mail_parser::{MessageParser, MimeHeaders};
|
||||
|
||||
use super::convert::{calendar, email};
|
||||
use super::folders::resolve;
|
||||
use super::sync::{parse_server_id, server_id};
|
||||
use super::{Ctx, Reply};
|
||||
use crate::backend::{mime, smtp, Kind, FT_CALENDAR, FT_SENT};
|
||||
use crate::db;
|
||||
use crate::util::{html_escape, html_to_text};
|
||||
use crate::wbxml::Element;
|
||||
|
||||
fn fail(ctx: &mut Ctx, status: u32, why: &str) -> Reply {
|
||||
ctx.status = format!("error: {why}");
|
||||
tracing::warn!(user = %ctx.user(), cmd = %ctx.req.cmd, "send failed: {why}");
|
||||
if ctx.req.ver() >= 140 {
|
||||
Reply::Wbxml(
|
||||
Element::new(&format!("ComposeMail:{}", ctx.req.cmd))
|
||||
.with_text("ComposeMail:Status", status.to_string()),
|
||||
)
|
||||
} else {
|
||||
Reply::Status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
fn addrs(m: &mail_parser::Message, out: &mut Vec<String>) {
|
||||
for a in [m.to(), m.cc(), m.bcc()].into_iter().flatten() {
|
||||
for x in a.iter() {
|
||||
if let Some(ad) = x.address() {
|
||||
let ad = ad.trim().to_string();
|
||||
if ad.contains('@') && !out.iter().any(|o| o.eq_ignore_ascii_case(&ad)) {
|
||||
out.push(ad);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_mail(ctx: &mut Ctx, req: Option<Element>, body: &[u8]) -> Result<Reply> {
|
||||
let cmd = ctx.req.cmd.clone();
|
||||
// ---- collect parameters (WBXML for AS 14+, query + raw MIME below)
|
||||
let (mime_raw, save_sent, src_folder, src_item, src_long, replace_mime) = match &req {
|
||||
Some(r) => {
|
||||
let mime = r
|
||||
.child("ComposeMail:MIME")
|
||||
.map(|m| m.bytes_value())
|
||||
.unwrap_or_default();
|
||||
let src = r.child("ComposeMail:Source");
|
||||
(
|
||||
mime,
|
||||
r.has("ComposeMail:SaveInSentItems"),
|
||||
src.and_then(|s| s.child_text("ComposeMail:FolderId")),
|
||||
src.and_then(|s| s.child_text("ComposeMail:ItemId")),
|
||||
src.and_then(|s| s.child_text("ComposeMail:LongId")),
|
||||
r.has("ComposeMail:ReplaceMime"),
|
||||
)
|
||||
}
|
||||
None => (
|
||||
body.to_vec(),
|
||||
ctx.req.save_in_sent,
|
||||
ctx.req.collection_id.clone(),
|
||||
ctx.req.item_id.clone(),
|
||||
ctx.req.long_id.clone(),
|
||||
false,
|
||||
),
|
||||
};
|
||||
if mime_raw.is_empty() {
|
||||
return Ok(fail(ctx, 107, "empty MIME"));
|
||||
}
|
||||
let Some(msg) = MessageParser::default().parse(&mime_raw) else {
|
||||
return Ok(fail(ctx, 107, "invalid MIME"));
|
||||
};
|
||||
let mut rcpts = Vec::new();
|
||||
addrs(&msg, &mut rcpts);
|
||||
if rcpts.is_empty() {
|
||||
return Ok(fail(ctx, 107, "no recipients"));
|
||||
}
|
||||
|
||||
// ---- original message for smart reply / forward
|
||||
let mut original: Option<(crate::backend::BFolder, u32, Vec<u8>)> = None;
|
||||
if cmd != "SendMail" {
|
||||
let (fid, iid) = if let Some(l) = src_long.clone() {
|
||||
let mut p = l.splitn(2, ':');
|
||||
(p.next().map(|s| s.to_string()), Some(l))
|
||||
} else {
|
||||
(src_folder.clone(), src_item.clone())
|
||||
};
|
||||
if let (Some(fid), Some(iid)) = (fid, iid) {
|
||||
let iid = if iid.contains(':') {
|
||||
iid
|
||||
} else {
|
||||
format!("{fid}:{iid}")
|
||||
};
|
||||
if let Some(folder) = resolve(ctx, &fid).await? {
|
||||
if let Some(uid) = parse_server_id(ctx, &iid, &folder.id)
|
||||
.await?
|
||||
.and_then(|u| u.parse::<u32>().ok())
|
||||
{
|
||||
if let Ok(m) = ctx.sess.fetch_mail(folder.path(), uid).await {
|
||||
original = Some((folder, uid, m.body));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if original.is_none() {
|
||||
tracing::info!(user = %ctx.user(), "smart reply/forward source not found, sending as is");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- compose
|
||||
let from_addr = ctx.sess.email.clone();
|
||||
let mut out: Vec<u8> = match (&original, replace_mime) {
|
||||
(Some((_, _, orig)), false) => {
|
||||
compose_smart(ctx, &msg, &mime_raw, orig, cmd == "SmartForward")?
|
||||
}
|
||||
_ => mime_raw.clone(),
|
||||
};
|
||||
// sender handling
|
||||
let from_hdr = msg
|
||||
.from()
|
||||
.and_then(|f| f.first())
|
||||
.and_then(|a| a.address())
|
||||
.map(|s| s.to_string());
|
||||
match from_hdr {
|
||||
None => {
|
||||
out = mime::prepend_headers(&out, &[("From".into(), from_addr.clone())]);
|
||||
}
|
||||
Some(f) if ctx.s.smtp.enforce_from && !f.eq_ignore_ascii_case(&from_addr) => {
|
||||
let name = msg
|
||||
.from()
|
||||
.and_then(|f| f.first())
|
||||
.and_then(|a| a.name())
|
||||
.map(|n| n.to_string());
|
||||
out = mime::strip_headers(&out, &["From"]);
|
||||
let v = match name {
|
||||
Some(n) => format!("{} <{}>", mime::encode_word(&n), from_addr),
|
||||
None => from_addr.clone(),
|
||||
};
|
||||
out = mime::prepend_headers(&out, &[("From".into(), v)]);
|
||||
tracing::info!(user = %ctx.user(), "From header {f} replaced with {from_addr}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if msg.date().is_none() {
|
||||
out = mime::prepend_headers(&out, &[("Date".into(), chrono::Utc::now().to_rfc2822())]);
|
||||
}
|
||||
if msg.message_id().is_none() {
|
||||
let dom = from_addr.split('@').nth(1).unwrap_or("activepush.local");
|
||||
out = mime::prepend_headers(
|
||||
&out,
|
||||
&[(
|
||||
"Message-ID".into(),
|
||||
format!("<{}@{}>", uuid::Uuid::new_v4(), dom),
|
||||
)],
|
||||
);
|
||||
}
|
||||
let wire = mime::strip_headers(&out, &["Bcc"]);
|
||||
|
||||
let password = ctx.sess.password().to_string();
|
||||
if let Err(e) = smtp::send(
|
||||
&ctx.s.smtp,
|
||||
&ctx.sess.login,
|
||||
&password,
|
||||
&from_addr,
|
||||
&rcpts,
|
||||
&wire,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(fail(ctx, 110, &format!("{e:#}")));
|
||||
}
|
||||
ctx.app.metrics.mail_sent();
|
||||
ctx.items_in += 1;
|
||||
tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, recipients = rcpts.len(), bytes = wire.len(), "{cmd} delivered to SMTP");
|
||||
|
||||
if save_sent && ctx.s.smtp.save_in_sent {
|
||||
match ctx.sess.special_folder(FT_SENT).await {
|
||||
Ok(Some(sent)) => {
|
||||
if let Err(e) = ctx.sess.mail_append(sent.path(), "\\Seen", &out).await {
|
||||
tracing::warn!(user = %ctx.user(), "saving to Sent failed: {e:#}");
|
||||
}
|
||||
}
|
||||
_ => tracing::warn!(user = %ctx.user(), "no Sent folder found; copy not saved"),
|
||||
}
|
||||
}
|
||||
if let Some((folder, uid, _)) = &original {
|
||||
let flag = if cmd == "SmartForward" {
|
||||
"$Forwarded"
|
||||
} else {
|
||||
"\\Answered"
|
||||
};
|
||||
let _ = ctx.sess.mail_add_flag(folder.path(), *uid, flag).await;
|
||||
}
|
||||
Ok(Reply::Empty)
|
||||
}
|
||||
|
||||
fn compose_smart(
|
||||
ctx: &Ctx,
|
||||
msg: &mail_parser::Message,
|
||||
raw: &[u8],
|
||||
orig_raw: &[u8],
|
||||
forward: bool,
|
||||
) -> Result<Vec<u8>> {
|
||||
let orig = MessageParser::default()
|
||||
.parse(orig_raw)
|
||||
.ok_or_else(|| anyhow!("original message unparseable"))?;
|
||||
// headers from the client message (minus MIME structure headers)
|
||||
let (hblock, _) = mime::split_headers(raw);
|
||||
let mut headers: Vec<(String, String)> = mime::header_lines(hblock)
|
||||
.into_iter()
|
||||
.filter(|(k, _)| {
|
||||
![
|
||||
"content-type",
|
||||
"content-transfer-encoding",
|
||||
"mime-version",
|
||||
"content-disposition",
|
||||
]
|
||||
.contains(&k.to_ascii_lowercase().as_str())
|
||||
})
|
||||
.collect();
|
||||
if !forward {
|
||||
if let Some(mid) = orig.message_id() {
|
||||
if !headers
|
||||
.iter()
|
||||
.any(|(k, _)| k.eq_ignore_ascii_case("In-Reply-To"))
|
||||
{
|
||||
headers.push(("In-Reply-To".into(), format!("<{mid}>")));
|
||||
}
|
||||
if !headers
|
||||
.iter()
|
||||
.any(|(k, _)| k.eq_ignore_ascii_case("References"))
|
||||
{
|
||||
let mut refs: Vec<String> = orig
|
||||
.references()
|
||||
.as_text_list()
|
||||
.map(|l| l.iter().map(|r| format!("<{r}>")).collect())
|
||||
.or_else(|| orig.references().as_text().map(|r| vec![format!("<{r}>")]))
|
||||
.unwrap_or_default();
|
||||
refs.push(format!("<{mid}>"));
|
||||
headers.push(("References".into(), refs.join(" ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
let new_text = msg.body_text(0).map(|s| s.into_owned()).unwrap_or_default();
|
||||
let new_html = if msg.html_body_count() > 0
|
||||
&& msg
|
||||
.html_bodies()
|
||||
.any(|p| matches!(p.body, mail_parser::PartType::Html(_)))
|
||||
{
|
||||
msg.body_html(0).map(|s| s.into_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let o_text = orig
|
||||
.body_text(0)
|
||||
.map(|s| s.into_owned())
|
||||
.unwrap_or_else(|| {
|
||||
orig.body_html(0)
|
||||
.map(|h| html_to_text(&h))
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let o_html = if orig.html_body_count() > 0
|
||||
&& orig
|
||||
.html_bodies()
|
||||
.any(|p| matches!(p.body, mail_parser::PartType::Html(_)))
|
||||
{
|
||||
orig.body_html(0).map(|s| s.into_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let addr = |a: Option<&mail_parser::Address>| {
|
||||
a.map(|a| {
|
||||
a.iter()
|
||||
.map(|x| match (x.name(), x.address()) {
|
||||
(Some(n), Some(ad)) => format!("{n} <{ad}>"),
|
||||
(None, Some(ad)) => ad.to_string(),
|
||||
_ => String::new(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let date = orig.date().map(|d| d.to_rfc822()).unwrap_or_default();
|
||||
let subj = orig.subject().unwrap_or("").to_string();
|
||||
let from = addr(orig.from());
|
||||
let to = addr(orig.to());
|
||||
let (text, html, mut atts) = if forward && !ctx.s.imap.inline_forward {
|
||||
(
|
||||
new_text.clone(),
|
||||
new_html.clone(),
|
||||
vec![mime::Attachment {
|
||||
name: format!("{}.eml", if subj.is_empty() { "message" } else { &subj }),
|
||||
content_type: "message/rfc822".into(),
|
||||
data: orig_raw.to_vec(),
|
||||
content_id: None,
|
||||
inline: false,
|
||||
}],
|
||||
)
|
||||
} else if forward {
|
||||
let head = format!("-------- Original Message --------\nSubject: {subj}\nDate: {date}\nFrom: {from}\nTo: {to}\n\n");
|
||||
let text = format!("{new_text}\n\n{head}{o_text}");
|
||||
let html = new_html.clone().map(|nh| {
|
||||
let oh = o_html.clone().unwrap_or_else(|| format!("<pre>{}</pre>", html_escape(&o_text)));
|
||||
format!(
|
||||
"{nh}<br><div>-------- Original Message --------<br>Subject: {}<br>Date: {}<br>From: {}<br>To: {}<br><br>{oh}</div>",
|
||||
html_escape(&subj),
|
||||
html_escape(&date),
|
||||
html_escape(&from),
|
||||
html_escape(&to)
|
||||
)
|
||||
});
|
||||
let mut a = Vec::new();
|
||||
for (i, part) in orig.attachments().enumerate() {
|
||||
if let Some((name, ct, data)) = email::attachment(orig_raw, i) {
|
||||
a.push(mime::Attachment {
|
||||
name,
|
||||
content_type: ct,
|
||||
data,
|
||||
content_id: part.content_id().map(|s| s.to_string()),
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
(text, html, a)
|
||||
} else {
|
||||
let quoted: String = o_text.lines().map(|l| format!("> {l}\n")).collect();
|
||||
let text = format!("{new_text}\n\nOn {date}, {from} wrote:\n{quoted}");
|
||||
let html = new_html.clone().map(|nh| {
|
||||
let oh = o_html.clone().unwrap_or_else(|| format!("<pre>{}</pre>", html_escape(&o_text)));
|
||||
format!(
|
||||
"{nh}<br><div>On {}, {} wrote:</div><blockquote style=\"margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex\">{oh}</blockquote>",
|
||||
html_escape(&date),
|
||||
html_escape(&from)
|
||||
)
|
||||
});
|
||||
(text, html, Vec::new())
|
||||
};
|
||||
// attachments the client added itself
|
||||
for (i, part) in msg.attachments().enumerate() {
|
||||
if let Some((name, ct, data)) = email::attachment(raw, i) {
|
||||
atts.push(mime::Attachment {
|
||||
name,
|
||||
content_type: ct,
|
||||
data,
|
||||
content_id: part.content_id().map(|s| s.to_string()),
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(mime::build(&headers, Some(&text), html.as_deref(), &atts))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ MeetingResponse
|
||||
|
||||
pub async fn meeting_response(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||
let mut root = Element::new("MeetingResponse:MeetingResponse");
|
||||
let Some(req) = req else {
|
||||
return Ok(Reply::Wbxml(root));
|
||||
};
|
||||
for r in req.children_named("MeetingResponse:Request") {
|
||||
let resp_code = r.child_u32("MeetingResponse:UserResponse").unwrap_or(0);
|
||||
let col = r
|
||||
.child_text("MeetingResponse:CollectionId")
|
||||
.unwrap_or_default();
|
||||
let rid = r
|
||||
.child_text("MeetingResponse:RequestId")
|
||||
.or_else(|| r.child_text("Search:LongId"))
|
||||
.unwrap_or_default();
|
||||
let mut res = Element::new("MeetingResponse:Result")
|
||||
.with_text("MeetingResponse:RequestId", rid.clone());
|
||||
let outcome: Result<Option<String>> = async {
|
||||
let col = if col.is_empty() {
|
||||
super::sync::collection_of(&rid).to_string()
|
||||
} else {
|
||||
col.clone()
|
||||
};
|
||||
let folder = resolve(ctx, &col)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("collection not found"))?;
|
||||
let sid = if rid.contains(':') {
|
||||
rid.clone()
|
||||
} else {
|
||||
format!("{col}:{rid}")
|
||||
};
|
||||
match folder.kind() {
|
||||
Kind::Mail => {
|
||||
let uid: u32 = parse_server_id(ctx, &sid, &folder.id)
|
||||
.await?
|
||||
.and_then(|u| u.parse().ok())
|
||||
.ok_or_else(|| anyhow!("item not found"))?;
|
||||
let m = ctx.sess.fetch_mail(folder.path(), uid).await?;
|
||||
let ics =
|
||||
email::calendar_of(&m.body).ok_or_else(|| anyhow!("no calendar part"))?;
|
||||
let partstat = match resp_code {
|
||||
1 => "ACCEPTED",
|
||||
2 => "TENTATIVE",
|
||||
_ => "DECLINED",
|
||||
};
|
||||
let mut cal_id = None;
|
||||
if resp_code != 3 && ctx.s.caldav.enabled {
|
||||
if let Some(cal) = ctx.sess.special_folder(FT_CALENDAR).await? {
|
||||
let updated = calendar::set_partstat(&ics, &ctx.sess.email, partstat)
|
||||
.ok_or_else(|| anyhow!("bad calendar data"))?;
|
||||
let (id, _) = ctx.sess.dav_put(&cal, None, updated, None).await?;
|
||||
let cal_sid = folder_short(ctx, &cal.id).await?;
|
||||
cal_id = Some(server_id(ctx, &cal_sid, &cal.id, &id).await?);
|
||||
}
|
||||
}
|
||||
// the device sends the reply e-mail itself; remove the invitation like Z-Push
|
||||
let _ = ctx.sess.mail_delete(&folder, uid, true).await;
|
||||
Ok(cal_id)
|
||||
}
|
||||
Kind::Calendar => {
|
||||
let id = parse_server_id(ctx, &sid, &folder.id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("item not found"))?;
|
||||
let cur = ctx.sess.fetch_dav(&folder, &id).await?;
|
||||
let partstat = match resp_code {
|
||||
1 => "ACCEPTED",
|
||||
2 => "TENTATIVE",
|
||||
_ => "DECLINED",
|
||||
};
|
||||
let updated = calendar::set_partstat(
|
||||
cur.data.as_deref().unwrap_or(""),
|
||||
&ctx.sess.email,
|
||||
partstat,
|
||||
)
|
||||
.ok_or_else(|| anyhow!("bad calendar data"))?;
|
||||
ctx.sess
|
||||
.dav_put(&folder, Some(&id), updated, Some(&cur.etag))
|
||||
.await?;
|
||||
Ok(Some(sid))
|
||||
}
|
||||
_ => Err(anyhow!("invalid collection")),
|
||||
}
|
||||
}
|
||||
.await;
|
||||
match outcome {
|
||||
Ok(cal) => {
|
||||
res.push_text("MeetingResponse:Status", "1");
|
||||
if let Some(c) = cal {
|
||||
if resp_code != 3 {
|
||||
res.push_text("MeetingResponse:CalendarId", c);
|
||||
}
|
||||
}
|
||||
db::audit(
|
||||
&ctx.app.db,
|
||||
ctx.user(),
|
||||
"meeting_response",
|
||||
&rid,
|
||||
&ctx.ip,
|
||||
&resp_code.to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(user = %ctx.user(), "MeetingResponse failed: {e:#}");
|
||||
res.push_text(
|
||||
"MeetingResponse:Status",
|
||||
if e.to_string().contains("not found") {
|
||||
"2"
|
||||
} else {
|
||||
"4"
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
root.push(res);
|
||||
}
|
||||
Ok(Reply::Wbxml(root))
|
||||
}
|
||||
|
||||
async fn folder_short(ctx: &Ctx, bid: &str) -> Result<String> {
|
||||
Ok(db::folder_short_id(&ctx.app.db, ctx.user(), bid)
|
||||
.await?
|
||||
.to_string())
|
||||
}
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
//! Settings, Search (GAL + Mailbox), ResolveRecipients.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::convert::{contact, Options};
|
||||
use super::folders::resolve;
|
||||
use super::provision::device_info_from;
|
||||
use super::sync::server_id;
|
||||
use super::{Ctx, Reply};
|
||||
use crate::backend::{dav, imap, Kind};
|
||||
use crate::config::expand_user_template;
|
||||
use crate::db::{self, DeviceField};
|
||||
use crate::wbxml::Element;
|
||||
|
||||
// ------------------------------------------------------------------ Settings
|
||||
|
||||
pub async fn settings(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||
let mut root = Element::new("Settings:Settings").with_text("Settings:Status", "1");
|
||||
let Some(req) = req else {
|
||||
return Ok(Reply::Wbxml(root));
|
||||
};
|
||||
for item in &req.children {
|
||||
match item.tag() {
|
||||
"Oof" => {
|
||||
let mut o = Element::new("Settings:Oof");
|
||||
if item.has("Settings:Get") {
|
||||
o.push_text("Settings:Status", "1");
|
||||
o.push(Element::new("Settings:Get").with_text("Settings:OofState", "0"));
|
||||
} else {
|
||||
// out-of-office is managed by the mail server (e.g. Sieve vacation)
|
||||
o.push_text("Settings:Status", "2");
|
||||
}
|
||||
root.push(o);
|
||||
}
|
||||
"DeviceInformation" => {
|
||||
if let Some(set) = item.child("Settings:Set") {
|
||||
db::device_set_info(&ctx.app.db, ctx.dev.id, &device_info_from(set)).await?;
|
||||
}
|
||||
root.push(
|
||||
Element::new("Settings:DeviceInformation").with_text("Settings:Status", "1"),
|
||||
);
|
||||
}
|
||||
"DevicePassword" => {
|
||||
if let Some(pw) = item.path(&["Settings:Set", "Settings:Password"]) {
|
||||
let v = pw.text_value();
|
||||
if v.len() <= 256 {
|
||||
db::device_set_field(
|
||||
&ctx.app.db,
|
||||
ctx.dev.id,
|
||||
DeviceField::RecoveryPassword,
|
||||
None,
|
||||
Some(&v),
|
||||
)
|
||||
.await?;
|
||||
db::audit(
|
||||
&ctx.app.db,
|
||||
ctx.user(),
|
||||
"recovery_password_set",
|
||||
&ctx.req.device_id,
|
||||
&ctx.ip,
|
||||
"",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
root.push(
|
||||
Element::new("Settings:DevicePassword").with_text("Settings:Status", "1"),
|
||||
);
|
||||
}
|
||||
"UserInformation" => {
|
||||
let email = ctx.sess.email.clone();
|
||||
let mut get = Element::new("Settings:Get");
|
||||
if ctx.req.ver() >= 141 {
|
||||
get.push(
|
||||
Element::new("Settings:Accounts").with(
|
||||
Element::new("Settings:Account")
|
||||
.with_text("Settings:UserDisplayName", email.clone())
|
||||
.with(
|
||||
Element::new("Settings:EmailAddresses")
|
||||
.with_text("Settings:PrimarySmtpAddress", email.clone()),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
get.push(
|
||||
Element::new("Settings:EmailAddresses")
|
||||
.with_text("Settings:SmtpAddress", email),
|
||||
);
|
||||
}
|
||||
root.push(
|
||||
Element::new("Settings:UserInformation")
|
||||
.with_text("Settings:Status", "1")
|
||||
.with(get),
|
||||
);
|
||||
}
|
||||
"RightsManagementInformation" => {
|
||||
root.push(
|
||||
Element::new("Settings:RightsManagementInformation")
|
||||
.with_text("Settings:Status", "1")
|
||||
.with(
|
||||
Element::new("Settings:Get")
|
||||
.with(Element::new("RightsManagement:RightsManagementTemplates")),
|
||||
),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Reply::Wbxml(root))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ GAL helpers
|
||||
|
||||
pub struct Person {
|
||||
pub summary: contact::CardSummary,
|
||||
pub from_gal: bool,
|
||||
}
|
||||
|
||||
pub async fn find_people(ctx: &mut Ctx, query: &str, limit: usize) -> Result<Vec<Person>> {
|
||||
let mut out: Vec<Person> = Vec::new();
|
||||
let q = query.trim();
|
||||
if q.chars().count() < ctx.s.carddav.gal_min_length.max(1) {
|
||||
return Ok(out);
|
||||
}
|
||||
let Some((client, home)) = ctx.sess.carddav()? else {
|
||||
return Ok(out);
|
||||
};
|
||||
let s = ctx.s.clone();
|
||||
let mut books: Vec<(String, bool)> = Vec::new();
|
||||
if !s.carddav.gal_path.is_empty() {
|
||||
books.push((
|
||||
dav::encode_path(&expand_user_template(
|
||||
&s.carddav.gal_path,
|
||||
ctx.user(),
|
||||
&s.general.default_domain,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
if let Ok(cols) = client.collections(&home, dav::DavKind::AddressBook).await {
|
||||
for c in cols {
|
||||
let p = dav::encode_path(&c.href);
|
||||
if !books.iter().any(|(b, _)| *b == p) {
|
||||
books.push((p, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (b, gal) in books {
|
||||
let res = match client.search_cards(&b, q).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::debug!("GAL search in {b} failed: {e:#}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for r in res {
|
||||
if let Some(sm) = r.data.as_deref().and_then(contact::summary) {
|
||||
if sm.emails.is_empty() && sm.display_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let dup = out.iter().any(|p| {
|
||||
!sm.emails.is_empty() && p.summary.emails.first() == sm.emails.first()
|
||||
});
|
||||
if !dup {
|
||||
out.push(Person {
|
||||
summary: sm,
|
||||
from_gal: gal,
|
||||
});
|
||||
}
|
||||
if out.len() >= limit {
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_range(r: Option<String>, def: usize) -> (usize, usize) {
|
||||
r.and_then(|r| {
|
||||
let (a, b) = r.split_once('-')?;
|
||||
Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
|
||||
})
|
||||
.filter(|(a, b)| a <= b)
|
||||
.unwrap_or((0, def.saturating_sub(1)))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Search
|
||||
|
||||
pub async fn search(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||
let status_only =
|
||||
|s: &str| Reply::Wbxml(Element::new("Search:Search").with_text("Search:Status", s));
|
||||
let Some(req) = req else {
|
||||
return Ok(status_only("2"));
|
||||
};
|
||||
let Some(store) = req.child("Search:Store") else {
|
||||
return Ok(status_only("2"));
|
||||
};
|
||||
let name = store.child_text("Search:Name").unwrap_or_default();
|
||||
let opts_el = store.child("Search:Options");
|
||||
let max = ctx.s.sync.search_max_results as usize;
|
||||
let (from, to) = parse_range(
|
||||
opts_el.and_then(|o| o.child_text("Search:Range")),
|
||||
max.min(100),
|
||||
);
|
||||
let to = to.min(from + max.max(1) - 1);
|
||||
let mut st = Element::new("Search:Store");
|
||||
let started = Instant::now();
|
||||
let deadline = Duration::from_secs(ctx.s.sync.search_wait_secs.max(3));
|
||||
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"gal" => {
|
||||
let q = store.child_text("Search:Query").unwrap_or_default();
|
||||
let people = find_people(ctx, &q, to + 1).await.unwrap_or_default();
|
||||
st.push_text("Search:Status", "1");
|
||||
let total = people.len();
|
||||
for p in people.into_iter().skip(from).take(to + 1 - from) {
|
||||
let s = p.summary;
|
||||
let mut props = Element::new("Search:Properties");
|
||||
props.push_text(
|
||||
"GAL:DisplayName",
|
||||
if s.display_name.is_empty() {
|
||||
s.emails.first().cloned().unwrap_or_default()
|
||||
} else {
|
||||
s.display_name.clone()
|
||||
},
|
||||
);
|
||||
props.push_opt("GAL:Phone", (!s.phone.is_empty()).then(|| s.phone.clone()));
|
||||
props.push_opt(
|
||||
"GAL:Office",
|
||||
(!s.office.is_empty()).then(|| s.office.clone()),
|
||||
);
|
||||
props.push_opt("GAL:Title", (!s.title.is_empty()).then(|| s.title.clone()));
|
||||
props.push_opt(
|
||||
"GAL:Company",
|
||||
(!s.company.is_empty()).then(|| s.company.clone()),
|
||||
);
|
||||
props.push_opt(
|
||||
"GAL:FirstName",
|
||||
(!s.first.is_empty()).then(|| s.first.clone()),
|
||||
);
|
||||
props.push_opt("GAL:LastName", (!s.last.is_empty()).then(|| s.last.clone()));
|
||||
props.push_opt(
|
||||
"GAL:MobilePhone",
|
||||
(!s.mobile.is_empty()).then(|| s.mobile.clone()),
|
||||
);
|
||||
if let Some(e) = s.emails.first() {
|
||||
props.push_text("GAL:EmailAddress", e.clone());
|
||||
}
|
||||
st.push(Element::new("Search:Result").with(props));
|
||||
}
|
||||
if total > 0 {
|
||||
st.push_text(
|
||||
"Search:Range",
|
||||
format!(
|
||||
"{from}-{}",
|
||||
(from + total.min(to + 1 - from)).saturating_sub(1)
|
||||
),
|
||||
);
|
||||
}
|
||||
st.push_text("Search:Total", total.to_string());
|
||||
ctx.items_out += total as u64;
|
||||
}
|
||||
"mailbox" => {
|
||||
let Some(query) = store.child("Search:Query") else {
|
||||
return Ok(status_only("2"));
|
||||
};
|
||||
let and = query.child("Search:And").unwrap_or(query);
|
||||
let text = and.child_text("Search:FreeText").unwrap_or_default();
|
||||
let col = and.child_text("AirSync:CollectionId");
|
||||
let since = and
|
||||
.child("Search:GreaterThan")
|
||||
.and_then(|g| g.child_text("Search:Value"))
|
||||
.and_then(|v| crate::backend::tz::parse_eas_date(&v));
|
||||
let deep = opts_el
|
||||
.map(|o| o.has("Search:DeepTraversal"))
|
||||
.unwrap_or(false);
|
||||
let mut opts = opts_el.map(Options::parse).unwrap_or_default();
|
||||
if opts.prefs.is_empty() {
|
||||
opts.prefs.push(super::convert::BodyPref {
|
||||
typ: 1,
|
||||
trunc: Some(5120),
|
||||
preview: Some(255),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
// folders to search
|
||||
let all = ctx.sess.folders().await?;
|
||||
let mut folders = Vec::new();
|
||||
match &col {
|
||||
Some(c) => {
|
||||
if let Some(f) = resolve(ctx, c).await? {
|
||||
if deep {
|
||||
let pref = f.id.clone();
|
||||
for x in all.iter().filter(|x| {
|
||||
x.kind() == Kind::Mail
|
||||
&& (x.id == pref
|
||||
|| x.id.starts_with(&format!("{pref}/"))
|
||||
|| x.id.starts_with(&format!("{pref}.")))
|
||||
}) {
|
||||
folders.push(x.clone());
|
||||
}
|
||||
} else {
|
||||
folders.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => folders.extend(all.iter().filter(|x| x.kind() == Kind::Mail).cloned()),
|
||||
}
|
||||
let mut hits: Vec<(crate::backend::BFolder, u32)> = Vec::new();
|
||||
for f in folders {
|
||||
if started.elapsed() > deadline {
|
||||
break;
|
||||
}
|
||||
match ctx.sess.mail_search(&f, &text, since).await {
|
||||
Ok(uids) => hits.extend(uids.into_iter().rev().map(|u| (f.clone(), u))),
|
||||
Err(e) => tracing::debug!("search in {} failed: {e:#}", f.name),
|
||||
}
|
||||
}
|
||||
let total = hits.len();
|
||||
st.push_text("Search:Status", "1");
|
||||
for (f, uid) in hits.into_iter().skip(from).take(to + 1 - from) {
|
||||
let Ok(m) = ctx.sess.fetch_mail(f.path(), uid).await else {
|
||||
continue;
|
||||
};
|
||||
let cid = super::folders::short_id(ctx, &f.id).await?;
|
||||
let sid = server_id(ctx, &cid, &f.id, &uid.to_string()).await?;
|
||||
let conv = super::convert::email::to_eas(
|
||||
&m,
|
||||
&super::convert::email::MailContext {
|
||||
collection: &cid,
|
||||
ver: ctx.req.ver(),
|
||||
opts: &opts,
|
||||
default_tz: ctx.s.timezone(),
|
||||
},
|
||||
);
|
||||
let mut props = Element::new("Search:Properties");
|
||||
props.children = conv.app.children;
|
||||
st.push(
|
||||
Element::new("Search:Result")
|
||||
.with_text("AirSync:Class", "Email")
|
||||
.with_text("Search:LongId", sid)
|
||||
.with_text("AirSync:CollectionId", cid)
|
||||
.with(props),
|
||||
);
|
||||
ctx.items_out += 1;
|
||||
}
|
||||
if total > 0 {
|
||||
st.push_text(
|
||||
"Search:Range",
|
||||
format!(
|
||||
"{from}-{}",
|
||||
(from + total.min(to + 1 - from)).saturating_sub(1)
|
||||
),
|
||||
);
|
||||
}
|
||||
st.push_text("Search:Total", total.to_string());
|
||||
}
|
||||
_ => {
|
||||
st.push_text("Search:Status", "3");
|
||||
}
|
||||
}
|
||||
let _ = imap::quote; // keep module linked for docs
|
||||
Ok(Reply::Wbxml(
|
||||
Element::new("Search:Search")
|
||||
.with_text("Search:Status", "1")
|
||||
.with(Element::new("Search:Response").with(st)),
|
||||
))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ ResolveRecipients
|
||||
|
||||
pub async fn resolve_recipients(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||
let mut root = Element::new("ResolveRecipients:ResolveRecipients");
|
||||
let Some(req) = req else {
|
||||
return Ok(Reply::Wbxml(
|
||||
root.with_text("ResolveRecipients:Status", "5"),
|
||||
));
|
||||
};
|
||||
let opts = req.child("ResolveRecipients:Options");
|
||||
let max_amb = opts
|
||||
.and_then(|o| o.child_u32("ResolveRecipients:MaxAmbiguousRecipients"))
|
||||
.unwrap_or(19)
|
||||
.clamp(1, 100) as usize;
|
||||
let want_certs = opts
|
||||
.and_then(|o| o.child_u32("ResolveRecipients:CertificateRetrieval"))
|
||||
.unwrap_or(1)
|
||||
> 1;
|
||||
let want_avail = opts
|
||||
.map(|o| o.has("ResolveRecipients:Availability"))
|
||||
.unwrap_or(false);
|
||||
root.push_text("ResolveRecipients:Status", "1");
|
||||
for to in req.children_named("ResolveRecipients:To") {
|
||||
let q = to.text_value();
|
||||
let people = find_people(ctx, &q, max_amb).await.unwrap_or_default();
|
||||
let mut resp =
|
||||
Element::new("ResolveRecipients:Response").with_text("ResolveRecipients:To", q.clone());
|
||||
let mut recips: Vec<(u8, String, String)> = people
|
||||
.into_iter()
|
||||
.filter_map(|p| {
|
||||
let e = p.summary.emails.first()?.clone();
|
||||
let n = if p.summary.display_name.is_empty() {
|
||||
e.clone()
|
||||
} else {
|
||||
p.summary.display_name.clone()
|
||||
};
|
||||
Some((if p.from_gal { 1 } else { 2 }, n, e))
|
||||
})
|
||||
.collect();
|
||||
if recips.is_empty() && q.contains('@') && !q.contains(' ') {
|
||||
recips.push((1, q.clone(), q.clone()));
|
||||
}
|
||||
let status = match recips.len() {
|
||||
0 => "4",
|
||||
1 => "1",
|
||||
_ => "2",
|
||||
};
|
||||
resp.push_text("ResolveRecipients:Status", status);
|
||||
resp.push_text("ResolveRecipients:RecipientCount", recips.len().to_string());
|
||||
for (t, n, e) in recips {
|
||||
let mut r = Element::new("ResolveRecipients:Recipient")
|
||||
.with_text("ResolveRecipients:Type", t.to_string())
|
||||
.with_text("ResolveRecipients:DisplayName", n)
|
||||
.with_text("ResolveRecipients:EmailAddress", e);
|
||||
if want_avail {
|
||||
r.push(
|
||||
Element::new("ResolveRecipients:Availability")
|
||||
.with_text("ResolveRecipients:Status", "163"),
|
||||
);
|
||||
}
|
||||
if want_certs {
|
||||
r.push(
|
||||
Element::new("ResolveRecipients:Certificates")
|
||||
.with_text("ResolveRecipients:Status", "7")
|
||||
.with_text("ResolveRecipients:CertificateCount", "0"),
|
||||
);
|
||||
}
|
||||
resp.push(r);
|
||||
}
|
||||
root.push(resp);
|
||||
}
|
||||
Ok(Reply::Wbxml(root))
|
||||
}
|
||||
+836
@@ -0,0 +1,836 @@
|
||||
//! Exchange ActiveSync HTTP endpoint (`/Microsoft-Server-ActiveSync`).
|
||||
|
||||
pub mod convert;
|
||||
pub mod folders;
|
||||
pub mod itemops;
|
||||
pub mod mailcmd;
|
||||
pub mod misc;
|
||||
pub mod ping;
|
||||
pub mod provision;
|
||||
pub mod state;
|
||||
pub mod sync;
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{ConnectInfo, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use base64::Engine;
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::backend::{AuthResult, Session};
|
||||
use crate::config::{LoginFormat, Settings};
|
||||
use crate::db::{self, Device};
|
||||
use crate::metrics::{ActiveRequest, Event};
|
||||
use crate::util::{self, now};
|
||||
use crate::wbxml::{self, Element};
|
||||
|
||||
pub const SUPPORTED_VERSIONS: &[&str] = &["2.5", "12.0", "12.1", "14.0", "14.1"];
|
||||
pub const COMMANDS: &str = "Sync,SendMail,SmartForward,SmartReply,GetAttachment,GetHierarchy,CreateCollection,DeleteCollection,MoveCollection,FolderSync,FolderCreate,FolderDelete,FolderUpdate,MoveItems,GetItemEstimate,MeetingResponse,Search,Settings,Ping,ItemOperations,Provision,ResolveRecipients,ValidateCert";
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EasRequest {
|
||||
pub cmd: String,
|
||||
pub user: String,
|
||||
pub device_id: String,
|
||||
pub device_type: String,
|
||||
pub version: String,
|
||||
pub policy_key: Option<u32>,
|
||||
pub attachment_name: Option<String>,
|
||||
pub collection_id: Option<String>,
|
||||
pub item_id: Option<String>,
|
||||
pub long_id: Option<String>,
|
||||
pub parent_id: Option<String>,
|
||||
pub occurrence: Option<String>,
|
||||
pub save_in_sent: bool,
|
||||
pub accept_multipart: bool,
|
||||
pub user_agent: String,
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
impl EasRequest {
|
||||
/// Protocol version as integer: 25, 120, 121, 140, 141.
|
||||
pub fn ver(&self) -> u32 {
|
||||
match self.version.as_str() {
|
||||
"2.5" => 25,
|
||||
"12.0" => 120,
|
||||
"12.1" => 121,
|
||||
"14.0" => 140,
|
||||
"14.1" => 141,
|
||||
"16.0" => 160,
|
||||
"16.1" => 161,
|
||||
_ => 25,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BASE64_COMMANDS: &[(u8, &str)] = &[
|
||||
(0, "Sync"),
|
||||
(1, "SendMail"),
|
||||
(2, "SmartForward"),
|
||||
(3, "SmartReply"),
|
||||
(4, "GetAttachment"),
|
||||
(9, "FolderSync"),
|
||||
(10, "FolderCreate"),
|
||||
(11, "FolderDelete"),
|
||||
(12, "FolderUpdate"),
|
||||
(13, "MoveItems"),
|
||||
(14, "GetItemEstimate"),
|
||||
(15, "MeetingResponse"),
|
||||
(16, "Search"),
|
||||
(17, "Settings"),
|
||||
(18, "Ping"),
|
||||
(19, "ItemOperations"),
|
||||
(20, "Provision"),
|
||||
(21, "ResolveRecipients"),
|
||||
(22, "ValidateCert"),
|
||||
];
|
||||
|
||||
pub fn parse_request(uri: &Uri, headers: &HeaderMap) -> Result<EasRequest, &'static str> {
|
||||
let q = uri.query().unwrap_or("");
|
||||
let h = |n: &str| {
|
||||
headers
|
||||
.get(n)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string()
|
||||
};
|
||||
let mut r = EasRequest {
|
||||
user_agent: h("user-agent"),
|
||||
content_type: h("content-type"),
|
||||
..Default::default()
|
||||
};
|
||||
if q.contains("Cmd=") || q.contains("DeviceId=") || q.is_empty() {
|
||||
for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
|
||||
let v = v.to_string();
|
||||
match k.as_ref() {
|
||||
"Cmd" => r.cmd = v,
|
||||
"User" => r.user = v,
|
||||
"DeviceId" => r.device_id = v,
|
||||
"DeviceType" => r.device_type = v,
|
||||
"AttachmentName" => r.attachment_name = Some(v),
|
||||
"CollectionId" => r.collection_id = Some(v),
|
||||
"ItemId" => r.item_id = Some(v),
|
||||
"LongId" => r.long_id = Some(v),
|
||||
"ParentId" => r.parent_id = Some(v),
|
||||
"Occurrence" => r.occurrence = Some(v),
|
||||
"SaveInSent" => r.save_in_sent = v.eq_ignore_ascii_case("T"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
r.version = h("ms-asprotocolversion");
|
||||
let pk = h("x-ms-policykey");
|
||||
if !pk.is_empty() {
|
||||
r.policy_key = pk.parse().ok();
|
||||
}
|
||||
r.accept_multipart = h("ms-asacceptmultipart").eq_ignore_ascii_case("T");
|
||||
} else {
|
||||
// base64 encoded query (MS-ASHTTP 2.2.1.1.1.1)
|
||||
let raw = percent_encoding::percent_decode_str(q)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned();
|
||||
let b = base64::engine::general_purpose::STANDARD
|
||||
.decode(raw.trim())
|
||||
.map_err(|_| "invalid base64 query")?;
|
||||
struct Cur<'a> {
|
||||
b: &'a [u8],
|
||||
p: usize,
|
||||
}
|
||||
impl<'a> Cur<'a> {
|
||||
fn byte(&mut self) -> Result<u8, &'static str> {
|
||||
let v = *self.b.get(self.p).ok_or("truncated query")?;
|
||||
self.p += 1;
|
||||
Ok(v)
|
||||
}
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8], &'static str> {
|
||||
let s = self.b.get(self.p..self.p + n).ok_or("truncated query")?;
|
||||
self.p += n;
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
let mut c = Cur { b: &b, p: 0 };
|
||||
let ver = c.byte()?;
|
||||
r.version = format!("{}.{}", ver / 10, ver % 10);
|
||||
let code = c.byte()?;
|
||||
r.cmd = BASE64_COMMANDS
|
||||
.iter()
|
||||
.find(|(cc, _)| *cc == code)
|
||||
.map(|(_, n)| n.to_string())
|
||||
.ok_or("unknown command code")?;
|
||||
let _locale = c.take(2)?;
|
||||
let dlen = c.byte()? as usize;
|
||||
let dev = c.take(dlen)?;
|
||||
r.device_id = if dev.iter().all(|x| x.is_ascii_graphic()) {
|
||||
String::from_utf8_lossy(dev).into_owned()
|
||||
} else {
|
||||
hex::encode(dev)
|
||||
};
|
||||
let pklen = c.byte()? as usize;
|
||||
let pk = c.take(pklen)?;
|
||||
if pklen == 4 {
|
||||
r.policy_key = Some(u32::from_le_bytes([pk[0], pk[1], pk[2], pk[3]]));
|
||||
}
|
||||
let tlen = c.byte()? as usize;
|
||||
r.device_type = String::from_utf8_lossy(c.take(tlen)?).into_owned();
|
||||
while c.p + 1 < c.b.len() {
|
||||
let tag = c.byte()?;
|
||||
let len = c.byte()? as usize;
|
||||
let val = c.take(len)?;
|
||||
let sv = String::from_utf8_lossy(val).into_owned();
|
||||
match tag {
|
||||
0 => r.attachment_name = Some(sv),
|
||||
1 => r.collection_id = Some(sv),
|
||||
3 => r.item_id = Some(sv),
|
||||
4 => r.long_id = Some(sv),
|
||||
5 => r.parent_id = Some(sv),
|
||||
6 => r.occurrence = Some(sv),
|
||||
7 => {
|
||||
let o = val.first().copied().unwrap_or(0);
|
||||
r.save_in_sent = o & 0x01 != 0;
|
||||
r.accept_multipart = o & 0x02 != 0;
|
||||
}
|
||||
8 => r.user = sv,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.version.is_empty() {
|
||||
r.version = "2.5".into();
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn valid_device_id(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 128
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||||
}
|
||||
|
||||
fn valid_device_type(s: &str) -> bool {
|
||||
s.len() <= 64 && s.chars().all(|c| c.is_ascii_graphic())
|
||||
}
|
||||
|
||||
/// Determine the real client IP honouring trusted proxies.
|
||||
pub fn client_ip(peer: SocketAddr, headers: &HeaderMap, s: &Settings) -> IpAddr {
|
||||
let peer_ip = util::normalize(peer.ip());
|
||||
if !util::ip_in_any(&peer_ip, &s.security.trusted_proxies) {
|
||||
return peer_ip;
|
||||
}
|
||||
let hv = headers
|
||||
.get(s.security.real_ip_header.as_str())
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
// walk right-to-left skipping trusted proxies
|
||||
for part in hv.split(',').rev() {
|
||||
if let Ok(ip) = part.trim().parse::<IpAddr>() {
|
||||
let ip = util::normalize(ip);
|
||||
if !util::ip_in_any(&ip, &s.security.trusted_proxies) {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
peer_ip
|
||||
}
|
||||
|
||||
/// Parse `Authorization: Basic` and normalise the login according to settings.
|
||||
pub fn basic_auth(headers: &HeaderMap, s: &Settings) -> Option<(String, String)> {
|
||||
let v = headers.get("authorization")?.to_str().ok()?;
|
||||
let (scheme, data) = v.split_once(' ')?;
|
||||
if !scheme.eq_ignore_ascii_case("basic") {
|
||||
return None;
|
||||
}
|
||||
let dec = base64::engine::general_purpose::STANDARD
|
||||
.decode(data.trim())
|
||||
.ok()?;
|
||||
let dec = String::from_utf8(dec).ok()?;
|
||||
let (user, pass) = dec.split_once(':')?;
|
||||
let login = normalize_login(user, s)?;
|
||||
if pass.is_empty() || pass.len() > 1024 {
|
||||
return None;
|
||||
}
|
||||
Some((login, pass.to_string()))
|
||||
}
|
||||
|
||||
pub fn normalize_login(user: &str, s: &Settings) -> Option<String> {
|
||||
let mut u = user.trim().to_string();
|
||||
if let Some((_, rest)) = u.split_once('\\') {
|
||||
u = rest.to_string();
|
||||
}
|
||||
let u = match s.general.login_format {
|
||||
LoginFormat::AsGiven => u,
|
||||
LoginFormat::LocalPart => u.split('@').next().unwrap_or("").to_string(),
|
||||
LoginFormat::FullEmail => {
|
||||
if u.contains('@') || s.general.default_domain.is_empty() {
|
||||
u
|
||||
} else {
|
||||
format!("{u}@{}", s.general.default_domain)
|
||||
}
|
||||
}
|
||||
};
|
||||
let u = u.to_lowercase();
|
||||
util::valid_login(&u).then_some(u)
|
||||
}
|
||||
|
||||
/// Access policy checks shared by EAS and Autodiscover.
|
||||
pub fn user_allowed(login: &str, s: &Settings) -> bool {
|
||||
let sec = &s.security;
|
||||
if sec
|
||||
.blocked_users
|
||||
.iter()
|
||||
.any(|b| b.eq_ignore_ascii_case(login))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !sec.allowed_users.is_empty()
|
||||
&& !sec
|
||||
.allowed_users
|
||||
.iter()
|
||||
.any(|b| b.eq_ignore_ascii_case(login))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !s.general.allowed_domains.is_empty() {
|
||||
let dom = login.split('@').nth(1).unwrap_or(&s.general.default_domain);
|
||||
if !s
|
||||
.general
|
||||
.allowed_domains
|
||||
.iter()
|
||||
.any(|d| d.eq_ignore_ascii_case(dom))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub enum Reply {
|
||||
Wbxml(Element),
|
||||
Raw(&'static str, Vec<u8>),
|
||||
Empty,
|
||||
Status(StatusCode),
|
||||
}
|
||||
|
||||
pub struct Ctx {
|
||||
pub app: Arc<App>,
|
||||
pub s: Arc<Settings>,
|
||||
pub req: EasRequest,
|
||||
pub dev: Device,
|
||||
pub sess: Session,
|
||||
pub ip: String,
|
||||
pub items_out: u64,
|
||||
pub items_in: u64,
|
||||
pub status: String,
|
||||
pub debug: bool,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
pub fn ping_key(&self) -> String {
|
||||
format!("{}\u{0}{}", self.req.device_id, self.sess.login)
|
||||
}
|
||||
|
||||
pub fn user(&self) -> &str {
|
||||
&self.sess.login
|
||||
}
|
||||
}
|
||||
|
||||
fn unauthorized() -> Response {
|
||||
let mut r = (StatusCode::UNAUTHORIZED, "Access denied").into_response();
|
||||
r.headers_mut().insert(
|
||||
"WWW-Authenticate",
|
||||
HeaderValue::from_static("Basic realm=\"ActiveSync\""),
|
||||
);
|
||||
r
|
||||
}
|
||||
|
||||
fn server_headers(r: &mut Response, s: &Settings) {
|
||||
let h = r.headers_mut();
|
||||
h.insert("MS-Server-ActiveSync", HeaderValue::from_static("14.1"));
|
||||
h.insert("Cache-Control", HeaderValue::from_static("private"));
|
||||
let _ = s;
|
||||
}
|
||||
|
||||
fn versions(s: &Settings) -> String {
|
||||
let max = s.general.max_protocol.as_str();
|
||||
let idx = SUPPORTED_VERSIONS
|
||||
.iter()
|
||||
.position(|v| *v == max)
|
||||
.unwrap_or(SUPPORTED_VERSIONS.len() - 1);
|
||||
SUPPORTED_VERSIONS[..=idx].join(",")
|
||||
}
|
||||
|
||||
/// Axum handler for the ActiveSync endpoint.
|
||||
pub async fn handler(
|
||||
State(app): State<Arc<App>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body: Body,
|
||||
) -> Response {
|
||||
let s = app.settings();
|
||||
let ip = client_ip(peer, &headers, &s);
|
||||
let ip_s = ip.to_string();
|
||||
if !s.security.eas_allowed_networks.is_empty()
|
||||
&& !util::ip_in_any(&ip, &s.security.eas_allowed_networks)
|
||||
{
|
||||
tracing::warn!(ip = %ip_s, "ActiveSync request from network that is not allowed");
|
||||
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
|
||||
}
|
||||
if method == Method::GET {
|
||||
let mut r = (
|
||||
StatusCode::OK,
|
||||
[("Content-Type", "text/html; charset=utf-8")],
|
||||
"<!doctype html><title>ActiveSync</title><p>This is an Exchange ActiveSync endpoint and can only be accessed by ActiveSync-capable devices.</p>",
|
||||
)
|
||||
.into_response();
|
||||
server_headers(&mut r, &s);
|
||||
return r;
|
||||
}
|
||||
if method != Method::POST && method != Method::OPTIONS {
|
||||
return StatusCode::METHOD_NOT_ALLOWED.into_response();
|
||||
}
|
||||
|
||||
// ---- authentication
|
||||
let Some((login, password)) = basic_auth(&headers, &s) else {
|
||||
return unauthorized();
|
||||
};
|
||||
let ip_key = format!("ip:{ip_s}");
|
||||
let user_key = format!("user:{login}");
|
||||
for k in [&ip_key, &user_key] {
|
||||
if let Some(until) = app.auth.locked_until(k) {
|
||||
app.metrics.auth_failure();
|
||||
let mut r = (StatusCode::TOO_MANY_REQUESTS, "Too many failed logins").into_response();
|
||||
if let Ok(v) = HeaderValue::from_str(&(until - now()).max(1).to_string()) {
|
||||
r.headers_mut().insert("Retry-After", v);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
if !user_allowed(&login, &s) {
|
||||
tracing::warn!(user = %login, ip = %ip_s, "ActiveSync access denied by policy");
|
||||
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
|
||||
}
|
||||
let mut sess = app.session(&login, &password);
|
||||
let cache_key = app.pool.key(&login, &password);
|
||||
if !app.auth.is_cached(&cache_key) {
|
||||
match sess.authenticate().await {
|
||||
Ok(AuthResult::Ok) => {
|
||||
app.auth.success(&user_key);
|
||||
app.auth.cache_ok(cache_key, s.security.auth_cache_secs);
|
||||
}
|
||||
Ok(AuthResult::Denied) => {
|
||||
app.metrics.auth_failure();
|
||||
if s.security.log_auth_failures {
|
||||
tracing::warn!(target: "active_push::auth", "authentication failed user={login} ip={ip_s} service=activesync");
|
||||
}
|
||||
let max = s.security.auth_fail_max;
|
||||
let w = s.security.auth_fail_window_secs;
|
||||
let l = s.security.lockout_secs;
|
||||
if app.auth.fail(&ip_key, max * 3, w, l) || app.auth.fail(&user_key, max, w, l) {
|
||||
tracing::warn!(user = %login, ip = %ip_s, "login locked out after repeated failures");
|
||||
db::audit(
|
||||
&app.db,
|
||||
"system",
|
||||
"lockout",
|
||||
&login,
|
||||
&ip_s,
|
||||
"repeated ActiveSync login failures",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return unauthorized();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(user = %login, "backend unavailable during authentication: {e:#}");
|
||||
let mut r =
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "Backend unavailable").into_response();
|
||||
if let Ok(v) = HeaderValue::from_str(&s.sync.retry_after_secs.to_string()) {
|
||||
r.headers_mut().insert("Retry-After", v);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if method == Method::OPTIONS {
|
||||
let mut r = StatusCode::OK.into_response();
|
||||
server_headers(&mut r, &s);
|
||||
let h = r.headers_mut();
|
||||
h.insert(
|
||||
"MS-ASProtocolVersions",
|
||||
HeaderValue::from_str(&versions(&s)).unwrap(),
|
||||
);
|
||||
h.insert("MS-ASProtocolCommands", HeaderValue::from_static(COMMANDS));
|
||||
h.insert("Allow", HeaderValue::from_static("OPTIONS,POST"));
|
||||
h.insert("Public", HeaderValue::from_static("OPTIONS,POST"));
|
||||
sess.close().await;
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---- request
|
||||
let req = match parse_request(&uri, &headers) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::info!(ip = %ip_s, "bad ActiveSync request: {e}");
|
||||
return (StatusCode::BAD_REQUEST, e).into_response();
|
||||
}
|
||||
};
|
||||
let mut req = req;
|
||||
if !COMMANDS.split(',').any(|c| c == req.cmd) {
|
||||
req.cmd = "Unknown".into();
|
||||
}
|
||||
if !valid_device_id(&req.device_id)
|
||||
|| !valid_device_type(&req.device_type)
|
||||
|| req.cmd.is_empty()
|
||||
{
|
||||
return (StatusCode::BAD_REQUEST, "Missing or invalid parameters").into_response();
|
||||
}
|
||||
let blocked_type = s.security.blocked_device_types.iter().any(|b| {
|
||||
let b = b.to_lowercase();
|
||||
!b.is_empty()
|
||||
&& (req.device_type.to_lowercase().contains(&b)
|
||||
|| req.user_agent.to_lowercase().contains(&b))
|
||||
});
|
||||
if blocked_type {
|
||||
tracing::warn!(user = %login, device = %req.device_id, "device type blocked by policy");
|
||||
return (StatusCode::FORBIDDEN, "Device type not allowed").into_response();
|
||||
}
|
||||
|
||||
let max_body = (s.sync.max_request_mb * 1024 * 1024) as usize;
|
||||
let body = match http_body_util::Limited::new(body, max_body).collect().await {
|
||||
Ok(b) => b.to_bytes(),
|
||||
Err(_) => return (StatusCode::PAYLOAD_TOO_LARGE, "Request too large").into_response(),
|
||||
};
|
||||
|
||||
let (dev, created) = match db::device_get_or_create(
|
||||
&app.db,
|
||||
&req.device_id,
|
||||
&login,
|
||||
&req.device_type,
|
||||
&req.user_agent,
|
||||
!s.security.require_device_approval,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::error!("database error: {e:#}");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
if created {
|
||||
tracing::info!(user = %login, device = %req.device_id, kind = %req.device_type, "new device registered");
|
||||
db::audit(
|
||||
&app.db,
|
||||
&login,
|
||||
"device_new",
|
||||
&req.device_id,
|
||||
&ip_s,
|
||||
&req.user_agent,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if dev.blocked != 0 {
|
||||
return (StatusCode::FORBIDDEN, "Device blocked").into_response();
|
||||
}
|
||||
if dev.approved == 0 {
|
||||
tracing::info!(user = %login, device = %req.device_id, "device awaiting approval");
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
"Device awaiting administrator approval",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let _ = db::device_touch(
|
||||
&app.db,
|
||||
dev.id,
|
||||
&ip_s,
|
||||
&req.cmd,
|
||||
&req.version,
|
||||
&req.user_agent,
|
||||
&req.device_type,
|
||||
)
|
||||
.await;
|
||||
|
||||
let debug = s.logging.log_wbxml
|
||||
|| s.logging
|
||||
.debug_users
|
||||
.iter()
|
||||
.any(|u| u.eq_ignore_ascii_case(&login));
|
||||
let started = Instant::now();
|
||||
let active_id = app.metrics.begin(ActiveRequest {
|
||||
id: 0,
|
||||
user: login.clone(),
|
||||
device_id: req.device_id.clone(),
|
||||
device_type: req.device_type.clone(),
|
||||
command: req.cmd.clone(),
|
||||
ip: ip_s.clone(),
|
||||
started: now(),
|
||||
version: req.version.clone(),
|
||||
});
|
||||
|
||||
let mut ctx = Ctx {
|
||||
app: app.clone(),
|
||||
s: s.clone(),
|
||||
req: req.clone(),
|
||||
dev,
|
||||
sess,
|
||||
ip: ip_s.clone(),
|
||||
items_out: 0,
|
||||
items_in: 0,
|
||||
status: String::new(),
|
||||
debug,
|
||||
};
|
||||
// A new request from the device ends its hanging Ping/Sync.
|
||||
if ctx.req.cmd != "Ping" && ctx.req.cmd != "Sync" {
|
||||
app.metrics.ping_interrupt(&ctx.ping_key());
|
||||
}
|
||||
|
||||
let reply = dispatch(&mut ctx, &body).await;
|
||||
let (mut resp, http, out_len) = match reply {
|
||||
Ok(Reply::Wbxml(el)) => {
|
||||
if ctx.debug {
|
||||
tracing::info!(user = %login, cmd = %req.cmd, "response:\n{}", el.to_xml());
|
||||
}
|
||||
let bytes = wbxml::encode(&el);
|
||||
let n = bytes.len();
|
||||
let mut r = Response::new(Body::from(bytes));
|
||||
r.headers_mut().insert(
|
||||
"Content-Type",
|
||||
HeaderValue::from_static("application/vnd.ms-sync.wbxml"),
|
||||
);
|
||||
(r, 200u16, n)
|
||||
}
|
||||
Ok(Reply::Raw(ct, bytes)) => {
|
||||
let n = bytes.len();
|
||||
let mut r = Response::new(Body::from(bytes));
|
||||
r.headers_mut()
|
||||
.insert("Content-Type", HeaderValue::from_static(ct));
|
||||
(r, 200, n)
|
||||
}
|
||||
Ok(Reply::Empty) => (StatusCode::OK.into_response(), 200, 0),
|
||||
Ok(Reply::Status(code)) => (code.into_response(), code.as_u16(), 0),
|
||||
Err(e) => {
|
||||
tracing::error!(user = %login, device = %req.device_id, cmd = %req.cmd, "request failed: {e:#}");
|
||||
ctx.status = format!("error: {e}");
|
||||
let mut r = StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
if e.to_string().contains("IMAP") || e.to_string().contains("connect") {
|
||||
r = StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||
if let Ok(v) = HeaderValue::from_str(&s.sync.retry_after_secs.to_string()) {
|
||||
r.headers_mut().insert("Retry-After", v);
|
||||
}
|
||||
}
|
||||
let code = r.status().as_u16();
|
||||
(r, code, 0)
|
||||
}
|
||||
};
|
||||
server_headers(&mut resp, &s);
|
||||
let ms = started.elapsed().as_millis() as u64;
|
||||
if ms > 5000 && req.cmd != "Ping" && req.cmd != "Sync" {
|
||||
tracing::info!(user = %login, cmd = %req.cmd, ms, "slow request");
|
||||
}
|
||||
app.metrics.end(
|
||||
active_id,
|
||||
Event {
|
||||
ts: now(),
|
||||
user: login.clone(),
|
||||
device_id: req.device_id.clone(),
|
||||
device_type: req.device_type.clone(),
|
||||
command: req.cmd.clone(),
|
||||
ip: ip_s,
|
||||
http,
|
||||
status: if ctx.status.is_empty() {
|
||||
"ok".into()
|
||||
} else {
|
||||
ctx.status.clone()
|
||||
},
|
||||
ms,
|
||||
bytes_in: body.len() as u64,
|
||||
bytes_out: out_len as u64,
|
||||
items_out: ctx.items_out,
|
||||
items_in: ctx.items_in,
|
||||
},
|
||||
);
|
||||
ctx.sess.close().await;
|
||||
resp
|
||||
}
|
||||
|
||||
fn needs_provisioning(cmd: &str) -> bool {
|
||||
!matches!(cmd, "Provision" | "Ping")
|
||||
}
|
||||
|
||||
/// WBXML root/status for "device must provision" answers (AS 14+).
|
||||
fn provision_status_reply(cmd: &str, status: u32) -> Option<Element> {
|
||||
let (root, st) = match cmd {
|
||||
"Sync" => ("AirSync:Sync", "AirSync:Status"),
|
||||
"FolderSync" => ("FolderHierarchy:FolderSync", "FolderHierarchy:Status"),
|
||||
"FolderCreate" => ("FolderHierarchy:FolderCreate", "FolderHierarchy:Status"),
|
||||
"FolderUpdate" => ("FolderHierarchy:FolderUpdate", "FolderHierarchy:Status"),
|
||||
"FolderDelete" => ("FolderHierarchy:FolderDelete", "FolderHierarchy:Status"),
|
||||
"GetItemEstimate" => ("GetItemEstimate:GetItemEstimate", "GetItemEstimate:Status"),
|
||||
"Ping" => ("Ping:Ping", "Ping:Status"),
|
||||
"ItemOperations" => ("ItemOperations:ItemOperations", "ItemOperations:Status"),
|
||||
"Settings" => ("Settings:Settings", "Settings:Status"),
|
||||
"Search" => ("Search:Search", "Search:Status"),
|
||||
"SendMail" => ("ComposeMail:SendMail", "ComposeMail:Status"),
|
||||
"SmartReply" => ("ComposeMail:SmartReply", "ComposeMail:Status"),
|
||||
"SmartForward" => ("ComposeMail:SmartForward", "ComposeMail:Status"),
|
||||
"MeetingResponse" => ("MeetingResponse:MeetingResponse", "MeetingResponse:Status"),
|
||||
"ResolveRecipients" => (
|
||||
"ResolveRecipients:ResolveRecipients",
|
||||
"ResolveRecipients:Status",
|
||||
),
|
||||
"MoveItems" => ("Move:MoveItems", "Move:Status"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Element::new(root).with_text(st, status.to_string()))
|
||||
}
|
||||
|
||||
async fn dispatch(ctx: &mut Ctx, body: &[u8]) -> anyhow::Result<Reply> {
|
||||
// ---- provisioning enforcement
|
||||
let wipe_pending = matches!(
|
||||
ctx.dev.wipe_status,
|
||||
db::WIPE_PENDING
|
||||
| db::WIPE_REQUESTED
|
||||
| db::WIPE_ACCOUNT_PENDING
|
||||
| db::WIPE_ACCOUNT_REQUESTED
|
||||
);
|
||||
if ctx.s.provisioning.enabled
|
||||
&& (needs_provisioning(&ctx.req.cmd) || (wipe_pending && ctx.req.cmd != "Provision"))
|
||||
{
|
||||
let sent = ctx.req.policy_key;
|
||||
let current = ctx.dev.policy_key as u32;
|
||||
let policy_changed = !provision::policy_hash_matches(ctx);
|
||||
let ok = match sent {
|
||||
Some(k) => k != 0 && k == current && !policy_changed && !wipe_pending,
|
||||
None => ctx.s.provisioning.loose && !wipe_pending,
|
||||
};
|
||||
if !ok {
|
||||
ctx.status = "provisioning required".into();
|
||||
tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, "provisioning required");
|
||||
if ctx.req.ver() >= 140 {
|
||||
let code = if wipe_pending {
|
||||
140
|
||||
} else if sent.map(|k| k != 0).unwrap_or(false) {
|
||||
144
|
||||
} else {
|
||||
142
|
||||
};
|
||||
if let Some(e) = provision_status_reply(&ctx.req.cmd, code) {
|
||||
return Ok(Reply::Wbxml(e));
|
||||
}
|
||||
}
|
||||
return Ok(Reply::Status(StatusCode::from_u16(449).unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- plain-input commands (AS < 14 SendMail family uses raw MIME)
|
||||
let is_mime = ctx
|
||||
.req
|
||||
.content_type
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("message/rfc822");
|
||||
let req_el = if body.is_empty() || is_mime || ctx.req.cmd == "GetAttachment" {
|
||||
None
|
||||
} else {
|
||||
match wbxml::decode(body) {
|
||||
Ok(e) => {
|
||||
if ctx.debug {
|
||||
tracing::info!(user = %ctx.user(), cmd = %ctx.req.cmd, "request:\n{}", e.to_xml());
|
||||
}
|
||||
Some(e)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::info!(user = %ctx.user(), cmd = %ctx.req.cmd, "invalid WBXML: {err}");
|
||||
ctx.status = "invalid wbxml".into();
|
||||
return Ok(Reply::Status(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match ctx.req.cmd.as_str() {
|
||||
"Provision" => provision::provision(ctx, req_el).await,
|
||||
"FolderSync" => folders::folder_sync(ctx, req_el).await,
|
||||
"FolderCreate" | "FolderUpdate" | "FolderDelete" => {
|
||||
folders::folder_change(ctx, req_el).await
|
||||
}
|
||||
"GetHierarchy" => folders::get_hierarchy(ctx).await,
|
||||
"Sync" => sync::sync(ctx, req_el).await,
|
||||
"GetItemEstimate" => sync::get_item_estimate(ctx, req_el).await,
|
||||
"Ping" => ping::ping(ctx, req_el).await,
|
||||
"MoveItems" => sync::move_items(ctx, req_el).await,
|
||||
"SendMail" | "SmartReply" | "SmartForward" => mailcmd::send_mail(ctx, req_el, body).await,
|
||||
"GetAttachment" => itemops::get_attachment(ctx).await,
|
||||
"ItemOperations" => itemops::item_operations(ctx, req_el).await,
|
||||
"Settings" => misc::settings(ctx, req_el).await,
|
||||
"Search" => misc::search(ctx, req_el).await,
|
||||
"ResolveRecipients" => misc::resolve_recipients(ctx, req_el).await,
|
||||
"MeetingResponse" => mailcmd::meeting_response(ctx, req_el).await,
|
||||
"ValidateCert" => Ok(Reply::Wbxml(
|
||||
Element::new("ValidateCert:ValidateCert").with_text("ValidateCert:Status", "1"),
|
||||
)),
|
||||
other => {
|
||||
tracing::info!(cmd = other, "unsupported ActiveSync command");
|
||||
ctx.status = "not implemented".into();
|
||||
Ok(Reply::Status(StatusCode::NOT_IMPLEMENTED))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_query() {
|
||||
// version 14.1, Sync (0), locale 0x0409, devid "abc", pk 4 bytes, type "iPhone", User "jan"
|
||||
let mut b = vec![141u8, 0, 0x09, 0x04, 3];
|
||||
b.extend_from_slice(b"abc");
|
||||
b.push(4);
|
||||
b.extend_from_slice(&7u32.to_le_bytes());
|
||||
b.push(6);
|
||||
b.extend_from_slice(b"iPhone");
|
||||
b.extend_from_slice(&[8, 3]);
|
||||
b.extend_from_slice(b"jan");
|
||||
let q = base64::engine::general_purpose::STANDARD.encode(&b);
|
||||
let uri: Uri = format!("/Microsoft-Server-ActiveSync?{q}").parse().unwrap();
|
||||
let r = parse_request(&uri, &HeaderMap::new()).unwrap();
|
||||
assert_eq!(r.cmd, "Sync");
|
||||
assert_eq!(r.version, "14.1");
|
||||
assert_eq!(r.device_id, "abc");
|
||||
assert_eq!(r.policy_key, Some(7));
|
||||
assert_eq!(r.device_type, "iPhone");
|
||||
assert_eq!(r.user, "jan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_query() {
|
||||
let uri: Uri = "/Microsoft-Server-ActiveSync?Cmd=FolderSync&User=jan&DeviceId=ABC123&DeviceType=Android"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("MS-ASProtocolVersion", HeaderValue::from_static("14.0"));
|
||||
h.insert("X-MS-PolicyKey", HeaderValue::from_static("123"));
|
||||
let r = parse_request(&uri, &h).unwrap();
|
||||
assert_eq!(r.cmd, "FolderSync");
|
||||
assert_eq!(r.ver(), 140);
|
||||
assert_eq!(r.policy_key, Some(123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_formats() {
|
||||
let mut s = Settings::default();
|
||||
s.general.default_domain = "firma.pl".into();
|
||||
s.general.login_format = LoginFormat::FullEmail;
|
||||
assert_eq!(normalize_login("DOM\\Jan", &s).unwrap(), "jan@firma.pl");
|
||||
s.general.login_format = LoginFormat::LocalPart;
|
||||
assert_eq!(normalize_login("jan@firma.pl", &s).unwrap(), "jan");
|
||||
assert!(normalize_login("a b", &s).is_none());
|
||||
}
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
//! Ping (push) and the shared "wait for changes" loop used by hanging Sync.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::folders::resolve;
|
||||
use super::state::{self, CollectionState};
|
||||
use super::sync::{diff, effective_filter, filter_since};
|
||||
use super::{Ctx, Reply};
|
||||
use crate::backend::{BFolder, Kind};
|
||||
use crate::db;
|
||||
use crate::metrics::PingInfo;
|
||||
use crate::util::now;
|
||||
use crate::wbxml::Element;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct PingFolder {
|
||||
id: String,
|
||||
class: String,
|
||||
}
|
||||
|
||||
fn ping_status(status: u32) -> Element {
|
||||
Element::new("Ping:Ping").with_text("Ping:Status", status.to_string())
|
||||
}
|
||||
|
||||
pub async fn ping(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||
let s = ctx.s.clone();
|
||||
let mut lifetime = ctx.dev.ping_lifetime.max(0) as u64;
|
||||
let mut folders: Vec<PingFolder> =
|
||||
serde_json::from_str(&ctx.dev.ping_folders).unwrap_or_default();
|
||||
if let Some(r) = &req {
|
||||
if let Some(h) = r.child_u32("Ping:HeartbeatInterval") {
|
||||
lifetime = h as u64;
|
||||
}
|
||||
if let Some(f) = r.child("Ping:Folders") {
|
||||
folders = f
|
||||
.children_named("Ping:Folder")
|
||||
.map(|x| PingFolder {
|
||||
id: x.child_text("Ping:Id").unwrap_or_default(),
|
||||
class: x.child_text("Ping:Class").unwrap_or_default(),
|
||||
})
|
||||
.filter(|f| !f.id.is_empty())
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
if lifetime == 0 || folders.is_empty() {
|
||||
ctx.status = "ping: missing parameters".into();
|
||||
return Ok(Reply::Wbxml(ping_status(3)));
|
||||
}
|
||||
if lifetime < s.ping.lifetime_min || lifetime > s.ping.lifetime_max {
|
||||
let lim = if lifetime < s.ping.lifetime_min {
|
||||
s.ping.lifetime_min
|
||||
} else {
|
||||
s.ping.lifetime_max
|
||||
};
|
||||
return Ok(Reply::Wbxml(
|
||||
ping_status(5).with_text("Ping:HeartbeatInterval", lim.to_string()),
|
||||
));
|
||||
}
|
||||
if folders.len() > s.ping.max_folders {
|
||||
return Ok(Reply::Wbxml(
|
||||
ping_status(6).with_text("Ping:MaxFolders", s.ping.max_folders.to_string()),
|
||||
));
|
||||
}
|
||||
// persist for subsequent empty Pings
|
||||
let fj = serde_json::to_string(&folders)?;
|
||||
if fj != ctx.dev.ping_folders {
|
||||
db::device_set_field(
|
||||
&ctx.app.db,
|
||||
ctx.dev.id,
|
||||
db::DeviceField::PingFolders,
|
||||
None,
|
||||
Some(&fj),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if lifetime as i64 != ctx.dev.ping_lifetime {
|
||||
db::device_set_field(
|
||||
&ctx.app.db,
|
||||
ctx.dev.id,
|
||||
db::DeviceField::PingLifetime,
|
||||
Some(lifetime as i64),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let ids: Vec<String> = folders.iter().map(|f| f.id.clone()).collect();
|
||||
match wait_for_changes(ctx, &ids, lifetime, "ping").await {
|
||||
Err(e) if e.to_string() == "hierarchy" => {
|
||||
ctx.status = "ping: folder sync required".into();
|
||||
Ok(Reply::Wbxml(ping_status(7)))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
Ok(None) => {
|
||||
ctx.status = "ping: no changes".into();
|
||||
Ok(Reply::Wbxml(ping_status(1)))
|
||||
}
|
||||
Ok(Some(changed)) => {
|
||||
ctx.status = format!("ping: {} changed", changed.len());
|
||||
let mut f = Element::new("Ping:Folders");
|
||||
for c in changed {
|
||||
f.push_text("Ping:Folder", c);
|
||||
}
|
||||
Ok(Reply::Wbxml(ping_status(2).with(f)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Watched {
|
||||
id: String,
|
||||
folder: BFolder,
|
||||
sig: String,
|
||||
}
|
||||
|
||||
/// Does the device's latest state for `cid` differ from the backend?
|
||||
async fn has_changes(ctx: &mut Ctx, cid: &str, folder: &BFolder) -> Result<bool> {
|
||||
let Some((_, _, st)) = state::load_latest::<CollectionState>(ctx, cid).await? else {
|
||||
// never synced: nothing to report until the device primes the folder
|
||||
return Ok(false);
|
||||
};
|
||||
let filter = effective_filter(ctx, folder.kind(), st.filter);
|
||||
let listing = match folder.kind() {
|
||||
Kind::Tasks if filter == 8 => ctx.sess.list(folder, None).await?,
|
||||
_ => ctx.sess.list(folder, filter_since(filter)).await?,
|
||||
};
|
||||
if !st.meta.is_empty() && !listing.meta.is_empty() && st.meta != listing.meta {
|
||||
return Ok(true);
|
||||
}
|
||||
let d = diff(&st.items, &listing, folder.kind());
|
||||
Ok(!(d.adds.is_empty() && d.changes.is_empty() && d.deletes.is_empty()))
|
||||
}
|
||||
|
||||
/// Wait until one of the collections changes. Returns Ok(None) on timeout/interruption,
|
||||
/// Err("hierarchy") if a collection does not exist anymore.
|
||||
pub async fn wait_for_changes(
|
||||
ctx: &mut Ctx,
|
||||
ids: &[String],
|
||||
lifetime: u64,
|
||||
kind: &'static str,
|
||||
) -> Result<Option<Vec<String>>> {
|
||||
let mut watched = Vec::new();
|
||||
for id in ids {
|
||||
match resolve(ctx, id).await? {
|
||||
Some(f) => watched.push(Watched {
|
||||
id: id.clone(),
|
||||
folder: f,
|
||||
sig: String::new(),
|
||||
}),
|
||||
None => {
|
||||
ctx.app.stale_add(ctx.dev.id, id);
|
||||
if kind == "ping" {
|
||||
return Err(anyhow::anyhow!("hierarchy"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let key = ctx.ping_key();
|
||||
let (cancel, gen) = ctx.app.metrics.ping_start(
|
||||
&key,
|
||||
PingInfo {
|
||||
user: ctx.user().to_string(),
|
||||
device_id: ctx.req.device_id.clone(),
|
||||
device_type: ctx.req.device_type.clone(),
|
||||
started: now(),
|
||||
lifetime,
|
||||
folders: watched.len(),
|
||||
kind,
|
||||
},
|
||||
);
|
||||
let result = async {
|
||||
// initial check: changes that happened while the device was not pinging
|
||||
let mut changed = Vec::new();
|
||||
for w in watched.iter_mut() {
|
||||
w.sig = ctx.sess.signature(&w.folder).await.unwrap_or_default();
|
||||
let st = state::load_latest::<CollectionState>(ctx, &w.id).await?;
|
||||
let stored = st.map(|(_, _, s)| s.signature).unwrap_or_default();
|
||||
if (stored.is_empty() || stored != w.sig)
|
||||
&& has_changes(ctx, &w.id.clone(), &w.folder.clone()).await?
|
||||
{
|
||||
changed.push(w.id.clone());
|
||||
}
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
return Ok(Some(changed));
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(lifetime.saturating_sub(5).max(5));
|
||||
let interval = Duration::from_secs(ctx.s.ping.check_interval_secs.max(5));
|
||||
let mut checks = 0u32;
|
||||
loop {
|
||||
let now_i = Instant::now();
|
||||
if now_i >= deadline {
|
||||
return Ok(None);
|
||||
}
|
||||
let sleep = interval.min(deadline - now_i);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(sleep) => {}
|
||||
_ = cancel.notified() => return Ok(None),
|
||||
_ = ctx.app.shutdown.cancelled() => return Ok(None),
|
||||
}
|
||||
checks += 1;
|
||||
let mut changed = Vec::new();
|
||||
for w in watched.iter_mut() {
|
||||
let sig = match ctx.sess.signature(&w.folder).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::debug!("ping signature failed: {e:#}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if sig != w.sig {
|
||||
w.sig = sig;
|
||||
if has_changes(ctx, &w.id.clone(), &w.folder.clone()).await? {
|
||||
changed.push(w.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
return Ok(Some(changed));
|
||||
}
|
||||
// every ~10 checks verify the folders still exist
|
||||
if checks % 10 == 0 && kind == "ping" {
|
||||
ctx.sess.invalidate_folders();
|
||||
let all = ctx.sess.folders().await?;
|
||||
if watched
|
||||
.iter()
|
||||
.any(|w| !all.iter().any(|f| f.id == w.folder.id))
|
||||
{
|
||||
return Err(anyhow::anyhow!("hierarchy"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.await;
|
||||
ctx.app.metrics.ping_end(&key, gen);
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Provision command (policies + remote wipe).
|
||||
|
||||
use anyhow::Result;
|
||||
use rand::Rng;
|
||||
|
||||
use super::{Ctx, Reply};
|
||||
use crate::config::Policy;
|
||||
use crate::db::{self, DeviceField, DeviceInfo};
|
||||
use crate::util::sha256_hex;
|
||||
use crate::wbxml::Element;
|
||||
|
||||
pub fn policy_hash(p: &Policy) -> String {
|
||||
sha256_hex(serde_json::to_string(p).unwrap_or_default().as_bytes())[..16].to_string()
|
||||
}
|
||||
|
||||
pub fn policy_hash_matches(ctx: &Ctx) -> bool {
|
||||
let p = ctx.s.policy_for(ctx.user());
|
||||
ctx.dev.policy_hash.is_empty() || ctx.dev.policy_hash == policy_hash(p)
|
||||
}
|
||||
|
||||
fn b(v: bool) -> &'static str {
|
||||
if v {
|
||||
"1"
|
||||
} else {
|
||||
"0"
|
||||
}
|
||||
}
|
||||
|
||||
/// MS-ASPROV EASProvisionDoc.
|
||||
pub fn provision_doc(p: &Policy, ver: u32) -> Element {
|
||||
let mut d = Element::new("Provision:EASProvisionDoc");
|
||||
d.push_text("Provision:DevicePasswordEnabled", b(p.devpwenabled));
|
||||
d.push_text(
|
||||
"Provision:AlphanumericDevicePasswordRequired",
|
||||
b(p.alphanumpwreq),
|
||||
);
|
||||
d.push_text("Provision:PasswordRecoveryEnabled", b(p.pwrecoveryenabled));
|
||||
d.push_text("Provision:DeviceEncryptionEnabled", b(p.devencenabled));
|
||||
d.push_text("Provision:AttachmentsEnabled", b(p.attenabled));
|
||||
d.push_text(
|
||||
"Provision:MinDevicePasswordLength",
|
||||
p.mindevpwlenngth.to_string(),
|
||||
);
|
||||
d.push_text(
|
||||
"Provision:MaxInactivityTimeDeviceLock",
|
||||
p.maxinacttimedevlock.to_string(),
|
||||
);
|
||||
d.push_text(
|
||||
"Provision:MaxDevicePasswordFailedAttempts",
|
||||
p.maxdevpwfailedattempts.to_string(),
|
||||
);
|
||||
if p.maxattsize >= 0 {
|
||||
d.push_text("Provision:MaxAttachmentSize", p.maxattsize.to_string());
|
||||
} else {
|
||||
d.push(Element::new("Provision:MaxAttachmentSize"));
|
||||
}
|
||||
d.push_text("Provision:AllowSimpleDevicePassword", b(p.allowsimpledevpw));
|
||||
if p.devpwexpiration > 0 {
|
||||
d.push_text(
|
||||
"Provision:DevicePasswordExpiration",
|
||||
p.devpwexpiration.to_string(),
|
||||
);
|
||||
} else {
|
||||
d.push(Element::new("Provision:DevicePasswordExpiration"));
|
||||
}
|
||||
d.push_text(
|
||||
"Provision:DevicePasswordHistory",
|
||||
p.devpwhistory.to_string(),
|
||||
);
|
||||
if ver >= 121 {
|
||||
d.push_text("Provision:AllowStorageCard", b(p.allowstoragecard));
|
||||
d.push_text("Provision:AllowCamera", b(p.allowcam));
|
||||
d.push_text("Provision:RequireDeviceEncryption", b(p.reqdevenc));
|
||||
d.push_text(
|
||||
"Provision:AllowUnsignedApplications",
|
||||
b(p.allowunsignedapps),
|
||||
);
|
||||
d.push_text(
|
||||
"Provision:AllowUnsignedInstallationPackages",
|
||||
b(p.allowunsignedinstallationpackages),
|
||||
);
|
||||
d.push_text(
|
||||
"Provision:MinDevicePasswordComplexCharacters",
|
||||
p.mindevcomplexchars.to_string(),
|
||||
);
|
||||
d.push_text("Provision:AllowWiFi", b(p.allowwifi));
|
||||
d.push_text("Provision:AllowTextMessaging", b(p.allowtextmessaging));
|
||||
d.push_text("Provision:AllowPOPIMAPEmail", b(p.allowpopimapemail));
|
||||
d.push_text("Provision:AllowBluetooth", p.allowbluetooth.to_string());
|
||||
d.push_text("Provision:AllowIrDA", b(p.allowirda));
|
||||
d.push_text(
|
||||
"Provision:RequireManualSyncWhenRoaming",
|
||||
b(p.reqmansyncroam),
|
||||
);
|
||||
d.push_text("Provision:AllowDesktopSync", b(p.allowdesktopsync));
|
||||
d.push_text(
|
||||
"Provision:MaxCalendarAgeFilter",
|
||||
p.maxcalagefilter.to_string(),
|
||||
);
|
||||
d.push_text("Provision:AllowHTMLEmail", b(p.allowhtmlemail));
|
||||
d.push_text(
|
||||
"Provision:MaxEmailAgeFilter",
|
||||
p.maxemailagefilter.to_string(),
|
||||
);
|
||||
if p.maxemailbodytruncsize >= 0 {
|
||||
d.push_text(
|
||||
"Provision:MaxEmailBodyTruncationSize",
|
||||
p.maxemailbodytruncsize.to_string(),
|
||||
);
|
||||
} else {
|
||||
d.push(Element::new("Provision:MaxEmailBodyTruncationSize"));
|
||||
}
|
||||
if p.maxemailhtmlbodytruncsize >= 0 {
|
||||
d.push_text(
|
||||
"Provision:MaxEmailHTMLBodyTruncationSize",
|
||||
p.maxemailhtmlbodytruncsize.to_string(),
|
||||
);
|
||||
} else {
|
||||
d.push(Element::new("Provision:MaxEmailHTMLBodyTruncationSize"));
|
||||
}
|
||||
d.push_text(
|
||||
"Provision:RequireSignedSMIMEMessages",
|
||||
b(p.reqsignedsmimemessages),
|
||||
);
|
||||
d.push_text(
|
||||
"Provision:RequireEncryptedSMIMEMessages",
|
||||
b(p.reqencsmimemessages),
|
||||
);
|
||||
d.push_text(
|
||||
"Provision:RequireSignedSMIMEAlgorithm",
|
||||
p.reqsignedsmimealgorithm.to_string(),
|
||||
);
|
||||
d.push_text(
|
||||
"Provision:RequireEncryptionSMIMEAlgorithm",
|
||||
p.reqencsmimealgorithm.to_string(),
|
||||
);
|
||||
d.push_text(
|
||||
"Provision:AllowSMIMEEncryptionAlgorithmNegotiation",
|
||||
p.allowsmimeencalgneg.to_string(),
|
||||
);
|
||||
d.push_text("Provision:AllowSMIMESoftCerts", b(p.allowsmimesoftcerts));
|
||||
d.push_text("Provision:AllowBrowser", b(p.allowbrowser));
|
||||
d.push_text("Provision:AllowConsumerEmail", b(p.allowconsumeremail));
|
||||
d.push_text("Provision:AllowRemoteDesktop", b(p.allowremotedesktop));
|
||||
d.push_text("Provision:AllowInternetSharing", b(p.allowinternetsharing));
|
||||
d.push(Element::new("Provision:UnapprovedInROMApplicationList"));
|
||||
d.push(Element::new("Provision:ApprovedApplicationList"));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
pub fn device_info_from(set: &Element) -> DeviceInfo {
|
||||
let g = |n: &str| {
|
||||
set.child_text(n)
|
||||
.map(|s| crate::util::truncate_bytes(&s, 200).to_string())
|
||||
};
|
||||
DeviceInfo {
|
||||
model: g("Settings:Model"),
|
||||
imei: g("Settings:IMEI"),
|
||||
friendly_name: g("Settings:FriendlyName"),
|
||||
os: g("Settings:OS"),
|
||||
os_language: g("Settings:OSLanguage"),
|
||||
phone_number: g("Settings:PhoneNumber"),
|
||||
mobile_operator: g("Settings:MobileOperator"),
|
||||
user_agent: g("Settings:UserAgent"),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_key() -> u32 {
|
||||
rand::thread_rng().gen_range(1_000_000..u32::MAX)
|
||||
}
|
||||
|
||||
pub async fn provision(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
|
||||
let Some(req) = req else {
|
||||
return Ok(Reply::Wbxml(
|
||||
Element::new("Provision:Provision").with_text("Provision:Status", "2"),
|
||||
));
|
||||
};
|
||||
let ver = ctx.req.ver();
|
||||
let mut resp = Element::new("Provision:Provision").with_text("Provision:Status", "1");
|
||||
|
||||
if let Some(di) = req.child("Settings:DeviceInformation") {
|
||||
if let Some(set) = di.child("Settings:Set") {
|
||||
db::device_set_info(&ctx.app.db, ctx.dev.id, &device_info_from(set)).await?;
|
||||
}
|
||||
resp.push(Element::new("Settings:DeviceInformation").with_text("Settings:Status", "1"));
|
||||
}
|
||||
|
||||
// ---- remote wipe acknowledgement
|
||||
if let Some(rw) = req
|
||||
.child("Provision:RemoteWipe")
|
||||
.or_else(|| req.child("Provision:AccountOnlyRemoteWipe"))
|
||||
{
|
||||
let st = rw.child_u32("Provision:Status").unwrap_or(0);
|
||||
tracing::warn!(user = %ctx.user(), device = %ctx.req.device_id, status = st, "device acknowledged remote wipe");
|
||||
db::device_wipe_done(
|
||||
&ctx.app.db,
|
||||
ctx.dev.id,
|
||||
if st == 1 {
|
||||
db::WIPE_WIPED
|
||||
} else {
|
||||
db::WIPE_REQUESTED
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
db::audit(
|
||||
&ctx.app.db,
|
||||
ctx.user(),
|
||||
"wipe_ack",
|
||||
&ctx.req.device_id,
|
||||
&ctx.ip,
|
||||
&format!("status {st}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(Reply::Wbxml(resp));
|
||||
}
|
||||
|
||||
let wipe = matches!(ctx.dev.wipe_status, db::WIPE_PENDING | db::WIPE_REQUESTED);
|
||||
|
||||
if let Some(pol) = req.path(&["Provision:Policies", "Provision:Policy"]) {
|
||||
let ptype = pol.child_text("Provision:PolicyType").unwrap_or_default();
|
||||
let mut pe =
|
||||
Element::new("Provision:Policy").with_text("Provision:PolicyType", ptype.clone());
|
||||
if ptype != "MS-EAS-Provisioning-WBXML" && ptype != "MS-WAP-Provisioning-XML" {
|
||||
pe.push_text("Provision:Status", "3");
|
||||
resp.push(Element::new("Provision:Policies").with(pe));
|
||||
return Ok(Reply::Wbxml(resp));
|
||||
}
|
||||
if !ctx.s.provisioning.enabled {
|
||||
pe.push_text("Provision:Status", "2");
|
||||
resp.push(Element::new("Provision:Policies").with(pe));
|
||||
return Ok(Reply::Wbxml(resp));
|
||||
}
|
||||
let policy = ctx.s.policy_for(ctx.user()).clone();
|
||||
match pol
|
||||
.child_text("Provision:PolicyKey")
|
||||
.and_then(|k| k.parse::<u32>().ok())
|
||||
{
|
||||
None => {
|
||||
// phase 1: deliver the policy with a temporary key
|
||||
let temp = new_key();
|
||||
db::device_set_field(
|
||||
&ctx.app.db,
|
||||
ctx.dev.id,
|
||||
DeviceField::PolicyTempKey,
|
||||
Some(temp as i64),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
pe.push_text("Provision:Status", "1");
|
||||
pe.push_text("Provision:PolicyKey", temp.to_string());
|
||||
let data = if ptype == "MS-WAP-Provisioning-XML" {
|
||||
Element::text(
|
||||
"Provision:Data",
|
||||
format!(
|
||||
"<wap-provisioningdoc><characteristic type=\"SecurityPolicy\"><parm name=\"4131\" value=\"{}\"/><parm name=\"4133\" value=\"1\"/></characteristic></wap-provisioningdoc>",
|
||||
if policy.devpwenabled { 0 } else { 1 }
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Element::new("Provision:Data").with(provision_doc(&policy, ver))
|
||||
};
|
||||
pe.push(data);
|
||||
ctx.status = "policy sent".into();
|
||||
}
|
||||
Some(key) => {
|
||||
// phase 2: acknowledgement
|
||||
let client_status = pol.child_u32("Provision:Status").unwrap_or(1);
|
||||
let ok_key = key == ctx.dev.policy_temp_key as u32
|
||||
|| key == ctx.dev.policy_key as u32
|
||||
|| ctx.dev.policy_temp_key == 0;
|
||||
if !ok_key {
|
||||
pe.push_text("Provision:Status", "5");
|
||||
} else if client_status == 1 || (client_status == 2 && ctx.s.provisioning.loose) {
|
||||
let fin = new_key();
|
||||
db::device_set_field(
|
||||
&ctx.app.db,
|
||||
ctx.dev.id,
|
||||
DeviceField::PolicyKey,
|
||||
Some(fin as i64),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
db::device_set_field(
|
||||
&ctx.app.db,
|
||||
ctx.dev.id,
|
||||
DeviceField::PolicyTempKey,
|
||||
Some(0),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
db::device_set_field(
|
||||
&ctx.app.db,
|
||||
ctx.dev.id,
|
||||
DeviceField::PolicyName,
|
||||
None,
|
||||
Some(&policy.name),
|
||||
)
|
||||
.await?;
|
||||
db::device_set_field(
|
||||
&ctx.app.db,
|
||||
ctx.dev.id,
|
||||
DeviceField::PolicyHash,
|
||||
None,
|
||||
Some(&policy_hash(&policy)),
|
||||
)
|
||||
.await?;
|
||||
pe.push_text("Provision:Status", "1");
|
||||
pe.push_text("Provision:PolicyKey", fin.to_string());
|
||||
ctx.status = "provisioned".into();
|
||||
tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, policy = %policy.name, "device provisioned");
|
||||
} else {
|
||||
tracing::warn!(user = %ctx.user(), device = %ctx.req.device_id, client_status, "device could not apply policy");
|
||||
pe.push_text("Provision:Status", "1");
|
||||
pe.push_text("Provision:PolicyKey", "0");
|
||||
ctx.status = format!("device rejected policy ({client_status})");
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.push(Element::new("Provision:Policies").with(pe));
|
||||
}
|
||||
|
||||
if wipe {
|
||||
resp.push(Element::new("Provision:RemoteWipe"));
|
||||
db::device_set_field(
|
||||
&ctx.app.db,
|
||||
ctx.dev.id,
|
||||
DeviceField::WipeStatus,
|
||||
Some(db::WIPE_REQUESTED),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
db::audit(
|
||||
&ctx.app.db,
|
||||
"system",
|
||||
"wipe_sent",
|
||||
&ctx.req.device_id,
|
||||
&ctx.ip,
|
||||
ctx.user(),
|
||||
)
|
||||
.await;
|
||||
tracing::warn!(user = %ctx.user(), device = %ctx.req.device_id, "remote wipe command sent to device");
|
||||
ctx.status = "remote wipe sent".into();
|
||||
}
|
||||
Ok(Reply::Wbxml(resp))
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Sync keys and persisted per-device state (folder hierarchy + item fingerprints).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
use crate::db;
|
||||
|
||||
use super::convert::Options;
|
||||
use super::Ctx;
|
||||
|
||||
pub const HIERARCHY: &str = "h";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SyncKey {
|
||||
Initial,
|
||||
Key { uuid: String, counter: i64 },
|
||||
}
|
||||
|
||||
impl SyncKey {
|
||||
pub fn parse(s: &str) -> Option<SyncKey> {
|
||||
let s = s.trim();
|
||||
if s == "0" {
|
||||
return Some(SyncKey::Initial);
|
||||
}
|
||||
let rest = s.strip_prefix('{')?;
|
||||
let (uuid, counter) = rest.split_once('}')?;
|
||||
if uuid.len() != 36 || !uuid.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
|
||||
return None;
|
||||
}
|
||||
Some(SyncKey::Key {
|
||||
uuid: uuid.to_string(),
|
||||
counter: counter.parse().ok()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_key(uuid: &str, counter: i64) -> String {
|
||||
format!("{{{uuid}}}{counter}")
|
||||
}
|
||||
|
||||
pub fn new_uuid() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct HierarchyState {
|
||||
/// short id -> (parent short id, name, type)
|
||||
pub folders: BTreeMap<String, (String, String, u8)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CollectionState {
|
||||
pub backend_id: String,
|
||||
pub class: String,
|
||||
pub meta: String,
|
||||
pub filter: u8,
|
||||
/// item id -> fingerprint, as known by the device
|
||||
pub items: BTreeMap<String, String>,
|
||||
/// client id -> server id of the last processed Adds (idempotent retries)
|
||||
pub client_ids: Vec<(String, String)>,
|
||||
/// Options remembered for empty/partial Sync requests
|
||||
pub options: Option<Options>,
|
||||
/// last backend signature (Ping)
|
||||
pub signature: String,
|
||||
/// number of consecutive retries of the same key (loop detection)
|
||||
pub retries: u32,
|
||||
pub window: u32,
|
||||
}
|
||||
|
||||
pub async fn load<T: DeserializeOwned>(
|
||||
ctx: &Ctx,
|
||||
collection: &str,
|
||||
key: &SyncKey,
|
||||
) -> Result<Option<T>> {
|
||||
let SyncKey::Key { uuid, counter } = key else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(data) = db::state_get(&ctx.app.db, ctx.dev.id, collection, uuid, *counter).await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(serde_json::from_slice(&data).ok())
|
||||
}
|
||||
|
||||
pub async fn load_latest<T: DeserializeOwned>(
|
||||
ctx: &Ctx,
|
||||
collection: &str,
|
||||
) -> Result<Option<(String, i64, T)>> {
|
||||
let Some((uuid, counter, data)) = db::state_latest(&ctx.app.db, ctx.dev.id, collection).await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(serde_json::from_slice(&data)
|
||||
.ok()
|
||||
.map(|t| (uuid, counter, t)))
|
||||
}
|
||||
|
||||
pub async fn save<T: Serialize>(
|
||||
ctx: &Ctx,
|
||||
collection: &str,
|
||||
uuid: &str,
|
||||
counter: i64,
|
||||
v: &T,
|
||||
) -> Result<()> {
|
||||
let data = serde_json::to_vec(v)?;
|
||||
db::state_put(&ctx.app.db, ctx.dev.id, collection, uuid, counter, &data).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn keys() {
|
||||
let u = new_uuid();
|
||||
let k = make_key(&u, 5);
|
||||
assert_eq!(
|
||||
SyncKey::parse(&k),
|
||||
Some(SyncKey::Key {
|
||||
uuid: u,
|
||||
counter: 5
|
||||
})
|
||||
);
|
||||
assert_eq!(SyncKey::parse("0"), Some(SyncKey::Initial));
|
||||
assert_eq!(SyncKey::parse("garbage"), None);
|
||||
}
|
||||
}
|
||||
+1081
File diff suppressed because it is too large.
Load diff
+68
@@ -0,0 +1,68 @@
|
||||
//! ActivePush - Exchange ActiveSync gateway for IMAP/SMTP + CalDAV/CardDAV (Z-Push rewrite).
|
||||
|
||||
pub mod admin;
|
||||
pub mod app;
|
||||
pub mod autodiscover;
|
||||
pub mod backend;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod eas;
|
||||
pub mod logging;
|
||||
pub mod metrics;
|
||||
pub mod probe;
|
||||
pub mod server;
|
||||
pub mod tls;
|
||||
pub mod util;
|
||||
pub mod wbxml;
|
||||
pub mod xmltree;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::routing::any;
|
||||
use axum::Router;
|
||||
|
||||
/// Public (device facing) router: ActiveSync + Autodiscover. Paths match the nginx
|
||||
/// locations previously routed to Z-Push.
|
||||
pub fn public_router(app: Arc<app::App>) -> Router {
|
||||
Router::new()
|
||||
.route("/Microsoft-Server-ActiveSync", any(eas::handler))
|
||||
.route("/microsoft-server-activesync", any(eas::handler))
|
||||
.route("/AutoDiscover/AutoDiscover.xml", any(autodiscover::handler))
|
||||
.route("/Autodiscover/Autodiscover.xml", any(autodiscover::handler))
|
||||
.route("/autodiscover/autodiscover.xml", any(autodiscover::handler))
|
||||
.route("/healthz", axum::routing::get(healthz))
|
||||
.route("/", axum::routing::get(index))
|
||||
.fallback(not_found)
|
||||
.with_state(app)
|
||||
}
|
||||
|
||||
async fn index() -> impl axum::response::IntoResponse {
|
||||
(
|
||||
[("Content-Type", "text/plain; charset=utf-8")],
|
||||
"ActivePush - Exchange ActiveSync endpoint.\nDevices connect to /Microsoft-Server-ActiveSync.\n",
|
||||
)
|
||||
}
|
||||
|
||||
async fn not_found() -> impl axum::response::IntoResponse {
|
||||
(
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
[("Content-Type", "text/plain; charset=utf-8")],
|
||||
"Not found.\n",
|
||||
)
|
||||
}
|
||||
|
||||
/// Liveness + database check for load balancers and monitoring.
|
||||
async fn healthz(
|
||||
axum::extract::State(app): axum::extract::State<Arc<app::App>>,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
match sqlx::query_scalar::<_, i64>("SELECT 1")
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
{
|
||||
Ok(_) => (axum::http::StatusCode::OK, "ok\n"),
|
||||
Err(_) => (
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"database unavailable\n",
|
||||
),
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
//! tracing setup + in-memory ring buffer exposed in the admin panel.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::Serialize;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing::{Event, Level, Subscriber};
|
||||
use tracing_subscriber::layer::{Context, SubscriberExt};
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::{EnvFilter, Layer};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LogLine {
|
||||
pub ts: i64,
|
||||
pub level: String,
|
||||
pub target: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub struct LogRing {
|
||||
lines: Mutex<VecDeque<LogLine>>,
|
||||
cap: AtomicUsize,
|
||||
}
|
||||
|
||||
impl LogRing {
|
||||
pub fn new(cap: usize) -> Arc<Self> {
|
||||
Arc::new(LogRing {
|
||||
lines: Mutex::new(VecDeque::with_capacity(cap)),
|
||||
cap: AtomicUsize::new(cap.max(100)),
|
||||
})
|
||||
}
|
||||
pub fn set_cap(&self, cap: usize) {
|
||||
self.cap.store(cap.clamp(100, 100_000), Ordering::Relaxed);
|
||||
}
|
||||
pub fn push(&self, l: LogLine) {
|
||||
let cap = self.cap.load(Ordering::Relaxed);
|
||||
let mut g = self.lines.lock().unwrap();
|
||||
g.push_back(l);
|
||||
while g.len() > cap {
|
||||
g.pop_front();
|
||||
}
|
||||
}
|
||||
pub fn tail(&self, n: usize, min_level: Option<&str>, q: Option<&str>) -> Vec<LogLine> {
|
||||
let rank = |l: &str| match l {
|
||||
"ERROR" => 5,
|
||||
"WARN" => 4,
|
||||
"INFO" => 3,
|
||||
"DEBUG" => 2,
|
||||
_ => 1,
|
||||
};
|
||||
let min = min_level.map(rank).unwrap_or(0);
|
||||
let q = q.map(|s| s.to_lowercase());
|
||||
let g = self.lines.lock().unwrap();
|
||||
let mut out: Vec<LogLine> = g
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|l| rank(&l.level) >= min)
|
||||
.filter(|l| {
|
||||
q.as_ref()
|
||||
.map(|q| l.message.to_lowercase().contains(q) || l.target.contains(q))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.take(n)
|
||||
.cloned()
|
||||
.collect();
|
||||
out.reverse();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
struct RingLayer {
|
||||
ring: Arc<LogRing>,
|
||||
}
|
||||
|
||||
struct MsgVisitor(String);
|
||||
|
||||
impl Visit for MsgVisitor {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
if field.name() == "message" {
|
||||
let _ = write!(self.0, "{value:?}");
|
||||
} else {
|
||||
let _ = write!(self.0, " {}={:?}", field.name(), value);
|
||||
}
|
||||
}
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
if field.name() == "message" {
|
||||
self.0.push_str(value);
|
||||
} else {
|
||||
let _ = write!(self.0, " {}={}", field.name(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Subscriber> Layer<S> for RingLayer {
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
let meta = event.metadata();
|
||||
if *meta.level() > Level::INFO && !meta.target().starts_with("active_push") {
|
||||
return;
|
||||
}
|
||||
let mut v = MsgVisitor(String::new());
|
||||
event.record(&mut v);
|
||||
self.ring.push(LogLine {
|
||||
ts: chrono::Utc::now().timestamp(),
|
||||
level: meta.level().to_string(),
|
||||
target: meta.target().to_string(),
|
||||
message: v.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(ring: Arc<LogRing>, json: bool) {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("active_push=info,warn"));
|
||||
let ring_layer = RingLayer { ring }.with_filter(EnvFilter::new(
|
||||
std::env::var("ACTIVE_PUSH_PANEL_LOG").unwrap_or_else(|_| "active_push=info,warn".into()),
|
||||
));
|
||||
let registry = tracing_subscriber::registry().with(ring_layer);
|
||||
if json {
|
||||
registry
|
||||
.with(tracing_subscriber::fmt::layer().json().with_filter(filter))
|
||||
.init();
|
||||
} else {
|
||||
registry
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_target(false)
|
||||
.with_filter(filter),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
}
|
||||
+486
@@ -0,0 +1,486 @@
|
||||
use std::io::Read;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use active_push::app::{App, Boot};
|
||||
use active_push::{admin, db, logging, probe, public_router, server, tls};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "active-push",
|
||||
version,
|
||||
about = "ActivePush - Exchange ActiveSync server for IMAP/SMTP/CalDAV/CardDAV"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Data directory (SQLite database).
|
||||
#[arg(
|
||||
long,
|
||||
env = "ACTIVE_PUSH_DATA_DIR",
|
||||
default_value = "/var/lib/active-push",
|
||||
global = true
|
||||
)]
|
||||
data_dir: PathBuf,
|
||||
/// Device facing listener (put nginx/RustProxy in front of it).
|
||||
#[arg(
|
||||
long,
|
||||
env = "ACTIVE_PUSH_EAS_LISTEN",
|
||||
default_value = "127.0.0.1:8081",
|
||||
global = true
|
||||
)]
|
||||
eas_listen: SocketAddr,
|
||||
/// Administration panel listener.
|
||||
#[arg(
|
||||
long,
|
||||
env = "ACTIVE_PUSH_ADMIN_LISTEN",
|
||||
default_value = "127.0.0.1:9091",
|
||||
global = true
|
||||
)]
|
||||
admin_listen: SocketAddr,
|
||||
/// Allow the panel to listen on a non-loopback address.
|
||||
#[arg(long, env = "ACTIVE_PUSH_ALLOW_REMOTE_ADMIN", global = true)]
|
||||
allow_remote_admin: bool,
|
||||
/// Optional TLS for the panel (PEM files).
|
||||
#[arg(long, env = "ACTIVE_PUSH_ADMIN_TLS_CERT", global = true)]
|
||||
admin_tls_cert: Option<String>,
|
||||
#[arg(long, env = "ACTIVE_PUSH_ADMIN_TLS_KEY", global = true)]
|
||||
admin_tls_key: Option<String>,
|
||||
/// Optional TLS for the ActiveSync listener (normally TLS ends at the reverse proxy).
|
||||
#[arg(long, env = "ACTIVE_PUSH_EAS_TLS_CERT", global = true)]
|
||||
eas_tls_cert: Option<String>,
|
||||
#[arg(long, env = "ACTIVE_PUSH_EAS_TLS_KEY", global = true)]
|
||||
eas_tls_key: Option<String>,
|
||||
/// JSON log output (for journald/ELK).
|
||||
#[arg(long, env = "ACTIVE_PUSH_LOG_JSON", global = true)]
|
||||
log_json: bool,
|
||||
#[command(subcommand)]
|
||||
cmd: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Run the server (default).
|
||||
Serve,
|
||||
/// Administrator management.
|
||||
Admin {
|
||||
#[command(subcommand)]
|
||||
cmd: AdminCmd,
|
||||
},
|
||||
/// Settings import/export.
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
cmd: ConfigCmd,
|
||||
},
|
||||
/// Device management (z-push-admin equivalents).
|
||||
Device {
|
||||
#[command(subcommand)]
|
||||
cmd: DeviceCmd,
|
||||
},
|
||||
/// ActiveSync smoke test against a running server.
|
||||
Probe {
|
||||
#[arg(long)]
|
||||
url: String,
|
||||
#[arg(long)]
|
||||
user: String,
|
||||
/// Read the password from this environment variable (otherwise prompt).
|
||||
#[arg(long)]
|
||||
password_env: Option<String>,
|
||||
#[arg(long)]
|
||||
insecure: bool,
|
||||
/// Device id used for the test partnership.
|
||||
#[arg(long, default_value = "probe0000000001")]
|
||||
device_id: String,
|
||||
#[arg(long, default_value = "ActivePushProbe")]
|
||||
device_type: String,
|
||||
/// Model reported via DeviceInformation.
|
||||
#[arg(long, default_value = "ActivePush probe")]
|
||||
model: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AdminCmd {
|
||||
/// Print a one-time setup token (valid 1 hour) for creating the first admin in the panel.
|
||||
SetupToken,
|
||||
/// Create the first administrator (password is prompted).
|
||||
Init {
|
||||
#[arg(long)]
|
||||
username: String,
|
||||
},
|
||||
/// Reset an administrator password and revoke all sessions.
|
||||
ResetPassword {
|
||||
#[arg(long)]
|
||||
username: Option<String>,
|
||||
},
|
||||
/// Revoke all panel sessions.
|
||||
LogoutAll,
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ConfigCmd {
|
||||
/// Print current settings as JSON (secrets redacted unless --with-secrets).
|
||||
Get {
|
||||
#[arg(long)]
|
||||
with_secrets: bool,
|
||||
},
|
||||
/// Validate and store settings from a JSON file ("-" = stdin). A running server picks
|
||||
/// them up on restart; use the panel for live changes.
|
||||
Apply { file: String },
|
||||
/// Validate a JSON settings file without storing it.
|
||||
Validate { file: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum DeviceCmd {
|
||||
List,
|
||||
/// Request a remote wipe.
|
||||
Wipe {
|
||||
id: i64,
|
||||
},
|
||||
/// Forget all sync states (device resynchronises everything).
|
||||
Resync {
|
||||
id: i64,
|
||||
},
|
||||
Remove {
|
||||
id: i64,
|
||||
},
|
||||
Block {
|
||||
id: i64,
|
||||
},
|
||||
Unblock {
|
||||
id: i64,
|
||||
},
|
||||
}
|
||||
|
||||
fn read_password(prompt: &str) -> Result<String> {
|
||||
let p1 = rpassword::prompt_password(prompt)?;
|
||||
let p2 = rpassword::prompt_password("Repeat password: ")?;
|
||||
if p1 != p2 {
|
||||
bail!("passwords do not match");
|
||||
}
|
||||
Ok(p1)
|
||||
}
|
||||
|
||||
fn read_file(f: &str) -> Result<String> {
|
||||
if f == "-" {
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_to_string(&mut s)?;
|
||||
Ok(s)
|
||||
} else {
|
||||
std::fs::read_to_string(f).with_context(|| format!("read {f}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
rt.block_on(run(cli))
|
||||
}
|
||||
|
||||
async fn run(cli: Cli) -> Result<()> {
|
||||
let ring = logging::LogRing::new(2000);
|
||||
let cmd = cli.cmd.unwrap_or(Cmd::Serve);
|
||||
if matches!(cmd, Cmd::Serve) {
|
||||
logging::init(ring.clone(), cli.log_json);
|
||||
} else {
|
||||
// CLI commands: quiet logging to stderr
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "warn".into()),
|
||||
))
|
||||
.with_writer(std::io::stderr)
|
||||
.try_init();
|
||||
}
|
||||
if let Cmd::Probe {
|
||||
url,
|
||||
user,
|
||||
password_env,
|
||||
insecure,
|
||||
device_id,
|
||||
device_type,
|
||||
model,
|
||||
} = &cmd
|
||||
{
|
||||
let pass = match password_env {
|
||||
Some(v) => {
|
||||
std::env::var(v).with_context(|| format!("environment variable {v} not set"))?
|
||||
}
|
||||
None => rpassword::prompt_password("Password: ")?,
|
||||
};
|
||||
return probe::run(url, user, &pass, *insecure, device_id, device_type, model).await;
|
||||
}
|
||||
let boot = Boot {
|
||||
data_dir: cli.data_dir.clone(),
|
||||
eas_listen: cli.eas_listen,
|
||||
admin_listen: cli.admin_listen,
|
||||
allow_remote_admin: cli.allow_remote_admin,
|
||||
admin_tls: cli.admin_tls_cert.clone().zip(cli.admin_tls_key.clone()),
|
||||
eas_tls: cli.eas_tls_cert.clone().zip(cli.eas_tls_key.clone()),
|
||||
};
|
||||
check_data_dir_owner(&boot.data_dir)?;
|
||||
let app = App::new(boot, ring).await?;
|
||||
match cmd {
|
||||
Cmd::Serve => serve(app).await,
|
||||
Cmd::Admin { cmd } => match cmd {
|
||||
AdminCmd::SetupToken => {
|
||||
let t = admin::cli_setup_token(&app).await?;
|
||||
println!("{t}");
|
||||
eprintln!("One-time setup token (valid 60 minutes). Open the panel and paste it to create the administrator.");
|
||||
Ok(())
|
||||
}
|
||||
AdminCmd::Init { username } => {
|
||||
let pw = read_password("New administrator password (min. 12 characters): ")?;
|
||||
admin::cli_init(&app, &username, &pw).await?;
|
||||
println!("Administrator {username} created.");
|
||||
Ok(())
|
||||
}
|
||||
AdminCmd::ResetPassword { username } => {
|
||||
let pw = read_password("New password (min. 12 characters): ")?;
|
||||
let u = admin::cli_reset_password(&app, username.as_deref(), &pw).await?;
|
||||
println!("Password for {u} changed; all panel sessions were revoked.");
|
||||
Ok(())
|
||||
}
|
||||
AdminCmd::LogoutAll => {
|
||||
let n = admin::cli_logout_all(&app).await?;
|
||||
println!("{n} session(s) revoked.");
|
||||
Ok(())
|
||||
}
|
||||
AdminCmd::Status => {
|
||||
println!("{}", admin::cli_status(&app).await?);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
Cmd::Config { cmd } => match cmd {
|
||||
ConfigCmd::Get { with_secrets } => {
|
||||
let s = app.settings();
|
||||
let v = if with_secrets {
|
||||
serde_json::to_value(&*s)?
|
||||
} else {
|
||||
s.redacted()
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&v)?);
|
||||
Ok(())
|
||||
}
|
||||
ConfigCmd::Apply { file } => {
|
||||
let v: serde_json::Value = serde_json::from_str(&read_file(&file)?)?;
|
||||
let s = app
|
||||
.settings()
|
||||
.merge_update(v)
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
let rev = app.update_settings(s).await?;
|
||||
db::audit(
|
||||
&app.db,
|
||||
"cli",
|
||||
"settings_saved",
|
||||
"",
|
||||
"cli",
|
||||
&format!("revision {rev}"),
|
||||
)
|
||||
.await;
|
||||
println!("Settings stored (revision {rev}). Restart the service or save in the panel to apply live.");
|
||||
Ok(())
|
||||
}
|
||||
ConfigCmd::Validate { file } => {
|
||||
let v: serde_json::Value = serde_json::from_str(&read_file(&file)?)?;
|
||||
app.settings()
|
||||
.merge_update(v)
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
println!("Settings are valid.");
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
Cmd::Device { cmd } => {
|
||||
match cmd {
|
||||
DeviceCmd::List => {
|
||||
println!(
|
||||
"{:>5} {:<28} {:<16} {:<30} {:<19} STATE",
|
||||
"ID", "USER", "TYPE", "DEVICE ID", "LAST SEEN"
|
||||
);
|
||||
for d in db::device_list(&app.db).await? {
|
||||
let state = if d.blocked == 1 {
|
||||
"blocked"
|
||||
} else if d.wipe_status == db::WIPE_WIPED {
|
||||
"wiped"
|
||||
} else if d.wipe_status >= db::WIPE_PENDING {
|
||||
"wipe pending"
|
||||
} else if d.approved == 0 {
|
||||
"awaiting approval"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
println!(
|
||||
"{:>5} {:<28} {:<16} {:<30} {:<19} {state}",
|
||||
d.id,
|
||||
d.username,
|
||||
d.device_type,
|
||||
d.device_id,
|
||||
active_push::util::format_ts(d.last_seen)
|
||||
);
|
||||
}
|
||||
}
|
||||
DeviceCmd::Wipe { id } => {
|
||||
db::device_request_wipe(&app.db, id, "cli", false).await?;
|
||||
db::audit(&app.db, "cli", "device_wipe", &id.to_string(), "cli", "").await;
|
||||
println!("Remote wipe requested for device {id}; it is executed on the next device connection.");
|
||||
}
|
||||
DeviceCmd::Resync { id } => {
|
||||
db::device_reset_states(&app.db, id).await?;
|
||||
println!("States of device {id} removed.");
|
||||
}
|
||||
DeviceCmd::Remove { id } => {
|
||||
db::device_delete(&app.db, id).await?;
|
||||
println!("Device {id} removed.");
|
||||
}
|
||||
DeviceCmd::Block { id } => {
|
||||
db::device_set_field(&app.db, id, db::DeviceField::Blocked, Some(1), None)
|
||||
.await?;
|
||||
println!("Device {id} blocked.");
|
||||
}
|
||||
DeviceCmd::Unblock { id } => {
|
||||
db::device_set_field(&app.db, id, db::DeviceField::Blocked, Some(0), None)
|
||||
.await?;
|
||||
println!("Device {id} unblocked.");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Probe { .. } => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse to touch a data directory owned by another user (e.g. `active-push admin …` run as
|
||||
/// root against the service's /var/lib/active-push would leave root-owned database files).
|
||||
fn check_data_dir_owner(dir: &std::path::Path) -> Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let Ok(meta) = std::fs::metadata(dir) else {
|
||||
return Ok(());
|
||||
};
|
||||
let euid = std::fs::read_to_string("/proc/self/status")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines().find(|l| l.starts_with("Uid:")).and_then(|l| {
|
||||
l.split_whitespace()
|
||||
.nth(2)
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
})
|
||||
});
|
||||
if let Some(euid) = euid {
|
||||
if euid != meta.uid() && std::env::var("ACTIVE_PUSH_ALLOW_FOREIGN_DATA_DIR").is_err() {
|
||||
bail!(
|
||||
"{} belongs to uid {}, but this command runs as uid {euid}.\nRun it as the service user, e.g.: sudo -u active-push active-push …",
|
||||
dir.display(),
|
||||
meta.uid()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve(app: Arc<App>) -> Result<()> {
|
||||
let boot = app.boot.clone();
|
||||
if !boot.admin_listen.ip().is_loopback() && !boot.allow_remote_admin {
|
||||
bail!(
|
||||
"refusing to expose the admin panel on {} - use a loopback address, an SSH tunnel/reverse proxy, or pass --allow-remote-admin",
|
||||
boot.admin_listen
|
||||
);
|
||||
}
|
||||
let admin_tls = match &boot.admin_tls {
|
||||
Some((c, k)) => Some(tls::server_config(c, k)?),
|
||||
None => None,
|
||||
};
|
||||
let eas_tls = match &boot.eas_tls {
|
||||
Some((c, k)) => Some(tls::server_config(c, k)?),
|
||||
None => None,
|
||||
};
|
||||
let eas_l = tokio::net::TcpListener::bind(boot.eas_listen)
|
||||
.await
|
||||
.with_context(|| format!("bind ActiveSync listener {}", boot.eas_listen))?;
|
||||
let adm_l = tokio::net::TcpListener::bind(boot.admin_listen)
|
||||
.await
|
||||
.with_context(|| format!("bind admin listener {}", boot.admin_listen))?;
|
||||
let s = app.settings();
|
||||
tracing::info!(
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
eas = %boot.eas_listen,
|
||||
panel = %boot.admin_listen,
|
||||
imap = %format!("{}:{}", s.imap.host, s.imap.port),
|
||||
caldav = s.caldav.enabled,
|
||||
carddav = s.carddav.enabled,
|
||||
"ActivePush started"
|
||||
);
|
||||
let has_admin: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM admins")
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
if has_admin == 0 {
|
||||
tracing::warn!(
|
||||
"no administrator yet - run `active-push admin setup-token` and open the panel"
|
||||
);
|
||||
}
|
||||
|
||||
// background housekeeping
|
||||
let bg = app.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(60));
|
||||
let mut n: u64 = 0;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tick.tick() => {}
|
||||
_ = bg.shutdown.cancelled() => break,
|
||||
}
|
||||
n += 1;
|
||||
let s = bg.settings();
|
||||
bg.pool
|
||||
.evict_idle(Duration::from_secs(s.imap.pool_idle_secs));
|
||||
bg.auth.housekeeping();
|
||||
bg.metrics.prune();
|
||||
if n % 5 == 0 {
|
||||
bg.metrics.flush_hour(&bg.db).await;
|
||||
}
|
||||
if n % 60 == 1 {
|
||||
if let Err(e) = db::housekeeping(&bg.db, &s).await {
|
||||
tracing::warn!("housekeeping failed: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let shutdown = app.shutdown.clone();
|
||||
let eas = tokio::spawn(server::serve(
|
||||
"activesync",
|
||||
eas_l,
|
||||
public_router(app.clone()),
|
||||
eas_tls,
|
||||
shutdown.clone(),
|
||||
));
|
||||
let adm = tokio::spawn(server::serve(
|
||||
"panel",
|
||||
adm_l,
|
||||
admin::router(app.clone()),
|
||||
admin_tls,
|
||||
shutdown.clone(),
|
||||
));
|
||||
|
||||
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {}
|
||||
_ = term.recv() => {}
|
||||
}
|
||||
tracing::info!("shutdown requested");
|
||||
shutdown.cancel();
|
||||
let _ = tokio::join!(eas, adm);
|
||||
app.metrics.flush_hour(&app.db).await;
|
||||
app.db.close().await;
|
||||
tracing::info!("bye");
|
||||
Ok(())
|
||||
}
|
||||
+479
@@ -0,0 +1,479 @@
|
||||
//! In-memory metrics, live request registry and per-device ping tracking.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{broadcast, Notify};
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::util::now;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Counters {
|
||||
pub requests: AtomicU64,
|
||||
pub errors: AtomicU64,
|
||||
pub auth_failures: AtomicU64,
|
||||
pub bytes_in: AtomicU64,
|
||||
pub bytes_out: AtomicU64,
|
||||
pub items_out: AtomicU64,
|
||||
pub items_in: AtomicU64,
|
||||
pub mails_sent: AtomicU64,
|
||||
pub autodiscover: AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct CmdStat {
|
||||
pub count: u64,
|
||||
pub errors: u64,
|
||||
pub total_ms: u64,
|
||||
pub max_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct Bucket {
|
||||
pub t: i64,
|
||||
pub requests: u64,
|
||||
pub errors: u64,
|
||||
pub auth_failures: u64,
|
||||
pub items_out: u64,
|
||||
pub items_in: u64,
|
||||
pub bytes_in: u64,
|
||||
pub bytes_out: u64,
|
||||
pub total_ms: u64,
|
||||
pub pings: u64,
|
||||
pub mails_sent: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ActiveRequest {
|
||||
pub id: u64,
|
||||
pub user: String,
|
||||
pub device_id: String,
|
||||
pub device_type: String,
|
||||
pub command: String,
|
||||
pub ip: String,
|
||||
pub started: i64,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Event {
|
||||
pub ts: i64,
|
||||
pub user: String,
|
||||
pub device_id: String,
|
||||
pub device_type: String,
|
||||
pub command: String,
|
||||
pub ip: String,
|
||||
pub http: u16,
|
||||
pub status: String,
|
||||
pub ms: u64,
|
||||
pub bytes_in: u64,
|
||||
pub bytes_out: u64,
|
||||
pub items_out: u64,
|
||||
pub items_in: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PingInfo {
|
||||
pub user: String,
|
||||
pub device_id: String,
|
||||
pub device_type: String,
|
||||
pub started: i64,
|
||||
pub lifetime: u64,
|
||||
pub folders: usize,
|
||||
pub kind: &'static str,
|
||||
}
|
||||
|
||||
struct PingSlot {
|
||||
info: PingInfo,
|
||||
cancel: Arc<Notify>,
|
||||
gen: u64,
|
||||
}
|
||||
|
||||
pub struct Metrics {
|
||||
pub c: Counters,
|
||||
commands: Mutex<HashMap<String, CmdStat>>,
|
||||
minutes: Mutex<VecDeque<Bucket>>,
|
||||
hour_acc: Mutex<(i64, Bucket, HashMap<String, u64>)>,
|
||||
active: Mutex<HashMap<u64, ActiveRequest>>,
|
||||
next_id: AtomicU64,
|
||||
recent: Mutex<VecDeque<Event>>,
|
||||
pub events: broadcast::Sender<Event>,
|
||||
pings: Mutex<HashMap<String, PingSlot>>,
|
||||
ping_gen: AtomicU64,
|
||||
users_seen: Mutex<HashMap<String, i64>>,
|
||||
}
|
||||
|
||||
impl Default for Metrics {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
pub fn new() -> Self {
|
||||
let (tx, _) = broadcast::channel(512);
|
||||
Metrics {
|
||||
c: Counters::default(),
|
||||
commands: Mutex::new(HashMap::new()),
|
||||
minutes: Mutex::new(VecDeque::with_capacity(1441)),
|
||||
hour_acc: Mutex::new((now() / 3600 * 3600, Bucket::default(), HashMap::new())),
|
||||
active: Mutex::new(HashMap::new()),
|
||||
next_id: AtomicU64::new(1),
|
||||
recent: Mutex::new(VecDeque::with_capacity(501)),
|
||||
events: tx,
|
||||
pings: Mutex::new(HashMap::new()),
|
||||
ping_gen: AtomicU64::new(1),
|
||||
users_seen: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_bucket(&self, f: impl FnOnce(&mut Bucket)) {
|
||||
let minute = now() / 60 * 60;
|
||||
let mut m = self.minutes.lock().unwrap();
|
||||
if m.back().map(|b| b.t) != Some(minute) {
|
||||
m.push_back(Bucket {
|
||||
t: minute,
|
||||
..Default::default()
|
||||
});
|
||||
while m.len() > 1440 {
|
||||
m.pop_front();
|
||||
}
|
||||
}
|
||||
f(m.back_mut().unwrap());
|
||||
}
|
||||
|
||||
pub fn begin(&self, r: ActiveRequest) -> u64 {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let mut r = r;
|
||||
r.id = id;
|
||||
if !r.user.is_empty() {
|
||||
self.users_seen
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(r.user.clone(), now());
|
||||
}
|
||||
self.active.lock().unwrap().insert(id, r);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn end(&self, id: u64, ev: Event) {
|
||||
self.active.lock().unwrap().remove(&id);
|
||||
self.record(ev);
|
||||
}
|
||||
|
||||
pub fn record(&self, ev: Event) {
|
||||
let failed = ev.http >= 500 || ev.status.starts_with("error");
|
||||
self.c.requests.fetch_add(1, Ordering::Relaxed);
|
||||
if failed {
|
||||
self.c.errors.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
self.c.bytes_in.fetch_add(ev.bytes_in, Ordering::Relaxed);
|
||||
self.c.bytes_out.fetch_add(ev.bytes_out, Ordering::Relaxed);
|
||||
self.c.items_out.fetch_add(ev.items_out, Ordering::Relaxed);
|
||||
self.c.items_in.fetch_add(ev.items_in, Ordering::Relaxed);
|
||||
{
|
||||
let mut cmds = self.commands.lock().unwrap();
|
||||
let s = cmds.entry(ev.command.clone()).or_default();
|
||||
s.count += 1;
|
||||
if failed {
|
||||
s.errors += 1;
|
||||
}
|
||||
// pings/hanging syncs would skew latency
|
||||
if ev.command != "Ping" {
|
||||
s.total_ms += ev.ms;
|
||||
s.max_ms = s.max_ms.max(ev.ms);
|
||||
}
|
||||
}
|
||||
let is_ping = ev.command == "Ping";
|
||||
self.with_bucket(|b| {
|
||||
b.requests += 1;
|
||||
if failed {
|
||||
b.errors += 1;
|
||||
}
|
||||
b.items_out += ev.items_out;
|
||||
b.items_in += ev.items_in;
|
||||
b.bytes_in += ev.bytes_in;
|
||||
b.bytes_out += ev.bytes_out;
|
||||
if is_ping {
|
||||
b.pings += 1;
|
||||
} else {
|
||||
b.total_ms += ev.ms;
|
||||
}
|
||||
});
|
||||
{
|
||||
let mut h = self.hour_acc.lock().unwrap();
|
||||
h.1.requests += 1;
|
||||
if failed {
|
||||
h.1.errors += 1;
|
||||
}
|
||||
h.1.bytes_in += ev.bytes_in;
|
||||
h.1.bytes_out += ev.bytes_out;
|
||||
h.1.items_out += ev.items_out;
|
||||
h.1.items_in += ev.items_in;
|
||||
if !is_ping {
|
||||
h.1.total_ms += ev.ms;
|
||||
}
|
||||
*h.2.entry(ev.command.clone()).or_default() += 1;
|
||||
}
|
||||
{
|
||||
let mut r = self.recent.lock().unwrap();
|
||||
r.push_back(ev.clone());
|
||||
while r.len() > 500 {
|
||||
r.pop_front();
|
||||
}
|
||||
}
|
||||
let _ = self.events.send(ev);
|
||||
}
|
||||
|
||||
pub fn auth_failure(&self) {
|
||||
self.c.auth_failures.fetch_add(1, Ordering::Relaxed);
|
||||
self.with_bucket(|b| b.auth_failures += 1);
|
||||
self.hour_acc.lock().unwrap().1.auth_failures += 1;
|
||||
}
|
||||
|
||||
pub fn mail_sent(&self) {
|
||||
self.c.mails_sent.fetch_add(1, Ordering::Relaxed);
|
||||
self.with_bucket(|b| b.mails_sent += 1);
|
||||
self.hour_acc.lock().unwrap().1.mails_sent += 1;
|
||||
}
|
||||
|
||||
pub fn active(&self) -> Vec<ActiveRequest> {
|
||||
let mut v: Vec<_> = self.active.lock().unwrap().values().cloned().collect();
|
||||
v.sort_by_key(|a| a.started);
|
||||
v
|
||||
}
|
||||
|
||||
pub fn recent(&self, n: usize) -> Vec<Event> {
|
||||
let r = self.recent.lock().unwrap();
|
||||
r.iter().rev().take(n).cloned().collect()
|
||||
}
|
||||
|
||||
pub fn minutes(&self, n: usize) -> Vec<Bucket> {
|
||||
let m = self.minutes.lock().unwrap();
|
||||
let skip = m.len().saturating_sub(n);
|
||||
m.iter().skip(skip).cloned().collect()
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> HashMap<String, CmdStat> {
|
||||
self.commands.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Drop per-user activity entries older than a day (bounded memory).
|
||||
pub fn prune(&self) {
|
||||
let cutoff = now() - 86400;
|
||||
self.users_seen.lock().unwrap().retain(|_, t| *t >= cutoff);
|
||||
}
|
||||
|
||||
pub fn users_active(&self, window_secs: i64) -> usize {
|
||||
let cutoff = now() - window_secs;
|
||||
self.users_seen
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|t| **t >= cutoff)
|
||||
.count()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- pings
|
||||
|
||||
/// Register a hanging request for a device. Any previous hanging request of the same
|
||||
/// device is cancelled (the device opened a new connection).
|
||||
pub fn ping_start(&self, key: &str, info: PingInfo) -> (Arc<Notify>, u64) {
|
||||
let gen = self.ping_gen.fetch_add(1, Ordering::Relaxed);
|
||||
let cancel = Arc::new(Notify::new());
|
||||
let mut p = self.pings.lock().unwrap();
|
||||
if let Some(old) = p.insert(
|
||||
key.to_string(),
|
||||
PingSlot {
|
||||
info,
|
||||
cancel: cancel.clone(),
|
||||
gen,
|
||||
},
|
||||
) {
|
||||
old.cancel.notify_one();
|
||||
}
|
||||
(cancel, gen)
|
||||
}
|
||||
|
||||
pub fn ping_end(&self, key: &str, gen: u64) {
|
||||
let mut p = self.pings.lock().unwrap();
|
||||
if p.get(key).map(|s| s.gen) == Some(gen) {
|
||||
p.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// A new request from a device interrupts its hanging Ping/Sync.
|
||||
pub fn ping_interrupt(&self, key: &str) {
|
||||
if let Some(s) = self.pings.lock().unwrap().remove(key) {
|
||||
s.cancel.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pings(&self) -> Vec<PingInfo> {
|
||||
self.pings
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.map(|s| s.info.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn ping_count(&self) -> usize {
|
||||
self.pings.lock().unwrap().len()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- persistence
|
||||
|
||||
/// Flush the current hour accumulator into SQLite (called periodically).
|
||||
pub async fn flush_hour(&self, db: &Db) {
|
||||
let (hour, b, cmds) = {
|
||||
let mut h = self.hour_acc.lock().unwrap();
|
||||
let cur_hour = now() / 3600 * 3600;
|
||||
let snapshot = (h.0, std::mem::take(&mut h.1), std::mem::take(&mut h.2));
|
||||
h.0 = cur_hour;
|
||||
snapshot
|
||||
};
|
||||
if b.requests == 0 && b.auth_failures == 0 && b.mails_sent == 0 {
|
||||
return;
|
||||
}
|
||||
// merge command json
|
||||
let existing: Option<String> =
|
||||
sqlx::query_scalar("SELECT commands FROM stats_hourly WHERE hour = ?")
|
||||
.bind(hour)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let mut merged: HashMap<String, u64> = existing
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
for (k, v) in cmds {
|
||||
*merged.entry(k).or_default() += v;
|
||||
}
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO stats_hourly (hour, requests, errors, auth_failures, bytes_in, bytes_out, items_out, items_in, mails_sent, total_ms, commands)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(hour) DO UPDATE SET requests = requests + excluded.requests, errors = errors + excluded.errors,
|
||||
auth_failures = auth_failures + excluded.auth_failures, bytes_in = bytes_in + excluded.bytes_in,
|
||||
bytes_out = bytes_out + excluded.bytes_out, items_out = items_out + excluded.items_out,
|
||||
items_in = items_in + excluded.items_in, mails_sent = mails_sent + excluded.mails_sent,
|
||||
total_ms = total_ms + excluded.total_ms, commands = excluded.commands",
|
||||
)
|
||||
.bind(hour)
|
||||
.bind(b.requests as i64)
|
||||
.bind(b.errors as i64)
|
||||
.bind(b.auth_failures as i64)
|
||||
.bind(b.bytes_in as i64)
|
||||
.bind(b.bytes_out as i64)
|
||||
.bind(b.items_out as i64)
|
||||
.bind(b.items_in as i64)
|
||||
.bind(b.mails_sent as i64)
|
||||
.bind(b.total_ms as i64)
|
||||
.bind(serde_json::to_string(&merged).unwrap_or_default())
|
||||
.execute(db)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple sliding-window failure counter + lockout (per IP and per user).
|
||||
pub struct AuthGuard {
|
||||
fails: Mutex<HashMap<String, VecDeque<i64>>>,
|
||||
locked: Mutex<HashMap<String, i64>>,
|
||||
ok_cache: Mutex<HashMap<[u8; 32], i64>>,
|
||||
}
|
||||
|
||||
impl Default for AuthGuard {
|
||||
fn default() -> Self {
|
||||
AuthGuard {
|
||||
fails: Mutex::new(HashMap::new()),
|
||||
locked: Mutex::new(HashMap::new()),
|
||||
ok_cache: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthGuard {
|
||||
pub fn locked_until(&self, key: &str) -> Option<i64> {
|
||||
let mut l = self.locked.lock().unwrap();
|
||||
match l.get(key) {
|
||||
Some(t) if *t > now() => Some(*t),
|
||||
Some(_) => {
|
||||
l.remove(key);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a failure; returns true if the key just became locked.
|
||||
pub fn fail(&self, key: &str, max: u32, window: u64, lockout: u64) -> bool {
|
||||
let t = now();
|
||||
let mut f = self.fails.lock().unwrap();
|
||||
let q = f.entry(key.to_string()).or_default();
|
||||
q.push_back(t);
|
||||
while q.front().map(|x| *x < t - window as i64).unwrap_or(false) {
|
||||
q.pop_front();
|
||||
}
|
||||
if max > 0 && q.len() as u32 >= max {
|
||||
q.clear();
|
||||
self.locked
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key.to_string(), t + lockout as i64);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn success(&self, key: &str) {
|
||||
self.fails.lock().unwrap().remove(key);
|
||||
}
|
||||
|
||||
pub fn cache_ok(&self, k: [u8; 32], ttl: u64) {
|
||||
if ttl > 0 {
|
||||
self.ok_cache.lock().unwrap().insert(k, now() + ttl as i64);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_cached(&self, k: &[u8; 32]) -> bool {
|
||||
self.ok_cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(k)
|
||||
.map(|t| *t > now())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn clear_cache(&self) {
|
||||
self.ok_cache.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
pub fn lockouts(&self) -> Vec<(String, i64)> {
|
||||
let t = now();
|
||||
self.locked
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(_, v)| **v > t)
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn unlock(&self, key: &str) {
|
||||
self.locked.lock().unwrap().remove(key);
|
||||
self.fails.lock().unwrap().remove(key);
|
||||
}
|
||||
|
||||
pub fn housekeeping(&self) {
|
||||
let t = now();
|
||||
self.locked.lock().unwrap().retain(|_, v| *v > t);
|
||||
self.ok_cache.lock().unwrap().retain(|_, v| *v > t);
|
||||
self.fails
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|_, q| q.back().map(|x| *x > t - 86400).unwrap_or(false));
|
||||
}
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
//! `active-push probe` - a small ActiveSync client used for smoke tests and diagnostics.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use crate::wbxml::{self, Element};
|
||||
|
||||
pub struct Probe {
|
||||
http: reqwest::Client,
|
||||
url: String,
|
||||
user: String,
|
||||
pass: String,
|
||||
device: String,
|
||||
pub device_type: String,
|
||||
pub model: String,
|
||||
pub policy_key: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
pub fn new(url: &str, user: &str, pass: &str, device: &str, insecure: bool) -> Result<Self> {
|
||||
let http = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(insecure)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()?;
|
||||
Ok(Probe {
|
||||
http,
|
||||
url: url.trim_end_matches('/').to_string(),
|
||||
user: user.into(),
|
||||
pass: pass.into(),
|
||||
device: device.into(),
|
||||
device_type: "ActivePushProbe".into(),
|
||||
model: "ActivePush probe".into(),
|
||||
policy_key: "0".into(),
|
||||
version: "14.1".into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn options(&self) -> Result<(u16, String, String)> {
|
||||
let r = self
|
||||
.http
|
||||
.request(reqwest::Method::OPTIONS, &self.url)
|
||||
.basic_auth(&self.user, Some(&self.pass))
|
||||
.send()
|
||||
.await?;
|
||||
let h = |n: &str| {
|
||||
r.headers()
|
||||
.get(n)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
};
|
||||
Ok((
|
||||
r.status().as_u16(),
|
||||
h("ms-asprotocolversions"),
|
||||
h("ms-asprotocolcommands"),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn cmd(&self, cmd: &str, body: Option<Element>) -> Result<(u16, Option<Element>)> {
|
||||
self.cmd_q(cmd, body, "").await
|
||||
}
|
||||
|
||||
pub async fn cmd_q(
|
||||
&self,
|
||||
cmd: &str,
|
||||
body: Option<Element>,
|
||||
extra_q: &str,
|
||||
) -> Result<(u16, Option<Element>)> {
|
||||
let url = format!(
|
||||
"{}?Cmd={cmd}&User={}&DeviceId={}&DeviceType={}{extra_q}",
|
||||
self.url,
|
||||
url::form_urlencoded::byte_serialize(self.user.as_bytes()).collect::<String>(),
|
||||
self.device,
|
||||
url::form_urlencoded::byte_serialize(self.device_type.as_bytes()).collect::<String>()
|
||||
);
|
||||
let mut rb = self
|
||||
.http
|
||||
.post(url)
|
||||
.basic_auth(&self.user, Some(&self.pass))
|
||||
.header("MS-ASProtocolVersion", &self.version)
|
||||
.header("X-MS-PolicyKey", &self.policy_key)
|
||||
.header("User-Agent", "ActivePush-Probe/1.0")
|
||||
.header("Content-Type", "application/vnd.ms-sync.wbxml");
|
||||
if let Some(b) = body {
|
||||
rb = rb.body(wbxml::encode(&b));
|
||||
}
|
||||
let r = rb.send().await.with_context(|| format!("{cmd} request"))?;
|
||||
let st = r.status().as_u16();
|
||||
let bytes = r.bytes().await?;
|
||||
if bytes.is_empty() || st != 200 {
|
||||
return Ok((st, None));
|
||||
}
|
||||
Ok((st, Some(wbxml::decode(&bytes).context("decode response")?)))
|
||||
}
|
||||
|
||||
pub async fn provision(&mut self) -> Result<String> {
|
||||
let req = Element::new("Provision:Provision")
|
||||
.with(
|
||||
Element::new("Settings:DeviceInformation").with(
|
||||
Element::new("Settings:Set")
|
||||
.with_text("Settings:Model", self.model.clone())
|
||||
.with_text("Settings:FriendlyName", self.model.clone())
|
||||
.with_text("Settings:OS", "ActivePush probe")
|
||||
.with_text("Settings:UserAgent", "ActivePush-Probe/1.0"),
|
||||
),
|
||||
)
|
||||
.with(
|
||||
Element::new("Provision:Policies").with(
|
||||
Element::new("Provision:Policy")
|
||||
.with_text("Provision:PolicyType", "MS-EAS-Provisioning-WBXML"),
|
||||
),
|
||||
);
|
||||
let (_, r) = self.cmd("Provision", Some(req)).await?;
|
||||
let r = r.context("empty provision response")?;
|
||||
let pol = r
|
||||
.path(&["Provision:Policies", "Provision:Policy"])
|
||||
.context("no policy")?;
|
||||
if pol.child_text("Provision:Status").as_deref() == Some("2") {
|
||||
return Ok("provisioning disabled on server".into());
|
||||
}
|
||||
let temp = pol
|
||||
.child_text("Provision:PolicyKey")
|
||||
.context("no temp key")?;
|
||||
self.policy_key = temp.clone();
|
||||
let ack = Element::new("Provision:Provision").with(
|
||||
Element::new("Provision:Policies").with(
|
||||
Element::new("Provision:Policy")
|
||||
.with_text("Provision:PolicyType", "MS-EAS-Provisioning-WBXML")
|
||||
.with_text("Provision:PolicyKey", temp)
|
||||
.with_text("Provision:Status", "1"),
|
||||
),
|
||||
);
|
||||
let (_, r) = self.cmd("Provision", Some(ack)).await?;
|
||||
let r = r.context("empty provision ack")?;
|
||||
let key = r
|
||||
.path(&["Provision:Policies", "Provision:Policy"])
|
||||
.and_then(|p| p.child_text("Provision:PolicyKey"))
|
||||
.context("no final key")?;
|
||||
self.policy_key = key.clone();
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
pub async fn folder_sync(&self, key: &str) -> Result<Element> {
|
||||
let (st, r) = self
|
||||
.cmd(
|
||||
"FolderSync",
|
||||
Some(
|
||||
Element::new("FolderHierarchy:FolderSync")
|
||||
.with_text("FolderHierarchy:SyncKey", key),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
r.with_context(|| format!("FolderSync returned HTTP {st}"))
|
||||
}
|
||||
|
||||
pub async fn sync(
|
||||
&self,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
window: u32,
|
||||
commands: Option<Element>,
|
||||
) -> Result<Element> {
|
||||
let mut col = Element::new("AirSync:Collection")
|
||||
.with_text("AirSync:SyncKey", key)
|
||||
.with_text("AirSync:CollectionId", collection);
|
||||
if key != "0" {
|
||||
col.push(Element::new("AirSync:DeletesAsMoves"));
|
||||
col.push(Element::new("AirSync:GetChanges"));
|
||||
col.push_text("AirSync:WindowSize", window.to_string());
|
||||
col.push(
|
||||
Element::new("AirSync:Options").with(
|
||||
Element::new("AirSyncBase:BodyPreference")
|
||||
.with_text("AirSyncBase:Type", "1")
|
||||
.with_text("AirSyncBase:TruncationSize", "2048"),
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(c) = commands {
|
||||
col.push(c);
|
||||
}
|
||||
let req = Element::new("AirSync:Sync").with(Element::new("AirSync:Collections").with(col));
|
||||
let (st, r) = self.cmd("Sync", Some(req)).await?;
|
||||
r.with_context(|| format!("Sync returned HTTP {st} without body"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a read-only smoke test and print a report.
|
||||
pub async fn run(
|
||||
url: &str,
|
||||
user: &str,
|
||||
pass: &str,
|
||||
insecure: bool,
|
||||
device_id: &str,
|
||||
device_type: &str,
|
||||
model: &str,
|
||||
) -> Result<()> {
|
||||
let mut p = Probe::new(url, user, pass, device_id, insecure)?;
|
||||
p.device_type = device_type.to_string();
|
||||
p.model = model.to_string();
|
||||
let (st, vers, _) = p.options().await?;
|
||||
println!("OPTIONS HTTP {st} versions: {vers}");
|
||||
if st == 401 {
|
||||
bail!("authentication failed");
|
||||
}
|
||||
match p.provision().await {
|
||||
Ok(k) => println!("Provision policy key {k}"),
|
||||
Err(e) => println!("Provision failed: {e:#}"),
|
||||
}
|
||||
let fs = p.folder_sync("0").await?;
|
||||
let status = fs.child_text("FolderHierarchy:Status").unwrap_or_default();
|
||||
let mut inbox = None;
|
||||
let mut n = 0;
|
||||
if let Some(ch) = fs.child("FolderHierarchy:Changes") {
|
||||
for a in ch.children_named("FolderHierarchy:Add") {
|
||||
n += 1;
|
||||
let t = a.child_text("FolderHierarchy:Type").unwrap_or_default();
|
||||
let name = a
|
||||
.child_text("FolderHierarchy:DisplayName")
|
||||
.unwrap_or_default();
|
||||
let id = a.child_text("FolderHierarchy:ServerId").unwrap_or_default();
|
||||
println!(" folder {id:>4} type {t:>2} {name}");
|
||||
if t == "2" {
|
||||
inbox = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("FolderSync status {status}, {n} folders");
|
||||
if let Some(ib) = inbox {
|
||||
let s0 = p.sync(&ib, "0", 0, None).await?;
|
||||
let key = s0
|
||||
.path(&["AirSync:Collections", "AirSync:Collection"])
|
||||
.and_then(|c| c.child_text("AirSync:SyncKey"))
|
||||
.context("no sync key")?;
|
||||
let s1 = p.sync(&ib, &key, 5, None).await?;
|
||||
let col = s1
|
||||
.path(&["AirSync:Collections", "AirSync:Collection"])
|
||||
.context("no collection")?;
|
||||
let adds = col
|
||||
.child("AirSync:Commands")
|
||||
.map(|c| c.children.len())
|
||||
.unwrap_or(0);
|
||||
println!(
|
||||
"Sync Inbox status {}, {adds} item(s){}",
|
||||
col.child_text("AirSync:Status").unwrap_or_default(),
|
||||
if col.has("AirSync:MoreAvailable") {
|
||||
", more available"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
if let Some(first) = col
|
||||
.child("AirSync:Commands")
|
||||
.and_then(|c| c.children.first())
|
||||
{
|
||||
if let Some(app) = first.child("AirSync:ApplicationData") {
|
||||
println!(
|
||||
" newest: {} from {}",
|
||||
app.child_text("Email:Subject").unwrap_or_default(),
|
||||
app.child_text("Email:From").unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("OK");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! HTTP(S) listeners with graceful shutdown.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::Router;
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||
use hyper_util::server::conn::auto::Builder;
|
||||
use hyper_util::service::TowerToHyperService;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub async fn serve(
|
||||
name: &'static str,
|
||||
listener: TcpListener,
|
||||
router: Router,
|
||||
tls: Option<Arc<rustls::ServerConfig>>,
|
||||
shutdown: CancellationToken,
|
||||
) {
|
||||
let acceptor = tls.map(tokio_rustls::TlsAcceptor::from);
|
||||
let mut conns = JoinSet::new();
|
||||
loop {
|
||||
let (stream, peer) = tokio::select! {
|
||||
r = listener.accept() => match r {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
tracing::warn!("{name}: accept failed: {e}");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
_ = shutdown.cancelled() => break,
|
||||
};
|
||||
let _ = stream.set_nodelay(true);
|
||||
let svc = router
|
||||
.clone()
|
||||
.layer(axum::Extension(ConnectInfo::<SocketAddr>(peer)));
|
||||
let acceptor = acceptor.clone();
|
||||
let stop = shutdown.clone();
|
||||
conns.spawn(async move {
|
||||
let hyper_svc = TowerToHyperService::new(svc);
|
||||
let mut builder = Builder::new(TokioExecutor::new());
|
||||
builder
|
||||
.http1()
|
||||
.timer(hyper_util::rt::TokioTimer::new())
|
||||
.header_read_timeout(Duration::from_secs(30))
|
||||
.max_buf_size(256 * 1024);
|
||||
builder.http2().max_concurrent_streams(64);
|
||||
match acceptor {
|
||||
Some(a) => {
|
||||
let tls = match tokio::time::timeout(Duration::from_secs(15), a.accept(stream))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(s)) => s,
|
||||
_ => return,
|
||||
};
|
||||
let conn = builder.serve_connection_with_upgrades(TokioIo::new(tls), hyper_svc);
|
||||
tokio::pin!(conn);
|
||||
tokio::select! {
|
||||
_ = conn.as_mut() => {}
|
||||
_ = stop.cancelled() => {
|
||||
conn.as_mut().graceful_shutdown();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(10), conn).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let conn =
|
||||
builder.serve_connection_with_upgrades(TokioIo::new(stream), hyper_svc);
|
||||
tokio::pin!(conn);
|
||||
tokio::select! {
|
||||
_ = conn.as_mut() => {}
|
||||
_ = stop.cancelled() => {
|
||||
conn.as_mut().graceful_shutdown();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(10), conn).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// reap finished connections
|
||||
while let Some(r) = conns.try_join_next() {
|
||||
let _ = r;
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
"{name}: stopping, waiting for {} connection(s)",
|
||||
conns.len()
|
||||
);
|
||||
let _ = tokio::time::timeout(Duration::from_secs(12), async {
|
||||
while conns.join_next().await.is_some() {}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
//! Shared rustls client configuration (system trust store + optional insecure mode).
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme};
|
||||
|
||||
fn provider() -> Arc<rustls::crypto::CryptoProvider> {
|
||||
Arc::new(rustls::crypto::ring::default_provider())
|
||||
}
|
||||
|
||||
fn roots() -> RootCertStore {
|
||||
let mut store = RootCertStore::empty();
|
||||
let native = rustls_native_certs::load_native_certs();
|
||||
for cert in native.certs {
|
||||
let _ = store.add(cert);
|
||||
}
|
||||
if store.is_empty() {
|
||||
store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
}
|
||||
store
|
||||
}
|
||||
|
||||
pub fn client_config(verify: bool) -> Arc<ClientConfig> {
|
||||
static VERIFIED: OnceLock<Arc<ClientConfig>> = OnceLock::new();
|
||||
static INSECURE: OnceLock<Arc<ClientConfig>> = OnceLock::new();
|
||||
if verify {
|
||||
VERIFIED
|
||||
.get_or_init(|| {
|
||||
Arc::new(
|
||||
ClientConfig::builder_with_provider(provider())
|
||||
.with_safe_default_protocol_versions()
|
||||
.expect("tls versions")
|
||||
.with_root_certificates(roots())
|
||||
.with_no_client_auth(),
|
||||
)
|
||||
})
|
||||
.clone()
|
||||
} else {
|
||||
INSECURE
|
||||
.get_or_init(|| {
|
||||
Arc::new(
|
||||
ClientConfig::builder_with_provider(provider())
|
||||
.with_safe_default_protocol_versions()
|
||||
.expect("tls versions")
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoVerify))
|
||||
.with_no_client_auth(),
|
||||
)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts any certificate. Only used when the administrator explicitly disables
|
||||
/// verification for a backend (e.g. self-signed certificate on localhost).
|
||||
#[derive(Debug)]
|
||||
struct NoVerify;
|
||||
|
||||
impl ServerCertVerifier for NoVerify {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a PEM certificate chain + key for the optional built-in HTTPS listeners.
|
||||
pub fn server_config(cert_path: &str, key_path: &str) -> anyhow::Result<Arc<rustls::ServerConfig>> {
|
||||
use anyhow::Context;
|
||||
let certs = rustls_pemfile::certs(&mut std::io::BufReader::new(
|
||||
std::fs::File::open(cert_path).with_context(|| format!("open {cert_path}"))?,
|
||||
))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let key = rustls_pemfile::private_key(&mut std::io::BufReader::new(
|
||||
std::fs::File::open(key_path).with_context(|| format!("open {key_path}"))?,
|
||||
))?
|
||||
.ok_or_else(|| anyhow::anyhow!("no private key in {key_path}"))?;
|
||||
let mut cfg = rustls::ServerConfig::builder_with_provider(provider())
|
||||
.with_safe_default_protocol_versions()?
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?;
|
||||
cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
||||
Ok(Arc::new(cfg))
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub fn now() -> i64 {
|
||||
chrono::Utc::now().timestamp()
|
||||
}
|
||||
|
||||
pub fn random_token(bytes: usize) -> String {
|
||||
let mut b = vec![0u8; bytes];
|
||||
rand::rngs::OsRng.fill_bytes(&mut b);
|
||||
hex::encode(b)
|
||||
}
|
||||
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(data))
|
||||
}
|
||||
|
||||
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
use subtle::ConstantTimeEq;
|
||||
a.len() == b.len() && bool::from(a.ct_eq(b))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Cidr {
|
||||
addr: IpAddr,
|
||||
prefix: u8,
|
||||
}
|
||||
|
||||
impl Cidr {
|
||||
pub fn parse(s: &str) -> Option<Cidr> {
|
||||
let s = s.trim();
|
||||
let (a, p) = match s.split_once('/') {
|
||||
Some((a, p)) => (a, Some(p)),
|
||||
None => (s, None),
|
||||
};
|
||||
let addr: IpAddr = a.parse().ok()?;
|
||||
let max = if addr.is_ipv4() { 32 } else { 128 };
|
||||
let prefix = match p {
|
||||
Some(p) => p.parse().ok().filter(|x: &u8| *x <= max)?,
|
||||
None => max,
|
||||
};
|
||||
Some(Cidr { addr, prefix })
|
||||
}
|
||||
|
||||
pub fn contains(&self, ip: &IpAddr) -> bool {
|
||||
let ip = normalize(*ip);
|
||||
match (self.addr, ip) {
|
||||
(IpAddr::V4(n), IpAddr::V4(i)) => {
|
||||
let mask = if self.prefix == 0 {
|
||||
0
|
||||
} else {
|
||||
u32::MAX << (32 - self.prefix)
|
||||
};
|
||||
(u32::from(n) & mask) == (u32::from(i) & mask)
|
||||
}
|
||||
(IpAddr::V6(n), IpAddr::V6(i)) => {
|
||||
let mask = if self.prefix == 0 {
|
||||
0
|
||||
} else {
|
||||
u128::MAX << (128 - self.prefix)
|
||||
};
|
||||
(u128::from(n) & mask) == (u128::from(i) & mask)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize(ip: IpAddr) -> IpAddr {
|
||||
match ip {
|
||||
IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip),
|
||||
v4 => v4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ip_in_any(ip: &IpAddr, nets: &[String]) -> bool {
|
||||
nets.iter()
|
||||
.filter_map(|n| Cidr::parse(n))
|
||||
.any(|c| c.contains(ip))
|
||||
}
|
||||
|
||||
pub fn html_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn xml_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
c if (c as u32) < 0x20 && c != '\n' && c != '\r' && c != '\t' => {}
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Truncate a string to at most `n` bytes on a char boundary.
|
||||
pub fn truncate_bytes(s: &str, n: usize) -> &str {
|
||||
if s.len() <= n {
|
||||
return s;
|
||||
}
|
||||
let mut i = n;
|
||||
while i > 0 && !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
&s[..i]
|
||||
}
|
||||
|
||||
/// Convert HTML to readable plain text (used when a device wants text but only HTML exists).
|
||||
pub fn html_to_text(html: &str) -> String {
|
||||
let mut out = String::with_capacity(html.len() / 2);
|
||||
let lower = html.to_ascii_lowercase();
|
||||
let bytes = html.as_bytes();
|
||||
let mut i = 0;
|
||||
let mut skip_until: Option<&str> = None;
|
||||
while i < bytes.len() {
|
||||
if let Some(end) = skip_until {
|
||||
if lower[i..].starts_with(end) {
|
||||
skip_until = None;
|
||||
i += end.len();
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if bytes[i] == b'<' {
|
||||
let close = match html[i..].find('>') {
|
||||
Some(c) => i + c,
|
||||
None => break,
|
||||
};
|
||||
let tag = lower[i + 1..close].trim_start_matches('/');
|
||||
let name: String = tag
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphanumeric())
|
||||
.collect();
|
||||
match name.as_str() {
|
||||
"script" => skip_until = Some("</script>"),
|
||||
"style" => skip_until = Some("</style>"),
|
||||
"head" => skip_until = Some("</head>"),
|
||||
"br" => out.push('\n'),
|
||||
"p" | "div" | "tr" | "li" | "h1" | "h2" | "h3" | "h4" | "blockquote" | "table" => {
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
if name == "li" && !lower[i + 1..].starts_with('/') {
|
||||
out.push_str("• ");
|
||||
}
|
||||
}
|
||||
"td" | "th" => out.push('\t'),
|
||||
_ => {}
|
||||
}
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
if bytes[i] == b'&' {
|
||||
if let Some(semi) = html[i..].find(';').filter(|s| *s < 10) {
|
||||
let ent = &html[i + 1..i + semi];
|
||||
let rep = match ent {
|
||||
"nbsp" => Some(' '),
|
||||
"amp" => Some('&'),
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" | "#39" => Some('\''),
|
||||
e if e.starts_with("#x") => u32::from_str_radix(&e[2..], 16)
|
||||
.ok()
|
||||
.and_then(char::from_u32),
|
||||
e if e.starts_with('#') => e[1..].parse().ok().and_then(char::from_u32),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(c) = rep {
|
||||
out.push(c);
|
||||
i += semi + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let ch = html[i..].chars().next().unwrap_or(' ');
|
||||
if ch == '\r' || ch == '\n' || ch == '\t' {
|
||||
if !out.ends_with(' ') && !out.ends_with('\n') {
|
||||
out.push(' ');
|
||||
}
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
// collapse 3+ newlines
|
||||
let mut res = String::with_capacity(out.len());
|
||||
let mut nl = 0;
|
||||
for c in out.chars() {
|
||||
if c == '\n' {
|
||||
nl += 1;
|
||||
if nl > 2 {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
nl = 0;
|
||||
}
|
||||
res.push(c);
|
||||
}
|
||||
res.trim().to_string()
|
||||
}
|
||||
|
||||
pub fn text_to_html(text: &str) -> String {
|
||||
let mut out = String::from("<html><head><meta charset=\"utf-8\"></head><body><div style=\"white-space:pre-wrap;font-family:sans-serif\">");
|
||||
out.push_str(&html_escape(text));
|
||||
out.push_str("</div></body></html>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Validate a login name received from a device.
|
||||
pub fn valid_login(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 254
|
||||
&& s.chars().all(|c| {
|
||||
!c.is_control()
|
||||
&& !c.is_whitespace()
|
||||
&& !matches!(c, '/' | '\\' | '"' | '\'' | '<' | '>' | '%' | '*' | '?')
|
||||
})
|
||||
}
|
||||
|
||||
pub fn format_ts(ts: i64) -> String {
|
||||
chrono::DateTime::from_timestamp(ts, 0)
|
||||
.map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cidr() {
|
||||
let c = Cidr::parse("10.0.0.0/8").unwrap();
|
||||
assert!(c.contains(&"10.2.3.4".parse().unwrap()));
|
||||
assert!(!c.contains(&"11.2.3.4".parse().unwrap()));
|
||||
let l = Cidr::parse("127.0.0.1").unwrap();
|
||||
assert!(l.contains(&"::ffff:127.0.0.1".parse().unwrap()));
|
||||
assert!(Cidr::parse("::1/128")
|
||||
.unwrap()
|
||||
.contains(&"::1".parse().unwrap()));
|
||||
assert!(Cidr::parse("1.2.3.4/33").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html2text() {
|
||||
let t = html_to_text("<html><head><style>x{}</style></head><body><p>Hello <b>World</b></p><br>a & b</body></html>");
|
||||
assert_eq!(t, "Hello World\n\na & b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logins() {
|
||||
assert!(valid_login("jan.kowalski@firma.pl"));
|
||||
assert!(!valid_login("a b"));
|
||||
assert!(!valid_login("../etc"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
// Generated from Z-Push wbxmldefs.php with MS-ASWBXML standard tag names.
|
||||
pub const NAMESPACES: &[&str] = &[
|
||||
"AirSync",
|
||||
"Contacts",
|
||||
"Email",
|
||||
"AirNotify",
|
||||
"Calendar",
|
||||
"Move",
|
||||
"GetItemEstimate",
|
||||
"FolderHierarchy",
|
||||
"MeetingResponse",
|
||||
"Tasks",
|
||||
"ResolveRecipients",
|
||||
"ValidateCert",
|
||||
"Contacts2",
|
||||
"Ping",
|
||||
"Provision",
|
||||
"Search",
|
||||
"GAL",
|
||||
"AirSyncBase",
|
||||
"Settings",
|
||||
"DocumentLibrary",
|
||||
"ItemOperations",
|
||||
"ComposeMail",
|
||||
"Email2",
|
||||
"Notes",
|
||||
"RightsManagement",
|
||||
"Find",
|
||||
];
|
||||
|
||||
pub const PAGES: &[&[(u8, &str)]] = &[
|
||||
// 0 AirSync
|
||||
&[
|
||||
(0x05, "Sync"),
|
||||
(0x06, "Responses"),
|
||||
(0x07, "Add"),
|
||||
(0x08, "Change"),
|
||||
(0x09, "Delete"),
|
||||
(0x0A, "Fetch"),
|
||||
(0x0B, "SyncKey"),
|
||||
(0x0C, "ClientId"),
|
||||
(0x0D, "ServerId"),
|
||||
(0x0E, "Status"),
|
||||
(0x0F, "Collection"),
|
||||
(0x10, "Class"),
|
||||
(0x11, "Version"),
|
||||
(0x12, "CollectionId"),
|
||||
(0x13, "GetChanges"),
|
||||
(0x14, "MoreAvailable"),
|
||||
(0x15, "WindowSize"),
|
||||
(0x16, "Commands"),
|
||||
(0x17, "Options"),
|
||||
(0x18, "FilterType"),
|
||||
(0x19, "Truncation"),
|
||||
(0x1A, "RTFTruncation"),
|
||||
(0x1B, "Conflict"),
|
||||
(0x1C, "Collections"),
|
||||
(0x1D, "ApplicationData"),
|
||||
(0x1E, "DeletesAsMoves"),
|
||||
(0x1F, "NotifyGUID"),
|
||||
(0x20, "Supported"),
|
||||
(0x21, "SoftDelete"),
|
||||
(0x22, "MIMESupport"),
|
||||
(0x23, "MIMETruncation"),
|
||||
(0x24, "Wait"),
|
||||
(0x25, "Limit"),
|
||||
(0x26, "Partial"),
|
||||
(0x27, "ConversationMode"),
|
||||
(0x28, "MaxItems"),
|
||||
(0x29, "HeartbeatInterval"),
|
||||
],
|
||||
// 1 Contacts
|
||||
&[
|
||||
(0x05, "Anniversary"),
|
||||
(0x06, "AssistantName"),
|
||||
(0x07, "AssistantPhoneNumber"),
|
||||
(0x08, "Birthday"),
|
||||
(0x09, "Body"),
|
||||
(0x0A, "BodySize"),
|
||||
(0x0B, "BodyTruncated"),
|
||||
(0x0C, "Business2PhoneNumber"),
|
||||
(0x0D, "BusinessCity"),
|
||||
(0x0E, "BusinessCountry"),
|
||||
(0x0F, "BusinessPostalCode"),
|
||||
(0x10, "BusinessState"),
|
||||
(0x11, "BusinessStreet"),
|
||||
(0x12, "BusinessFaxNumber"),
|
||||
(0x13, "BusinessPhoneNumber"),
|
||||
(0x14, "CarPhoneNumber"),
|
||||
(0x15, "Categories"),
|
||||
(0x16, "Category"),
|
||||
(0x17, "Children"),
|
||||
(0x18, "Child"),
|
||||
(0x19, "CompanyName"),
|
||||
(0x1A, "Department"),
|
||||
(0x1B, "Email1Address"),
|
||||
(0x1C, "Email2Address"),
|
||||
(0x1D, "Email3Address"),
|
||||
(0x1E, "FileAs"),
|
||||
(0x1F, "FirstName"),
|
||||
(0x20, "Home2PhoneNumber"),
|
||||
(0x21, "HomeCity"),
|
||||
(0x22, "HomeCountry"),
|
||||
(0x23, "HomePostalCode"),
|
||||
(0x24, "HomeState"),
|
||||
(0x25, "HomeStreet"),
|
||||
(0x26, "HomeFaxNumber"),
|
||||
(0x27, "HomePhoneNumber"),
|
||||
(0x28, "JobTitle"),
|
||||
(0x29, "LastName"),
|
||||
(0x2A, "MiddleName"),
|
||||
(0x2B, "MobilePhoneNumber"),
|
||||
(0x2C, "OfficeLocation"),
|
||||
(0x2D, "OtherCity"),
|
||||
(0x2E, "OtherCountry"),
|
||||
(0x2F, "OtherPostalCode"),
|
||||
(0x30, "OtherState"),
|
||||
(0x31, "OtherStreet"),
|
||||
(0x32, "PagerNumber"),
|
||||
(0x33, "RadioPhoneNumber"),
|
||||
(0x34, "Spouse"),
|
||||
(0x35, "Suffix"),
|
||||
(0x36, "Title"),
|
||||
(0x37, "WebPage"),
|
||||
(0x38, "YomiCompanyName"),
|
||||
(0x39, "YomiFirstName"),
|
||||
(0x3A, "YomiLastName"),
|
||||
(0x3B, "Rtf"),
|
||||
(0x3C, "Picture"),
|
||||
(0x3D, "Alias"),
|
||||
(0x3E, "WeightedRank"),
|
||||
],
|
||||
// 2 Email
|
||||
&[
|
||||
(0x05, "Attachment"),
|
||||
(0x06, "Attachments"),
|
||||
(0x07, "AttName"),
|
||||
(0x08, "AttSize"),
|
||||
(0x09, "AttOid"),
|
||||
(0x0A, "AttMethod"),
|
||||
(0x0B, "AttRemoved"),
|
||||
(0x0C, "Body"),
|
||||
(0x0D, "BodySize"),
|
||||
(0x0E, "BodyTruncated"),
|
||||
(0x0F, "DateReceived"),
|
||||
(0x10, "DisplayName"),
|
||||
(0x11, "DisplayTo"),
|
||||
(0x12, "Importance"),
|
||||
(0x13, "MessageClass"),
|
||||
(0x14, "Subject"),
|
||||
(0x15, "Read"),
|
||||
(0x16, "To"),
|
||||
(0x17, "Cc"),
|
||||
(0x18, "From"),
|
||||
(0x19, "ReplyTo"),
|
||||
(0x1A, "AllDayEvent"),
|
||||
(0x1B, "Categories"),
|
||||
(0x1C, "Category"),
|
||||
(0x1D, "DtStamp"),
|
||||
(0x1E, "EndTime"),
|
||||
(0x1F, "InstanceType"),
|
||||
(0x20, "BusyStatus"),
|
||||
(0x21, "Location"),
|
||||
(0x22, "MeetingRequest"),
|
||||
(0x23, "Organizer"),
|
||||
(0x24, "RecurrenceId"),
|
||||
(0x25, "Reminder"),
|
||||
(0x26, "ResponseRequested"),
|
||||
(0x27, "Recurrences"),
|
||||
(0x28, "Recurrence"),
|
||||
(0x29, "Type"),
|
||||
(0x2A, "Until"),
|
||||
(0x2B, "Occurrences"),
|
||||
(0x2C, "Interval"),
|
||||
(0x2D, "DayOfWeek"),
|
||||
(0x2E, "DayOfMonth"),
|
||||
(0x2F, "WeekOfMonth"),
|
||||
(0x30, "MonthOfYear"),
|
||||
(0x31, "StartTime"),
|
||||
(0x32, "Sensitivity"),
|
||||
(0x33, "TimeZone"),
|
||||
(0x34, "GlobalObjId"),
|
||||
(0x35, "ThreadTopic"),
|
||||
(0x36, "MIMEData"),
|
||||
(0x37, "MIMETruncated"),
|
||||
(0x38, "MIMESize"),
|
||||
(0x39, "InternetCPID"),
|
||||
(0x3A, "Flag"),
|
||||
(0x3B, "FlagStatus"),
|
||||
(0x3C, "ContentClass"),
|
||||
(0x3D, "FlagType"),
|
||||
(0x3E, "CompleteTime"),
|
||||
(0x3F, "DisallowNewTimeProposal"),
|
||||
],
|
||||
// 3 AirNotify
|
||||
&[
|
||||
(0x05, "Notify"),
|
||||
(0x06, "Notification"),
|
||||
(0x07, "Version"),
|
||||
(0x08, "Lifetime"),
|
||||
(0x09, "DeviceInfo"),
|
||||
(0x0A, "Enable"),
|
||||
(0x0B, "Folder"),
|
||||
(0x0C, "ServerEntryId"),
|
||||
(0x0D, "DeviceAddress"),
|
||||
(0x0E, "ValidCarrierProfiles"),
|
||||
(0x0F, "CarrierProfile"),
|
||||
(0x10, "Status"),
|
||||
(0x11, "Replies"),
|
||||
(0x12, "Devices"),
|
||||
(0x13, "Device"),
|
||||
(0x14, "Id"),
|
||||
(0x15, "Expiry"),
|
||||
(0x16, "NotifyGUID"),
|
||||
],
|
||||
// 4 Calendar
|
||||
&[
|
||||
(0x05, "TimeZone"),
|
||||
(0x06, "AllDayEvent"),
|
||||
(0x07, "Attendees"),
|
||||
(0x08, "Attendee"),
|
||||
(0x09, "Email"),
|
||||
(0x0A, "Name"),
|
||||
(0x0B, "Body"),
|
||||
(0x0C, "BodyTruncated"),
|
||||
(0x0D, "BusyStatus"),
|
||||
(0x0E, "Categories"),
|
||||
(0x0F, "Category"),
|
||||
(0x10, "Rtf"),
|
||||
(0x11, "DtStamp"),
|
||||
(0x12, "EndTime"),
|
||||
(0x13, "Exception"),
|
||||
(0x14, "Exceptions"),
|
||||
(0x15, "Deleted"),
|
||||
(0x16, "ExceptionStartTime"),
|
||||
(0x17, "Location"),
|
||||
(0x18, "MeetingStatus"),
|
||||
(0x19, "OrganizerEmail"),
|
||||
(0x1A, "OrganizerName"),
|
||||
(0x1B, "Recurrence"),
|
||||
(0x1C, "Type"),
|
||||
(0x1D, "Until"),
|
||||
(0x1E, "Occurrences"),
|
||||
(0x1F, "Interval"),
|
||||
(0x20, "DayOfWeek"),
|
||||
(0x21, "DayOfMonth"),
|
||||
(0x22, "WeekOfMonth"),
|
||||
(0x23, "MonthOfYear"),
|
||||
(0x24, "Reminder"),
|
||||
(0x25, "Sensitivity"),
|
||||
(0x26, "Subject"),
|
||||
(0x27, "StartTime"),
|
||||
(0x28, "UID"),
|
||||
(0x29, "AttendeeStatus"),
|
||||
(0x2A, "AttendeeType"),
|
||||
(0x2B, "Attachment"),
|
||||
(0x2C, "Attachments"),
|
||||
(0x2D, "AttName"),
|
||||
(0x2E, "AttSize"),
|
||||
(0x2F, "AttOid"),
|
||||
(0x30, "AttMethod"),
|
||||
(0x31, "AttRemoved"),
|
||||
(0x32, "DisplayName"),
|
||||
(0x33, "DisallowNewTimeProposal"),
|
||||
(0x34, "ResponseRequested"),
|
||||
(0x35, "AppointmentReplyTime"),
|
||||
(0x36, "ResponseType"),
|
||||
(0x37, "CalendarType"),
|
||||
(0x38, "IsLeapMonth"),
|
||||
(0x39, "FirstDayOfWeek"),
|
||||
(0x3A, "OnlineMeetingConfLink"),
|
||||
(0x3B, "OnlineMeetingExternalLink"),
|
||||
(0x3C, "ClientUid"),
|
||||
],
|
||||
// 5 Move
|
||||
&[
|
||||
(0x05, "MoveItems"),
|
||||
(0x06, "Move"),
|
||||
(0x07, "SrcMsgId"),
|
||||
(0x08, "SrcFldId"),
|
||||
(0x09, "DstFldId"),
|
||||
(0x0A, "Response"),
|
||||
(0x0B, "Status"),
|
||||
(0x0C, "DstMsgId"),
|
||||
],
|
||||
// 6 GetItemEstimate
|
||||
&[
|
||||
(0x05, "GetItemEstimate"),
|
||||
(0x06, "Version"),
|
||||
(0x07, "Collections"),
|
||||
(0x08, "Collection"),
|
||||
(0x09, "Class"),
|
||||
(0x0A, "CollectionId"),
|
||||
(0x0B, "DateTime"),
|
||||
(0x0C, "Estimate"),
|
||||
(0x0D, "Response"),
|
||||
(0x0E, "Status"),
|
||||
],
|
||||
// 7 FolderHierarchy
|
||||
&[
|
||||
(0x05, "Folders"),
|
||||
(0x06, "Folder"),
|
||||
(0x07, "DisplayName"),
|
||||
(0x08, "ServerId"),
|
||||
(0x09, "ParentId"),
|
||||
(0x0A, "Type"),
|
||||
(0x0B, "Response"),
|
||||
(0x0C, "Status"),
|
||||
(0x0D, "ContentClass"),
|
||||
(0x0E, "Changes"),
|
||||
(0x0F, "Add"),
|
||||
(0x10, "Delete"),
|
||||
(0x11, "Update"),
|
||||
(0x12, "SyncKey"),
|
||||
(0x13, "FolderCreate"),
|
||||
(0x14, "FolderDelete"),
|
||||
(0x15, "FolderUpdate"),
|
||||
(0x16, "FolderSync"),
|
||||
(0x17, "Count"),
|
||||
(0x18, "Version"),
|
||||
],
|
||||
// 8 MeetingResponse
|
||||
&[
|
||||
(0x05, "CalendarId"),
|
||||
(0x06, "CollectionId"),
|
||||
(0x07, "MeetingResponse"),
|
||||
(0x08, "RequestId"),
|
||||
(0x09, "Request"),
|
||||
(0x0A, "Result"),
|
||||
(0x0B, "Status"),
|
||||
(0x0C, "UserResponse"),
|
||||
(0x0D, "Version"),
|
||||
(0x0E, "InstanceId"),
|
||||
(0x10, "ProposedStartTime"),
|
||||
(0x11, "ProposedEndTime"),
|
||||
(0x12, "SendResponse"),
|
||||
],
|
||||
// 9 Tasks
|
||||
&[
|
||||
(0x05, "Body"),
|
||||
(0x06, "BodySize"),
|
||||
(0x07, "BodyTruncated"),
|
||||
(0x08, "Categories"),
|
||||
(0x09, "Category"),
|
||||
(0x0A, "Complete"),
|
||||
(0x0B, "DateCompleted"),
|
||||
(0x0C, "DueDate"),
|
||||
(0x0D, "UtcDueDate"),
|
||||
(0x0E, "Importance"),
|
||||
(0x0F, "Recurrence"),
|
||||
(0x10, "Type"),
|
||||
(0x11, "Start"),
|
||||
(0x12, "Until"),
|
||||
(0x13, "Occurrences"),
|
||||
(0x14, "Interval"),
|
||||
(0x15, "DayOfMonth"),
|
||||
(0x16, "DayOfWeek"),
|
||||
(0x17, "WeekOfMonth"),
|
||||
(0x18, "MonthOfYear"),
|
||||
(0x19, "Regenerate"),
|
||||
(0x1A, "DeadOccur"),
|
||||
(0x1B, "ReminderSet"),
|
||||
(0x1C, "ReminderTime"),
|
||||
(0x1D, "Sensitivity"),
|
||||
(0x1E, "StartDate"),
|
||||
(0x1F, "UtcStartDate"),
|
||||
(0x20, "Subject"),
|
||||
(0x21, "Rtf"),
|
||||
(0x22, "OrdinalDate"),
|
||||
(0x23, "SubOrdinalDate"),
|
||||
(0x24, "CalendarType"),
|
||||
(0x25, "IsLeapMonth"),
|
||||
(0x26, "FirstDayOfWeek"),
|
||||
],
|
||||
// 10 ResolveRecipients
|
||||
&[
|
||||
(0x05, "ResolveRecipients"),
|
||||
(0x06, "Response"),
|
||||
(0x07, "Status"),
|
||||
(0x08, "Type"),
|
||||
(0x09, "Recipient"),
|
||||
(0x0A, "DisplayName"),
|
||||
(0x0B, "EmailAddress"),
|
||||
(0x0C, "Certificates"),
|
||||
(0x0D, "Certificate"),
|
||||
(0x0E, "MiniCertificate"),
|
||||
(0x0F, "Options"),
|
||||
(0x10, "To"),
|
||||
(0x11, "CertificateRetrieval"),
|
||||
(0x12, "RecipientCount"),
|
||||
(0x13, "MaxCertificates"),
|
||||
(0x14, "MaxAmbiguousRecipients"),
|
||||
(0x15, "CertificateCount"),
|
||||
(0x16, "Availability"),
|
||||
(0x17, "StartTime"),
|
||||
(0x18, "EndTime"),
|
||||
(0x19, "MergedFreeBusy"),
|
||||
(0x1A, "Picture"),
|
||||
(0x1B, "MaxSize"),
|
||||
(0x1C, "Data"),
|
||||
(0x1D, "MaxPictures"),
|
||||
],
|
||||
// 11 ValidateCert
|
||||
&[
|
||||
(0x05, "ValidateCert"),
|
||||
(0x06, "Certificates"),
|
||||
(0x07, "Certificate"),
|
||||
(0x08, "CertificateChain"),
|
||||
(0x09, "CheckCRL"),
|
||||
(0x0A, "Status"),
|
||||
],
|
||||
// 12 Contacts2
|
||||
&[
|
||||
(0x05, "CustomerId"),
|
||||
(0x06, "GovernmentId"),
|
||||
(0x07, "IMAddress"),
|
||||
(0x08, "IMAddress2"),
|
||||
(0x09, "IMAddress3"),
|
||||
(0x0A, "ManagerName"),
|
||||
(0x0B, "CompanyMainPhone"),
|
||||
(0x0C, "AccountName"),
|
||||
(0x0D, "NickName"),
|
||||
(0x0E, "MMS"),
|
||||
],
|
||||
// 13 Ping
|
||||
&[
|
||||
(0x05, "Ping"),
|
||||
(0x06, "AutdState"),
|
||||
(0x07, "Status"),
|
||||
(0x08, "HeartbeatInterval"),
|
||||
(0x09, "Folders"),
|
||||
(0x0A, "Folder"),
|
||||
(0x0B, "Id"),
|
||||
(0x0C, "Class"),
|
||||
(0x0D, "MaxFolders"),
|
||||
(0x0E, "Version"),
|
||||
],
|
||||
// 14 Provision
|
||||
&[
|
||||
(0x05, "Provision"),
|
||||
(0x06, "Policies"),
|
||||
(0x07, "Policy"),
|
||||
(0x08, "PolicyType"),
|
||||
(0x09, "PolicyKey"),
|
||||
(0x0A, "Data"),
|
||||
(0x0B, "Status"),
|
||||
(0x0C, "RemoteWipe"),
|
||||
(0x0D, "EASProvisionDoc"),
|
||||
(0x0E, "DevicePasswordEnabled"),
|
||||
(0x0F, "AlphanumericDevicePasswordRequired"),
|
||||
(0x10, "DeviceEncryptionEnabled"),
|
||||
(0x11, "PasswordRecoveryEnabled"),
|
||||
(0x12, "DocumentBrowseEnabled"),
|
||||
(0x13, "AttachmentsEnabled"),
|
||||
(0x14, "MinDevicePasswordLength"),
|
||||
(0x15, "MaxInactivityTimeDeviceLock"),
|
||||
(0x16, "MaxDevicePasswordFailedAttempts"),
|
||||
(0x17, "MaxAttachmentSize"),
|
||||
(0x18, "AllowSimpleDevicePassword"),
|
||||
(0x19, "DevicePasswordExpiration"),
|
||||
(0x1A, "DevicePasswordHistory"),
|
||||
(0x1B, "AllowStorageCard"),
|
||||
(0x1C, "AllowCamera"),
|
||||
(0x1D, "RequireDeviceEncryption"),
|
||||
(0x1E, "AllowUnsignedApplications"),
|
||||
(0x1F, "AllowUnsignedInstallationPackages"),
|
||||
(0x20, "MinDevicePasswordComplexCharacters"),
|
||||
(0x21, "AllowWiFi"),
|
||||
(0x22, "AllowTextMessaging"),
|
||||
(0x23, "AllowPOPIMAPEmail"),
|
||||
(0x24, "AllowBluetooth"),
|
||||
(0x25, "AllowIrDA"),
|
||||
(0x26, "RequireManualSyncWhenRoaming"),
|
||||
(0x27, "AllowDesktopSync"),
|
||||
(0x28, "MaxCalendarAgeFilter"),
|
||||
(0x29, "AllowHTMLEmail"),
|
||||
(0x2A, "MaxEmailAgeFilter"),
|
||||
(0x2B, "MaxEmailBodyTruncationSize"),
|
||||
(0x2C, "MaxEmailHTMLBodyTruncationSize"),
|
||||
(0x2D, "RequireSignedSMIMEMessages"),
|
||||
(0x2E, "RequireEncryptedSMIMEMessages"),
|
||||
(0x2F, "RequireSignedSMIMEAlgorithm"),
|
||||
(0x30, "RequireEncryptionSMIMEAlgorithm"),
|
||||
(0x31, "AllowSMIMEEncryptionAlgorithmNegotiation"),
|
||||
(0x32, "AllowSMIMESoftCerts"),
|
||||
(0x33, "AllowBrowser"),
|
||||
(0x34, "AllowConsumerEmail"),
|
||||
(0x35, "AllowRemoteDesktop"),
|
||||
(0x36, "AllowInternetSharing"),
|
||||
(0x37, "UnapprovedInROMApplicationList"),
|
||||
(0x38, "ApplicationName"),
|
||||
(0x39, "ApprovedApplicationList"),
|
||||
(0x3A, "Hash"),
|
||||
(0x3B, "AccountOnlyRemoteWipe"),
|
||||
],
|
||||
// 15 Search
|
||||
&[
|
||||
(0x05, "Search"),
|
||||
(0x07, "Store"),
|
||||
(0x08, "Name"),
|
||||
(0x09, "Query"),
|
||||
(0x0A, "Options"),
|
||||
(0x0B, "Range"),
|
||||
(0x0C, "Status"),
|
||||
(0x0D, "Response"),
|
||||
(0x0E, "Result"),
|
||||
(0x0F, "Properties"),
|
||||
(0x10, "Total"),
|
||||
(0x11, "EqualTo"),
|
||||
(0x12, "Value"),
|
||||
(0x13, "And"),
|
||||
(0x14, "Or"),
|
||||
(0x15, "FreeText"),
|
||||
(0x17, "DeepTraversal"),
|
||||
(0x18, "LongId"),
|
||||
(0x19, "RebuildResults"),
|
||||
(0x1A, "LessThan"),
|
||||
(0x1B, "GreaterThan"),
|
||||
(0x1C, "Schema"),
|
||||
(0x1D, "Supported"),
|
||||
(0x1E, "UserName"),
|
||||
(0x1F, "Password"),
|
||||
(0x20, "ConversationId"),
|
||||
(0x21, "Picture"),
|
||||
(0x22, "MaxSize"),
|
||||
(0x23, "MaxPictures"),
|
||||
],
|
||||
// 16 GAL
|
||||
&[
|
||||
(0x05, "DisplayName"),
|
||||
(0x06, "Phone"),
|
||||
(0x07, "Office"),
|
||||
(0x08, "Title"),
|
||||
(0x09, "Company"),
|
||||
(0x0A, "Alias"),
|
||||
(0x0B, "FirstName"),
|
||||
(0x0C, "LastName"),
|
||||
(0x0D, "HomePhone"),
|
||||
(0x0E, "MobilePhone"),
|
||||
(0x0F, "EmailAddress"),
|
||||
(0x10, "Picture"),
|
||||
(0x11, "Status"),
|
||||
(0x12, "Data"),
|
||||
],
|
||||
// 17 AirSyncBase
|
||||
&[
|
||||
(0x05, "BodyPreference"),
|
||||
(0x06, "Type"),
|
||||
(0x07, "TruncationSize"),
|
||||
(0x08, "AllOrNone"),
|
||||
(0x0A, "Body"),
|
||||
(0x0B, "Data"),
|
||||
(0x0C, "EstimatedDataSize"),
|
||||
(0x0D, "Truncated"),
|
||||
(0x0E, "Attachments"),
|
||||
(0x0F, "Attachment"),
|
||||
(0x10, "DisplayName"),
|
||||
(0x11, "FileReference"),
|
||||
(0x12, "Method"),
|
||||
(0x13, "ContentId"),
|
||||
(0x14, "ContentLocation"),
|
||||
(0x15, "IsInline"),
|
||||
(0x16, "NativeBodyType"),
|
||||
(0x17, "ContentType"),
|
||||
(0x18, "Preview"),
|
||||
(0x19, "BodyPartPreference"),
|
||||
(0x1A, "BodyPart"),
|
||||
(0x1B, "Status"),
|
||||
(0x1C, "Add"),
|
||||
(0x1D, "Delete"),
|
||||
(0x1E, "ClientId"),
|
||||
(0x1F, "Content"),
|
||||
(0x20, "Location"),
|
||||
(0x21, "Annotation"),
|
||||
(0x22, "Street"),
|
||||
(0x23, "City"),
|
||||
(0x24, "State"),
|
||||
(0x25, "Country"),
|
||||
(0x26, "PostalCode"),
|
||||
(0x27, "Latitude"),
|
||||
(0x28, "Longitude"),
|
||||
(0x29, "Accuracy"),
|
||||
(0x2A, "Altitude"),
|
||||
(0x2B, "AltitudeAccuracy"),
|
||||
(0x2C, "LocationUri"),
|
||||
(0x2D, "InstanceId"),
|
||||
],
|
||||
// 18 Settings
|
||||
&[
|
||||
(0x05, "Settings"),
|
||||
(0x06, "Status"),
|
||||
(0x07, "Get"),
|
||||
(0x08, "Set"),
|
||||
(0x09, "Oof"),
|
||||
(0x0A, "OofState"),
|
||||
(0x0B, "StartTime"),
|
||||
(0x0C, "EndTime"),
|
||||
(0x0D, "OofMessage"),
|
||||
(0x0E, "AppliesToInternal"),
|
||||
(0x0F, "AppliesToExternalKnown"),
|
||||
(0x10, "AppliesToExternalUnknown"),
|
||||
(0x11, "Enabled"),
|
||||
(0x12, "ReplyMessage"),
|
||||
(0x13, "BodyType"),
|
||||
(0x14, "DevicePassword"),
|
||||
(0x15, "Password"),
|
||||
(0x16, "DeviceInformation"),
|
||||
(0x17, "Model"),
|
||||
(0x18, "IMEI"),
|
||||
(0x19, "FriendlyName"),
|
||||
(0x1A, "OS"),
|
||||
(0x1B, "OSLanguage"),
|
||||
(0x1C, "PhoneNumber"),
|
||||
(0x1D, "UserInformation"),
|
||||
(0x1E, "EmailAddresses"),
|
||||
(0x1F, "SmtpAddress"),
|
||||
(0x20, "UserAgent"),
|
||||
(0x21, "EnableOutboundSMS"),
|
||||
(0x22, "MobileOperator"),
|
||||
(0x23, "PrimarySmtpAddress"),
|
||||
(0x24, "Accounts"),
|
||||
(0x25, "Account"),
|
||||
(0x26, "AccountId"),
|
||||
(0x27, "AccountName"),
|
||||
(0x28, "UserDisplayName"),
|
||||
(0x29, "SendDisabled"),
|
||||
(0x2B, "RightsManagementInformation"),
|
||||
],
|
||||
// 19 DocumentLibrary
|
||||
&[
|
||||
(0x05, "LinkId"),
|
||||
(0x06, "DisplayName"),
|
||||
(0x07, "IsFolder"),
|
||||
(0x08, "CreationDate"),
|
||||
(0x09, "LastModifiedDate"),
|
||||
(0x0A, "IsHidden"),
|
||||
(0x0B, "ContentLength"),
|
||||
(0x0C, "ContentType"),
|
||||
],
|
||||
// 20 ItemOperations
|
||||
&[
|
||||
(0x05, "ItemOperations"),
|
||||
(0x06, "Fetch"),
|
||||
(0x07, "Store"),
|
||||
(0x08, "Options"),
|
||||
(0x09, "Range"),
|
||||
(0x0A, "Total"),
|
||||
(0x0B, "Properties"),
|
||||
(0x0C, "Data"),
|
||||
(0x0D, "Status"),
|
||||
(0x0E, "Response"),
|
||||
(0x0F, "Version"),
|
||||
(0x10, "Schema"),
|
||||
(0x11, "Part"),
|
||||
(0x12, "EmptyFolderContents"),
|
||||
(0x13, "DeleteSubFolders"),
|
||||
(0x14, "UserName"),
|
||||
(0x15, "Password"),
|
||||
(0x16, "Move"),
|
||||
(0x17, "DstFldId"),
|
||||
(0x18, "ConversationId"),
|
||||
(0x19, "MoveAlways"),
|
||||
],
|
||||
// 21 ComposeMail
|
||||
&[
|
||||
(0x05, "SendMail"),
|
||||
(0x06, "SmartForward"),
|
||||
(0x07, "SmartReply"),
|
||||
(0x08, "SaveInSentItems"),
|
||||
(0x09, "ReplaceMime"),
|
||||
(0x0A, "Type"),
|
||||
(0x0B, "Source"),
|
||||
(0x0C, "FolderId"),
|
||||
(0x0D, "ItemId"),
|
||||
(0x0E, "LongId"),
|
||||
(0x0F, "InstanceId"),
|
||||
(0x10, "MIME"),
|
||||
(0x11, "ClientId"),
|
||||
(0x12, "Status"),
|
||||
(0x13, "AccountId"),
|
||||
(0x15, "Forwardees"),
|
||||
(0x16, "Forwardee"),
|
||||
(0x17, "Name"),
|
||||
(0x18, "Email"),
|
||||
],
|
||||
// 22 Email2
|
||||
&[
|
||||
(0x05, "UmCallerId"),
|
||||
(0x06, "UmUserNotes"),
|
||||
(0x07, "UmAttDuration"),
|
||||
(0x08, "UmAttOrder"),
|
||||
(0x09, "ConversationId"),
|
||||
(0x0A, "ConversationIndex"),
|
||||
(0x0B, "LastVerbExecuted"),
|
||||
(0x0C, "LastVerbExecutionTime"),
|
||||
(0x0D, "ReceivedAsBcc"),
|
||||
(0x0E, "Sender"),
|
||||
(0x0F, "CalendarType"),
|
||||
(0x10, "IsLeapMonth"),
|
||||
(0x11, "AccountId"),
|
||||
(0x12, "FirstDayOfWeek"),
|
||||
(0x13, "MeetingMessageType"),
|
||||
(0x15, "IsDraft"),
|
||||
(0x16, "Bcc"),
|
||||
(0x17, "Send"),
|
||||
],
|
||||
// 23 Notes
|
||||
&[
|
||||
(0x05, "Subject"),
|
||||
(0x06, "MessageClass"),
|
||||
(0x07, "LastModifiedDate"),
|
||||
(0x08, "Categories"),
|
||||
(0x09, "Category"),
|
||||
],
|
||||
// 24 RightsManagement
|
||||
&[
|
||||
(0x05, "RightsManagementSupport"),
|
||||
(0x06, "RightsManagementTemplates"),
|
||||
(0x07, "RightsManagementTemplate"),
|
||||
(0x08, "RightsManagementLicense"),
|
||||
(0x09, "EditAllowed"),
|
||||
(0x0A, "ReplyAllowed"),
|
||||
(0x0B, "ReplyAllAllowed"),
|
||||
(0x0C, "ForwardAllowed"),
|
||||
(0x0D, "ModifyRecipientsAllowed"),
|
||||
(0x0E, "ExtractAllowed"),
|
||||
(0x0F, "PrintAllowed"),
|
||||
(0x10, "ExportAllowed"),
|
||||
(0x11, "ProgrammaticAccessAllowed"),
|
||||
(0x12, "Owner"),
|
||||
(0x13, "ContentExpiryDate"),
|
||||
(0x14, "TemplateID"),
|
||||
(0x15, "TemplateName"),
|
||||
(0x16, "TemplateDescription"),
|
||||
(0x17, "ContentOwner"),
|
||||
(0x18, "RemoveRightsManagementProtection"),
|
||||
],
|
||||
// 25 Find
|
||||
&[
|
||||
(0x05, "Find"),
|
||||
(0x06, "SearchId"),
|
||||
(0x07, "ExecuteSearch"),
|
||||
(0x08, "MailBoxSearchCriterion"),
|
||||
(0x09, "Query"),
|
||||
(0x0A, "Status"),
|
||||
(0x0B, "FreeText"),
|
||||
(0x0C, "Options"),
|
||||
(0x0D, "Range"),
|
||||
(0x0E, "DeepTraversal"),
|
||||
(0x11, "Response"),
|
||||
(0x12, "Result"),
|
||||
(0x13, "Properties"),
|
||||
(0x14, "Preview"),
|
||||
(0x15, "HasAttachments"),
|
||||
(0x16, "Total"),
|
||||
(0x17, "DisplayCc"),
|
||||
(0x18, "DisplayBcc"),
|
||||
(0x19, "GalSearchCriterion"),
|
||||
(0x20, "MaxPictures"),
|
||||
(0x21, "MaxSize"),
|
||||
(0x22, "Picture"),
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,556 @@
|
||||
//! WBXML (MS-ASWBXML) encoder/decoder with a small element tree.
|
||||
//!
|
||||
//! Elements are addressed by `"Namespace:Tag"` names, e.g. `"AirSync:SyncKey"`.
|
||||
|
||||
mod codepages;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use codepages::NAMESPACES;
|
||||
|
||||
const SWITCH_PAGE: u8 = 0x00;
|
||||
const END: u8 = 0x01;
|
||||
const ENTITY: u8 = 0x02;
|
||||
const STR_I: u8 = 0x03;
|
||||
const LITERAL: u8 = 0x04;
|
||||
const STR_T: u8 = 0x83;
|
||||
const OPAQUE: u8 = 0xC3;
|
||||
const WITH_CONTENT: u8 = 0x40;
|
||||
const WITH_ATTRIBUTES: u8 = 0x80;
|
||||
|
||||
/// Hard limits protecting the decoder against hostile input.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const MAX_ELEMENTS: usize = 500_000;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WbxmlError {
|
||||
#[error("unexpected end of WBXML stream")]
|
||||
Eof,
|
||||
#[error("invalid WBXML: {0}")]
|
||||
Invalid(&'static str),
|
||||
#[error("unknown WBXML tag {0}")]
|
||||
UnknownName(String),
|
||||
}
|
||||
|
||||
struct Tables {
|
||||
by_name: HashMap<String, (u8, u8)>,
|
||||
}
|
||||
|
||||
fn tables() -> &'static Tables {
|
||||
static T: OnceLock<Tables> = OnceLock::new();
|
||||
T.get_or_init(|| {
|
||||
let mut by_name = HashMap::new();
|
||||
for (page, tags) in codepages::PAGES.iter().enumerate() {
|
||||
let ns = codepages::NAMESPACES[page];
|
||||
for (code, tag) in tags.iter() {
|
||||
by_name.insert(format!("{ns}:{tag}"), (page as u8, *code));
|
||||
}
|
||||
}
|
||||
Tables { by_name }
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a `Namespace:Tag` name into its (page, token) pair.
|
||||
pub fn lookup(name: &str) -> Option<(u8, u8)> {
|
||||
tables().by_name.get(name).copied()
|
||||
}
|
||||
|
||||
fn tag_name(page: u8, code: u8) -> (&'static str, &'static str) {
|
||||
let ns = codepages::NAMESPACES
|
||||
.get(page as usize)
|
||||
.copied()
|
||||
.unwrap_or("Unknown");
|
||||
let tag = codepages::PAGES
|
||||
.get(page as usize)
|
||||
.and_then(|t| t.iter().find(|(c, _)| *c == code).map(|(_, n)| *n))
|
||||
.unwrap_or("Unknown");
|
||||
(ns, tag)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Content {
|
||||
None,
|
||||
Text(String),
|
||||
Opaque(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Element {
|
||||
pub page: u8,
|
||||
pub code: u8,
|
||||
pub children: Vec<Element>,
|
||||
pub content: Content,
|
||||
}
|
||||
|
||||
impl Element {
|
||||
/// Create a new element. Panics on unknown names: tag names are compile-time literals
|
||||
/// in this code base and covered by tests.
|
||||
pub fn new(name: &str) -> Self {
|
||||
let (page, code) = lookup(name).unwrap_or_else(|| panic!("unknown WBXML tag name {name}"));
|
||||
Element {
|
||||
page,
|
||||
code,
|
||||
children: Vec::new(),
|
||||
content: Content::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ns(&self) -> &'static str {
|
||||
tag_name(self.page, self.code).0
|
||||
}
|
||||
|
||||
pub fn tag(&self) -> &'static str {
|
||||
tag_name(self.page, self.code).1
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
let (ns, tag) = tag_name(self.page, self.code);
|
||||
format!("{ns}:{tag}")
|
||||
}
|
||||
|
||||
pub fn is(&self, name: &str) -> bool {
|
||||
lookup(name) == Some((self.page, self.code))
|
||||
}
|
||||
|
||||
// ----- builder helpers -----
|
||||
|
||||
pub fn text(name: &str, value: impl Into<String>) -> Self {
|
||||
let mut e = Element::new(name);
|
||||
e.content = Content::Text(value.into());
|
||||
e
|
||||
}
|
||||
|
||||
pub fn opaque(name: &str, value: Vec<u8>) -> Self {
|
||||
let mut e = Element::new(name);
|
||||
e.content = Content::Opaque(value);
|
||||
e
|
||||
}
|
||||
|
||||
pub fn with(mut self, child: Element) -> Self {
|
||||
self.children.push(child);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_text(mut self, name: &str, value: impl Into<String>) -> Self {
|
||||
self.children.push(Element::text(name, value));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_opt(mut self, name: &str, value: Option<impl Into<String>>) -> Self {
|
||||
if let Some(v) = value {
|
||||
self.children.push(Element::text(name, v));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_empty(mut self, name: &str) -> Self {
|
||||
self.children.push(Element::new(name));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn push(&mut self, child: Element) {
|
||||
self.children.push(child);
|
||||
}
|
||||
|
||||
pub fn push_text(&mut self, name: &str, value: impl Into<String>) {
|
||||
self.children.push(Element::text(name, value));
|
||||
}
|
||||
|
||||
pub fn push_opt(&mut self, name: &str, value: Option<impl Into<String>>) {
|
||||
if let Some(v) = value {
|
||||
self.children.push(Element::text(name, v));
|
||||
}
|
||||
}
|
||||
|
||||
// ----- query helpers -----
|
||||
|
||||
pub fn child(&self, name: &str) -> Option<&Element> {
|
||||
let key = lookup(name)?;
|
||||
self.children.iter().find(|c| (c.page, c.code) == key)
|
||||
}
|
||||
|
||||
pub fn children_named<'a>(&'a self, name: &str) -> impl Iterator<Item = &'a Element> + 'a {
|
||||
let key = lookup(name);
|
||||
self.children
|
||||
.iter()
|
||||
.filter(move |c| Some((c.page, c.code)) == key)
|
||||
}
|
||||
|
||||
/// Text of a direct child element.
|
||||
pub fn child_text(&self, name: &str) -> Option<String> {
|
||||
self.child(name).map(|c| c.text_value())
|
||||
}
|
||||
|
||||
pub fn child_u32(&self, name: &str) -> Option<u32> {
|
||||
self.child_text(name).and_then(|t| t.trim().parse().ok())
|
||||
}
|
||||
|
||||
pub fn has(&self, name: &str) -> bool {
|
||||
self.child(name).is_some()
|
||||
}
|
||||
|
||||
/// Follow a path of direct children.
|
||||
pub fn path(&self, names: &[&str]) -> Option<&Element> {
|
||||
let mut cur = self;
|
||||
for n in names {
|
||||
cur = cur.child(n)?;
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
pub fn text_value(&self) -> String {
|
||||
match &self.content {
|
||||
Content::Text(s) => s.clone(),
|
||||
Content::Opaque(b) => String::from_utf8_lossy(b).into_owned(),
|
||||
Content::None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bytes_value(&self) -> Vec<u8> {
|
||||
match &self.content {
|
||||
Content::Text(s) => s.as_bytes().to_vec(),
|
||||
Content::Opaque(b) => b.clone(),
|
||||
Content::None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Human readable XML rendering for debug logs (data truncated).
|
||||
pub fn to_xml(&self) -> String {
|
||||
let mut out = String::new();
|
||||
self.write_xml(&mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
fn write_xml(&self, out: &mut String, depth: usize) {
|
||||
let pad = " ".repeat(depth);
|
||||
let (ns, tag) = tag_name(self.page, self.code);
|
||||
let name = format!("{ns}:{tag}");
|
||||
match (&self.content, self.children.is_empty()) {
|
||||
(Content::None, true) => {
|
||||
let _ = writeln!(out, "{pad}<{name}/>");
|
||||
}
|
||||
(Content::Text(t), _) => {
|
||||
let shown = if tag == "Password" || tag == "Data" && t.len() > 256 {
|
||||
format!("[{} chars]", t.len())
|
||||
} else if t.len() > 256 {
|
||||
format!("{}…", &t[..t.floor_char_boundary_compat(256)])
|
||||
} else {
|
||||
t.clone()
|
||||
};
|
||||
let _ = writeln!(out, "{pad}<{name}>{}</{name}>", xml_escape(&shown));
|
||||
}
|
||||
(Content::Opaque(b), _) => {
|
||||
let _ = writeln!(out, "{pad}<{name}>[opaque {} bytes]</{name}>", b.len());
|
||||
}
|
||||
(Content::None, false) => {
|
||||
let _ = writeln!(out, "{pad}<{name}>");
|
||||
for c in &self.children {
|
||||
c.write_xml(out, depth + 1);
|
||||
}
|
||||
let _ = writeln!(out, "{pad}</{name}>");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait FloorBoundary {
|
||||
fn floor_char_boundary_compat(&self, idx: usize) -> usize;
|
||||
}
|
||||
|
||||
impl FloorBoundary for String {
|
||||
fn floor_char_boundary_compat(&self, idx: usize) -> usize {
|
||||
let mut i = idx.min(self.len());
|
||||
while i > 0 && !self.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- encoder
|
||||
|
||||
pub fn encode(root: &Element) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(512);
|
||||
out.extend_from_slice(&[0x03, 0x01, 0x6A, 0x00]);
|
||||
let mut page = 0u8;
|
||||
encode_element(root, &mut out, &mut page);
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_element(e: &Element, out: &mut Vec<u8>, page: &mut u8) {
|
||||
if e.page != *page {
|
||||
out.push(SWITCH_PAGE);
|
||||
out.push(e.page);
|
||||
*page = e.page;
|
||||
}
|
||||
let has_content = !e.children.is_empty() || !matches!(e.content, Content::None);
|
||||
out.push(if has_content {
|
||||
e.code | WITH_CONTENT
|
||||
} else {
|
||||
e.code
|
||||
});
|
||||
if !has_content {
|
||||
return;
|
||||
}
|
||||
match &e.content {
|
||||
Content::Text(t) => {
|
||||
out.push(STR_I);
|
||||
// NUL bytes would terminate the inline string; strip them.
|
||||
out.extend(t.bytes().filter(|b| *b != 0));
|
||||
out.push(0);
|
||||
}
|
||||
Content::Opaque(b) => {
|
||||
out.push(OPAQUE);
|
||||
write_mb_u32(out, b.len() as u32);
|
||||
out.extend_from_slice(b);
|
||||
}
|
||||
Content::None => {}
|
||||
}
|
||||
for c in &e.children {
|
||||
encode_element(c, out, page);
|
||||
}
|
||||
out.push(END);
|
||||
}
|
||||
|
||||
fn write_mb_u32(out: &mut Vec<u8>, mut v: u32) {
|
||||
let mut buf = [0u8; 5];
|
||||
let mut i = 4;
|
||||
buf[i] = (v & 0x7f) as u8;
|
||||
v >>= 7;
|
||||
while v > 0 {
|
||||
i -= 1;
|
||||
buf[i] = 0x80 | (v & 0x7f) as u8;
|
||||
v >>= 7;
|
||||
}
|
||||
out.extend_from_slice(&buf[i..]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- decoder
|
||||
|
||||
struct Reader<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
fn byte(&mut self) -> Result<u8, WbxmlError> {
|
||||
let b = *self.data.get(self.pos).ok_or(WbxmlError::Eof)?;
|
||||
self.pos += 1;
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.data.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn mb_u32(&mut self) -> Result<u32, WbxmlError> {
|
||||
let mut v: u32 = 0;
|
||||
for _ in 0..5 {
|
||||
let b = self.byte()?;
|
||||
v = v
|
||||
.checked_shl(7)
|
||||
.ok_or(WbxmlError::Invalid("mb_u_int32 overflow"))?
|
||||
| (b & 0x7f) as u32;
|
||||
if b & 0x80 == 0 {
|
||||
return Ok(v);
|
||||
}
|
||||
}
|
||||
Err(WbxmlError::Invalid("mb_u_int32 too long"))
|
||||
}
|
||||
|
||||
fn cstr(&mut self) -> Result<Vec<u8>, WbxmlError> {
|
||||
let start = self.pos;
|
||||
let rel = self.data[start..]
|
||||
.iter()
|
||||
.position(|b| *b == 0)
|
||||
.ok_or(WbxmlError::Eof)?;
|
||||
self.pos = start + rel + 1;
|
||||
Ok(self.data[start..start + rel].to_vec())
|
||||
}
|
||||
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8], WbxmlError> {
|
||||
if self.pos + n > self.data.len() {
|
||||
return Err(WbxmlError::Eof);
|
||||
}
|
||||
let s = &self.data[self.pos..self.pos + n];
|
||||
self.pos += n;
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode(data: &[u8]) -> Result<Element, WbxmlError> {
|
||||
let mut r = Reader { data, pos: 0 };
|
||||
let _version = r.byte()?;
|
||||
let _public_id = r.mb_u32()?;
|
||||
let _charset = r.mb_u32()?;
|
||||
let strtbl_len = r.mb_u32()? as usize;
|
||||
let strtbl = r.take(strtbl_len)?.to_vec();
|
||||
let mut page = 0u8;
|
||||
let mut count = 0usize;
|
||||
// skip leading switch pages
|
||||
loop {
|
||||
match r.peek() {
|
||||
Some(SWITCH_PAGE) => {
|
||||
r.byte()?;
|
||||
page = r.byte()?;
|
||||
}
|
||||
Some(_) => break,
|
||||
None => return Err(WbxmlError::Eof),
|
||||
}
|
||||
}
|
||||
decode_element(&mut r, &mut page, &strtbl, 0, &mut count)
|
||||
}
|
||||
|
||||
fn decode_element(
|
||||
r: &mut Reader,
|
||||
page: &mut u8,
|
||||
strtbl: &[u8],
|
||||
depth: usize,
|
||||
count: &mut usize,
|
||||
) -> Result<Element, WbxmlError> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(WbxmlError::Invalid("nesting too deep"));
|
||||
}
|
||||
*count += 1;
|
||||
if *count > MAX_ELEMENTS {
|
||||
return Err(WbxmlError::Invalid("too many elements"));
|
||||
}
|
||||
let tok = r.byte()?;
|
||||
if tok == LITERAL {
|
||||
return Err(WbxmlError::Invalid("literal tags not supported"));
|
||||
}
|
||||
if tok & WITH_ATTRIBUTES != 0 {
|
||||
return Err(WbxmlError::Invalid("attributes not supported"));
|
||||
}
|
||||
let mut el = Element {
|
||||
page: *page,
|
||||
code: tok & 0x3f,
|
||||
children: Vec::new(),
|
||||
content: Content::None,
|
||||
};
|
||||
if tok & WITH_CONTENT == 0 {
|
||||
return Ok(el);
|
||||
}
|
||||
let mut text: Option<Vec<u8>> = None;
|
||||
let mut opaque: Option<Vec<u8>> = None;
|
||||
loop {
|
||||
let next = r.peek().ok_or(WbxmlError::Eof)?;
|
||||
match next {
|
||||
END => {
|
||||
r.byte()?;
|
||||
break;
|
||||
}
|
||||
SWITCH_PAGE => {
|
||||
r.byte()?;
|
||||
*page = r.byte()?;
|
||||
}
|
||||
STR_I => {
|
||||
r.byte()?;
|
||||
let s = r.cstr()?;
|
||||
text.get_or_insert_with(Vec::new).extend_from_slice(&s);
|
||||
}
|
||||
STR_T => {
|
||||
r.byte()?;
|
||||
let off = r.mb_u32()? as usize;
|
||||
let rest = strtbl
|
||||
.get(off..)
|
||||
.ok_or(WbxmlError::Invalid("bad string table offset"))?;
|
||||
let end = rest.iter().position(|b| *b == 0).unwrap_or(rest.len());
|
||||
text.get_or_insert_with(Vec::new)
|
||||
.extend_from_slice(&rest[..end]);
|
||||
}
|
||||
ENTITY => {
|
||||
r.byte()?;
|
||||
let cp = r.mb_u32()?;
|
||||
if let Some(c) = char::from_u32(cp) {
|
||||
let mut b = [0u8; 4];
|
||||
text.get_or_insert_with(Vec::new)
|
||||
.extend_from_slice(c.encode_utf8(&mut b).as_bytes());
|
||||
}
|
||||
}
|
||||
OPAQUE => {
|
||||
r.byte()?;
|
||||
let len = r.mb_u32()? as usize;
|
||||
let b = r.take(len)?;
|
||||
opaque.get_or_insert_with(Vec::new).extend_from_slice(b);
|
||||
}
|
||||
_ => {
|
||||
let child = decode_element(r, page, strtbl, depth + 1, count)?;
|
||||
el.children.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(o) = opaque {
|
||||
el.content = Content::Opaque(o);
|
||||
} else if let Some(t) = text {
|
||||
el.content = Content::Text(String::from_utf8_lossy(&t).into_owned());
|
||||
}
|
||||
Ok(el)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let root = Element::new("AirSync:Sync").with(
|
||||
Element::new("AirSync:Collections").with(
|
||||
Element::new("AirSync:Collection")
|
||||
.with_text("AirSync:SyncKey", "{abc}1")
|
||||
.with_text("AirSync:CollectionId", "5")
|
||||
.with_empty("AirSync:GetChanges")
|
||||
.with(
|
||||
Element::new("AirSync:Options")
|
||||
.with(
|
||||
Element::new("AirSyncBase:BodyPreference")
|
||||
.with_text("AirSyncBase:Type", "2"),
|
||||
)
|
||||
.with(Element::opaque("ComposeMail:MIME", vec![0, 1, 2, 255])),
|
||||
),
|
||||
),
|
||||
);
|
||||
let bytes = encode(&root);
|
||||
let back = decode(&bytes).unwrap();
|
||||
assert_eq!(root, back);
|
||||
let col = back
|
||||
.path(&["AirSync:Collections", "AirSync:Collection"])
|
||||
.unwrap();
|
||||
assert_eq!(col.child_text("AirSync:SyncKey").unwrap(), "{abc}1");
|
||||
assert!(col.has("AirSync:GetChanges"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mb_u32() {
|
||||
for v in [0u32, 1, 127, 128, 300, 16383, 16384, 1 << 28, u32::MAX] {
|
||||
let mut out = vec![];
|
||||
write_mb_u32(&mut out, v);
|
||||
let mut r = Reader { data: &out, pos: 0 };
|
||||
assert_eq!(r.mb_u32().unwrap(), v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_names_unique_per_page() {
|
||||
for (p, tags) in codepages::PAGES.iter().enumerate() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (c, _) in tags.iter() {
|
||||
assert!(seen.insert(*c), "duplicate token {c:#x} on page {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage() {
|
||||
assert!(decode(&[0x03, 0x01, 0x6a, 0x00, 0x45]).is_err());
|
||||
assert!(decode(&[]).is_err());
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
//! Tiny namespace-aware XML DOM built on quick-xml (no DTD / entity expansion).
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::name::ResolveResult;
|
||||
use quick_xml::NsReader;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct XNode {
|
||||
pub ns: String,
|
||||
pub name: String,
|
||||
pub text: String,
|
||||
pub attrs: Vec<(String, String)>,
|
||||
pub children: Vec<XNode>,
|
||||
}
|
||||
|
||||
impl XNode {
|
||||
pub fn attr(&self, name: &str) -> Option<&str> {
|
||||
self.attrs
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
pub fn child(&self, name: &str) -> Option<&XNode> {
|
||||
self.children
|
||||
.iter()
|
||||
.find(|c| c.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a XNode> + 'a {
|
||||
self.children
|
||||
.iter()
|
||||
.filter(move |c| c.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
/// Depth-first search for the first descendant with this local name.
|
||||
pub fn find(&self, name: &str) -> Option<&XNode> {
|
||||
for c in &self.children {
|
||||
if c.name.eq_ignore_ascii_case(name) {
|
||||
return Some(c);
|
||||
}
|
||||
if let Some(f) = c.find(name) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub fn find_all<'a>(&'a self, name: &str, out: &mut Vec<&'a XNode>) {
|
||||
for c in &self.children {
|
||||
if c.name.eq_ignore_ascii_case(name) {
|
||||
out.push(c);
|
||||
}
|
||||
c.find_all(name, out);
|
||||
}
|
||||
}
|
||||
pub fn text_of(&self, name: &str) -> Option<String> {
|
||||
self.find(name).map(|n| n.text.trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn attrs_of(e: &quick_xml::events::BytesStart) -> Vec<(String, String)> {
|
||||
e.attributes()
|
||||
.flatten()
|
||||
.take(64)
|
||||
.map(|a| {
|
||||
(
|
||||
String::from_utf8_lossy(a.key.local_name().as_ref()).into_owned(),
|
||||
a.unescape_value()
|
||||
.map(|v| v.into_owned())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn parse(xml: &[u8]) -> Result<XNode> {
|
||||
if xml.len() > 64 * 1024 * 1024 {
|
||||
bail!("XML document too large");
|
||||
}
|
||||
let text = String::from_utf8_lossy(xml);
|
||||
// Refuse DTDs outright (XXE / billion laughs protection).
|
||||
if text.contains("<!DOCTYPE") || text.contains("<!ENTITY") {
|
||||
bail!("XML DTD not allowed");
|
||||
}
|
||||
let mut reader = NsReader::from_str(&text);
|
||||
reader.config_mut().trim_text(false);
|
||||
let mut stack: Vec<XNode> = vec![XNode::default()];
|
||||
let mut depth = 0usize;
|
||||
loop {
|
||||
match reader.read_resolved_event()? {
|
||||
(ns, Event::Start(e)) => {
|
||||
depth += 1;
|
||||
if depth > 128 {
|
||||
bail!("XML nesting too deep");
|
||||
}
|
||||
let ns = match ns {
|
||||
ResolveResult::Bound(n) => String::from_utf8_lossy(n.as_ref()).into_owned(),
|
||||
_ => String::new(),
|
||||
};
|
||||
stack.push(XNode {
|
||||
ns,
|
||||
name: String::from_utf8_lossy(e.local_name().as_ref()).into_owned(),
|
||||
attrs: attrs_of(&e),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
(ns, Event::Empty(e)) => {
|
||||
let ns = match ns {
|
||||
ResolveResult::Bound(n) => String::from_utf8_lossy(n.as_ref()).into_owned(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let node = XNode {
|
||||
ns,
|
||||
name: String::from_utf8_lossy(e.local_name().as_ref()).into_owned(),
|
||||
attrs: attrs_of(&e),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(top) = stack.last_mut() {
|
||||
top.children.push(node);
|
||||
}
|
||||
}
|
||||
(_, Event::End(_)) => {
|
||||
depth = depth.saturating_sub(1);
|
||||
let node = stack.pop().unwrap_or_default();
|
||||
match stack.last_mut() {
|
||||
Some(top) => top.children.push(node),
|
||||
None => bail!("unbalanced XML"),
|
||||
}
|
||||
}
|
||||
(_, Event::Text(t)) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = t.unescape().map(|c| c.into_owned()).unwrap_or_default();
|
||||
top.text.push_str(&s);
|
||||
}
|
||||
}
|
||||
(_, Event::CData(t)) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
top.text.push_str(&String::from_utf8_lossy(&t));
|
||||
}
|
||||
}
|
||||
(_, Event::Eof) => break,
|
||||
(_, Event::DocType(_)) => bail!("XML DTD not allowed"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let root = stack.pop().unwrap_or_default();
|
||||
root.children
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("empty XML document"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn parse_ns() {
|
||||
let x = br#"<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/a/b.ics</d:href><d:propstat><d:prop><d:getetag>"x"</d:getetag></d:prop></d:propstat></d:response></d:multistatus>"#;
|
||||
let n = super::parse(x).unwrap();
|
||||
assert_eq!(n.name, "multistatus");
|
||||
assert_eq!(n.ns, "DAV:");
|
||||
assert_eq!(n.text_of("getetag").unwrap(), "\"x\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_dtd() {
|
||||
assert!(super::parse(b"<!DOCTYPE x [<!ENTITY a \"b\">]><x>&a;</x>").is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user