Cut over-engineering across the workspace; fix mobile overflow

- db.rs: exec/row/rows helpers replace the per-query lock and prepare
  code; inserts use RETURNING; migrations are one list
- pimdav: duplicated stamp, partstat, escape and item helpers merged;
  itip::organize removed
- API: the system address book export route is gone, collection 0
  export serves the same file; can_edit dropped from PIM details;
  PimKind is the api-types enum; repeated helpers merged
- Web: api.rs wrappers are async fns; browser actions share one Acts
  struct; copy, retry and share-group markup is shared; the Icon
  component is gone
- Tests: pim test helpers live in common; base64 and http-body-util
  dev-deps removed; CI e2e runs only the embedded test; just test
  covers the workspace
- Mobile: the file pager, long breadcrumbs, the narrow toolbar and the
  search bar no longer widen the page; the PIM main panel keeps its
  240px minimum below 1180px
- The Escape listener reads signals untracked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commitbb280c2e218bc5c1b37894c19e0f5e3a208d3616
Parent908d2dd
68 files changed, 3219 insertions(+), 4392 deletions(-)
▾M.gitignore
@@ -3,7 +3,6 @@
/web/dist
/server/dist
/web/cm6.js
dovenest
*.sqlite
*.sqlite-wal
*.sqlite-shm
▾M.hearthforge-ci.toml
@@ -83,10 +83,9 @@ name = "lint"
run_sh = "cd project && just lint"
warn_on_fail = true
# `just test` covers server, api-types and pimdav only, hence the extra web run.
[[steps]]
name = "test"
run_sh = "cd project && just test && cargo test -p web"
run_sh = "cd project && just test"
# ── build ────────────────────────────────────────────────────────────────────
[[steps]]
@@ -99,7 +98,7 @@ publish_file = ["/ci/cache/target/release/dovenest"]
# server/dist, so run the embedded suite against it directly.
[[steps]]
name = "e2e"
run_sh = "cd project && cargo test -p server --features embedded"
run_sh = "cd project && cargo test -p server --features embedded --test api embedded"
# ── release ──────────────────────────────────────────────────────────────────
[[steps]]
▾MCargo.lock
@@ -305,12 +305,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -2894,7 +2888,6 @@ dependencies = [
"api-types",
"argon2",
"axum",
"base64 0.23.1",
"bytes",
"chrono",
"clap",
@@ -2905,7 +2898,6 @@ dependencies = [
"getrandom 0.4.3",
"grep",
"headers",
"http-body-util",
"ignore",
"image",
"infer",
▾Mapi-types/src/lib.rs
@@ -96,8 +96,6 @@ pub const IMPORT_SUFFIX: &str = "/import";
pub const PIM_IMPORT_NEW: &str = "/api/pim/import";
/// `GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}`: the collection as one file.
pub const EXPORT_SUFFIX: &str = "/export";
/// `GET`: the system address book as one file. It has no collection id.
pub const PIM_SYSTEM_EXPORT: &str = "/api/pim/system/export";
/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}`: one event or contact
/// as [`PimObjectDetail`]. `{...}/{name}{PHOTO_SUFFIX}`: a contact's photo as
/// a WebP thumbnail.
@@ -494,6 +492,42 @@ pub struct Me {
pub public_url: Option<String>,
}
/// PUT `{AUTH_ME}`: the signed-in user's profile settings, answered with
/// [`Me`]. Omitted fields stay unchanged. `null` in `language` follows the
/// browser; in `default_root_id` it clears the default root.
#[derive(Serialize, Deserialize, Default)]
pub struct ProfilePatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub single_click_open: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumbnails: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "patch_field"
)]
pub language: Option<Option<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "patch_field"
)]
pub default_root_id: Option<Option<i64>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub week_start: Option<u8>,
}
/// Deserializes a nullable patch field into the three-state value:
/// `"de"` → `Some(Some("de"))`, `null` → `Some(None)`. A missing field never
/// calls this and stays `None` through `#[serde(default)]`.
fn patch_field<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Deserialize::deserialize(deserializer).map(Some)
}
/// GET/POST `{SHARES}`, GET `{SHARE}/{token}`.
#[derive(Serialize, Deserialize, Clone)]
pub struct ShareInfo {
@@ -737,8 +771,6 @@ pub struct PimEventDetail {
/// The owner's PARTSTAT on the series' master, when they attend it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub series_partstat: Option<String>,
/// The signed-in user may change the object with a client.
pub can_edit: bool,
/// The calendar owner is an attendee and the signed-in user may answer
/// for them.
pub can_reply: bool,
@@ -786,7 +818,6 @@ pub struct PimContactDetail {
pub members: Vec<String>,
/// `{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}`.
pub photo_url: Option<String>,
pub can_edit: bool,
}
/// `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}`.
▾Mjustfile
@@ -35,9 +35,9 @@ frontend: build-cm
build: frontend
cargo build --locked --release -p server --features embedded
# Production end-to-end: build the real frontend, embed it, and run the full server test suite.
# Production end-to-end: build the real frontend, embed it, and check the embedded assets.
e2e: frontend
cargo test -p server --features embedded
cargo test -p server --features embedded --test api embedded
# Requires bun (https://bun.sh); web/bun.lock pins the dependencies and
# web/cm6.js is a gitignored build artifact. Runs automatically via `build`,
@@ -50,9 +50,9 @@ build-cm:
run: dev-data build
./target/release/dovenest --root {{dev-root}} --db {{dev-db}} --cache {{dev-cache}}
# Server and pimdav test suites (unit + integration tests).
# All workspace test suites (unit + integration tests).
test:
cargo test -p server -p api-types -p pimdav
cargo test --workspace
# Lint: formatting check + clippy with warnings denied.
lint:
▾Mpimdav/src/expand.rs
@@ -61,12 +61,7 @@ pub(crate) fn expand_in(
let mut out = Expansion::default();
let mut groups: HashMap<(&ICalendarComponentType, &str), Vec<usize>> = HashMap::new();
for (i, c) in cal.components.iter().enumerate() {
if !matches!(
c.component_type,
ICalendarComponentType::VEvent
| ICalendarComponentType::VTodo
| ICalendarComponentType::VJournal
) {
if !is_item(c) {
continue;
}
match c.uid() {
@@ -81,6 +76,16 @@ pub(crate) fn expand_in(
out
}
/// A VEVENT, VTODO or VJOURNAL.
pub(crate) fn is_item(c: &ICalendarComponent) -> bool {
matches!(
c.component_type,
ICalendarComponentType::VEvent
| ICalendarComponentType::VTodo
| ICalendarComponentType::VJournal
)
}
/// A date or date-time value and the zone it is in.
#[derive(Debug, Clone)]
pub(crate) struct Stamp {
@@ -505,19 +510,17 @@ fn occurrences_capped(
},
None => None,
};
// Whole steps keep the period grid, so INTERVAL and BYxxx still line up.
let unit = match rule.freq {
ICalendarFrequency::Weekly => 7 * 86400,
ICalendarFrequency::Daily => 86400,
ICalendarFrequency::Hourly => 3600,
ICalendarFrequency::Minutely => 60,
ICalendarFrequency::Secondly => 1,
_ => 0,
};
let mut first = m.start.local;
// BYSETPOS picks from a whole period, which a mid-period start would cut.
if unit > 0 && rule.count.is_none() && rule.bysetpos.is_empty() {
let step = unit * i64::from(rule.interval.unwrap_or(1).max(1));
// Whole steps keep the period grid, so INTERVAL and BYxxx still line up.
// Months and years vary in length. BYSETPOS picks from a whole period,
// which a mid-period start would cut.
if !matches!(
rule.freq,
ICalendarFrequency::Yearly | ICalendarFrequency::Monthly
) && rule.count.is_none()
&& rule.bysetpos.is_empty()
{
let step = period(rule).num_seconds();
let behind = (from_local - first).num_seconds();
if behind > 0 {
first += TimeDelta::seconds(behind / step * step);
@@ -644,7 +647,11 @@ pub(crate) fn stamp(zones: &Zones, v: &PartialDateTime, tzid: Option<&str>) -> O
})
}
fn prop_stamp(zones: &Zones, c: &ICalendarComponent, prop: &ICalendarProperty) -> Option<Stamp> {
pub(crate) fn prop_stamp(
zones: &Zones,
c: &ICalendarComponent,
prop: &ICalendarProperty,
) -> Option<Stamp> {
let e = c.property(prop)?;
stamp(zones, e.values.first()?.as_partial_date_time()?, e.tz_id())
}
▾Mpimdav/src/filter.rs
@@ -16,10 +16,11 @@ use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc};
use unicode_normalization::UnicodeNormalization;
use xmltree::Element;
use crate::expand::{Instance, expand, stamp};
use crate::expand::{Instance, expand, prop_stamp, stamp};
use crate::freebusy::periods;
use crate::itip::endless;
use crate::report::Refused;
use crate::text::{param_parts, prop, split_unquoted, unescape_text, unfold, value};
use crate::xml::{CALDAV, CARDDAV, Name, child, elements, text};
use crate::zone::{Zone, Zones, add};
@@ -115,17 +116,10 @@ impl TextMatch {
Some("i;unicode-casemap") => Collation::UnicodeCasemap,
Some(_) => return Err(Refused::Condition(Name::new(ns, "supported-collation"))),
};
let match_type = match e.attributes.get("match-type").map(String::as_str) {
None | Some("contains") => MatchType::Contains,
Some("equals") => MatchType::Equals,
Some("starts-with") => MatchType::StartsWith,
Some("ends-with") => MatchType::EndsWith,
Some(_) => return Err(Refused::Invalid),
};
Ok(TextMatch {
text: text(e),
collation,
match_type,
match_type: match_type(e)?,
negate: e.attributes.get("negate-condition").map(String::as_str) == Some("yes"),
})
}
@@ -142,6 +136,16 @@ impl TextMatch {
}
}
pub(crate) fn match_type(e: &Element) -> Result<MatchType, Refused> {
match e.attributes.get("match-type").map(String::as_str) {
None | Some("contains") => Ok(MatchType::Contains),
Some("equals") => Ok(MatchType::Equals),
Some("starts-with") => Ok(MatchType::StartsWith),
Some("ends-with") => Ok(MatchType::EndsWith),
Some(_) => Err(Refused::Invalid),
}
}
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
@@ -404,15 +408,7 @@ impl Ctx<'_> {
fn overlaps(&self, i: usize, r: &TimeRange) -> bool {
let c = &self.cal.components[i];
let has = |p: ICalendarProperty| c.has_property(&p);
let time = |p: ICalendarProperty| {
let e = c.property(&p)?;
stamp(
&self.zones,
e.values.first()?.as_partial_date_time()?,
e.tz_id(),
)
.map(|s| s.utc())
};
let time = |p: ICalendarProperty| Some(prop_stamp(&self.zones, c, &p)?.utc());
match c.component_type {
ICalendarComponentType::VEvent
| ICalendarComponentType::VTodo
@@ -519,15 +515,7 @@ impl Ctx<'_> {
// A task without DTSTART has no instances. Its alarm can only
// relate to DUE (RFC 4791, 9.9).
if !owner.has_property(&ICalendarProperty::Dtstart) {
return owner
.property(&ICalendarProperty::Due)
.and_then(|e| {
stamp(
&self.zones,
e.values.first()?.as_partial_date_time()?,
e.tz_id(),
)
})
return prop_stamp(&self.zones, owner, &ICalendarProperty::Due)
.is_some_and(|due| hit(add(due.utc(), offset)));
}
let from_end = trigger.parameter(&ICalendarParameterName::Related)
@@ -662,14 +650,9 @@ fn ical_line(e: &ICalendarEntry) -> Line {
/// Parses one content line as calcard's writer produces it.
fn line(written: &str) -> Line {
let unfolded = written.replace("\r\n ", "").replace("\r\n\t", "");
let unfolded = unfolded.trim_end_matches(['\r', '\n']);
let head = split_unquoted(unfolded, ':')[0];
let value = unfolded.get(head.len() + 1..).unwrap_or_default();
let mut head = split_unquoted(head, ';').into_iter();
let name = head.next().unwrap_or_default();
let name = name.rsplit('.').next().unwrap_or(name).to_ascii_uppercase();
let params = head
let l = unfold(written);
let params = param_parts(&l)
.into_iter()
.filter_map(|p| p.split_once('='))
.map(|(k, v)| {
let values = split_unquoted(v, ',')
@@ -680,40 +663,8 @@ fn line(written: &str) -> Line {
})
.collect();
Line {
name,
name: prop(&l),
params,
value: unescape(value),
}
}
fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
let mut out = Vec::new();
let (mut quoted, mut from) = (false, 0);
for (i, c) in s.char_indices() {
if c == '"' {
quoted = !quoted;
} else if c == sep && !quoted {
out.push(&s[from..i]);
from = i + 1;
}
}
out.push(&s[from..]);
out
}
fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n' | 'N') => out.push('\n'),
Some(c) => out.push(c),
None => out.push('\\'),
}
value: unescape_text(value(&l)),
}
out
}
▾Mpimdav/src/freebusy.rs
@@ -2,16 +2,16 @@
//! for free-busy requests to a scheduling outbox (RFC 6638, 5).
use calcard::icalendar::{
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarFreeBusyType,
ICalendarMethod, ICalendarParameterName, ICalendarParameterValue, ICalendarPeriod,
ICalendarProperty, ICalendarStatus, ICalendarTransparency, ICalendarValue,
ICalendar, ICalendarComponentType, ICalendarEntry, ICalendarFreeBusyType, ICalendarMethod,
ICalendarParameterName, ICalendarParameterValue, ICalendarPeriod, ICalendarProperty,
ICalendarStatus, ICalendarTransparency, ICalendarValue,
};
use chrono::{DateTime, Utc};
use xmltree::XMLNode;
use crate::expand::{expand, stamp};
use crate::expand::{expand, prop_stamp, stamp};
use crate::filter::TimeRange;
use crate::itip::Is;
use crate::itip::{Is, own_partstat};
use crate::xml::{CALDAV, Name, document, el, hrefs, with_children, with_text};
use crate::zone::{Zone, Zones, add};
@@ -173,23 +173,6 @@ pub(crate) fn period(
}
}
/// The PARTSTAT of `me` in a component, if it attends.
fn own_partstat(c: &ICalendarComponent, me: Is) -> Option<String> {
c.properties(&ICalendarProperty::Attendee)
.find(|e| {
e.values
.first()
.and_then(|v| v.as_text())
.is_some_and(|a| me(a.trim()))
})
.map(|e| {
e.parameter(&ICalendarParameterName::Partstat)
.and_then(|p| p.as_text())
.unwrap_or("NEEDS-ACTION")
.to_ascii_uppercase()
})
}
/// A free-busy request POSTed to a scheduling outbox (RFC 6638, 5).
#[derive(Debug, Clone, PartialEq)]
pub struct Request {
@@ -225,10 +208,7 @@ pub fn request(body: &[u8]) -> Result<Request, Name> {
return Err(invalid());
};
let zones = Zones::new(&cal, Zone::Utc);
let at = |p: &ICalendarProperty| {
let e = c.property(p)?;
Some(stamp(&zones, e.values.first()?.as_partial_date_time()?, e.tz_id())?.utc())
};
let at = |p: &ICalendarProperty| Some(prop_stamp(&zones, c, p)?.utc());
let text = |e: &ICalendarEntry| Some(e.values.first()?.as_text()?.trim().to_string());
let (Some(start), Some(end)) = (
at(&ICalendarProperty::Dtstart),
@@ -264,10 +244,7 @@ pub fn reply(periods: &[Period], req: &Request, attendee: &str, now: DateTime<Ut
let mut props = fold(format!("ORGANIZER:{}", clean(&req.organizer)))
+ &fold(format!("ATTENDEE:{}", clean(attendee)));
if let Some(uid) = &req.uid {
let uid = clean(uid)
.replace('\\', "\\\\")
.replace(';', "\\;")
.replace(',', "\\,");
let uid = crate::text::escape_text(&clean(uid));
props.push_str(&fold(format!("UID:{uid}")));
}
render(periods, &req.range, now, "METHOD:REPLY\r\n", &props)
▾Mpimdav/src/itip.rs
@@ -19,7 +19,9 @@ use calcard::icalendar::{
use chrono::{DateTime, TimeDelta, Utc};
use xmltree::Element;
use crate::expand::{Expansion, MAX_OCCURRENCES, expand, expand_in, stamp, until_utc};
use crate::expand::{
Expansion, MAX_OCCURRENCES, expand, expand_in, is_item, prop_stamp, rule, stamp, until_utc,
};
use crate::filter::TimeRange;
use crate::freebusy::{Busy, Period, merge};
use crate::object::MAX_COMPONENTS;
@@ -100,44 +102,16 @@ pub fn role(cal: &ICalendar, owner: Is) -> Result<Role, Refused> {
pub fn organizer(cal: &ICalendar) -> Option<&str> {
cal.components
.iter()
.filter(|c| is_scheduled(c))
.filter(|c| is_item(c))
.find_map(|c| address(c.property(&ICalendarProperty::Organizer)?))
}
/// An organizer's PUT (`new`) or DELETE (`None`) of a scheduling object.
/// `old` is the stored organizer object, if any. Returns what to store and
/// what to deliver.
pub fn organize(
old: Option<&ICalendar>,
new: Option<ICalendar>,
organizer: Is,
now: DateTime<Utc>,
) -> (Option<ICalendar>, Vec<Message>) {
let mut force = Vec::new();
let new = new.map(|n| guard(old, &n, organizer, &mut force));
let messages = messages(old, new.as_ref(), organizer, &force, now);
(new, messages)
}
/// The first half of [`organize`] for a PUT: what to store, with the
/// attendee state the server owns, and the attendees whose REQUEST is
/// forced. [`messages`] is the second half.
pub fn prepare(
old: Option<&ICalendar>,
new: &ICalendar,
organizer: Is,
) -> (ICalendar, Vec<String>) {
let mut force = Vec::new();
let store = guard(old, new, organizer, &mut force);
(store, force)
}
/// Names who acted for the owner: `SENT-BY` on the owner's ORGANIZER and
/// ATTENDEE properties when `sender` is someone else, none when it is the
/// owner. Only for writes that send a message, so every message names its
/// real sender.
pub fn stamp_sender(cal: &mut ICalendar, owner: Is, sender: Option<&str>) {
for c in cal.components.iter_mut().filter(|c| is_scheduled(c)) {
for c in cal.components.iter_mut().filter(|c| is_item(c)) {
for e in &mut c.entries {
let theirs = matches!(
e.name,
@@ -161,9 +135,10 @@ pub fn stamp_sender(cal: &mut ICalendar, owner: Is, sender: Option<&str>) {
/// and accepted beyond. Anything else is checked to its end, at most ten
/// years ahead.
pub fn answer_horizon(copy: &ICalendar) -> TimeDelta {
let endless = copy.components.iter().any(|c| {
is_scheduled(c) && !c.has_property(&ICalendarProperty::RecurrenceId) && endless(c)
});
let endless = copy
.components
.iter()
.any(|c| is_item(c) && !c.has_property(&ICalendarProperty::RecurrenceId) && endless(c));
TimeDelta::days(if endless { 731 } else { 3653 })
}
@@ -246,17 +221,7 @@ pub fn auto_answer(
true => ICalendarParticipationStatus::Declined,
false => ICalendarParticipationStatus::Accepted,
};
for e in
c.c.entries
.iter_mut()
.filter(|e| e.name == ICalendarProperty::Attendee && address(e).is_some_and(me))
{
set_param(
e,
ICalendarParameterName::Partstat,
partstat(answer.clone()),
);
}
set_own_partstat(&mut c.c, me, answer);
}
obj.done()
}
@@ -674,25 +639,15 @@ pub fn decline(old: &ICalendar, me: Is, now: DateTime<Utc>) -> Option<Message> {
if cancelled {
return None;
}
let comps: Vec<Node> =
old.comps()
.filter(|c| own_partstat(&c.c, me).is_some())
.map(|c| {
let mut part = reply_part(c, me);
for e in
part.c.entries.iter_mut().filter(|e| {
e.name == ICalendarProperty::Attendee && address(e).is_some_and(me)
})
{
set_param(
e,
ICalendarParameterName::Partstat,
partstat(ICalendarParticipationStatus::Declined),
);
}
part
})
.collect();
let comps: Vec<Node> = old
.comps()
.filter(|c| own_partstat(&c.c, me).is_some())
.map(|c| {
let mut part = reply_part(c, me);
set_own_partstat(&mut part.c, me, ICalendarParticipationStatus::Declined);
part
})
.collect();
if comps.is_empty() {
return None;
}
@@ -967,15 +922,17 @@ pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> Appli
// The organizer object
// ---------------------------------------------------------------------------
/// The client's organizer object with the attendee state the server owns:
/// their PARTSTAT (reset on a reschedule, RFC 6638 3.2.8) and
/// SCHEDULE-STATUS. Collects SCHEDULE-FORCE-SEND=REQUEST into `force`.
fn guard(
/// An organizer's PUT: the client's object with the attendee state the
/// server owns, their PARTSTAT (reset on a reschedule, RFC 6638 3.2.8) and
/// SCHEDULE-STATUS, and the attendees SCHEDULE-FORCE-SEND=REQUEST names.
/// `old` is the stored organizer object, if any. [`messages`] tells what the
/// PUT sends.
pub fn prepare(
old: Option<&ICalendar>,
new: &ICalendar,
organizer: Is,
force: &mut Vec<String>,
) -> ICalendar {
) -> (ICalendar, Vec<String>) {
let mut force = Vec::new();
let old = old.map(Obj::new);
let mut next = Obj::new(new);
// A new override takes the state of the THISANDFUTURE override that moves
@@ -1063,7 +1020,7 @@ fn guard(
);
}
}
next.done()
(next.done(), force)
}
/// Whether a change moves instances in time (RFC 6638, 3.2.8). Shortening a
@@ -1117,10 +1074,7 @@ fn canonical(r: &ICalendarRecurrenceRule) -> ICalendarRecurrenceRule {
fn rules(c: &ICalendarComponent, prop: &ICalendarProperty) -> Vec<ICalendarRecurrenceRule> {
let mut v: Vec<_> = c
.properties(prop)
.filter_map(|e| match e.values.first()? {
ICalendarValue::RecurrenceRule(r) => Some(canonical(r)),
_ => None,
})
.filter_map(|e| Some(canonical(rule(e.values.first()?)?)))
.collect();
v.sort_by_key(|r| format!("{r:?}"));
v
@@ -1483,7 +1437,7 @@ impl Obj {
let mut by_key = HashMap::new();
let mut ranges = Vec::new();
for (i, n) in self.root.children.iter().enumerate() {
if !is_scheduled(&n.c) {
if !is_item(&n.c) {
continue;
}
let key = self.key(&n.c);
@@ -1510,7 +1464,7 @@ impl Obj {
/// Appends a child and returns where it is.
fn push(&mut self, n: Node) -> usize {
let at = self.root.children.len();
let key = is_scheduled(&n.c).then(|| self.key(&n.c));
let key = is_item(&n.c).then(|| self.key(&n.c));
let range = is_range(&n.c);
self.root.children.push(n);
if let (Some(ix), Some(key)) = (self.index.get_mut(), key) {
@@ -1530,13 +1484,11 @@ impl Obj {
}
fn comps(&self) -> impl Iterator<Item = &Node> {
self.root.children.iter().filter(|n| is_scheduled(&n.c))
self.root.children.iter().filter(|n| is_item(&n.c))
}
fn comps_mut(&mut self) -> impl Iterator<Item = &mut Node> {
self.children_mut()
.iter_mut()
.filter(|n| is_scheduled(&n.c))
self.children_mut().iter_mut().filter(|n| is_item(&n.c))
}
fn master(&self) -> Option<&Node> {
@@ -1613,7 +1565,7 @@ impl Obj {
self.root
.children
.iter()
.map(|n| match is_scheduled(&n.c) {
.map(|n| match is_item(&n.c) {
true => attendees(&n.c)
.filter(|e| server_agent(e))
.filter_map(address)
@@ -1630,7 +1582,7 @@ impl Obj {
fn view(&self, invited: &[bool]) -> Option<Vec<Node>> {
let overrides = || {
self.root.children.iter().enumerate().filter(|(_, c)| {
is_scheduled(&c.c) && c.c.has_property(&ICalendarProperty::RecurrenceId)
is_item(&c.c) && c.c.has_property(&ICalendarProperty::RecurrenceId)
})
};
let mut out = Vec::new();
@@ -1805,12 +1757,7 @@ impl Obj {
o: &ICalendarRecurrenceRule,
n: &ICalendarRecurrenceRule,
) -> Option<Ordering> {
let dtstart = c.property(&ICalendarProperty::Dtstart)?;
let s = stamp(
&self.zones,
dtstart.values.first()?.as_partial_date_time()?,
dtstart.tz_id(),
)?;
let s = prop_stamp(&self.zones, c, &ICalendarProperty::Dtstart)?;
let end = |u: &PartialDateTime| Some(until_utc(u, &s)?.timestamp());
let start = self.start(c)?;
// The last start COUNT keeps and the one after it.
@@ -2264,15 +2211,6 @@ impl Obj {
// Properties and parameters
// ---------------------------------------------------------------------------
fn is_scheduled(c: &ICalendarComponent) -> bool {
matches!(
c.component_type,
ICalendarComponentType::VEvent
| ICalendarComponentType::VTodo
| ICalendarComponentType::VJournal
)
}
fn address(e: &ICalendarEntry) -> Option<&str> {
e.values.first()?.as_text().map(str::trim)
}
@@ -2298,10 +2236,9 @@ fn organizer_of(c: &ICalendarComponent) -> Option<String> {
/// Whether `c` repeats without end.
pub(crate) fn endless(c: &ICalendarComponent) -> bool {
c.properties(&ICalendarProperty::Rrule).any(|e| {
matches!(e.values.first(), Some(ICalendarValue::RecurrenceRule(r))
if r.count.is_none() && r.until.is_none())
})
c.properties(&ICalendarProperty::Rrule)
.filter_map(|e| rule(e.values.first()?))
.any(|r| r.count.is_none() && r.until.is_none())
}
fn addresses(c: &ICalendarComponent) -> Vec<String> {
@@ -2328,7 +2265,7 @@ fn same_attendee<'a>(c: &'a ICalendarComponent, e: &ICalendarEntry) -> Option<&'
attendees(c).find(|x| address(x).is_some_and(|b| b.eq_ignore_ascii_case(a)))
}
fn own_partstat(c: &ICalendarComponent, me: Is) -> Option<String> {
pub(crate) fn own_partstat(c: &ICalendarComponent, me: Is) -> Option<String> {
attendees(c).find(|e| address(e).is_some_and(me)).map(|e| {
param(e, &ICalendarParameterName::Partstat)
.unwrap_or("NEEDS-ACTION")
@@ -2336,6 +2273,16 @@ fn own_partstat(c: &ICalendarComponent, me: Is) -> Option<String> {
})
}
fn set_own_partstat(c: &mut ICalendarComponent, me: Is, stat: ICalendarParticipationStatus) {
for e in c
.entries
.iter_mut()
.filter(|e| e.name == ICalendarProperty::Attendee && address(e).is_some_and(me))
{
set_param(e, ICalendarParameterName::Partstat, partstat(stat.clone()));
}
}
/// `SCHEDULE-AGENT` absent or `SERVER`. Unknown values count as `NONE`.
fn server_agent(e: &ICalendarEntry) -> bool {
param(e, &ICalendarParameterName::ScheduleAgent)
▾Mpimdav/src/object.rs
@@ -10,6 +10,7 @@ use calcard::{Entry, Parser};
use chrono::{DateTime, Utc};
use xmltree::Element;
use crate::expand::is_item;
use crate::text::{logical_lines, name, unfold, value};
use crate::xml::{CALDAV, CARDDAV, el};
@@ -73,24 +74,13 @@ pub fn calendar(body: &[u8], supported: &[&str]) -> Result<CalendarObject, Inval
if too_costly(&cal) {
return Err(Invalid::CalendarResource);
}
let scheduled = |t: &ICalendarComponentType| {
matches!(
t,
ICalendarComponentType::VEvent
| ICalendarComponentType::VTodo
| ICalendarComponentType::VJournal
)
};
let top = root
.component_ids
.iter()
.filter_map(|&id| cal.components.get(id as usize));
// One nested inside another escapes the one-UID check below.
let all = cal
.components
.iter()
.filter(|c| scheduled(&c.component_type));
if all.count() != top.clone().filter(|c| scheduled(&c.component_type)).count() {
let all = cal.components.iter().filter(|c| is_item(c));
if all.count() != top.clone().filter(|c| is_item(c)).count() {
return Err(Invalid::CalendarResource);
}
let mut found: Option<CalendarObject> = None;
▾Mpimdav/src/principal.rs
@@ -3,8 +3,9 @@
use xmltree::Element;
use crate::filter::{Collation, MatchType, TextMatch};
use crate::filter::{Collation, TextMatch, match_type};
use crate::report::{Refused, limit};
use crate::text::escape_text;
use crate::xml::{CALDAV, CALSERVER, DAV, Name, Propfind, child, elements, text};
/// The `calendar-user-type` of a principal.
@@ -89,17 +90,10 @@ impl Search {
}
fn term_text(e: &Element) -> Result<TextMatch, Refused> {
let match_type = match e.attributes.get("match-type").map(String::as_str) {
None | Some("contains") => MatchType::Contains,
Some("starts-with") => MatchType::StartsWith,
Some("ends-with") => MatchType::EndsWith,
Some("equals") => MatchType::Equals,
Some(_) => return Err(Refused::Invalid),
};
Ok(TextMatch {
text: text(e),
collation: Collation::UnicodeCasemap,
match_type,
match_type: match_type(e)?,
negate: false,
})
}
@@ -180,25 +174,9 @@ pub fn card(uid: &str, p: &Principal, email: &str) -> String {
// RFC 6869.
UserType::Resource => "device",
};
let fn_ = escape(p.display);
let fn_ = escape_text(p.display);
format!(
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:{fn_}\r\nN:{fn_};;;;\r\nEMAIL;TYPE=INTERNET:{}\r\nKIND:{kind}\r\nEND:VCARD\r\n",
escape(email)
escape_text(email)
)
}
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' | ',' | ';' => {
out.push('\\');
out.push(c);
}
'\n' => out.push_str("\\n"),
'\r' => {}
_ => out.push(c),
}
}
out
}
▾Mpimdav/src/render.rs
@@ -14,7 +14,7 @@ use calcard::vcard::{VCard, VCardValue, VCardVersion};
use chrono::{DateTime, Utc};
use xmltree::Element;
use crate::expand::{expand, original, stamp};
use crate::expand::{expand, is_item, original, stamp};
use crate::filter::{TimeRange, time_range};
use crate::freebusy::period;
use crate::report::Refused;
@@ -311,14 +311,10 @@ fn limit_recurrence(cal: &ICalendar, range: &TimeRange, floating: &Zone) -> ICal
let used: HashSet<usize> = exp.instances.iter().map(|x| x.component).collect();
let zones = Zones::new(cal, floating.clone());
// An override moved out of the range still removes an instance in it.
let master = cal.components.iter().position(|c| {
matches!(
c.component_type,
ICalendarComponentType::VEvent
| ICalendarComponentType::VTodo
| ICalendarComponentType::VJournal
) && !c.has_property(&ICalendarProperty::RecurrenceId)
});
let master = cal
.components
.iter()
.position(|c| is_item(c) && !c.has_property(&ICalendarProperty::RecurrenceId));
let replaces_in_range = |rid: &ICalendarEntry| {
let Some(s) = rid
.values
▾Mpimdav/src/text.rs
@@ -74,22 +74,23 @@ pub(crate) fn head(line: &str) -> &str {
/// The raw parameters of a line, split at `;` outside quoted values. A
/// quoted TZID may hold `;` and `:`.
pub(crate) fn param_parts(line: &str) -> Vec<&str> {
let head = head(line);
let mut parts = Vec::new();
let (mut quoted, mut start) = (false, 0);
for (i, c) in head.char_indices() {
match c {
'"' => quoted = !quoted,
';' if !quoted => {
parts.push(&head[start..i]);
start = i + 1;
}
_ => {}
split_unquoted(head(line), ';').split_off(1)
}
/// `s` split at each `sep` outside double quotes.
pub(crate) fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
let mut out = Vec::new();
let (mut quoted, mut from) = (false, 0);
for (i, c) in s.char_indices() {
if c == '"' {
quoted = !quoted;
} else if c == sep && !quoted {
out.push(&s[from..i]);
from = i + 1;
}
}
parts.push(&head[start..]);
parts.remove(0);
parts
out.push(&s[from..]);
out
}
/// The value of parameter `key` of an unfolded line, without quotes.
▾Mpimdav/src/view.rs
@@ -3,12 +3,11 @@
use std::collections::HashMap;
use calcard::icalendar::{
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameterName,
ICalendarProperty, ICalendarValue,
ICalendar, ICalendarComponent, ICalendarEntry, ICalendarParameterName, ICalendarProperty,
};
use chrono::{DateTime, TimeDelta, Utc};
use crate::expand::{Instance, expand};
use crate::expand::{Instance, expand, is_item, prop_stamp, rule};
use crate::itip::Is;
use crate::text::{group, logical_lines, param, param_parts, prop, unescape_text, unfold, value};
use crate::zone::{Zone, Zones, add};
@@ -64,15 +63,6 @@ impl EventInfo {
}
}
pub fn is_item(c: &ICalendarComponent) -> bool {
matches!(
c.component_type,
ICalendarComponentType::VEvent
| ICalendarComponentType::VTodo
| ICalendarComponentType::VJournal
)
}
fn text(c: &ICalendarComponent, prop: &ICalendarProperty) -> Option<String> {
let v = c.property(prop)?.values.first()?.as_text()?.trim();
(!v.is_empty()).then(|| v.to_string())
@@ -129,10 +119,7 @@ pub fn event_info(cal: &ICalendar, index: usize, owner: Is) -> EventInfo {
.collect(),
rrule: master
.property(&ICalendarProperty::Rrule)
.and_then(|e| match e.values.first()? {
ICalendarValue::RecurrenceRule(r) => Some(r.to_string()),
_ => None,
}),
.and_then(|e| Some(rule(e.values.first()?)?.to_string())),
organizer: c.property(&ICalendarProperty::Organizer).and_then(person),
attendees: c
.properties(&ICalendarProperty::Attendee)
@@ -165,9 +152,7 @@ pub fn component_for(
};
if let Some(rid) = recurrence_id {
let found = items().find(|(_, c)| {
c.property(&ICalendarProperty::RecurrenceId)
.and_then(|e| instant(&zones, e))
.is_some_and(|t| t == rid)
prop_stamp(&zones, c, &ICalendarProperty::RecurrenceId).is_some_and(|s| s.utc() == rid)
});
if let Some((i, _)) = found {
return Some(i);
@@ -192,28 +177,18 @@ pub fn instance_for(
let window = match recurrence_id {
// A THISANDFUTURE override moves the later instances by its offset.
Some(rid) if !c.has_property(&ICalendarProperty::RecurrenceId) => {
let offsets: Vec<TimeDelta> = cal
let (low, high) = cal
.components
.iter()
.filter_map(|o| {
let r = o.property(&ICalendarProperty::RecurrenceId)?;
r.parameter(&ICalendarParameterName::Range)?;
let start = instant(&zones, o.property(&ICalendarProperty::Dtstart)?)?;
Some(start - instant(&zones, r)?)
o.property(&ICalendarProperty::RecurrenceId)?
.parameter(&ICalendarParameterName::Range)?;
let at = |p| Some(prop_stamp(&zones, o, p)?.utc());
Some(at(&ICalendarProperty::Dtstart)? - at(&ICalendarProperty::RecurrenceId)?)
})
.collect();
let low = offsets
.iter()
.copied()
.min()
.unwrap_or_default()
.min(TimeDelta::zero());
let high = offsets
.iter()
.copied()
.max()
.unwrap_or_default()
.max(TimeDelta::zero());
.fold((TimeDelta::zero(), TimeDelta::zero()), |(l, h), d| {
(l.min(d), h.max(d))
});
// A day each side: the move keeps wall-clock time, so a DST change
// between the instances shifts it by up to an hour.
let day = TimeDelta::days(1);
@@ -221,7 +196,7 @@ pub fn instance_for(
}
// An override, or an object that does not recur, starts at its DTSTART.
_ => {
let at = instant(&zones, c.property(&ICalendarProperty::Dtstart)?)?;
let at = prop_stamp(&zones, c, &ICalendarProperty::Dtstart)?.utc();
at..add(at, TimeDelta::seconds(1))
}
};
@@ -231,20 +206,6 @@ pub fn instance_for(
.find(|i| recurrence_id.is_none() || i.recurrence_id == recurrence_id)
}
/// A date or date-time property as a UTC instant. Floating times and dates
/// are read in the floating zone of `zones`.
pub fn instant(zones: &Zones, e: &ICalendarEntry) -> Option<DateTime<Utc>> {
let v = e.values.first()?.as_partial_date_time()?;
let dt = v.to_date_time()?;
Some(match dt.offset {
Some(o) if v.hour.is_some() => {
(dt.date_time - chrono::TimeDelta::seconds(o.local_minus_utc().into())).and_utc()
}
_ if v.hour.is_none() => zones.floating().to_utc(dt.date_time),
_ => zones.get(e.tz_id()).to_utc(dt.date_time),
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct Labeled {
pub label: Option<String>,
@@ -327,7 +288,6 @@ pub fn card(vcard: &str) -> Card {
})
.collect::<Vec<_>>()
};
let plain = |v: &str| unescape_text(v);
let mailto = |v: &str| {
let v = unescape_text(v);
v.strip_prefix("mailto:").map(str::to_string).unwrap_or(v)
@@ -336,17 +296,10 @@ pub fn card(vcard: &str) -> Card {
let v = unescape_text(v);
v.strip_prefix("tel:").map(str::to_string).unwrap_or(v)
};
let components = |v: &str| split_structured(v);
let adr = |v: &str| {
// PO box, extended, street, locality, region, code, country.
let p = components(v);
let at = |i: usize| {
p.get(i)
.map(String::as_str)
.unwrap_or("")
.trim()
.to_string()
};
let p = split_structured(v);
let at = |i| part(&p, i);
let city = [at(5), at(3)]
.into_iter()
.filter(|s| !s.is_empty())
@@ -378,29 +331,22 @@ pub fn card(vcard: &str) -> Card {
e.eq_ignore_ascii_case("b") || e.eq_ignore_ascii_case("base64")
}))
});
let n = first("N").map(|_| {
let raw = lines.iter().find(|l| prop(l) == "N").expect("found above");
let p = components(value(raw));
let at = |i: usize| {
p.get(i)
.map(String::as_str)
.unwrap_or("")
.trim()
.to_string()
};
let structured = |p: &str| {
lines
.iter()
.find(|l| prop(l) == p)
.map(|l| split_structured(value(l)))
};
let n = structured("N").map(|p| {
let at = |i| part(&p, i);
[at(3), at(1), at(2), at(0), at(4)]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ")
});
let org = first("ORG").map(|_| {
let raw = lines
.iter()
.find(|l| prop(l) == "ORG")
.expect("found above");
components(value(raw))
.into_iter()
let org = structured("ORG").map(|p| {
p.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
@@ -426,7 +372,7 @@ pub fn card(vcard: &str) -> Card {
emails,
phones: all("TEL", &tel),
addresses: all("ADR", &adr),
urls: all("URL", &plain),
urls: all("URL", &unescape_text),
birthday,
anniversary,
note: first("NOTE"),
@@ -436,6 +382,10 @@ pub fn card(vcard: &str) -> Card {
}
}
fn part(parts: &[String], i: usize) -> String {
parts.get(i).map_or("", |s| s.trim()).to_string()
}
/// The parts of a structured value (N, ADR, ORG): split at `;` that are not
/// escaped, then unescaped.
fn split_structured(v: &str) -> Vec<String> {
▾Mpimdav/tests/itip.rs
@@ -46,6 +46,25 @@ fn text(c: &ICalendar) -> String {
c.to_string().replace("\r\n ", "")
}
/// An organizer's PUT (`new`) or DELETE (`None`): what to store and what
/// to deliver.
fn organize(
old: Option<&ICalendar>,
new: Option<ICalendar>,
organizer: itip::Is,
now: chrono::DateTime<Utc>,
) -> (Option<ICalendar>, Vec<Message>) {
let (new, force) = match new {
Some(n) => {
let (store, force) = itip::prepare(old, &n, organizer);
(Some(store), force)
}
None => (None, Vec::new()),
};
let msgs = itip::messages(old, new.as_ref(), organizer, &force, now);
(new, msgs)
}
fn to<'a>(msgs: &'a [Message], who: &str) -> Option<&'a Message> {
msgs.iter().find(|m| m.to.eq_ignore_ascii_case(who))
}
@@ -83,7 +102,7 @@ END:VALARM
END:VEVENT
"
));
let (store, msgs) = itip::organize(None, Some(new), &is(ALICE), now());
let (store, msgs) = organize(None, Some(new), &is(ALICE), now());
let store = text(&store.unwrap());
assert!(
store.contains(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}")),
@@ -112,7 +131,7 @@ fn reschedule_resets_participation() {
// A new title keeps bob's answer, even from a client with a stale copy.
let retitled = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n")))
.replace("SUMMARY:Sync", "SUMMARY:Weekly sync");
let (store, msgs) = itip::organize(
let (store, msgs) = organize(
Some(&old),
Some(ICalendar::parse(&retitled).unwrap()),
&is(ALICE),
@@ -126,7 +145,7 @@ fn reschedule_resets_participation() {
assert!(!to(&msgs, BOB).unwrap().quiet);
let moved = text(&old).replace("DTSTART:20260105T100000Z", "DTSTART:20260105T090000Z");
let (store, msgs) = itip::organize(
let (store, msgs) = organize(
Some(&old),
Some(ICalendar::parse(&moved).unwrap()),
&is(ALICE),
@@ -139,7 +158,7 @@ fn reschedule_resets_participation() {
// Shortening the series is no reschedule.
let shorter = text(&old).replace("COUNT=4", "COUNT=2");
let (store, _) = itip::organize(
let (store, _) = organize(
Some(&old),
Some(ICalendar::parse(&shorter).unwrap()),
&is(ALICE),
@@ -156,7 +175,7 @@ fn unchanged_or_forced() {
let resaved = text(&old)
.replace("DTSTAMP:20260101T000000Z", "DTSTAMP:20260101T080000Z")
.replace("SUMMARY:Sync", "SUMMARY:Sync\r\nX-MOZ-GENERATION:3");
let (_, msgs) = itip::organize(
let (_, msgs) = organize(
Some(&old),
Some(ICalendar::parse(&resaved).unwrap()),
&is(ALICE),
@@ -167,7 +186,7 @@ fn unchanged_or_forced() {
"ATTENDEE;PARTSTAT=ACCEPTED:mailto:bob",
"ATTENDEE;SCHEDULE-FORCE-SEND=REQUEST;PARTSTAT=ACCEPTED:mailto:bob",
);
let (store, msgs) = itip::organize(
let (store, msgs) = organize(
Some(&old),
Some(ICalendar::parse(&forced).unwrap()),
&is(ALICE),
@@ -180,7 +199,7 @@ fn unchanged_or_forced() {
#[test]
fn removed_attendee_and_delete_cancel() {
let old = meeting(&format!("ATTENDEE:{BOB}\nATTENDEE:{CAROL}\n"));
let (_, msgs) = itip::organize(
let (_, msgs) = organize(
Some(&old),
Some(meeting(&format!("ATTENDEE:{BOB}\n"))),
&is(ALICE),
@@ -192,7 +211,7 @@ fn removed_attendee_and_delete_cancel() {
// The attendee list changed for bob too.
assert_eq!(to(&msgs, BOB).unwrap().method, Method::Request);
let (store, msgs) = itip::organize(Some(&old), None, &is(ALICE), now());
let (store, msgs) = organize(Some(&old), None, &is(ALICE), now());
assert!(store.is_none());
assert!(msgs.iter().all(|m| m.method == Method::Cancel));
assert_eq!(msgs.len(), 2);
@@ -218,7 +237,7 @@ ATTENDEE:{BOB}
END:VEVENT
"
));
let (_, msgs) = itip::organize(None, Some(old), &is(ALICE), now());
let (_, msgs) = organize(None, Some(old), &is(ALICE), now());
let carol = text(&to(&msgs, CAROL).unwrap().cal);
assert!(carol.contains("EXDATE:20260112T100000Z"), "{carol}");
assert!(!carol.contains("RECURRENCE-ID"), "{carol}");
@@ -232,7 +251,7 @@ END:VEVENT
#[test]
fn accept_and_apply_reply() {
let org = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
let (_, msgs) = itip::organize(None, Some(org.clone()), &is(ALICE), now());
let (_, msgs) = organize(None, Some(org.clone()), &is(ALICE), now());
let copy = itip::receive(None, to(&msgs, BOB).unwrap()).unwrap();
assert!(!text(&copy).contains("METHOD"));
@@ -364,7 +383,7 @@ fn attendee_delete_declines() {
#[test]
fn updates_keep_what_the_attendee_owns() {
let org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
let (_, msgs) = itip::organize(None, Some(org.clone()), &is(ALICE), now());
let (_, msgs) = organize(None, Some(org.clone()), &is(ALICE), now());
let copy = itip::receive(None, to(&msgs, BOB).unwrap()).unwrap();
let copy = ICalendar::parse(text(&copy).replace(
"SUMMARY:Sync",
@@ -373,7 +392,7 @@ fn updates_keep_what_the_attendee_owns() {
.unwrap();
let retitled =
ICalendar::parse(text(&org).replace("SUMMARY:Sync", "SUMMARY:Planning")).unwrap();
let (_, msgs) = itip::organize(Some(&org), Some(retitled), &is(ALICE), now());
let (_, msgs) = organize(Some(&org), Some(retitled), &is(ALICE), now());
let updated = text(&itip::receive(Some(&copy), to(&msgs, BOB).unwrap()).unwrap());
assert!(
updated.contains("SUMMARY:Planning")
@@ -382,7 +401,7 @@ fn updates_keep_what_the_attendee_owns() {
"{updated}"
);
let (_, msgs) = itip::organize(Some(&org), None, &is(ALICE), now());
let (_, msgs) = organize(Some(&org), None, &is(ALICE), now());
let cancelled = text(&itip::receive(Some(&copy), to(&msgs, BOB).unwrap()).unwrap());
assert!(cancelled.contains("STATUS:CANCELLED"), "{cancelled}");
}
@@ -579,7 +598,7 @@ fn a_room_declines_one_instance_a_this_and_future_override_moves() {
fn a_client_scheduled_attendee_gets_no_cancel() {
let old = meeting(&format!("ATTENDEE:{BOB}\n"));
let new = meeting(&format!("ATTENDEE;SCHEDULE-AGENT=CLIENT:{BOB}\n"));
let (_, msgs) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
let (_, msgs) = organize(Some(&old), Some(new), &is(ALICE), now());
assert!(to(&msgs, BOB).is_none(), "{msgs:?}");
}
@@ -591,7 +610,7 @@ fn the_highest_sequence_does_not_overflow() {
let moved = text(&old)
.replace("DTSTART:20260105T100000Z", "DTSTART:20260105T090000Z")
.replace(&format!("ATTENDEE:{CAROL}\r\n"), "");
let (store, msgs) = itip::organize(
let (store, msgs) = organize(
Some(&old),
Some(ICalendar::parse(&moved).unwrap()),
&is(ALICE),
@@ -625,7 +644,7 @@ fn a_date_until_on_the_same_day_extends_the_series() {
let old = ICalendar::parse(&old).unwrap();
// The DATE includes the instance at 10:00 on the 19th.
let longer = text(&old).replace("UNTIL=20260119T000000Z", "UNTIL=20260119");
let (store, _) = itip::organize(
let (store, _) = organize(
Some(&old),
Some(ICalendar::parse(&longer).unwrap()),
&is(ALICE),
@@ -733,7 +752,7 @@ fn an_attendee_dropped_from_the_master_gets_a_cancel_despite_a_client_override()
over("")
));
let new = cal(&format!("{}{}", series(""), over(";SCHEDULE-AGENT=CLIENT")));
let (_, msgs) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
let (_, msgs) = organize(Some(&old), Some(new), &is(ALICE), now());
let cancel = to(&msgs, BOB).expect("a CANCEL for the series");
assert_eq!(cancel.method, Method::Cancel);
assert!(
@@ -762,7 +781,7 @@ fn a_new_override_at_the_shifted_time_is_no_move() {
DTEND:20260119T130000Z\nSUMMARY:renamed\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
shifted_series()
));
let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
let (store, _) = organize(Some(&old), Some(new), &is(ALICE), now());
let store = text(&store.unwrap());
assert_eq!(
store.matches(&format!("PARTSTAT=ACCEPTED:{BOB}")).count(),
@@ -784,7 +803,7 @@ fn a_copy_without_its_master_keeps_the_shift() {
"{range}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\
DTEND:20260119T130000Z\nSUMMARY:renamed\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
));
let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
let (store, _) = organize(Some(&old), Some(new), &is(ALICE), now());
let store = text(&store.unwrap());
assert_eq!(
store.matches(&format!("PARTSTAT=ACCEPTED:{BOB}")).count(),
@@ -801,7 +820,7 @@ fn a_new_override_that_moves_bumps_the_sequence() {
DTEND:20260119T160000Z\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
shifted_series()
));
let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
let (store, _) = organize(Some(&old), Some(new), &is(ALICE), now());
let store = text(&store.unwrap());
assert!(store.contains("SEQUENCE:1"), "{store}");
assert!(
@@ -820,7 +839,7 @@ fn a_changed_exrule_is_a_reschedule() {
.filter(|l| !l.starts_with("EXRULE"))
.collect();
assert_ne!(removed, text(&old));
let (store, _) = itip::organize(
let (store, _) = organize(
Some(&old),
Some(ICalendar::parse(&removed).unwrap()),
&is(ALICE),
@@ -863,7 +882,7 @@ fn a_rule_in_another_form_is_the_same_rule() {
"RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,TU",
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU,MO;WKST=MO;COUNT=4",
);
let (store, _) = itip::organize(
let (store, _) = organize(
Some(&accepted),
Some(ICalendar::parse(&rewritten).unwrap()),
&is(ALICE),
@@ -918,7 +937,7 @@ fn a_range_an_attendee_is_left_out_of_ends_their_series() {
DTSTART:20260119T100000Z\nDTEND:20260119T110000Z\nORGANIZER:{ALICE}\n\
ATTENDEE:{CAROL}\nEND:VEVENT\n"
));
let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
let bob = &to(&msgs, BOB).unwrap().cal;
let starts: Vec<_> = expand(
bob,
@@ -1074,7 +1093,7 @@ fn a_range_from_the_first_instance_cancels_the_whole_series() {
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n"
));
let (_, msgs) = itip::organize(Some(&old), Some(org), &is(ALICE), now());
let (_, msgs) = organize(Some(&old), Some(org), &is(ALICE), now());
let bob = to(&msgs, BOB).unwrap();
assert_eq!(bob.method, Method::Cancel, "{}", text(&bob.cal));
}
@@ -1132,7 +1151,7 @@ fn a_new_override_that_changes_the_length_is_a_reschedule() {
.trim_end_matches("END:VCALENDAR\r\n")
.replace("\r\n", "\n")
));
let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
let (store, _) = organize(Some(&old), Some(new), &is(ALICE), now());
let store = text(&store.unwrap());
let longer = store.split("RECURRENCE-ID").nth(1).unwrap();
assert!(longer.contains("SEQUENCE:1"), "{store}");
@@ -1209,13 +1228,13 @@ fn a_reordered_save_sends_nothing() {
let old = meeting(&format!(
"ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nLOCATION:Room 1\n"
));
let (store, _) = itip::organize(None, Some(old), &is(ALICE), now());
let (store, _) = organize(None, Some(old), &is(ALICE), now());
let old = store.unwrap();
let reordered = text(&old)
.replace("SUMMARY:Sync\r\n", "")
.replace("LOCATION:Room 1\r\n", "LOCATION:Room 1\r\nSUMMARY:Sync\r\n")
.replace("DTSTAMP:20260101T000000Z", "DTSTAMP:20260102T000000Z");
let (_, msgs) = itip::organize(
let (_, msgs) = organize(
Some(&old),
Some(ICalendar::parse(&reordered).unwrap()),
&is(ALICE),
@@ -1308,7 +1327,7 @@ fn a_stale_range_past_the_series_cuts_nothing() {
DTSTART:20260115T120000Z\nDTEND:20260115T130000Z\nORGANIZER:{ALICE}\n\
ATTENDEE:{CAROL}\nEND:VEVENT\n"
));
let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
let bob = text(&to(&msgs, BOB).unwrap().cal);
assert!(bob.contains("COUNT=3"), "{bob}");
assert!(!bob.contains("UNTIL"), "{bob}");
@@ -1320,7 +1339,7 @@ fn a_huge_duration_does_not_panic() {
"BEGIN:VEVENT\nUID:m1\nDTSTART;TZID=Europe/Berlin:20260105T100000\nDURATION:P99999999W\n\
ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\nEND:VEVENT\n"
));
itip::organize(Some(&huge), Some(huge.clone()), &is(ALICE), now());
organize(Some(&huge), Some(huge.clone()), &is(ALICE), now());
let accepted = ICalendar::parse(text(&huge).replace("NEEDS-ACTION", "ACCEPTED")).unwrap();
assert!(itip::attend(&huge, accepted, &is(BOB), now()).is_ok());
}
@@ -1350,7 +1369,7 @@ fn many_attendees_and_ranges_stay_fast() {
second(0)
));
let t = std::time::Instant::now();
let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed());
let u1 = text(&to(&msgs, "mailto:u1@example.com").unwrap().cal);
assert!(u1.contains("UNTIL=20260105T100639Z"), "{u1}");
@@ -1427,11 +1446,11 @@ fn clients_may_name_the_week_start_or_confirm_their_instance() {
#[test]
fn a_sequence_bump_reaches_the_copies_quietly() {
let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
let (store, _) = itip::organize(None, Some(old), &is(ALICE), now());
let (store, _) = organize(None, Some(old), &is(ALICE), now());
let old = store.unwrap();
let bumped =
ICalendar::parse(text(&old).replace("SUMMARY:Sync", "SUMMARY:Sync\r\nSEQUENCE:3")).unwrap();
let (store, msgs) = itip::organize(Some(&old), Some(bumped), &is(ALICE), now());
let (store, msgs) = organize(Some(&old), Some(bumped), &is(ALICE), now());
let bob = to(&msgs, BOB).expect("no update for bob");
assert!(bob.quiet);
assert!(text(&bob.cal).contains("SEQUENCE:3"));
@@ -1465,14 +1484,14 @@ fn many_attendees_share_one_message_body() {
SUMMARY:All hands\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n{people}END:VEVENT\n"
));
let t = std::time::Instant::now();
let (store, msgs) = itip::organize(None, Some(new), &is(ALICE), now());
let (store, msgs) = organize(None, Some(new), &is(ALICE), now());
assert_eq!(msgs.len(), 2000);
assert!(
msgs.iter()
.all(|m| std::sync::Arc::ptr_eq(&m.cal, &msgs[0].cal))
);
let store = store.unwrap();
let (_, again) = itip::organize(Some(&store), Some(store.clone()), &is(ALICE), now());
let (_, again) = organize(Some(&store), Some(store.clone()), &is(ALICE), now());
assert!(again.is_empty());
assert!(t.elapsed().as_secs() < 2, "{:?}", t.elapsed());
}
@@ -1592,7 +1611,7 @@ fn keeps_bobs_answer(old_rule: &str, new_rule: &str) -> bool {
let m = text(&meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")));
ICalendar::parse(m.replace("COUNT=4", rule)).unwrap()
};
let (store, _) = itip::organize(
let (store, _) = organize(
Some(&series(old_rule)),
Some(series(new_rule)),
&is(ALICE),
@@ -1649,7 +1668,7 @@ fn moving_a_far_until_costs_only_the_gap() {
))
};
let t = std::time::Instant::now();
let (store, _) = itip::organize(
let (store, _) = organize(
Some(&series("99991231T000000Z", "ACCEPTED")),
Some(series("99991230T000000Z", "ACCEPTED")),
&is(ALICE),
@@ -1674,7 +1693,7 @@ fn rule_ends_compare_in_the_wall_time_of_the_start() {
};
// Whether alice's edit keeps bob's answer, and whether bob may write it.
let check = |start: &str, old: &str, new: &str| {
let (store, _) = itip::organize(
let (store, _) = organize(
Some(&series(start, old, "ACCEPTED")),
Some(series(start, new, "ACCEPTED")),
&is(ALICE),
@@ -1722,7 +1741,7 @@ fn a_range_on_the_last_day_of_a_date_until_ends_the_series_before_it() {
DTSTART:20260119T100000Z\nDTEND:20260119T110000Z\nORGANIZER:{ALICE}\n\
ATTENDEE:{CAROL}\nEND:VEVENT\n"
));
let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
let bob = &to(&msgs, BOB).unwrap().cal;
let window = at(1, 0)..at(1, 0) + chrono::TimeDelta::days(90);
let starts: Vec<_> = expand(bob, window, Zone::Utc)
@@ -1757,7 +1776,7 @@ fn far_rule_changes_on_many_components_share_one_budget() {
cal(&body)
};
let t = std::time::Instant::now();
let (store, _) = itip::organize(
let (store, _) = organize(
Some(&series("20270101T000000Z", "ACCEPTED")),
Some(series("20570101T000000Z", "ACCEPTED")),
&is(ALICE),
@@ -1840,7 +1859,7 @@ fn many_ranges_one_attendee_is_left_out_of_stay_fast() {
day(0)
));
let t = std::time::Instant::now();
let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed());
let carol = text(&to(&msgs, CAROL).unwrap().cal);
assert!(carol.contains("UNTIL=20260115T095959Z"), "{carol}");
@@ -1877,7 +1896,7 @@ fn clients_may_spell_out_the_month_and_day_of_a_rule() {
answer("FREQ=MONTHLY;COUNT=3", "FREQ=MONTHLY;COUNT=3;BYMONTHDAY=6"),
Err(Refused::AttendeeChange)
);
let (store, _) = itip::organize(
let (store, _) = organize(
Some(&ICalendar::parse(copy("FREQ=YEARLY")).unwrap()),
Some(ICalendar::parse(copy("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=5")).unwrap()),
&is(ALICE),
@@ -1906,7 +1925,7 @@ fn a_spelled_out_rule_that_adds_leap_days_is_a_change() {
now(),
);
assert_eq!(answer.map(|_| ()), Err(Refused::AttendeeChange));
let (store, _) = itip::organize(
let (store, _) = organize(
Some(&ICalendar::parse(copy(old, "ACCEPTED")).unwrap()),
Some(ICalendar::parse(copy(new, "ACCEPTED")).unwrap()),
&is(ALICE),
@@ -1941,7 +1960,7 @@ fn far_ranges_on_an_endless_dense_series_stay_fast() {
day(0)
));
let t = std::time::Instant::now();
let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed());
assert!(to(&msgs, CAROL).is_some());
}
@@ -1967,7 +1986,7 @@ fn a_series_left_out_of_two_far_ranges_ends_at_the_first() {
range(a),
range(b)
));
let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
let bob = text(&to(&msgs, BOB).expect("bob is invited").cal);
assert!(bob.contains("UNTIL=20260601T095"), "{bob}");
}
▾Mserver/Cargo.toml
@@ -86,10 +86,8 @@ getrandom = "0.4"
webauthn-rs-proto = "0.5.5"
[dev-dependencies]
base64 = "0.23"
tempfile = "3"
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
[dependencies.rust-embed]
version = "8"
▾Mserver/src/api/admin.rs
@@ -26,6 +26,14 @@ use crate::fs;
// Helpers
// ---------------------------------------------------------------------------
fn user_not_found() -> ApiError {
ApiError::localized(
StatusCode::NOT_FOUND,
"user not found",
"err_user_not_found",
)
}
fn admin_user(state: &AppState, user: &crate::db::User, roots: &[RootRow]) -> AdminUser {
AdminUser {
id: user.id,
@@ -132,13 +140,11 @@ pub async fn update_user(
AxumPath(id): AxumPath<i64>,
Json(body): Json<UpdateUser>,
) -> Result<Json<AdminUser>, ApiError> {
let target = state.db.find_user_by_id(id).await?.ok_or_else(|| {
ApiError::localized(
StatusCode::NOT_FOUND,
"user not found",
"err_user_not_found",
)
})?;
let target = state
.db
.find_user_by_id(id)
.await?
.ok_or_else(user_not_found)?;
// Lockout guards: an admin cannot demote, disable, or delete themselves.
if id == admin.user.id {
@@ -192,13 +198,11 @@ pub async fn update_user(
.await?;
crate::auth::forget_verified();
let updated = state.db.find_user_by_id(id).await?.ok_or_else(|| {
ApiError::localized(
StatusCode::NOT_FOUND,
"user not found",
"err_user_not_found",
)
})?;
let updated = state
.db
.find_user_by_id(id)
.await?
.ok_or_else(user_not_found)?;
let roots = state.db.user_roots(updated.id).await?;
Ok(Json(admin_user(&state, &updated, &roots)))
}
@@ -216,13 +220,11 @@ pub async fn delete_user(
"err_own_delete",
));
}
let target = state.db.find_user_by_id(id).await?.ok_or_else(|| {
ApiError::localized(
StatusCode::NOT_FOUND,
"user not found",
"err_user_not_found",
)
})?;
let target = state
.db
.find_user_by_id(id)
.await?
.ok_or_else(user_not_found)?;
if target.is_admin && target.active && state.db.count_admins().await? <= 1 {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
@@ -241,11 +243,7 @@ pub async fn delete_user(
None => Vec::new(),
};
if !state.db.delete_user(id, &ops).await? {
return Err(ApiError::localized(
StatusCode::NOT_FOUND,
"user not found",
"err_user_not_found",
));
return Err(user_not_found());
}
Ok(Json(OkResp {}))
}
@@ -299,7 +297,7 @@ pub async fn delete_share(
_admin: AdminGuard,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<OkResp>, ApiError> {
if !state.db.admin_delete_share(id).await? {
if !state.db.delete_share(id, None).await? {
return Err(ApiError::localized(
StatusCode::NOT_FOUND,
"share not found",
@@ -325,7 +323,7 @@ pub async fn delete_pim_link(
_admin: AdminGuard,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<OkResp>, ApiError> {
if !state.db.admin_delete_pim_link(id).await? {
if !state.db.pim_delete_link(id, None).await? {
return Err(ApiError::new(StatusCode::NOT_FOUND, "link not found"));
}
Ok(Json(OkResp {}))
▾Mserver/src/api/auth.rs
@@ -1,11 +1,12 @@
use std::sync::Arc;
use api_types::{AuthMode, Credentials, LoginReq, LoginResp, Me, OkResp, RootInfo, UserInfo};
use api_types::{
AuthMode, Credentials, LoginReq, LoginResp, Me, OkResp, ProfilePatch, RootInfo, UserInfo,
};
use axum::Json;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode, Uri, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use crate::api::common::{
SessionUser, hash_password, root_info, session_auth, validate_account_name, validate_password,
@@ -76,37 +77,6 @@ async fn me_for(state: &AppState, user: &User, roots: Vec<RootRow>) -> Result<Me
})
}
/// PUT /api/auth/me
///
/// Update the signed-in user's profile settings. Each field is optional;
/// omitted fields are left untouched. Returns the fresh `/me` payload so
/// clients can apply the change immediately.
#[derive(Deserialize)]
pub(crate) struct ProfilePatch {
#[serde(default)]
pub single_click_open: Option<bool>,
pub thumbnails: Option<bool>,
#[serde(default, deserialize_with = "patch_field")]
pub language: Option<Option<String>>,
pub week_start: Option<u8>,
/// `null` clears the default root (back to the root picker).
#[serde(default, deserialize_with = "patch_field")]
pub default_root_id: Option<Option<i64>>,
}
/// Deserializes a nullable patch field into the three-state value:
/// `"de"` → `Some(Some("de"))`, `null` → `Some(None)` (a missing field
/// never calls this and stays `None` via `#[serde(default)]`). The inner
/// `Option<T>` already maps `null` → `None` and a value → `Some`, so only
/// the outer wrap is custom.
fn patch_field<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de>,
{
serde::Deserialize::deserialize(deserializer).map(Some)
}
/// A language tag we are willing to store: short, ASCII letters/digits and
/// `-`/`_` (BCP-47 style). Checked at the trust boundary so a raw API
/// client cannot write arbitrary blobs into the DB.
@@ -118,6 +88,8 @@ fn valid_language(tag: &str) -> bool {
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// PUT /api/auth/me. Answers with the fresh `/me` payload, so clients apply
/// the change at once.
pub async fn update_profile(
State(state): State<Arc<AppState>>,
SessionUser { mut user, roots }: SessionUser,
▾Mserver/src/api/common.rs
@@ -253,15 +253,35 @@ where
pub(crate) async fn hash_password(pw: &str) -> Result<String, ApiError> {
let pw = pw.to_string();
let _slot = crate::auth::ARGON2_SLOTS.acquire().await;
tokio::task::spawn_blocking(move || crate::auth::hash_password(&pw))
.await
.map_err(|_| ApiError::internal())?
.map_err(|e| {
blocking(move || {
crate::auth::hash_password(&pw).map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("hashing failed: {e}"),
)
})
})
.await
}
/// The hash of an optional share or feed password, of the trimmed text. A
/// blank one means none.
pub(crate) async fn optional_password_hash(pw: Option<&str>) -> Result<Option<String>, ApiError> {
match pw.map(str::trim) {
Some(pw) if !pw.is_empty() => {
validate_password(pw)?;
Ok(Some(hash_password(pw).await?))
}
_ => Ok(None),
}
}
/// The path of an href given as a path or as a full URL.
pub(crate) fn href_path(href: &str) -> Option<String> {
match href.starts_with('/') {
true => Some(href.to_string()),
false => Some(href.parse::<axum::http::Uri>().ok()?.path().to_string()),
}
}
/// A user-chosen label for a credential: trimmed, bounded, never empty.
▾Mserver/src/api/dav.rs
@@ -39,7 +39,7 @@ use dav_server::ls::{DavLock, DavLockSystem, LsFuture};
use dav_server::memls::MemLs;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use crate::api::common::{display_name, session_auth};
use crate::api::common::{display_name, href_path, session_auth};
use crate::auth;
use crate::db::RootRow;
use crate::error::AppState;
@@ -91,27 +91,10 @@ pub async fn share(State(state): State<Arc<AppState>>, req: Request<Body>) -> Re
if row.is_file {
return StatusCode::NOT_FOUND.into_response();
}
// A protected share takes its password over Basic, with the user name
// ignored. There is no account behind a share link to name.
if let Some(hash) = row.password_hash.clone() {
let Some((_, password)) = auth::basic_credentials(req.headers()) else {
return challenge();
};
// The hash is of the trimmed password.
let password = password.trim().to_owned();
let (pw, id, tok) = (password.clone(), row.id, token.clone());
let ok = auth::verify_cached(row.id, "", &password, move || async move {
// Throttled like `POST /api/share/{token}/unlock`, keyed the same
// way, so a mount client is not the cheap way to guess.
auth::throttle(&tok).await;
let ok = auth::verify_password_async(&pw, &hash).await;
auth::record_login(&tok, ok);
ok.then_some(id)
})
.await;
if ok.is_none() {
return challenge();
}
if let Some(hash) = &row.password_hash
&& !auth::basic_share_ok(req.headers(), row.id, &token, hash).await
{
return challenge();
}
let root = RootRow {
id: row.id,
@@ -147,13 +130,7 @@ fn path_in_mount(path: &str, prefix: &str) -> bool {
/// header parser reads it: a path as-is, a full URL (what mount clients send)
/// reduced to its path. `None` for a missing or unparseable header.
fn destination_path(headers: &HeaderMap) -> Option<String> {
let raw = headers.get("destination")?.to_str().ok()?;
if raw.starts_with('/') {
return Some(raw.to_string());
}
raw.parse::<axum::http::Uri>()
.ok()
.map(|u| u.path().to_string())
href_path(headers.get("destination")?.to_str().ok()?)
}
/// The token out of the *raw* URL path.
▾Mserver/src/api/files.rs
@@ -273,15 +273,15 @@ async fn preview(
let (full, name, is_dir, _size, mtime) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if is_dir {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"not a file",
"err_not_a_file",
));
return Err(not_a_file());
}
file_response(&full, &name, true, mtime, headers).await
}
fn not_a_file() -> ApiError {
ApiError::localized(StatusCode::BAD_REQUEST, "not a file", "err_not_a_file")
}
/// `GET ...?action=content` — raw file bytes for the text preview/editor.
/// Capped at `MAX_TEXT_BYTES`.
async fn content(
@@ -294,11 +294,7 @@ async fn content(
let (full, _name, is_dir, size, _mtime) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if is_dir {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"not a file",
"err_not_a_file",
));
return Err(not_a_file());
}
if size > MAX_TEXT_BYTES {
return Err(ApiError::localized(
@@ -431,9 +427,12 @@ pub async fn file_put(
.map(|s| s.target.clone());
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
let content = body.to_vec();
let mtime = blocking(move || match share_target {
Some(target) => fs::save_file_at(&server_root, &target, &content, expected),
None => fs::save_file(&server_root, &root_rel, &rel, &content, expected),
let mtime = blocking(move || {
let full = match share_target {
Some(target) => fs::resolve_file(&server_root, &target),
None => fs::resolve_path(&server_root, &root_rel, &rel),
}?;
fs::write_checked(&full, &content, expected)
})
.await?;
Ok(Json(SaveResp { mtime }))
@@ -647,20 +646,7 @@ pub async fn dispatch(
let is_exists = query.action.as_deref() == Some(api_types::ACTION_EXISTS);
let bytes = axum::body::to_bytes(req.into_body(), 1_000_000)
.await
.map_err(|_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid request body",
"err_bad_body",
)
})?;
let bad_body = |_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid request body",
"err_bad_body",
)
};
.map_err(bad_body)?;
if is_exists {
let body: ExistsReq = axum::Json::from_bytes(&bytes).map_err(bad_body)?.0;
return Ok(exists(state, auth, root_id, req_rel, body)
@@ -672,69 +658,42 @@ pub async fn dispatch(
.await?
.into_response());
}
match query.action.as_deref() {
Some(api_types::ACTION_MKDIR) => {
return Ok(mkdir(state, auth, root_id, req_rel).await?.into_response());
}
Some(api_types::ACTION_CREATE_FILE) => {
return Ok(create_file(state, auth, root_id, req_rel)
.await?
.into_response());
let (create, msg, code): (CreateFn, _, _) = match query.action.as_deref() {
Some(api_types::ACTION_MKDIR) => (
fs::mkdir,
"a folder name is required",
"err_folder_name_required",
),
Some(api_types::ACTION_CREATE_FILE) => (
fs::create_file,
"a file name is required",
"err_file_name_required",
),
_ => {
return Err(ApiError::localized(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"POST expects a multipart upload, a JSON mutation, or ?action=mkdir",
"err_bad_post",
));
}
_ => {}
}
Err(ApiError::localized(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"POST expects a multipart upload, a JSON mutation, or ?action=mkdir",
"err_bad_post",
))
}
// ---------------------------------------------------------------------------
// create file
// ---------------------------------------------------------------------------
/// Create an empty file in a writable root.
async fn create_file(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
) -> Result<Json<OkResp>, ApiError> {
};
let root = require_rw_root(&auth.roots, root_id)?;
if req_rel.trim().is_empty() {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"a file name is required",
"err_file_name_required",
));
return Err(ApiError::localized(StatusCode::BAD_REQUEST, msg, code));
}
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
blocking(move || fs::create_file(&server_root, &root_rel, &rel)).await?;
Ok(Json(OkResp {}))
blocking(move || create(&server_root, &root_rel, &rel)).await?;
Ok(Json(OkResp {}).into_response())
}
// ---------------------------------------------------------------------------
// mkdir
// ---------------------------------------------------------------------------
type CreateFn = fn(&std::path::Path, &str, &str) -> Result<(), fs::FsError>;
async fn mkdir(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
) -> Result<Json<OkResp>, ApiError> {
let root = require_rw_root(&auth.roots, root_id)?;
if req_rel.trim().is_empty() {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"a folder name is required",
"err_folder_name_required",
));
}
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
blocking(move || fs::mkdir(&server_root, &root_rel, &rel)).await?;
Ok(Json(OkResp {}))
fn bad_body<E>(_: E) -> ApiError {
ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid request body",
"err_bad_body",
)
}
// ---------------------------------------------------------------------------
@@ -981,7 +940,10 @@ async fn upload(
"some files already exist",
"err_files_exist",
)
.with_extra(serde_json::json!({ "skipped": skipped, "uploaded": uploaded })));
.with_extra(serde_json::Map::from_iter([
("skipped".into(), serde_json::json!(skipped)),
("uploaded".into(), serde_json::json!(uploaded)),
])));
}
Ok(Json(OkResp {}))
}
@@ -1206,19 +1168,17 @@ impl Drop for Scratch {
}
fn validate_rel_path(name: &str) -> Result<(), ApiError> {
for c in std::path::Path::new(name).components() {
match c {
Component::Normal(_) => {}
_ => {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid file path in upload",
"err_bad_upload_path",
));
}
}
if std::path::Path::new(name)
.components()
.all(|c| matches!(c, Component::Normal(_)))
{
return Ok(());
}
Ok(())
Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid file path in upload",
"err_bad_upload_path",
))
}
// ---------------------------------------------------------------------------
▾Mserver/src/api/mod.rs
@@ -6,8 +6,8 @@ use api_types::{
AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, CANDIDATES_SUFFIX, DAV, DAV_SHARE,
EXPORT_SUFFIX, FEED, FILES, FINISH_SUFFIX, IMPORT_SUFFIX, LINKS_SUFFIX, OBJECTS_SUFFIX,
PHOTO_SUFFIX, PIM, PIM_COLLECTIONS, PIM_CONTACTS, PIM_IMPORT_NEW, PIM_INSTANCES,
PIM_INVITATIONS, PIM_SHARES, PIM_SYSTEM_EXPORT, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
SHARES_SUFFIX, WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
PIM_INVITATIONS, PIM_SHARES, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SHARES_SUFFIX,
WELL_KNOWN_CALDAV, WELL_KNOWN_CARDDAV,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -177,15 +177,15 @@ pub fn router(state: Arc<AppState>) -> Router {
.post(files::dispatch)
.delete(files::delete),
)
.route(SHARES, get(shares::list))
.route(SHARES, post(shares::create))
.route(SHARES, get(shares::list).post(shares::create))
.route(&shares_id, delete(shares::delete))
.route(&share_token, get(shares::resolve))
.route(&share_unlock, post(shares::unlock))
.route(ADMIN_USERS, get(admin::list_users))
.route(ADMIN_USERS, post(admin::create_user))
.route(&admin_user_id, put(admin::update_user))
.route(&admin_user_id, delete(admin::delete_user))
.route(ADMIN_USERS, get(admin::list_users).post(admin::create_user))
.route(
&admin_user_id,
put(admin::update_user).delete(admin::delete_user),
)
.route(ADMIN_SHARES, get(admin::list_shares))
.route(&admin_share_id, delete(admin::delete_share))
.route(ADMIN_ROOMS, get(admin::list_rooms).post(admin::create_room))
@@ -214,13 +214,14 @@ pub fn router(state: Arc<AppState>) -> Router {
.route(&pim_import, post(pim_api::import))
.route(PIM_IMPORT_NEW, post(pim_api::import_new))
.route(&pim_export, get(pim_api::export))
.route(PIM_SYSTEM_EXPORT, get(pim_api::export_system))
.route(&pim_photo, get(pim_api::photo))
.route(ADMIN_PIM_LINKS, get(admin::list_pim_links))
.route(&admin_pim_link, delete(admin::delete_pim_link))
.route(&feed, get(pim_api::feed))
.route(ADMIN_SETTINGS, get(admin::get_settings))
.route(ADMIN_SETTINGS, put(admin::update_settings))
.route(
ADMIN_SETTINGS,
get(admin::get_settings).put(admin::update_settings),
)
// `any`, not a method filter: WebDAV's verbs (PROPFIND, MKCOL, MOVE, …)
// are not in axum's `MethodFilter`, and a method router's fallback
// takes every one of them.
▾Mserver/src/api/pim.rs
@@ -40,8 +40,8 @@ use pimdav::xml::{
use pimdav::zone::{self, Zone};
use pimdav::{contact, filter, freebusy, object};
use super::common::blocking;
use super::pim_schedule::{self, Directory, Stored, Writer};
use super::common::{blocking, href_path};
use super::pim_schedule::{self, Directory, Writer};
use sha2::{Digest, Sha256};
use xmltree::Element;
@@ -161,8 +161,7 @@ struct Me {
/// The account's principal, which owns its collections.
pid: i64,
admin: bool,
/// The scheduling address, for SENT-BY when acting for someone else.
address: String,
name: String,
/// The own principal href. Spelled as the request spelled the name when
/// it named this account: a client that asked for `/ALICE/` must get
/// hrefs it recognises.
@@ -197,6 +196,25 @@ impl Space {
fn object(&self, kind: PimKind, slug: &str, name: &str) -> String {
format!("{}{}", self.collection(kind, slug), seg(name))
}
/// A principal as PROPFIND and the searches describe it.
fn of(p: &PimPrincipal, me: &Me) -> Self {
Space {
id: p.id,
path: p.name.clone(),
display: p.display().to_string(),
kind: p.kind,
mine: p.id == me.pid,
}
}
/// Only the mailto address: Apple takes the first href in order unless
/// one is `preferred`, and an attendee matched by its principal URL gets
/// no reply buttons. Scheduling still accepts the principal URL and the
/// `urn:uuid:` form.
fn addresses(&self) -> Vec<String> {
vec![format!("mailto:{}", mailto(&self.path, self.kind))]
}
}
/// The URL of a principal.
@@ -206,11 +224,7 @@ pub(crate) fn principal_href(name: &str) -> String {
/// The principal name of a principal URL, given as a path or a full URL.
pub(super) fn principal_name(href: &str) -> Option<String> {
let path = match href.starts_with('/') {
true => href.to_string(),
false => href.parse::<axum::http::Uri>().ok()?.path().to_string(),
};
match parse_target(path.strip_prefix(PIM)?)? {
match parse_target(href_path(href)?.strip_prefix(PIM)?)? {
Target::Principal(name) => Some(name),
_ => None,
}
@@ -322,7 +336,7 @@ async fn resolve_space(
id: user.id,
pid: state.db.principal_of(user.id).await?,
admin: user.is_admin,
address: format!("mailto:{}", mailto(&user.name, UserType::Individual)),
name: user.name.clone(),
principal: principal_href(&user.name),
};
let Some(segment) = target.owner() else {
@@ -388,7 +402,7 @@ fn parse_target(path: &str) -> Option<Target> {
.collect::<Option<Vec<_>>>()?;
let kind = |s: &str| match s {
"calendars" => Some(PimKind::Calendar),
"addressbooks" => Some(PimKind::AddressBook),
"addressbooks" => Some(PimKind::Addressbook),
_ => None,
};
let mut it = segs.into_iter();
@@ -417,14 +431,14 @@ fn parse_target(path: &str) -> Option<Target> {
fn kind_segment(kind: PimKind) -> &'static str {
match kind {
PimKind::Calendar => "calendars",
PimKind::AddressBook => "addressbooks",
PimKind::Addressbook => "addressbooks",
}
}
fn kind_ns(kind: PimKind) -> &'static str {
match kind {
PimKind::Calendar => CALDAV,
PimKind::AddressBook => CARDDAV,
PimKind::Addressbook => CARDDAV,
}
}
@@ -436,6 +450,14 @@ fn status(code: StatusCode) -> Response<Body> {
code.into_response()
}
/// 403: another object of the collection, at `href`, has the UID.
fn uid_conflict(ns: &str, href: &str) -> Response<Body> {
error(
StatusCode::FORBIDDEN,
with_children(el(ns, "no-uid-conflict"), hrefs([href])),
)
}
fn xml_response(code: StatusCode, body: String) -> Response<Body> {
(
code,
@@ -521,37 +543,6 @@ pub(super) fn local_part(name: &str) -> String {
utf8_percent_encode(name, set).to_string()
}
/// A principal as PROPFIND and the searches describe it.
struct PrincipalView {
id: i64,
/// The URL segment.
path: String,
display: String,
kind: UserType,
/// The signed-in account itself.
me: bool,
}
impl PrincipalView {
fn of(p: &PimPrincipal, me: &Me) -> Self {
PrincipalView {
id: p.id,
path: p.name.clone(),
display: p.display().to_string(),
kind: p.kind,
me: p.id == me.pid,
}
}
/// Only the mailto address: Apple takes the first href in order unless
/// one is `preferred`, and an attendee matched by its principal URL gets
/// no reply buttons. Scheduling still accepts the principal URL and the
/// `urn:uuid:` form.
fn addresses(&self) -> Vec<String> {
vec![format!("mailto:{}", mailto(&self.path, self.kind))]
}
}
// ---------------------------------------------------------------------------
// Collections and members
// ---------------------------------------------------------------------------
@@ -635,7 +626,7 @@ pub(super) async fn birthdays_collection(
) -> Result<PimCollection, ApiError> {
let source: String = state
.db
.pim_collections(principal, PimKind::AddressBook)
.pim_collections(principal, PimKind::Addressbook)
.await?
.iter()
.map(|b| format!("{}:{}\n", b.id, b.seq))
@@ -653,7 +644,7 @@ pub(super) async fn birthdays(state: &AppState, principal: i64) -> Result<Member
let mut books = Vec::new();
for book in state
.db
.pim_collections(principal, PimKind::AddressBook)
.pim_collections(principal, PimKind::Addressbook)
.await?
{
books.push((book.id, state.db.pim_objects_with_data(book.id).await?));
@@ -744,7 +735,7 @@ impl Cx<'_> {
}));
}
let generated = match (kind, slug) {
(PimKind::AddressBook, DIRECTORY_SLUG) => Some(directory_collection(self.state).await?),
(PimKind::Addressbook, DIRECTORY_SLUG) => Some(directory_collection(self.state).await?),
(PimKind::Calendar, BIRTHDAYS_SLUG) => {
Some(birthdays_collection(self.state, space.id).await?)
}
@@ -759,13 +750,15 @@ impl Cx<'_> {
}
let Some(id) = slug
.strip_prefix(SHARED_PREFIX)
.and_then(|id| id.parse().ok())
.and_then(|id| id.parse::<i64>().ok())
else {
return Ok(None);
};
Ok(db
.pim_shared_collection(self.me.id, kind, id)
.pim_shared_collections(self.me.id, kind)
.await?
.into_iter()
.find(|(c, ..)| c.id == id)
.map(|(c, owner, mode)| lent(c, &owner, mode)))
}
@@ -793,7 +786,7 @@ impl Cx<'_> {
.collect();
if space.mine {
let generated = match kind {
PimKind::AddressBook => directory_collection(self.state).await?,
PimKind::Addressbook => directory_collection(self.state).await?,
PimKind::Calendar => birthdays_collection(self.state, space.id).await?,
};
out.push(Col {
@@ -835,14 +828,24 @@ impl Cx<'_> {
c: &PimCollection,
name: &str,
) -> Result<Option<(PimObject, Vec<u8>)>, ApiError> {
if generated(c.id) {
let all = self.members(c).await?;
return Ok(all.into_iter().find(|(o, _)| o.name == name));
}
Ok(self.state.db.pim_object(c.id, name).await?)
member_of(self.state, self.space().id, c.id, name).await
}
}
/// The member `name` of collection `id` of `principal`, generated or stored.
pub(super) async fn member_of(
state: &AppState,
principal: i64,
id: i64,
name: &str,
) -> Result<Option<(PimObject, Vec<u8>)>, ApiError> {
if generated(id) {
let all = members_of(state, principal, id).await?;
return Ok(all.into_iter().find(|(o, _)| o.name == name));
}
Ok(state.db.pim_object(id, name).await?)
}
/// Deletes a collection of principal `owner`. A calendar's scheduling
/// objects are cancelled for their attendees first. `Err` names the
/// precondition that refuses it: the calendar that receives invitations
@@ -882,7 +885,7 @@ pub(super) async fn delete_own(
db.pim_apply(&ops).await?;
return Ok(Ok(()));
}
db.pim_delete_collection(col.id).await?;
db.pim_apply(&[PimOp::DeleteCollection(col.id)]).await?;
Ok(Ok(()))
}
@@ -910,7 +913,7 @@ fn lent(mut c: PimCollection, owner: &str, mode: PimShareMode) -> Col {
enum Res {
Root,
Principals,
Principal(PrincipalView),
Principal(Space),
/// With its owner's principal href, whether the account may add to it,
/// and where its client properties live.
Home(String, Access, PropPlace),
@@ -951,23 +954,14 @@ impl Cx<'_> {
for p in self.state.db.pim_principals(true).await? {
list.push((
principal_href(&p.name),
Res::Principal(PrincipalView::of(&p, self.me)),
Res::Principal(Space::of(&p, self.me)),
));
}
}
}
Target::Principal(_) => {
let s = self.space();
list.push((
s.principal(),
Res::Principal(PrincipalView {
id: s.id,
path: s.path.clone(),
display: s.display.clone(),
kind: s.kind,
me: s.mine,
}),
));
list.push((s.principal(), Res::Principal(s.clone())));
}
Target::Home(kind, _) => {
let s = self.space();
@@ -1059,7 +1053,7 @@ impl Cx<'_> {
/// account's client settings.
async fn dead_props(&self, res: &Res) -> Result<Vec<Element>, ApiError> {
let place = match res {
Res::Principal(p) if p.me || (self.me.admin && p.kind != UserType::Individual) => {
Res::Principal(p) if p.mine || (self.me.admin && p.kind != UserType::Individual) => {
PropPlace::Principal(p.id)
}
Res::Home(_, _, place) if self.may_edit(self.space()) => *place,
@@ -1077,10 +1071,6 @@ impl Cx<'_> {
.filter_map(|p| Element::parse(p.xml.as_bytes()).ok())
.collect())
}
fn props(&self, res: &Res) -> Vec<Element> {
live_props(self.me, self.space, res)
}
}
/// Every live property of a resource, with its value.
@@ -1107,7 +1097,7 @@ fn live_props(me: &Me, space: Option<&Space>, res: &Res) -> Vec<Element> {
]),
Res::Principal(p) => {
// The own principal in the spelling of the request.
let href = match p.me {
let href = match p.mine {
true => me.principal.clone(),
false => principal_href(&p.path),
};
@@ -1130,11 +1120,11 @@ fn live_props(me: &Me, space: Option<&Space>, res: &Res) -> Vec<Element> {
)],
),
text(CALDAV, "calendar-user-type", p.kind.as_str()),
privileges(if p.me { Access::Own } else { Access::Read }),
privileges(if p.mine { Access::Own } else { Access::Read }),
principal_reports(),
]);
let home = |kind: PimKind| {
let name = match p.me {
let name = match p.mine {
true => space
.filter(|s| s.mine)
.map_or(p.path.clone(), |s| s.path.clone()),
@@ -1149,7 +1139,7 @@ fn live_props(me: &Me, space: Option<&Space>, res: &Res) -> Vec<Element> {
"calendar-home-set",
&home(PimKind::Calendar),
));
if p.me {
if p.mine {
let cal = home(PimKind::Calendar);
out.push(href_prop(
CALDAV,
@@ -1161,7 +1151,7 @@ fn live_props(me: &Me, space: Option<&Space>, res: &Res) -> Vec<Element> {
"schedule-outbox-URL",
&format!("{cal}{OUTBOX}/"),
));
let book = home(PimKind::AddressBook);
let book = home(PimKind::Addressbook);
out.push(href_prop(CARDDAV, "addressbook-home-set", &book));
out.push(href_prop(
CARDDAV,
@@ -1179,7 +1169,7 @@ fn live_props(me: &Me, space: Option<&Space>, res: &Res) -> Vec<Element> {
let c = &col.c;
let (types, desc) = match kind {
PimKind::Calendar => ((CALDAV, "calendar"), (CALDAV, "calendar-description")),
PimKind::AddressBook => (
PimKind::Addressbook => (
(CARDDAV, "addressbook"),
(CARDDAV, "addressbook-description"),
),
@@ -1243,7 +1233,7 @@ fn live_props(me: &Me, space: Option<&Space>, res: &Res) -> Vec<Element> {
// 3.0 only: a client told of 4.0 writes 4.0 groups, which
// Apple Contacts on the same account cannot read. A 4.0
// PUT is still stored, and served as 4.0 on request.
PimKind::AddressBook => out.push(with_children(
PimKind::Addressbook => out.push(with_children(
el(CARDDAV, "supported-address-data"),
[with_attr(
with_attr(
@@ -1369,7 +1359,7 @@ fn supported_reports(kind: PimKind) -> Element {
(CALDAV, "free-busy-query"),
(DAV, "sync-collection"),
],
PimKind::AddressBook => &[
PimKind::Addressbook => &[
(CARDDAV, "addressbook-multiget"),
(CARDDAV, "addressbook-query"),
(DAV, "sync-collection"),
@@ -1463,7 +1453,7 @@ fn sync_token(id: i64, seq: i64, issued: Option<i64>) -> String {
fn content_type(kind: PimKind, component: &str) -> String {
match kind {
PimKind::Calendar => format!("text/calendar; charset=utf-8; component={component}"),
PimKind::AddressBook => "text/vcard; charset=utf-8".to_string(),
PimKind::Addressbook => "text/vcard; charset=utf-8".to_string(),
}
}
@@ -1520,15 +1510,8 @@ impl Cx<'_> {
if !self.may_edit(s) {
return Ok(denied(&s.principal(), "write-properties"));
}
let view = PrincipalView {
id: s.id,
path: s.path.clone(),
display: s.display.clone(),
kind: s.kind,
me: s.mine,
};
let place = PropPlace::Principal(s.id);
(s.principal(), place, Res::Principal(view), None)
(s.principal(), place, Res::Principal(s.clone()), None)
}
_ => return Ok(status(StatusCode::FORBIDDEN)),
};
@@ -1550,7 +1533,10 @@ impl Cx<'_> {
default = Some(Ok(None));
}
}
let live: Vec<Name> = self.props(&res).iter().map(Name::of).collect();
let live: Vec<Name> = live_props(self.me, self.space, &res)
.iter()
.map(Name::of)
.collect();
let stored = self.state.db.pim_props(place).await?;
let mut patch = apply(
col.as_mut().map(|(k, c)| (*k, c)),
@@ -1604,12 +1590,8 @@ impl Cx<'_> {
/// The id of the own calendar at `href` that can receive invitations:
/// stored, not the inbox, taking events.
async fn receiving_calendar(&self, href: &str) -> Result<Option<i64>, ApiError> {
let path = match href.starts_with('/') {
true => href.to_string(),
false => match href.parse::<axum::http::Uri>() {
Ok(u) => u.path().to_string(),
Err(_) => return Ok(None),
},
let Some(path) = href_path(href) else {
return Ok(None);
};
let space = self.space();
let slug = match path.strip_prefix(PIM).and_then(parse_target) {
@@ -1679,7 +1661,7 @@ impl Cx<'_> {
slug: slug.clone(),
components: match kind {
PimKind::Calendar => "VEVENT,VTODO,VJOURNAL".to_string(),
PimKind::AddressBook => String::new(),
PimKind::Addressbook => String::new(),
},
..Default::default()
};
@@ -1691,7 +1673,10 @@ impl Cx<'_> {
owner: space.principal(),
},
);
let live: Vec<Name> = self.props(&res).iter().map(Name::of).collect();
let live: Vec<Name> = live_props(self.me, self.space, &res)
.iter()
.map(Name::of)
.collect();
let patch = apply(Some((*kind, &mut col)), &update, true, &live, &[]);
if !patch.ok() {
let root = match calendar {
@@ -1887,7 +1872,8 @@ fn apply(
patch
}
/// `#RRGGBB` or `#RRGGBBAA`, with `#RGB` widened to `#RRGGBB`.
/// `#rgb`, `#rrggbb` or `#rrggbbaa`: what clients write to `calendar-color`.
/// `#rgb` is widened to `#rrggbb`.
pub(super) fn color(v: &str) -> Option<String> {
let hex = v
.strip_prefix('#')
@@ -1919,34 +1905,21 @@ fn set_own(
) -> Option<bool> {
let cal = kind == PimKind::Calendar;
let value = || Some(xml::text(p)).filter(|v| !v.is_empty());
let short = |v: &Option<String>, max: usize, lines: bool| {
v.as_ref().is_none_or(|v| valid_text(v, max, lines))
let set_text = |field: &mut Option<String>, max: usize, lines: bool| {
let v = value();
let valid = v.as_ref().is_none_or(|v| valid_text(v, max, lines));
if valid {
*field = v;
}
valid
};
let description = match cal {
true => (CALDAV, "calendar-description"),
false => (CARDDAV, "addressbook-description"),
};
Some(match (name.ns.as_str(), name.local.as_str()) {
(DAV, "displayname") => {
let v = value();
let valid = short(&v, MAX_DISPLAYNAME, false);
if valid {
col.displayname = v;
}
valid
}
(CALDAV, "calendar-description") if cal => {
let v = value();
let valid = short(&v, MAX_DESCRIPTION, true);
if valid {
col.description = v;
}
valid
}
(CARDDAV, "addressbook-description") if !cal => {
let v = value();
let valid = short(&v, MAX_DESCRIPTION, true);
if valid {
col.description = v;
}
valid
}
(DAV, "displayname") => set_text(&mut col.displayname, MAX_DISPLAYNAME, false),
n if n == description => set_text(&mut col.description, MAX_DESCRIPTION, true),
(APPLE, "calendar-color") if cal => match value() {
None => {
col.color = None;
@@ -1980,7 +1953,7 @@ fn set_own(
(DAV, "resourcetype") if creating => {
let wanted = match kind {
PimKind::Calendar => (CALDAV, "calendar"),
PimKind::AddressBook => (CARDDAV, "addressbook"),
PimKind::Addressbook => (CARDDAV, "addressbook"),
};
xml::child(p, wanted.0, wanted.1).is_some()
}
@@ -2050,7 +2023,7 @@ impl Cx<'_> {
let Some((o, mut data)) = found else {
return Ok(status(StatusCode::NOT_FOUND));
};
if *kind == PimKind::AddressBook {
if *kind == PimKind::Addressbook {
let accept = headers.get("accept").and_then(|v| v.to_str().ok());
let req = render::AddressData {
props: None,
@@ -2125,7 +2098,7 @@ impl Cx<'_> {
let supported: Vec<&str> = components.split(',').collect();
object::calendar(&data, &supported).map(|o| (o.uid, o.component.to_string()))
}
PimKind::AddressBook => {
PimKind::Addressbook => {
object::vcard(&data).map(|uid| (uid.unwrap_or(name_c), "VCARD".into()))
}
};
@@ -2165,22 +2138,10 @@ impl Cx<'_> {
&& (*kind == PimKind::Calendar || object::vcard(stored).is_ok_and(|u| u.is_some()))
});
if renamed {
return Ok(error(
StatusCode::FORBIDDEN,
with_children(
el(ns, "no-uid-conflict"),
hrefs([space.object(*kind, slug, name).as_str()]),
),
));
return Ok(uid_conflict(ns, &space.object(*kind, slug, name)));
}
if let Some(holder) = db.pim_uid_holder(col.id, &uid, name).await? {
return Ok(error(
StatusCode::FORBIDDEN,
with_children(
el(ns, "no-uid-conflict"),
hrefs([space.object(*kind, slug, &holder).as_str()]),
),
));
return Ok(uid_conflict(ns, &space.object(*kind, slug, &holder)));
}
let stored = match kind {
PimKind::Calendar => {
@@ -2193,50 +2154,39 @@ impl Cx<'_> {
Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)),
}
}
PimKind::AddressBook => Stored {
data: data.to_vec(),
changed: false,
schedule_tag: None,
ops: Vec::new(),
},
PimKind::Addressbook => pim_schedule::unchanged(data, None),
};
let etag = etag_of(&stored.data);
let mut ops = vec![PimOp::Put {
collection_id: col.id,
obj: PimObject {
name: name.clone(),
uid,
component,
etag: etag.clone(),
schedule_tag: stored.schedule_tag.clone(),
..Default::default()
},
data: stored.data,
}];
ops.extend(stored.ops);
db.pim_apply(&ops).await?;
let (changed, schedule_tag) = (stored.changed, stored.schedule_tag.clone());
let obj = PimObject {
name: name.clone(),
uid,
component,
..Default::default()
};
db.pim_apply(&stored.into_ops(col.id, obj)).await?;
let code = match current {
Some(_) => StatusCode::NO_CONTENT,
None => StatusCode::CREATED,
};
let mut r = status(code);
// Only when the stored bytes are the request bytes (RFC 4791, 5.3.4).
if !stored.changed && stamped.is_none() {
if !changed && stamped.is_none() {
r.headers_mut()
.insert(ETAG, etag.parse().expect("hex is a valid header"));
}
with_schedule_tag(&mut r, stored.schedule_tag.as_deref());
with_schedule_tag(&mut r, schedule_tag.as_deref());
Ok(r)
}
/// The signed-in account writing into a calendar of `owner`.
fn writer<'a>(&self, owner: &'a PimPrincipal, access: Access) -> Writer<'a> {
Writer {
Writer::new(
owner,
may_schedule: access >= Access::Schedule,
sent_by: (access != Access::Own).then(|| self.me.address.clone()),
quiet: false,
}
self.me.pid,
&self.me.name,
access >= Access::Schedule,
)
}
/// The principal owning a collection, whose addresses decide how it takes
@@ -2388,7 +2338,7 @@ impl Cx<'_> {
Report::AddressbookMultiget { .. } | Report::AddressbookQuery { .. }
);
if (calendar_report && *kind != PimKind::Calendar)
|| (card_report && *kind != PimKind::AddressBook)
|| (card_report && *kind != PimKind::Addressbook)
{
return unsupported();
}
@@ -2665,7 +2615,7 @@ impl Cx<'_> {
let mut responses = Vec::new();
let mut truncated = false;
for p in self.state.db.pim_principals(true).await? {
let view = PrincipalView::of(&p, self.me);
let view = Space::of(&p, self.me);
let addresses = view.addresses();
let candidate = Principal {
name: &p.name,
@@ -2684,7 +2634,7 @@ impl Cx<'_> {
responses.push(select(
href,
&search.find,
self.props(&Res::Principal(view)),
live_props(self.me, self.space, &Res::Principal(view)),
));
}
if truncated {
@@ -2698,12 +2648,8 @@ impl Cx<'_> {
/// `(collection slug, object name)` of an href to an object of `kind` in
/// the space of this request. Takes a path or a full URL.
fn own_object(&self, kind: PimKind, href: &str) -> Option<(String, String)> {
let path = match href.starts_with('/') {
true => href.to_string(),
false => href.parse::<axum::http::Uri>().ok()?.path().to_string(),
};
let space = self.space?;
match parse_target(path.strip_prefix(PIM)?)? {
match parse_target(href_path(href)?.strip_prefix(PIM)?)? {
Target::Object(k, owner, slug, name)
if k == kind && owner.eq_ignore_ascii_case(&space.path) =>
{
@@ -2933,26 +2879,19 @@ impl Cx<'_> {
&precondition(headers),
)
.await?;
let tagged = |code| {
let mut r = status(code);
with_schedule_tag(&mut r, obj.schedule_tag.as_deref());
r
};
Ok(match written {
PimWrite::Created | PimWrite::Updated => {
let code = match written {
PimWrite::Created => StatusCode::CREATED,
_ => StatusCode::NO_CONTENT,
};
let mut r = status(code);
with_schedule_tag(&mut r, obj.schedule_tag.as_deref());
r
}
PimWrite::Created => tagged(StatusCode::CREATED),
PimWrite::Updated => tagged(StatusCode::NO_CONTENT),
PimWrite::NotFound => status(StatusCode::NOT_FOUND),
PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED),
PimWrite::UidConflict(holder) => error(
StatusCode::FORBIDDEN,
with_children(
el(kind_ns(*kind), "no-uid-conflict"),
hrefs([space.object(*kind, &to_slug, &holder).as_str()]),
),
),
PimWrite::Deleted => status(StatusCode::INTERNAL_SERVER_ERROR),
PimWrite::UidConflict(holder) => {
uid_conflict(kind_ns(*kind), &space.object(*kind, &to_slug, &holder))
}
})
}
}
▾Mserver/src/api/pim_api.rs
@@ -11,7 +11,6 @@
//! - `POST {PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}` — import a file
//! - `POST {PIM_IMPORT_NEW}` — import a file as a new collection
//! - `GET {PIM_COLLECTIONS}/{id}{EXPORT_SUFFIX}` — download
//! - `GET {PIM_SYSTEM_EXPORT}` — the same for the system address book
//!
//! - `GET {PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{name}{PHOTO_SUFFIX}` — a contact's photo
//!
@@ -22,8 +21,8 @@ use std::sync::Arc;
use api_types::{
AdminPimLink, CreatePimCollection, CreatePimLink, CreatePimShare, FEED, OkResp,
PimCollectionInfo, PimCollectionKind, PimImportNew, PimImportResult, PimLend, PimLinkInfo,
PimOwnShares, PimShareCandidate, PimShareInfo, PimShareMode, PimSkipped, UpdatePimCollection,
PimCollectionInfo, PimImportNew, PimImportResult, PimLend, PimLinkInfo, PimOwnShares,
PimShareCandidate, PimShareInfo, PimShareMode, PimSkipped, UpdatePimCollection,
};
use axum::Json;
use axum::body::Body;
@@ -32,22 +31,21 @@ use axum::http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, ETAG,
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use pimdav::bundle::{self, Detail};
use pimdav::principal::UserType;
use pimdav::{contact, object};
use sha2::{Digest, Sha256};
use crate::api::common::{SessionUser, blocking, hash_password, validate_password};
use crate::api::common::{SessionUser, blocking, optional_password_hash};
use crate::api::dav::challenge;
use crate::api::files::disposition;
use crate::api::pim::{
BIRTHDAYS, BIRTHDAYS_SLUG, DIRECTORY, DIRECTORY_SLUG, INBOX, MAX_COLLECTIONS, MAX_DESCRIPTION,
MAX_DISPLAYNAME, MAX_RESOURCE_SIZE, OUTBOX, SHARED_PREFIX, collection_href, color as hex_color,
delete_own, etag_of, generated, mailto, members_of, valid_text,
delete_own, generated, members_of, valid_text,
};
use crate::api::pim_schedule::{self, Directory, object_name};
use crate::api::pim_schedule::{self, Directory, extension, object_name};
use crate::api::pim_views;
use crate::auth;
use crate::db::{PimCollection, PimKind, PimLink, PimObject, PimOp, PropPlace, User};
use crate::db::{PimCollection, PimKind, PimLink, PimObject, PropPlace, User};
use crate::error::{ApiError, AppState};
/// The largest file an import reads.
@@ -62,13 +60,6 @@ const MAX_SPLIT: usize = 128 * 1024 * 1024;
/// How many skipped objects an import names.
const MAX_SKIPPED: usize = 100;
pub(super) fn wire_kind(kind: PimKind) -> PimCollectionKind {
match kind {
PimKind::Calendar => PimCollectionKind::Calendar,
PimKind::AddressBook => PimCollectionKind::Addressbook,
}
}
fn name_of(c: &PimCollection) -> String {
c.displayname.clone().unwrap_or_else(|| c.slug.clone())
}
@@ -83,7 +74,7 @@ fn info(
) -> PimCollectionInfo {
PimCollectionInfo {
id: c.id,
kind: wire_kind(kind),
kind,
name: name_of(c),
url,
owner: owner.to_string(),
@@ -119,7 +110,7 @@ pub async fn list(
.map(|c| c.id);
let counts = state.db.pim_share_counts(pid).await?;
let mut out = Vec::new();
for kind in [PimKind::Calendar, PimKind::AddressBook] {
for kind in [PimKind::Calendar, PimKind::Addressbook] {
for c in state.db.pim_collections(pid, kind).await? {
if kind == PimKind::Calendar && c.slug == INBOX {
continue;
@@ -135,7 +126,7 @@ pub async fn list(
}
let (slug, generated) = match kind {
PimKind::Calendar => (BIRTHDAYS_SLUG, generated_info(BIRTHDAYS)),
PimKind::AddressBook => (DIRECTORY_SLUG, generated_info(DIRECTORY)),
PimKind::Addressbook => (DIRECTORY_SLUG, generated_info(DIRECTORY)),
};
let url = collection_href(&me.name, kind, slug, None);
out.push(info(&generated, kind, url, &me.name, None));
@@ -174,14 +165,6 @@ fn generated_info(id: i64) -> PimCollection {
}
}
fn db_kind(kind: PimCollectionKind) -> PimKind {
match kind {
PimCollectionKind::Calendar => PimKind::Calendar,
PimCollectionKind::Addressbook => PimKind::AddressBook,
}
}
/// `#rgb`, `#rrggbb` or `#rrggbbaa`: what clients write to `calendar-color`.
fn bad_request(msg: &str) -> ApiError {
ApiError::new(StatusCode::BAD_REQUEST, msg)
}
@@ -200,7 +183,7 @@ fn slug_of(name: &str, kind: PimKind) -> String {
match slug.trim_end_matches('-') {
"" => match kind {
PimKind::Calendar => "calendar".to_string(),
PimKind::AddressBook => "contacts".to_string(),
PimKind::Addressbook => "contacts".to_string(),
},
s => s.to_string(),
}
@@ -219,7 +202,7 @@ pub async fn create(
let info = create_collection(
&state,
&auth.user,
db_kind(body.kind),
body.kind,
&body.name,
color,
body.description
@@ -270,7 +253,7 @@ async fn create_collection(
}
comps.join(",")
}
PimKind::AddressBook => String::new(),
PimKind::Addressbook => String::new(),
};
let base = slug_of(name, kind);
// A suffix would turn "shared" into the lent form "shared-2".
@@ -320,12 +303,7 @@ pub async fn update(
) -> Result<Json<PimCollectionInfo>, ApiError> {
// A DELETE in between would leave the default on a removed calendar.
let _lock = pim_schedule::LOCK.lock().await;
let id = own(&state, &auth, id).await?;
let (_, kind, mut col) = state
.db
.pim_collection_by_id(id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let (kind, mut col) = own(&state, &auth, id).await?;
let before = col.clone();
if let Some(name) = body.name {
let name = name.trim();
@@ -413,12 +391,16 @@ pub async fn delete(
}
}
/// The id of a collection the signed-in user owns, or 404.
async fn own(state: &AppState, auth: &SessionUser, id: i64) -> Result<i64, ApiError> {
/// A collection the signed-in user owns, or 404.
async fn own(
state: &AppState,
auth: &SessionUser,
id: i64,
) -> Result<(PimKind, PimCollection), ApiError> {
let pid = state.db.principal_of(auth.user.id).await?;
match state.db.pim_collection_by_id(id).await? {
// The inbox is not lent: it holds messages, not events.
Some((owner, _, c)) if owner == pid && c.slug != INBOX => Ok(id),
Some((owner, kind, c)) if owner == pid && c.slug != INBOX => Ok((kind, c)),
_ => Err(ApiError::new(StatusCode::NOT_FOUND, "collection not found")),
}
}
@@ -429,7 +411,7 @@ pub async fn shares(
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<Vec<PimShareInfo>>, ApiError> {
let id = own(&state, &auth, id).await?;
own(&state, &auth, id).await?;
let out = state
.db
.pim_shares(id)
@@ -453,7 +435,7 @@ pub async fn share_candidates(
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<Vec<PimShareCandidate>>, ApiError> {
let id = own(&state, &auth, id).await?;
own(&state, &auth, id).await?;
let out = state
.db
.pim_share_candidates(id, auth.user.id)
@@ -474,7 +456,7 @@ pub async fn share(
// PUT checks the access again under LOCK, so a narrower share applies at
// once. Under it, the collection cannot go before the share is written.
let _lock = pim_schedule::LOCK.lock().await;
let id = own(&state, &auth, id).await?;
own(&state, &auth, id).await?;
let name = body.user.trim();
let found = match state.db.pim_principal(name).await? {
Some(p) => p.user_id.map(|uid| (uid, p.name)),
@@ -510,7 +492,7 @@ pub async fn unshare(
auth: SessionUser,
AxumPath((id, user_id)): AxumPath<(i64, i64)>,
) -> Result<Json<OkResp>, ApiError> {
let id = own(&state, &auth, id).await?;
own(&state, &auth, id).await?;
let _lock = pim_schedule::LOCK.lock().await;
if !state.db.pim_remove_share(id, user_id).await? {
return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
@@ -530,7 +512,7 @@ pub(super) async fn reachable(
if generated(id) {
let (kind, col) = match id {
BIRTHDAYS => (PimKind::Calendar, generated_info(BIRTHDAYS)),
DIRECTORY => (PimKind::AddressBook, generated_info(DIRECTORY)),
DIRECTORY => (PimKind::Addressbook, generated_info(DIRECTORY)),
_ => return Err(not_found()),
};
return Ok((pid, kind, col, false));
@@ -548,8 +530,10 @@ pub(super) async fn reachable(
}
match state
.db
.pim_shared_collection(auth.user.id, kind, id)
.pim_shared_collections(auth.user.id, kind)
.await?
.into_iter()
.find(|(c, ..)| c.id == id)
{
Some((_, _, mode)) => Ok((owner, kind, c, mode != PimShareMode::Ro)),
None => Err(not_found()),
@@ -569,7 +553,7 @@ pub async fn photo(
) -> Result<Response, ApiError> {
let no_photo = || ApiError::new(StatusCode::NOT_FOUND, "no photo");
let (_, kind, _, _) = reachable(&state, &auth, id).await?;
if kind != PimKind::AddressBook {
if kind != PimKind::Addressbook {
return Err(no_photo());
}
let (obj, data) = state.db.pim_object(id, &name).await?.ok_or_else(no_photo)?;
@@ -593,13 +577,6 @@ pub async fn photo(
Ok((cached, [(CONTENT_TYPE, "image/webp")], bytes).into_response())
}
fn extension(kind: PimKind) -> &'static str {
match kind {
PimKind::Calendar => "ics",
PimKind::AddressBook => "vcf",
}
}
pub(super) fn link_info(link: &PimLink, kind: PimKind) -> PimLinkInfo {
PimLinkInfo {
id: link.id,
@@ -616,7 +593,7 @@ pub fn feed_entry(r: crate::db::PimLinkWithOwner) -> AdminPimLink {
link: link_info(&r.link, r.kind),
collection_id: r.link.collection_id,
collection_name: r.collection_name,
kind: wire_kind(r.kind),
kind: r.kind,
owner_id: r.owner_id,
owner_name: r.owner_name,
owner_active: r.owner_active,
@@ -638,7 +615,7 @@ pub async fn own_shares(
|(collection_id, collection_name, kind, user_id, user_name, mode)| PimLend {
collection_id,
collection_name,
kind: wire_kind(kind),
kind,
share: PimShareInfo {
user_id,
user_name,
@@ -656,12 +633,7 @@ pub async fn links(
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<Vec<PimLinkInfo>>, ApiError> {
let id = own(&state, &auth, id).await?;
let (_, kind, _) = state
.db
.pim_collection_by_id(id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let (kind, _) = own(&state, &auth, id).await?;
let links = state.db.pim_links(id).await?;
Ok(Json(links.iter().map(|l| link_info(l, kind)).collect()))
}
@@ -693,21 +665,10 @@ pub async fn create_link(
Ok(_) => {}
}
}
let password_hash = match body.password.as_deref().map(str::trim) {
Some(pw) if !pw.is_empty() => {
validate_password(pw)?;
Some(hash_password(pw).await?)
}
_ => None,
};
let password_hash = optional_password_hash(body.password.as_deref()).await?;
// The count and the insert hold the lock, so the cap holds.
let _lock = pim_schedule::LOCK.lock().await;
let id = own(&state, &auth, id).await?;
let (_, kind, _) = state
.db
.pim_collection_by_id(id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let (kind, _) = own(&state, &auth, id).await?;
if body.busy_only && kind != PimKind::Calendar {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
@@ -740,8 +701,8 @@ pub async fn delete_link(
auth: SessionUser,
AxumPath((id, link_id)): AxumPath<(i64, i64)>,
) -> Result<Json<OkResp>, ApiError> {
let id = own(&state, &auth, id).await?;
if !state.db.pim_delete_link(id, link_id).await? {
own(&state, &auth, id).await?;
if !state.db.pim_delete_link(link_id, Some(id)).await? {
return Err(ApiError::new(StatusCode::NOT_FOUND, "link not found"));
}
Ok(Json(OkResp {}))
@@ -763,26 +724,12 @@ pub async fn feed(
if link.is_expired() {
return Ok(StatusCode::GONE.into_response());
}
// Basic with the user name ignored, like a protected share mount.
if let Some(hash) = link.password_hash.clone() {
let Some((_, password)) = auth::basic_credentials(&headers) else {
return Ok(challenge());
};
// The hash is of the trimmed password, as for file shares.
let password = password.trim();
let (pw, id, tok) = (password.to_string(), link.id, link.token.clone());
// A negative realm: share ids are positive, and one share's password
// must never open a feed with the same id.
let ok = auth::verify_cached(-link.id, "", password, move || async move {
auth::throttle(&tok).await;
let ok = auth::verify_password_async(&pw, &hash).await;
auth::record_login(&tok, ok);
ok.then_some(id)
})
.await;
if ok.is_none() {
return Ok(challenge());
}
// A negative realm: share ids are positive, and one share's password
// must never open a feed with the same id.
if let Some(hash) = &link.password_hash
&& !auth::basic_share_ok(&headers, -link.id, &link.token, hash).await
{
return Ok(challenge());
}
let Some((owner, kind, col)) = state.db.pim_collection_by_id(link.collection_id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
@@ -823,7 +770,7 @@ pub async fn feed(
fn mime(kind: PimKind) -> &'static str {
match kind {
PimKind::Calendar => "text/calendar; charset=utf-8",
PimKind::AddressBook => "text/vcard; charset=utf-8",
PimKind::Addressbook => "text/vcard; charset=utf-8",
}
}
@@ -844,7 +791,7 @@ async fn render(
let texts: Vec<&str> = texts.iter().map(String::as_str).collect();
Ok(match kind {
PimKind::Calendar => bundle::calendar(&texts, Some(&name), detail),
PimKind::AddressBook => bundle::cards(&texts),
PimKind::Addressbook => bundle::cards(&texts),
})
})
.await
@@ -861,26 +808,6 @@ pub async fn export(
Ok(download(kind, &name_of(&col), body))
}
/// GET {PIM_SYSTEM_EXPORT}
pub async fn export_system(
State(state): State<Arc<AppState>>,
_auth: SessionUser,
) -> Result<Response, ApiError> {
let (col, body) = system_cards(&state).await?;
Ok(download(PimKind::AddressBook, &name_of(&col), body))
}
async fn system_cards(state: &AppState) -> Result<(PimCollection, String), ApiError> {
let col = crate::api::pim::directory_collection(state).await?;
let members = crate::api::pim::directory(state).await?;
let texts: Vec<String> = members
.into_iter()
.map(|(_, d)| String::from_utf8_lossy(&d).into_owned())
.collect();
let texts: Vec<&str> = texts.iter().map(String::as_str).collect();
Ok((col, bundle::cards(&texts)))
}
fn download(kind: PimKind, name: &str, body: String) -> Response {
let file = format!("{}.{}", name.replace(['/', '\\'], "_"), extension(kind));
(
@@ -915,7 +842,7 @@ pub async fn import(
#[derive(serde::Deserialize)]
pub struct ImportNewQuery {
kind: PimCollectionKind,
kind: PimKind,
name: Option<String>,
file: Option<String>,
color: Option<String>,
@@ -930,12 +857,12 @@ pub async fn import_new(
Query(q): Query<ImportNewQuery>,
body: Body,
) -> Result<Json<PimImportNew>, ApiError> {
let kind = db_kind(q.kind);
let kind = q.kind;
let text = read_import(body).await?;
let (parts, (own_name, own_color)) = blocking(move || -> Result<_, ApiError> {
let meta = match kind {
PimKind::Calendar => bundle::calendar_meta(&text),
PimKind::AddressBook => (None, None),
PimKind::Addressbook => (None, None),
};
Ok((split_import(kind, &text)?, meta))
})
@@ -1002,7 +929,7 @@ fn split_import(kind: PimKind, text: &str) -> Result<Vec<String>, ApiError> {
)
})?
}
PimKind::AddressBook => bundle::split_cards(text, &mut new_uid),
PimKind::Addressbook => bundle::split_cards(text, &mut new_uid),
};
if parts.is_empty() {
return Err(ApiError::new(
@@ -1052,13 +979,7 @@ async fn import_parts(
.get(owner)
.cloned()
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let mut w = pim_schedule::Writer {
owner: &owner,
may_schedule,
sent_by: (owner.id != me)
.then(|| format!("mailto:{}", mailto(&auth.user.name, UserType::Individual))),
quiet: false,
};
let mut w = pim_schedule::Writer::new(&owner, me, &auth.user.name, may_schedule);
let mut result = PimImportResult {
created: 0,
updated: 0,
@@ -1124,33 +1045,23 @@ async fn import_parts(
}
}
}
PimKind::AddressBook => pim_schedule::Stored {
data,
changed: false,
schedule_tag: None,
ops: Vec::new(),
},
PimKind::Addressbook => pim_schedule::unchanged(&data, None),
};
match existing {
Some(_) => updated += 1,
None => created += 1,
}
names.insert(uid.clone(), name.clone());
ops.push(PimOp::Put {
collection_id: col.id,
obj: PimObject {
name,
uid,
component,
etag: etag_of(&stored.data),
schedule_tag: stored.schedule_tag,
..Default::default()
},
data: stored.data,
});
let more = !stored.ops.is_empty();
let obj = PimObject {
name,
uid,
component,
..Default::default()
};
ops.extend(stored.into_ops(col.id, obj));
// Later parts see the copies and room bookings this one wrote.
if !stored.ops.is_empty() {
ops.extend(stored.ops);
if more {
state.db.pim_apply(&std::mem::take(&mut ops)).await?;
}
}
@@ -1183,7 +1094,7 @@ fn check_part(
PimKind::Calendar => {
object::calendar(part.as_bytes(), supported).map(|o| (o.uid, o.component.to_string()))
}
PimKind::AddressBook => {
PimKind::Addressbook => {
object::vcard(part.as_bytes()).map(|u| (u.unwrap_or_default(), "VCARD".into()))
}
};
@@ -1192,7 +1103,7 @@ fn check_part(
PimKind::Calendar => {
object::with_dtstamp(part.as_bytes(), now).unwrap_or_else(|| part.into_bytes())
}
PimKind::AddressBook => part.into_bytes(),
PimKind::Addressbook => part.into_bytes(),
};
Ok((uid, component, data))
}
▾Mserver/src/api/pim_schedule.rs
@@ -30,7 +30,7 @@ use tokio::sync::Mutex;
use xmltree::Element;
use super::pim::{
INBOX, MAIL_DOMAIN, OUTBOX, collection_href, etag_of, local_part, need_privilege,
INBOX, MAIL_DOMAIN, OUTBOX, collection_href, etag_of, local_part, mailto, need_privilege,
principal_name, principal_uuid, seg,
};
use crate::api::common::blocking;
@@ -73,6 +73,23 @@ pub(crate) struct Writer<'a> {
}
impl Writer<'_> {
/// The account `me` (principal id and name) writing into a calendar of
/// `owner`.
pub(crate) fn new<'a>(
owner: &'a PimPrincipal,
me: i64,
name: &str,
may_schedule: bool,
) -> Writer<'a> {
Writer {
owner,
may_schedule,
sent_by: (owner.id != me)
.then(|| format!("mailto:{}", mailto(name, UserType::Individual))),
quiet: false,
}
}
/// The owner itself.
pub(crate) fn owner(owner: &PimPrincipal) -> Writer<'_> {
Writer {
@@ -226,6 +243,24 @@ pub(crate) struct Stored {
pub ops: Vec<PimOp>,
}
impl Stored {
/// The write of this as `obj` into the collection, then the writes it
/// causes. Sets the ETag and Schedule-Tag of `obj`.
pub(crate) fn into_ops(self, collection_id: i64, obj: PimObject) -> Vec<PimOp> {
let mut ops = vec![PimOp::Put {
collection_id,
obj: PimObject {
etag: etag_of(&self.data),
schedule_tag: self.schedule_tag,
..obj
},
data: self.data,
}];
ops.extend(self.ops);
ops
}
}
/// A PUT of `body` over `old` into collection `at.0` under the name `at.1`.
/// `Err` names a failed scheduling precondition.
pub(crate) async fn put(
@@ -372,14 +407,19 @@ fn only_sequence(
/// The resource name the server picks for an object it creates.
pub(crate) fn object_name(uid: &str, kind: PimKind) -> String {
let ext = match kind {
let hash = crate::hex(&Sha256::digest(uid));
format!("{}.{}", &hash[..32], extension(kind))
}
/// The file extension of an object or a whole collection of `kind`.
pub(crate) fn extension(kind: PimKind) -> &'static str {
match kind {
PimKind::Calendar => "ics",
PimKind::AddressBook => "vcf",
};
format!("{}.{ext}", &crate::hex(&Sha256::digest(uid))[..32])
PimKind::Addressbook => "vcf",
}
}
fn unchanged(body: &[u8], schedule_tag: Option<String>) -> Stored {
pub(crate) fn unchanged(body: &[u8], schedule_tag: Option<String>) -> Stored {
Stored {
data: body.to_vec(),
changed: false,
@@ -442,7 +482,7 @@ async fn removed(
let mut ops = Vec::new();
match role {
Some(Role::Organizer) => {
let (_, messages) = itip::organize(Some(&old), None, &owns, now);
let messages = itip::messages(Some(&old), None, &owns, &[], now);
if !messages.is_empty() && !w.may_schedule {
return Ok(Err(w.refused("schedule-send-invite")));
}
▾Mserver/src/api/pim_views.rs
@@ -23,7 +23,6 @@ use pimdav::calcard::icalendar::{
};
use pimdav::expand::expand;
use pimdav::itip::{self, Role};
use pimdav::principal::UserType;
use pimdav::render;
use pimdav::view::{self, Card, EventInfo, Person};
use pimdav::zone::{self, Zone};
@@ -31,10 +30,10 @@ use serde::Deserialize;
use crate::api::common::SessionUser;
use crate::api::common::blocking;
use crate::api::pim::{BIRTHDAYS, DIRECTORY, INBOX, etag_of, generated, mailto, members_of, seg};
use crate::api::pim::{BIRTHDAYS, DIRECTORY, INBOX, generated, member_of, members_of, seg};
use crate::api::pim_api::reachable;
use crate::api::pim_schedule::{self, Directory, Writer};
use crate::db::{PimKind, PimObject, PimOp};
use crate::db::{PimKind, PimObject};
use crate::error::{ApiError, AppState};
/// The widest range `GET {PIM_INSTANCES}` expands.
@@ -83,7 +82,7 @@ async fn readable(
.collect();
out.push(match kind {
PimKind::Calendar => (BIRTHDAYS, pid),
PimKind::AddressBook => (DIRECTORY, pid),
PimKind::Addressbook => (DIRECTORY, pid),
});
for (col, _, _) in db.pim_shared_collections(auth.user.id, kind).await? {
if let Some((owner, _, _)) = db.pim_collection_by_id(col.id).await? {
@@ -214,14 +213,8 @@ pub async fn object(
Query(q): Query<DetailQuery>,
) -> Result<Json<PimObjectDetail>, ApiError> {
let not_found = || ApiError::new(StatusCode::NOT_FOUND, "object not found");
let (owner, kind, col, writable) = reachable(&state, &auth, id).await?;
let found = match generated(col.id) {
true => members_of(&state, owner, col.id)
.await?
.into_iter()
.find(|(o, _)| o.name == name),
false => state.db.pim_object(col.id, &name).await?,
};
let (owner, kind, col, _) = reachable(&state, &auth, id).await?;
let found = member_of(&state, owner, col.id, &name).await?;
let (obj, data) = &found.ok_or_else(not_found)?;
match kind {
PimKind::Calendar => {
@@ -275,11 +268,10 @@ pub async fn object(
.partstat()
.map(str::to_string)
}),
can_edit: writable,
can_reply: attendee && answers,
})))
}
PimKind::AddressBook => {
PimKind::Addressbook => {
let card = view::card(&String::from_utf8_lossy(data));
let members = match card.is_group {
true => {
@@ -304,7 +296,7 @@ pub async fn object(
)
});
Ok(Json(PimObjectDetail::Contact(contact_detail(
id, obj, card, members, photo_url, writable,
id, obj, card, members, photo_url,
))))
}
}
@@ -325,7 +317,6 @@ fn contact_detail(
card: Card,
members: Vec<String>,
photo_url: Option<String>,
can_edit: bool,
) -> PimContactDetail {
PimContactDetail {
collection_id: id,
@@ -344,7 +335,6 @@ fn contact_detail(
is_group: card.is_group,
members,
photo_url,
can_edit,
}
}
@@ -364,9 +354,10 @@ pub(super) async fn may_answer(
}
Ok(state
.db
.pim_shared_collection(auth.user.id, PimKind::Calendar, collection_id)
.pim_shared_collections(auth.user.id, PimKind::Calendar)
.await?
.is_some_and(|(_, _, mode)| mode == PimShareMode::RwSchedule))
.into_iter()
.any(|(c, _, mode)| c.id == collection_id && mode == PimShareMode::RwSchedule))
}
#[derive(Deserialize)]
@@ -384,7 +375,7 @@ pub async fn contacts(
let needle = q.q.as_deref().map(str::trim).unwrap_or("").to_lowercase();
let wanted = wanted(q.collections.as_deref());
let mut sources = Vec::new();
for (id, owner) in readable(&state, &auth, PimKind::AddressBook).await? {
for (id, owner) in readable(&state, &auth, PimKind::Addressbook).await? {
if wanted.as_ref().is_some_and(|w| !w.contains(&id)) {
continue;
}
@@ -544,13 +535,7 @@ pub async fn reply(
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "instance not found"))?;
let new = render::write(&new);
let me = state.db.principal_of(auth.user.id).await?;
let w = Writer {
owner: &principal,
may_schedule: true,
sent_by: (me != owner)
.then(|| format!("mailto:{}", mailto(&auth.user.name, UserType::Individual))),
quiet: false,
};
let w = Writer::new(&principal, me, &auth.user.name, true);
let stored = match pim_schedule::put(
&state,
&dir,
@@ -564,16 +549,6 @@ pub async fn reply(
Ok(s) => s,
Err(condition) => return Err(ApiError::new(StatusCode::FORBIDDEN, &condition.name)),
};
let mut ops = vec![PimOp::Put {
collection_id: col.id,
obj: PimObject {
etag: etag_of(&stored.data),
schedule_tag: stored.schedule_tag.clone(),
..obj
},
data: stored.data,
}];
ops.extend(stored.ops);
state.db.pim_apply(&ops).await?;
state.db.pim_apply(&stored.into_ops(col.id, obj)).await?;
Ok(Json(OkResp {}))
}
▾Mserver/src/api/shares.rs
@@ -19,8 +19,8 @@ use axum::http::header::{HeaderMap, SET_COOKIE};
use axum::response::{IntoResponse, Response};
use crate::api::common::{
SessionUser, blocking, display_name, hash_password, live_share, share_is_locked, target_rel,
validate_password,
SessionUser, blocking, display_name, live_share, optional_password_hash, share_is_locked,
target_rel,
};
use crate::api::files::find_root;
use crate::auth;
@@ -81,13 +81,7 @@ pub async fn create(
// Validated and hashed before the row is written, so a rejected
// password cannot leave a half-made share behind.
let password_hash = match body.password.as_deref().map(str::trim) {
Some(pw) if !pw.is_empty() => {
validate_password(pw)?;
Some(hash_password(pw).await?)
}
_ => None,
};
let password_hash = optional_password_hash(body.password.as_deref()).await?;
let root = find_root(&auth.roots, body.root_id)?;
@@ -136,7 +130,7 @@ pub async fn delete(
auth: SessionUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<OkResp>, ApiError> {
if !state.db.delete_share(id, auth.user.id).await? {
if !state.db.delete_share(id, Some(auth.user.id)).await? {
return Err(ApiError::localized(
StatusCode::NOT_FOUND,
"share not found",
▾Mserver/src/archive.rs
@@ -73,10 +73,6 @@ pub fn build(
}
}
fn mtime_secs(m: &std::fs::Metadata) -> u64 {
crate::fs::mtime_secs(m).unwrap_or(0).max(0) as u64
}
/// Cycle guard: a symlink loop would otherwise recurse forever. Real trees
/// this deep are not worth archiving, so deeper levels are dropped.
const MAX_DEPTH: usize = 64;
@@ -151,7 +147,7 @@ fn tar_add<W: Write>(
let mut header = tar::Header::new_gnu();
let meta = std::fs::metadata(abs)?;
header.set_mode(if is_dir { 0o755 } else { 0o644 });
header.set_mtime(mtime_secs(&meta));
header.set_mtime(crate::fs::mtime_secs(&meta).unwrap_or(0).max(0) as u64);
if is_dir {
header.set_entry_type(tar::EntryType::Directory);
header.set_size(0);
@@ -183,17 +179,11 @@ fn build_zip<W: Write>(dir: &Path, top: &str, sink: W) -> io::Result<()> {
let mut zip = zip::write::ZipWriter::new_stream(sink);
let mut add = |entry: &str, abs: &Path, is_dir: bool| -> io::Result<()> {
let opts = zip::write::SimpleFileOptions::default();
let name = if is_dir {
format!("{entry}/")
} else {
entry.to_string()
};
if is_dir {
zip.add_directory(&name, opts)?;
zip.add_directory(format!("{entry}/"), opts)?;
} else {
zip.start_file(&name, opts)?;
let mut f = std::fs::File::open(abs)?;
io::copy(&mut f, &mut zip)?;
zip.start_file(entry, opts)?;
io::copy(&mut std::fs::File::open(abs)?, &mut zip)?;
}
Ok(())
};
▾Mserver/src/auth.rs
@@ -45,13 +45,13 @@ pub fn verify_password(password: &str, hash: &str) -> bool {
/// 32 random bytes, hex-encoded (64 chars).
pub fn random_token() -> String {
hex_token(32)
crate::hex(&random_bytes::<32>())
}
/// 16 random bytes, hex-encoded (32 chars). Used for public share links and
/// app password secrets.
pub fn short_token() -> String {
hex_token(16)
crate::hex(&random_bytes::<16>())
}
/// The stored form of an app password secret.
@@ -63,12 +63,6 @@ pub fn app_password_hash(secret: &str) -> String {
crate::hex(&Sha256::digest(secret.as_bytes()))
}
fn hex_token(bytes: usize) -> String {
let mut b = vec![0u8; bytes];
getrandom::fill(&mut b).expect("OS random source");
crate::hex(&b)
}
fn random_bytes<const N: usize>() -> [u8; N] {
let mut b = [0u8; N];
getrandom::fill(&mut b).expect("OS random source");
@@ -211,6 +205,33 @@ pub fn basic_credentials(headers: &axum::http::HeaderMap) -> Option<(String, Str
Some((auth.username().to_string(), auth.password().to_string()))
}
/// Whether the request carries the password of a protected share or feed,
/// over Basic with the user name ignored: there is no account behind a link
/// to name. The hash is of the trimmed password. `realm` keeps one link's
/// password from opening another.
pub async fn basic_share_ok(
headers: &axum::http::HeaderMap,
realm: i64,
token: &str,
hash: &str,
) -> bool {
let Some((_, password)) = basic_credentials(headers) else {
return false;
};
let password = password.trim();
let (pw, tok, hash) = (password.to_string(), token.to_string(), hash.to_string());
verify_cached(realm, "", password, move || async move {
// Throttled like `POST /api/share/{token}/unlock`, keyed the same
// way, so a mount client is not the cheap way to guess.
throttle(&tok).await;
let ok = verify_password_async(&pw, &hash).await;
record_login(&tok, ok);
ok.then_some(realm)
})
.await
.is_some()
}
/// `Max-Age` is `None` for a browser-session cookie.
fn cookie(name: &str, value: &str, max_age: Option<u64>, https: bool) -> String {
let mut c = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
▾Mserver/src/db.rs
@@ -1,52 +1,22 @@
use std::path::Path;
use std::sync::Arc;
pub use api_types::{AppPasswordInfo, AuthMode, Mode, PimShareMode};
pub use api_types::{AppPasswordInfo, AuthMode, Mode, PimCollectionKind as PimKind, PimShareMode};
pub use pimdav::principal::UserType;
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
use rusqlite::{Connection, OptionalExtension, Params, params};
use webauthn_rs::prelude::Uuid;
const SCHEMA_VERSION: i64 = 16;
/// SQL adapter for reading a [`Mode`]. A newtype is needed because both the
/// rusqlite traits and `Mode` are foreign to this crate. Writes bind
/// `Mode::as_str` directly.
///
/// The stored strings are unchanged ("rw"/"ro"), so old databases still read.
struct SqlMode(Mode);
impl FromSql for SqlMode {
fn column_result(v: ValueRef<'_>) -> FromSqlResult<Self> {
let s = v.as_str()?;
Mode::from_wire(s)
.map(SqlMode)
.ok_or_else(|| FromSqlError::Other(format!("unknown mode {s:?}").into()))
}
}
/// SQL adapter for [`PimShareMode`], for the same reason as [`SqlMode`].
struct SqlShareMode(PimShareMode);
impl FromSql for SqlShareMode {
fn column_result(v: ValueRef<'_>) -> FromSqlResult<Self> {
let s = v.as_str()?;
PimShareMode::from_wire(s)
.map(SqlShareMode)
.ok_or_else(|| FromSqlError::Other(format!("unknown share mode {s:?}").into()))
}
}
/// SQL adapter for [`AuthMode`], for the same reason as [`SqlMode`].
struct SqlAuthMode(AuthMode);
impl FromSql for SqlAuthMode {
fn column_result(v: ValueRef<'_>) -> FromSqlResult<Self> {
let s = v.as_str()?;
AuthMode::from_wire(s)
.map(SqlAuthMode)
.ok_or_else(|| FromSqlError::Other(format!("unknown auth mode {s:?}").into()))
}
/// Reads a text column through `parse`. The wire types are foreign to this
/// crate, so they cannot implement `FromSql`.
fn wire<T>(r: &rusqlite::Row, i: usize, parse: fn(&str) -> Option<T>) -> DbResult<T> {
let s: String = r.get(i)?;
parse(&s).ok_or_else(|| {
rusqlite::Error::FromSqlConversionFailure(
i,
rusqlite::types::Type::Text,
format!("unknown value {s:?}").into(),
)
})
}
#[derive(Debug, Clone)]
@@ -199,25 +169,18 @@ pub struct ShareWithCreator {
pub creator_active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PimKind {
Calendar,
AddressBook,
}
impl PimKind {
fn parse(s: &str) -> Self {
match s {
"cal" => PimKind::Calendar,
_ => PimKind::AddressBook,
}
fn pim_kind(s: &str) -> Option<PimKind> {
match s {
"cal" => Some(PimKind::Calendar),
"card" => Some(PimKind::Addressbook),
_ => None,
}
}
fn as_str(self) -> &'static str {
match self {
PimKind::Calendar => "cal",
PimKind::AddressBook => "card",
}
fn pim_kind_str(kind: PimKind) -> &'static str {
match kind {
PimKind::Calendar => "cal",
PimKind::Addressbook => "card",
}
}
@@ -254,7 +217,7 @@ impl PropPlace {
fn key(self) -> String {
match self {
PropPlace::Principal(id) => format!("principal:{id}"),
PropPlace::Home(id, kind) => format!("home-{}:{id}", kind.as_str()),
PropPlace::Home(id, kind) => format!("home-{}:{id}", pim_kind_str(kind)),
PropPlace::Collection(id) => format!("collection:{id}"),
}
}
@@ -318,7 +281,6 @@ pub enum PimOp {
pub enum PimWrite {
Created,
Updated,
Deleted,
NotFound,
PreconditionFailed,
/// Another object in the collection has this UID, under this name.
@@ -356,17 +318,13 @@ impl Precondition {
}
}
/// The columns [`map_user`] reads, in order. Every SELECT that builds a
/// [`User`] uses one of these two, so a new column is added in one place.
/// `USER_COLS_U` is the same list qualified for the queries that join
/// `users u` against `user_roots`.
const USER_COLS: &str = "id, name, is_admin != 0, active != 0, single_click != 0,
thumbnails != 0, language, default_root_id, auth_mode, pass_hash != '', week_start";
const USER_COLS_U: &str = "u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0,
u.thumbnails != 0, u.language, u.default_root_id, u.auth_mode, u.pass_hash != '',
u.week_start";
/// How many columns the two lists above cover. The joined queries read the
/// root columns starting here.
/// The columns [`map_user`] reads, in order. Qualified by the table name,
/// not an alias, so `RETURNING` can use them too.
const USER_COLS: &str = "users.id, users.name, users.is_admin != 0, users.active != 0,
users.single_click != 0, users.thumbnails != 0, users.language, users.default_root_id,
users.auth_mode, users.pass_hash != '', users.week_start";
/// How many columns [`USER_COLS`] covers. The joined queries read the root
/// columns starting here.
const USER_COL_COUNT: usize = 11;
/// Every query can fail, and every caller decides what to do about it.
@@ -410,7 +368,7 @@ impl Db {
"CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
[],
)?;
let version: i64 = conn
let version: usize = conn
.query_row(
"SELECT value FROM meta WHERE key = 'schema_version'",
[],
@@ -421,253 +379,55 @@ impl Db {
.unwrap_or(0);
let tx = conn.unchecked_transaction()?;
if version < 1 {
conn.execute_batch(SCHEMA_V1)?;
}
if version < 2 {
// User management (M7): a disabled flag so admins can suspend
// accounts without deleting them.
conn.execute_batch("ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1")?;
}
if version < 3 {
// Per-user profile settings: click-to-open mode. No migration
// from the old behaviour — everyone starts on the new default
// (off: single click selects, double click opens).
conn.execute_batch(
"ALTER TABLE users ADD COLUMN single_click INTEGER NOT NULL DEFAULT 0",
)?;
}
if version < 4 {
// Per-user UI language preference; NULL means "follow the
// browser".
conn.execute_batch("ALTER TABLE users ADD COLUMN language TEXT")?;
}
if version < 5 {
// The share list is queried by creator on every shares page.
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_shares_creator ON shares(creator_id)",
)?;
}
if version < 6 {
// Unlocks cascade with their share, which cascades with its
// creator's account.
conn.execute_batch(
"ALTER TABLE shares ADD COLUMN password_hash TEXT;
CREATE TABLE IF NOT EXISTS share_unlocks (
token TEXT PRIMARY KEY,
share_id INTEGER NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);",
)?;
}
if version < 7 {
// `delete_share` cascades into share_unlocks, which is a full
// scan of that table without this.
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_share_unlocks_share
ON share_unlocks(share_id)",
)?;
}
if version < 8 {
// On by default, so `--cache` is the only step needed to get
// thumbnails.
conn.execute_batch(
"ALTER TABLE users ADD COLUMN thumbnails INTEGER NOT NULL DEFAULT 1",
)?;
}
if version < 9 {
// Per-user default root. No foreign key on purpose: removing a
// root must not fail because of this column.
conn.execute_batch("ALTER TABLE users ADD COLUMN default_root_id INTEGER")?;
}
if version < 10 {
// Passkeys, and how they combine with the password.
//
// `webauthn_id` is the WebAuthn user handle: a random uuid the
// authenticator stores inside a discoverable credential and hands
// back at sign-in. It must never change once a passkey exists, or
// that passkey can no longer be traced to its account. Filled in
// lazily on the first registration, so accounts that never use a
// passkey keep it NULL.
conn.execute_batch(
"ALTER TABLE users ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'either';
ALTER TABLE users ADD COLUMN webauthn_id TEXT;
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
cred_id BLOB NOT NULL UNIQUE,
passkey TEXT NOT NULL,
name TEXT NOT NULL,
discoverable INTEGER,
created_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_webauthn_id
ON users(webauthn_id) WHERE webauthn_id IS NOT NULL;",
)?;
}
if version < 11 {
// `secret_hash` is UNIQUE because the lookup keys on it.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS app_passwords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
secret_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_app_passwords_user
ON app_passwords(user_id);",
)?;
}
if version < 12 {
// CalDAV and CardDAV. A principal owns collections: every account
// has one, and rooms and resources are principals without an
// account, so they share the account name space but no account
// query can return them. The trigger gives each new account its
// principal and refuses a name a room already has.
//
// `seq` counts every change to a collection and its members;
// `pim_changes` keeps the latest change per member, deletions
// included, for sync tokens. `pim_shares` lends a collection to
// another account. `schedule_tag` is NULL for objects that
// schedule nothing. A `transparent` calendar adds no busy time
// to scheduling (RFC 6638 `schedule-calendar-transp`).
// `pim_links` are public feeds; not rows of `shares`, because
// every path-based share query would then have to skip them.
// `pim_props` holds the properties clients set that the server
// does not interpret, as XML, per collection, home or principal.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS principals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('person','room','resource')),
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT,
CHECK ((kind = 'person') = (user_id IS NOT NULL))
);
INSERT INTO principals (user_id, kind, name)
SELECT id, 'person', name FROM users;
CREATE TRIGGER IF NOT EXISTS principal_of_user AFTER INSERT ON users
BEGIN
INSERT INTO principals (user_id, kind, name)
VALUES (NEW.id, 'person', NEW.name);
END;
CREATE TABLE IF NOT EXISTS pim_collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
principal_id INTEGER NOT NULL
REFERENCES principals(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('cal','card')),
slug TEXT NOT NULL,
displayname TEXT,
description TEXT,
color TEXT,
timezone TEXT,
sort_order TEXT,
components TEXT NOT NULL DEFAULT '',
transparent INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE (principal_id, kind, slug)
);
CREATE TABLE IF NOT EXISTS pim_objects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
uid TEXT NOT NULL,
component TEXT NOT NULL,
data BLOB NOT NULL,
etag TEXT NOT NULL,
modified_at TEXT NOT NULL,
schedule_tag TEXT,
UNIQUE (collection_id, name),
UNIQUE (collection_id, uid)
);
CREATE TABLE IF NOT EXISTS pim_changes (
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
seq INTEGER NOT NULL,
deleted INTEGER NOT NULL,
PRIMARY KEY (collection_id, name)
);
CREATE INDEX IF NOT EXISTS idx_pim_changes_seq
ON pim_changes(collection_id, seq);
CREATE TABLE IF NOT EXISTS pim_shares (
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mode TEXT NOT NULL CHECK (mode IN ('ro','rw','rw+schedule')),
PRIMARY KEY (collection_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_pim_shares_user ON pim_shares(user_id);
CREATE TABLE IF NOT EXISTS pim_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
busy_only INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
expires_at TEXT,
password_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_pim_links_collection
ON pim_links(collection_id);
CREATE TABLE IF NOT EXISTS pim_props (
place TEXT NOT NULL,
principal_id INTEGER REFERENCES principals(id) ON DELETE CASCADE,
collection_id INTEGER REFERENCES pim_collections(id) ON DELETE CASCADE,
ns TEXT NOT NULL,
name TEXT NOT NULL,
xml TEXT NOT NULL,
PRIMARY KEY (place, ns, name),
CHECK ((principal_id IS NULL) != (collection_id IS NULL))
);",
)?;
}
if version < 13 {
// Monday, the ISO 8601 week start.
conn.execute_batch(
"ALTER TABLE users ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1",
)?;
}
if version < 14 {
// The calendar that receives invitations (RFC 6638,
// schedule-default-calendar-URL). NULL: the oldest one.
conn.execute_batch(
"ALTER TABLE principals ADD COLUMN default_calendar_id INTEGER
REFERENCES pim_collections(id) ON DELETE SET NULL",
)?;
}
if version < 15 {
// Sync tokens below this are refused: the tombstones they need are gone.
conn.execute_batch(
"ALTER TABLE pim_collections ADD COLUMN pruned_seq INTEGER NOT NULL DEFAULT 0",
)?;
}
if version < 16 {
// The cascades from a deleted principal or collection.
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_pim_props_principal ON pim_props(principal_id);
CREATE INDEX IF NOT EXISTS idx_pim_props_collection ON pim_props(collection_id);",
)?;
for sql in MIGRATIONS.iter().skip(version) {
conn.execute_batch(sql)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
[MIGRATIONS.len().to_string()],
)?;
tx.commit()
}
async fn exec(&self, sql: &str, p: impl Params + Send) -> DbResult<usize> {
self.conn.lock().await.prepare_cached(sql)?.execute(p)
}
async fn row<T>(
&self,
sql: &str,
p: impl Params + Send,
f: impl FnOnce(&rusqlite::Row) -> DbResult<T> + Send,
) -> DbResult<Option<T>> {
self.conn
.lock()
.await
.prepare_cached(sql)?
.query_row(p, f)
.optional()
}
async fn rows<T>(
&self,
sql: &str,
p: impl Params + Send,
f: impl FnMut(&rusqlite::Row) -> DbResult<T> + Send,
) -> DbResult<Vec<T>> {
self.conn
.lock()
.await
.prepare_cached(sql)?
.query_map(p, f)?
.collect()
}
// ---------- users ----------
pub async fn user_count(&self) -> DbResult<i64> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached("SELECT COUNT(*) FROM users")?;
stmt.query_row([], |r| r.get(0))
Ok(self
.row("SELECT COUNT(*) FROM users", [], |r| r.get(0))
.await?
.unwrap_or(0))
}
/// Create the first admin account with the whole root visible (read-write).
@@ -679,35 +439,36 @@ impl Db {
pub async fn create_admin(&self, name: &str, pass_hash: &str) -> DbResult<Option<User>> {
let mut c = self.conn.lock().await;
let tx = c.transaction()?;
let inserted = tx.execute(
"INSERT INTO users (name, pass_hash, is_admin, created_at)
SELECT ?1, ?2, 1, ?3 WHERE NOT EXISTS (SELECT 1 FROM users)",
params![name, pass_hash, now()],
)?;
if inserted == 0 {
let user = tx
.query_row(
&format!(
"INSERT INTO users (name, pass_hash, is_admin, created_at)
SELECT ?1, ?2, 1, ?3 WHERE NOT EXISTS (SELECT 1 FROM users)
RETURNING {USER_COLS}"
),
params![name, pass_hash, now()],
map_user,
)
.optional()?;
let Some(user) = user else {
return Ok(None); // dropping `tx` rolls back
}
let user_id = tx.last_insert_rowid();
tx.execute(
"INSERT INTO user_roots (user_id, path, mode) VALUES (?1, '.', 'rw')",
params![user_id],
)?;
};
insert_roots(&tx, user.id, &[(".".to_string(), Mode::Rw)])?;
tx.commit()?;
Ok(Some(new_user(user_id, name, true)))
Ok(Some(user))
}
pub async fn verify_password(&self, name: &str, password: &str) -> DbResult<Option<User>> {
// The guard is scoped to the query alone. Argon2 below is slow by
// design; holding the single connection lock across it would make one
// login serialize every other database access.
let row: Option<(User, String)> = {
let c = self.conn.lock().await;
c.prepare_cached(&format!(
"SELECT {USER_COLS}, pass_hash FROM users WHERE name = ?1"
))?
.query_row([name], |r| Ok((map_user(r)?, r.get(USER_COL_COUNT)?)))
.optional()?
};
let row: Option<(User, String)> = self
.row(
&format!("SELECT {USER_COLS}, pass_hash FROM users WHERE name = ?1"),
[name],
|r| Ok((map_user(r)?, r.get(USER_COL_COUNT)?)),
)
.await?;
// An unknown name, a disabled account and a passkey-only account all
// still pay for one Argon2 verify, so the response time does not tell
// them apart from a real account with a wrong password.
@@ -720,18 +481,18 @@ impl Db {
}
pub async fn create_session(&self, user_id: i64, token: &str) -> DbResult<()> {
let c = self.conn.lock().await;
c.execute(
self.exec(
"INSERT INTO sessions (token, user_id, created_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4)",
params![token, user_id, now(), now()],
)?;
(token, user_id, now(), now()),
)
.await?;
Ok(())
}
pub async fn delete_session(&self, token: &str) -> DbResult<()> {
let c = self.conn.lock().await;
c.execute("DELETE FROM sessions WHERE token = ?1", [token])?;
self.exec("DELETE FROM sessions WHERE token = ?1", [token])
.await?;
Ok(())
}
@@ -743,11 +504,11 @@ impl Db {
) -> DbResult<Option<(User, Vec<RootRow>)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {USER_COLS_U}, r.id, r.path, r.mode
"SELECT {USER_COLS}, r.id, r.path, r.mode
FROM sessions s
JOIN users u ON u.id = s.user_id
LEFT JOIN user_roots r ON r.user_id = u.id
WHERE s.token = ?1 AND u.active = 1
JOIN users ON users.id = s.user_id
LEFT JOIN user_roots r ON r.user_id = users.id
WHERE s.token = ?1 AND users.active = 1
ORDER BY r.id",
))?;
// One row per root; a user without roots still returns one row, with
@@ -759,13 +520,7 @@ impl Db {
if user.is_none() {
user = Some(map_user(r)?);
}
if let Some(id) = r.get::<_, Option<i64>>(USER_COL_COUNT)? {
roots.push(RootRow {
id,
path: r.get(USER_COL_COUNT + 1)?,
mode: r.get::<_, SqlMode>(USER_COL_COUNT + 2)?.0,
});
}
roots.extend(map_root_at(r, USER_COL_COUNT)?);
}
Ok(user.map(|u| (u, roots)))
}
@@ -773,18 +528,14 @@ impl Db {
// ---------- roots ----------
pub async fn user_roots(&self, user_id: i64) -> DbResult<Vec<RootRow>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(
"SELECT id, path, mode FROM user_roots WHERE user_id = ?1 ORDER BY id",
)?;
let rows = stmt.query_map([user_id], |r| {
Ok(RootRow {
id: r.get(0)?,
path: r.get(1)?,
mode: r.get::<_, SqlMode>(2)?.0,
})
})?;
rows.collect()
let roots = self
.rows(
"SELECT id, path, mode FROM user_roots WHERE user_id = ?1 ORDER BY id",
[user_id],
|r| map_root_at(r, 0),
)
.await?;
Ok(roots.into_iter().flatten().collect())
}
// ---------- admin: user management (M7) ----------
@@ -794,10 +545,10 @@ impl Db {
pub async fn all_users_with_roots(&self) -> DbResult<Vec<(User, Vec<RootRow>)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {USER_COLS_U}, r.id, r.path, r.mode
FROM users u
LEFT JOIN user_roots r ON r.user_id = u.id
ORDER BY u.id, r.id",
"SELECT {USER_COLS}, r.id, r.path, r.mode
FROM users
LEFT JOIN user_roots r ON r.user_id = users.id
ORDER BY users.id, r.id",
))?;
// Rows arrive grouped by user, so a new user id starts a new group.
let mut out: Vec<(User, Vec<RootRow>)> = Vec::new();
@@ -807,41 +558,39 @@ impl Db {
if out.last().is_none_or(|(u, _)| u.id != uid) {
out.push((map_user(r)?, Vec::new()));
}
if let Some(id) = r.get::<_, Option<i64>>(USER_COL_COUNT)? {
out.last_mut().expect("pushed above").1.push(RootRow {
id,
path: r.get(USER_COL_COUNT + 1)?,
mode: r.get::<_, SqlMode>(USER_COL_COUNT + 2)?.0,
});
}
let roots = &mut out.last_mut().expect("pushed above").1;
roots.extend(map_root_at(r, USER_COL_COUNT)?);
}
Ok(out)
}
pub async fn find_user_by_id(&self, id: i64) -> DbResult<Option<User>> {
let c = self.conn.lock().await;
c.prepare_cached(&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"))?
.query_row([id], map_user)
.optional()
self.row(
&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"),
[id],
map_user,
)
.await
}
pub async fn find_user_by_name(&self, name: &str) -> DbResult<Option<User>> {
let c = self.conn.lock().await;
c.query_row(
self.row(
&format!("SELECT {USER_COLS} FROM users WHERE name = ?1"),
[name],
map_user,
)
.optional()
.await
}
pub async fn count_admins(&self) -> DbResult<i64> {
let c = self.conn.lock().await;
c.query_row(
"SELECT COUNT(*) FROM users WHERE is_admin = 1 AND active = 1",
[],
|r| r.get(0),
)
Ok(self
.row(
"SELECT COUNT(*) FROM users WHERE is_admin = 1 AND active = 1",
[],
|r| r.get(0),
)
.await?
.unwrap_or(0))
}
/// Create a user with the given roots (path, mode) pairs.
@@ -854,38 +603,35 @@ impl Db {
) -> DbResult<User> {
let mut c = self.conn.lock().await;
let tx = c.transaction()?;
tx.execute(
"INSERT INTO users (name, pass_hash, is_admin, active, created_at)
VALUES (?1, ?2, ?3, 1, ?4)",
let user = tx.query_row(
&format!(
"INSERT INTO users (name, pass_hash, is_admin, active, created_at)
VALUES (?1, ?2, ?3, 1, ?4) RETURNING {USER_COLS}"
),
params![name, pass_hash, is_admin as i64, now()],
map_user,
)?;
let user_id = tx.last_insert_rowid();
for (path, mode) in roots {
tx.execute(
"INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
params![user_id, path, mode.as_str()],
)?;
}
insert_roots(&tx, user.id, roots)?;
tx.commit()?;
Ok(new_user(user_id, name, is_admin))
Ok(user)
}
/// Write the profile settings a user edits for themselves.
pub async fn set_user_profile(&self, u: &User) -> DbResult<()> {
let c = self.conn.lock().await;
c.execute(
self.exec(
"UPDATE users SET single_click = ?1, thumbnails = ?2, language = ?3,
default_root_id = ?4, week_start = ?5
WHERE id = ?6",
params![
(
u.single_click,
u.thumbnails,
u.language,
u.language.as_deref(),
u.default_root_id,
u.week_start,
u.id
],
)?;
u.id,
),
)
.await?;
Ok(())
}
@@ -929,12 +675,7 @@ impl Db {
}
if let Some(roots) = roots {
tx.execute("DELETE FROM user_roots WHERE user_id = ?1", [id])?;
for (path, mode) in roots {
tx.execute(
"INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
params![id, path, mode.as_str()],
)?;
}
insert_roots(&tx, id, roots)?;
}
tx.commit()
}
@@ -949,11 +690,11 @@ impl Db {
/// Only for setting a real one. Clearing it is [`Db::clear_user_password`],
/// which has a rule to keep.
pub async fn set_password_keeping_sessions(&self, id: i64, pass_hash: &str) -> DbResult<()> {
let c = self.conn.lock().await;
c.execute(
self.exec(
"UPDATE users SET pass_hash = ?1 WHERE id = ?2",
params![pass_hash, id],
)?;
(pass_hash, id),
)
.await?;
Ok(())
}
@@ -985,11 +726,11 @@ impl Db {
/// Called after any credential change. Otherwise a session stolen before
/// the change keeps working for its full 30 days.
pub async fn delete_other_sessions(&self, user_id: i64, keep: &str) -> DbResult<()> {
let c = self.conn.lock().await;
c.execute(
self.exec(
"DELETE FROM sessions WHERE user_id = ?1 AND token != ?2",
params![user_id, keep],
)?;
(user_id, keep),
)
.await?;
Ok(())
}
@@ -1019,32 +760,32 @@ impl Db {
/// The account a discoverable credential's user handle points at.
pub async fn find_user_by_webauthn_id(&self, wid: &Uuid) -> DbResult<Option<User>> {
let c = self.conn.lock().await;
c.query_row(
self.row(
&format!("SELECT {USER_COLS} FROM users WHERE webauthn_id = ?1"),
[wid.to_string()],
map_user,
)
.optional()
.await
}
pub async fn user_passkeys(&self, user_id: i64) -> DbResult<Vec<PasskeyRow>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(
"SELECT id, name, created_at, last_used_at, discoverable, passkey
FROM passkeys WHERE user_id = ?1 ORDER BY id",
)?;
let rows = stmt.query_map([user_id], map_passkey)?;
rows.collect()
self.rows(
&format!("SELECT {PASSKEY_COLS} FROM passkeys WHERE user_id = ?1 ORDER BY id"),
[user_id],
map_passkey,
)
.await
}
pub async fn count_passkeys(&self, user_id: i64) -> DbResult<i64> {
let c = self.conn.lock().await;
c.query_row(
"SELECT COUNT(*) FROM passkeys WHERE user_id = ?1",
[user_id],
|r| r.get(0),
)
Ok(self
.row(
"SELECT COUNT(*) FROM passkeys WHERE user_id = ?1",
[user_id],
|r| r.get(0),
)
.await?
.unwrap_or(0))
}
/// The per-install secret behind the decoy credentials a named passkey
@@ -1080,11 +821,12 @@ impl Db {
/// would make a rare length as likely as a common one, and decoys that do
/// not match how the install actually looks are the thing worth avoiding.
pub async fn cred_id_lengths(&self) -> DbResult<Vec<usize>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare("SELECT length(cred_id) FROM passkeys ORDER BY id")?;
let rows = stmt.query_map([], |r| r.get::<_, i64>(0))?;
rows.map(|r| r.map(|n| n.max(1) as usize))
.collect::<Result<Vec<_>, _>>()
self.rows(
"SELECT length(cred_id) FROM passkeys ORDER BY id",
[],
|r| Ok(r.get::<_, i64>(0)?.max(1) as usize),
)
.await
}
/// Store a freshly registered passkey. A duplicate `cred_id` is a unique
@@ -1113,16 +855,12 @@ impl Db {
if held as usize >= PASSKEY_LIMIT {
return Ok(None);
}
tx.execute(
"INSERT INTO passkeys (user_id, cred_id, passkey, name, discoverable, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![user_id, cred_id, passkey, name, discoverable, now()],
)?;
let id = tx.last_insert_rowid();
let row = tx.query_row(
"SELECT id, name, created_at, last_used_at, discoverable, passkey
FROM passkeys WHERE id = ?1",
[id],
&format!(
"INSERT INTO passkeys (user_id, cred_id, passkey, name, discoverable, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6) RETURNING {PASSKEY_COLS}"
),
params![user_id, cred_id, passkey, name, discoverable, now()],
map_passkey,
)?;
tx.commit()?;
@@ -1150,24 +888,25 @@ impl Db {
/// Record a successful assertion: the re-serialized credential (its
/// signature counter and backup flags may have moved) and the time.
pub async fn passkey_used(&self, id: i64, passkey: &str) -> DbResult<()> {
let c = self.conn.lock().await;
c.execute(
self.exec(
"UPDATE passkeys SET passkey = ?1, last_used_at = ?2 WHERE id = ?3",
params![passkey, now(), id],
)?;
(passkey, now(), id),
)
.await?;
Ok(())
}
// ---------- app passwords (WebDAV) ----------
pub async fn app_passwords(&self, user_id: i64) -> DbResult<Vec<AppPasswordInfo>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(
"SELECT id, name, created_at, last_used_at
FROM app_passwords WHERE user_id = ?1 ORDER BY id",
)?;
let rows = stmt.query_map([user_id], map_app_password)?;
rows.collect()
self.rows(
&format!(
"SELECT {APP_PASSWORD_COLS} FROM app_passwords WHERE user_id = ?1 ORDER BY id"
),
[user_id],
map_app_password,
)
.await
}
/// `None` means the account is already at [`APP_PASSWORD_LIMIT`]. Count
@@ -1188,14 +927,12 @@ impl Db {
if held as usize >= APP_PASSWORD_LIMIT {
return Ok(None);
}
tx.execute(
"INSERT INTO app_passwords (user_id, name, secret_hash, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![user_id, name, secret_hash, now()],
)?;
let row = tx.query_row(
"SELECT id, name, created_at, last_used_at FROM app_passwords WHERE id = ?1",
[tx.last_insert_rowid()],
&format!(
"INSERT INTO app_passwords (user_id, name, secret_hash, created_at)
VALUES (?1, ?2, ?3, ?4) RETURNING {APP_PASSWORD_COLS}"
),
params![user_id, name, secret_hash, now()],
map_app_password,
)?;
tx.commit()?;
@@ -1207,11 +944,13 @@ impl Db {
/// No reachability check, unlike [`Db::delete_passkey`]: an app password
/// never signs in to the web UI.
pub async fn delete_app_password(&self, id: i64, user_id: i64) -> DbResult<bool> {
let c = self.conn.lock().await;
Ok(c.execute(
"DELETE FROM app_passwords WHERE id = ?1 AND user_id = ?2",
params![id, user_id],
)? > 0)
Ok(self
.exec(
"DELETE FROM app_passwords WHERE id = ?1 AND user_id = ?2",
(id, user_id),
)
.await?
> 0)
}
/// The account an app password opens. Stamps `last_used_at` on the way.
@@ -1222,10 +961,10 @@ impl Db {
let c = self.conn.lock().await;
let user = c
.prepare_cached(&format!(
"SELECT {USER_COLS_U}
"SELECT {USER_COLS}
FROM app_passwords a
JOIN users u ON u.id = a.user_id
WHERE a.secret_hash = ?1 AND u.active = 1"
JOIN users ON users.id = a.user_id
WHERE a.secret_hash = ?1 AND users.active = 1"
))?
.query_row([secret_hash], map_user)
.optional()?;
@@ -1244,9 +983,8 @@ impl Db {
/// The stored spelling of the name, which a Basic login may differ from
/// in case.
pub async fn user_name(&self, id: i64) -> DbResult<Option<String>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached("SELECT name FROM users WHERE id = ?1")?;
stmt.query_row([id], |r| r.get(0)).optional()
self.row("SELECT name FROM users WHERE id = ?1", [id], |r| r.get(0))
.await
}
/// Deletes an account after `ops`, in one transaction. The ops make other
@@ -1278,10 +1016,12 @@ impl Db {
password_hash: Option<&str>,
) -> DbResult<ShareRow> {
let c = self.conn.lock().await;
c.execute(
"INSERT INTO shares
(token, creator_id, target, is_file, mode, created_at, expires_at, password_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
c.query_row(
&format!(
"INSERT INTO shares
(token, creator_id, target, is_file, mode, created_at, expires_at, password_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) RETURNING {SHARE_COLS}"
),
params![
token,
creator_id,
@@ -1292,30 +1032,19 @@ impl Db {
expires_at,
password_hash
],
)?;
let id = c.last_insert_rowid();
Ok(ShareRow {
id,
token: token.to_string(),
creator_id,
target: target.to_string(),
is_file,
mode,
created_at: now(),
expires_at: expires_at.map(|s| s.to_string()),
password_hash: password_hash.map(|s| s.to_string()),
})
map_share,
)
}
/// Record that a visitor entered `share_id`'s password, and return the
/// token that proves it (the value of their unlock cookie).
pub async fn create_share_unlock(&self, share_id: i64) -> DbResult<String> {
let token = crate::auth::random_token();
let c = self.conn.lock().await;
c.execute(
self.exec(
"INSERT INTO share_unlocks (token, share_id, created_at) VALUES (?1, ?2, ?3)",
params![token, share_id, now()],
)?;
(&token, share_id, now()),
)
.await?;
Ok(token)
}
@@ -1377,32 +1106,32 @@ impl Db {
/// The share id is part of the lookup, so an unlock for one share cannot
/// open another.
pub async fn share_unlock_valid(&self, token: &str, share_id: i64) -> DbResult<bool> {
let c = self.conn.lock().await;
let mut stmt =
c.prepare_cached("SELECT 1 FROM share_unlocks WHERE token = ?1 AND share_id = ?2")?;
Ok(stmt
.query_row(params![token, share_id], |_| Ok(()))
.optional()?
Ok(self
.row(
"SELECT 1 FROM share_unlocks WHERE token = ?1 AND share_id = ?2",
(token, share_id),
|_| Ok(()),
)
.await?
.is_some())
}
pub async fn share_by_token(&self, token: &str) -> DbResult<Option<ShareRow>> {
let c = self.conn.lock().await;
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at,
password_hash
FROM shares WHERE token = ?1";
let mut stmt = c.prepare_cached(sql)?;
stmt.query_row([token], map_share).optional()
self.row(
&format!("SELECT {SHARE_COLS} FROM shares WHERE token = ?1"),
[token],
map_share,
)
.await
}
pub async fn user_shares(&self, creator_id: i64) -> DbResult<Vec<ShareRow>> {
let c = self.conn.lock().await;
let sql = "SELECT id, token, creator_id, target, is_file, mode, created_at, expires_at,
password_hash
FROM shares WHERE creator_id = ?1 ORDER BY id DESC";
let mut stmt = c.prepare_cached(sql)?;
let rows = stmt.query_map([creator_id], map_share)?;
rows.collect()
self.rows(
&format!("SELECT {SHARE_COLS} FROM shares WHERE creator_id = ?1 ORDER BY id DESC"),
[creator_id],
map_share,
)
.await
}
/// Revoke every share on `target` or on anything beneath it. Returns how
@@ -1416,22 +1145,24 @@ impl Db {
/// `substr` rather than `LIKE`: a target containing `%` or `_` would make
/// a `LIKE` pattern over-match and revoke unrelated shares.
pub async fn revoke_shares_at(&self, target: &str) -> DbResult<usize> {
let c = self.conn.lock().await;
c.execute(
self.exec(
"DELETE FROM shares
WHERE target = ?1 OR substr(target, 1, length(?1) + 1) = ?1 || '/'",
[target],
)
.await
}
/// Delete one of `creator_id`'s shares. `false` means no row matched.
pub async fn delete_share(&self, id: i64, creator_id: i64) -> DbResult<bool> {
let c = self.conn.lock().await;
let n = c.execute(
"DELETE FROM shares WHERE id = ?1 AND creator_id = ?2",
params![id, creator_id],
)?;
Ok(n > 0)
/// Delete a share: one of `creator`'s, or with `None` whoever created it.
/// `false` means no row matched.
pub async fn delete_share(&self, id: i64, creator: Option<i64>) -> DbResult<bool> {
Ok(self
.exec(
"DELETE FROM shares WHERE id = ?1 AND (?2 IS NULL OR creator_id = ?2)",
(id, creator),
)
.await?
> 0)
}
/// Every share on the server with its creator. Grouped by account name,
@@ -1440,30 +1171,23 @@ impl Db {
/// The join cannot miss: `shares.creator_id` cascades on delete, so a share
/// never outlives the account that made it.
pub async fn all_shares_with_creators(&self) -> DbResult<Vec<ShareWithCreator>> {
let c = self.conn.lock().await;
// Columns 0..8 are `map_share`'s order, unchanged from `user_shares`.
let sql = "SELECT s.id, s.token, s.creator_id, s.target, s.is_file, s.mode,
s.created_at, s.expires_at, s.password_hash,
u.name, u.active != 0
FROM shares s
JOIN users u ON u.id = s.creator_id
ORDER BY u.name COLLATE NOCASE, s.id DESC";
let mut stmt = c.prepare_cached(sql)?;
let rows = stmt.query_map([], |r| {
Ok(ShareWithCreator {
share: map_share(r)?,
creator_name: r.get(9)?,
creator_active: r.get(10)?,
})
})?;
rows.collect()
}
/// Revoke a share whoever created it. The owner-scoped
/// [`Self::delete_share`] is what the user-facing API uses.
pub async fn admin_delete_share(&self, id: i64) -> DbResult<bool> {
let c = self.conn.lock().await;
Ok(c.execute("DELETE FROM shares WHERE id = ?1", [id])? > 0)
self.rows(
&format!(
"SELECT {SHARE_COLS}, u.name, u.active != 0
FROM shares
JOIN users u ON u.id = shares.creator_id
ORDER BY u.name COLLATE NOCASE, shares.id DESC"
),
[],
|r| {
Ok(ShareWithCreator {
share: map_share(r)?,
creator_name: r.get(9)?,
creator_active: r.get(10)?,
})
},
)
.await
}
// ---------- settings ----------
@@ -1498,18 +1222,19 @@ impl Db {
}
pub async fn get_setting(&self, key: &str) -> DbResult<Option<String>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached("SELECT value FROM settings WHERE key = ?1")?;
stmt.query_row([key], |r| r.get(0)).optional()
self.row("SELECT value FROM settings WHERE key = ?1", [key], |r| {
r.get(0)
})
.await
}
pub async fn set_setting(&self, key: &str, value: &str) -> DbResult<()> {
let c = self.conn.lock().await;
c.execute(
self.exec(
"INSERT INTO settings (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = ?2",
params![key, value],
)?;
(key, value),
)
.await?;
Ok(())
}
@@ -1569,16 +1294,19 @@ impl Db {
principal_id: i64,
component: &str,
) -> DbResult<Option<PimCollection>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS} FROM pim_collections
WHERE principal_id = ?1 AND kind = 'cal' AND slug != 'inbox'
AND ',' || components || ',' LIKE '%,' || ?2 || ',%'
ORDER BY id IS NOT (SELECT default_calendar_id FROM principals WHERE id = ?1), id
LIMIT 1"
))?;
stmt.query_row(params![principal_id, component], map_pim_collection)
.optional()
self.row(
&format!(
"SELECT {PIM_COLLECTION_COLS} FROM pim_collections c
WHERE c.principal_id = ?1 AND c.kind = 'cal' AND c.slug != 'inbox'
AND ',' || c.components || ',' LIKE '%,' || ?2 || ',%'
ORDER BY c.id IS NOT (SELECT default_calendar_id FROM principals WHERE id = ?1),
c.id
LIMIT 1"
),
(principal_id, component),
map_pim_collection,
)
.await
}
/// The calendar [`Db::pim_calendar_for`] prefers. `None`: the oldest.
@@ -1587,11 +1315,11 @@ impl Db {
principal_id: i64,
collection_id: Option<i64>,
) -> DbResult<()> {
let c = self.conn.lock().await;
c.execute(
self.exec(
"UPDATE principals SET default_calendar_id = ?2 WHERE id = ?1",
params![principal_id, collection_id],
)?;
(principal_id, collection_id),
)
.await?;
Ok(())
}
@@ -1601,18 +1329,18 @@ impl Db {
principal_id: i64,
uid: &str,
) -> DbResult<Option<(i64, PimObject, Vec<u8>)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(
"SELECT o.name, o.uid, o.component, o.etag, length(o.data), o.modified_at,
o.schedule_tag, o.data, o.collection_id
FROM pim_objects o JOIN pim_collections c ON c.id = o.collection_id
WHERE c.principal_id = ?1 AND c.kind = 'cal' AND c.slug != 'inbox' AND o.uid = ?2
ORDER BY o.schedule_tag IS NULL, o.id LIMIT 1",
)?;
stmt.query_row(params![principal_id, uid], |r| {
Ok((r.get(8)?, map_pim_object(r)?, r.get(7)?))
})
.optional()
self.row(
&format!(
"SELECT {PIM_OBJECT_COLS}, o.data, o.collection_id
FROM pim_objects o JOIN pim_collections c ON c.id = o.collection_id
WHERE c.principal_id = ?1 AND c.kind = 'cal' AND c.slug != 'inbox'
AND o.uid = ?2
ORDER BY o.schedule_tag IS NULL, o.id LIMIT 1"
),
(principal_id, uid),
|r| Ok((r.get(8)?, map_pim_object(r)?, r.get(7)?)),
)
.await
}
/// The name of another object in the collection that has `uid`.
@@ -1622,12 +1350,12 @@ impl Db {
uid: &str,
name: &str,
) -> DbResult<Option<String>> {
let c = self.conn.lock().await;
c.prepare_cached(
self.row(
"SELECT name FROM pim_objects WHERE collection_id = ?1 AND uid = ?2 AND name != ?3",
)?
.query_row(params![collection_id, uid, name], |r| r.get(0))
.optional()
(collection_id, uid, name),
|r| r.get(0),
)
.await
}
/// Several object writes in one transaction.
@@ -1647,13 +1375,15 @@ impl Db {
principal_id: i64,
kind: PimKind,
) -> DbResult<Vec<PimCollection>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS} FROM pim_collections
WHERE principal_id = ?1 AND kind = ?2 ORDER BY id"
))?;
stmt.query_map(params![principal_id, kind.as_str()], map_pim_collection)?
.collect()
self.rows(
&format!(
"SELECT {PIM_COLLECTION_COLS} FROM pim_collections c
WHERE c.principal_id = ?1 AND c.kind = ?2 ORDER BY c.id"
),
(principal_id, pim_kind_str(kind)),
map_pim_collection,
)
.await
}
pub async fn pim_collection(
@@ -1662,16 +1392,15 @@ impl Db {
kind: PimKind,
slug: &str,
) -> DbResult<Option<PimCollection>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS} FROM pim_collections
WHERE principal_id = ?1 AND kind = ?2 AND slug = ?3"
))?;
stmt.query_row(
params![principal_id, kind.as_str(), slug],
self.row(
&format!(
"SELECT {PIM_COLLECTION_COLS} FROM pim_collections c
WHERE c.principal_id = ?1 AND c.kind = ?2 AND c.slug = ?3"
),
(principal_id, pim_kind_str(kind), slug),
map_pim_collection,
)
.optional()
.await
}
/// `false` if the slug is taken. `id` and `seq` of `new` are ignored.
@@ -1691,7 +1420,7 @@ impl Db {
ON CONFLICT (principal_id, kind, slug) DO NOTHING",
params![
principal_id,
kind.as_str(),
pim_kind_str(kind),
new.slug,
new.displayname,
new.description,
@@ -1771,33 +1500,30 @@ impl Db {
}
pub async fn pim_props(&self, place: PropPlace) -> DbResult<Vec<DeadProp>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(
self.rows(
"SELECT ns, name, xml FROM pim_props WHERE place = ?1 ORDER BY rowid",
)?;
stmt.query_map([place.key()], |r| {
Ok(DeadProp {
ns: r.get(0)?,
name: r.get(1)?,
xml: r.get(2)?,
})
})?
.collect()
}
pub async fn pim_delete_collection(&self, id: i64) -> DbResult<()> {
let c = self.conn.lock().await;
c.execute("DELETE FROM pim_collections WHERE id = ?1", [id])?;
self.forget_defaults();
Ok(())
[place.key()],
|r| {
Ok(DeadProp {
ns: r.get(0)?,
name: r.get(1)?,
xml: r.get(2)?,
})
},
)
.await
}
pub async fn pim_objects(&self, collection_id: i64) -> DbResult<Vec<PimObject>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_OBJECT_COLS} FROM pim_objects WHERE collection_id = ?1 ORDER BY name"
))?;
stmt.query_map([collection_id], map_pim_object)?.collect()
self.rows(
&format!(
"SELECT {PIM_OBJECT_COLS} FROM pim_objects o
WHERE o.collection_id = ?1 ORDER BY o.name"
),
[collection_id],
map_pim_object,
)
.await
}
pub async fn pim_object(
@@ -1805,27 +1531,30 @@ impl Db {
collection_id: i64,
name: &str,
) -> DbResult<Option<(PimObject, Vec<u8>)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_OBJECT_COLS}, data FROM pim_objects
WHERE collection_id = ?1 AND name = ?2"
))?;
stmt.query_row(params![collection_id, name], |r| {
Ok((map_pim_object(r)?, r.get(7)?))
})
.optional()
self.row(
&format!(
"SELECT {PIM_OBJECT_COLS}, o.data FROM pim_objects o
WHERE o.collection_id = ?1 AND o.name = ?2"
),
(collection_id, name),
|r| Ok((map_pim_object(r)?, r.get(7)?)),
)
.await
}
pub async fn pim_objects_with_data(
&self,
collection_id: i64,
) -> DbResult<Vec<(PimObject, Vec<u8>)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_OBJECT_COLS}, data FROM pim_objects WHERE collection_id = ?1 ORDER BY name"
))?;
stmt.query_map([collection_id], |r| Ok((map_pim_object(r)?, r.get(7)?)))?
.collect()
self.rows(
&format!(
"SELECT {PIM_OBJECT_COLS}, o.data FROM pim_objects o
WHERE o.collection_id = ?1 ORDER BY o.name"
),
[collection_id],
|r| Ok((map_pim_object(r)?, r.get(7)?)),
)
.await
}
/// `(name, seq, deleted)` of the members changed after `since`, oldest
@@ -1946,56 +1675,44 @@ impl Db {
/// An account, room or resource by URL name. Disabled accounts are
/// invisible.
pub async fn pim_principal(&self, name: &str) -> DbResult<Option<PimPrincipal>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE p.name = ?1 AND {VISIBLE}"
))?;
stmt.query_row([name], map_principal).optional()
self.row(
&format!("SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE p.name = ?1 AND {VISIBLE}"),
[name],
map_principal,
)
.await
}
/// Every principal, or with `visible_only` all but disabled accounts.
pub async fn pim_principals(&self, visible_only: bool) -> DbResult<Vec<PimPrincipal>> {
let c = self.conn.lock().await;
let filter = if visible_only { VISIBLE } else { "1" };
let mut stmt = c.prepare_cached(&format!(
"SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE {filter} ORDER BY p.id"
))?;
stmt.query_map([], map_principal)?.collect()
self.rows(
&format!("SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE {filter} ORDER BY p.id"),
[],
map_principal,
)
.await
}
/// The principal of an account.
pub async fn principal_of(&self, user_id: i64) -> DbResult<i64> {
let c = self.conn.lock().await;
c.prepare_cached("SELECT id FROM principals WHERE user_id = ?1")?
.query_row([user_id], |r| r.get(0))
self.row(
"SELECT id FROM principals WHERE user_id = ?1",
[user_id],
|r| r.get(0),
)
.await?
.ok_or(rusqlite::Error::QueryReturnedNoRows)
}
/// Whether an account, room or resource has this name.
pub async fn name_taken(&self, name: &str) -> DbResult<bool> {
let c = self.conn.lock().await;
c.prepare_cached("SELECT EXISTS (SELECT 1 FROM principals WHERE name = ?1)")?
.query_row([name], |r| r.get(0))
}
/// The collection `collection_id` as lent to `user_id`, with its owner's
/// name and the mode.
pub async fn pim_shared_collection(
&self,
user_id: i64,
kind: PimKind,
collection_id: i64,
) -> DbResult<Option<(PimCollection, String, PimShareMode)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS_C}, p.name, s.mode
FROM pim_shares s
JOIN pim_collections c ON c.id = s.collection_id
JOIN principals p ON p.id = c.principal_id
LEFT JOIN users u ON u.id = p.user_id
WHERE {VISIBLE} AND s.user_id = ?1 AND c.kind = ?2 AND c.id = ?3"
))?;
stmt.query_row(params![user_id, kind.as_str(), collection_id], map_shared)
.optional()
Ok(self
.row("SELECT 1 FROM principals WHERE name = ?1", [name], |_| {
Ok(())
})
.await?
.is_some())
}
/// Every collection of `kind` lent to `user_id`.
@@ -2004,17 +1721,25 @@ impl Db {
user_id: i64,
kind: PimKind,
) -> DbResult<Vec<(PimCollection, String, PimShareMode)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS_C}, p.name, s.mode
FROM pim_shares s
JOIN pim_collections c ON c.id = s.collection_id
JOIN principals p ON p.id = c.principal_id
LEFT JOIN users u ON u.id = p.user_id
WHERE {VISIBLE} AND s.user_id = ?1 AND c.kind = ?2 ORDER BY c.id"
))?;
stmt.query_map(params![user_id, kind.as_str()], map_shared)?
.collect()
self.rows(
&format!(
"SELECT {PIM_COLLECTION_COLS}, p.name, s.mode
FROM pim_shares s
JOIN pim_collections c ON c.id = s.collection_id
JOIN principals p ON p.id = c.principal_id
LEFT JOIN users u ON u.id = p.user_id
WHERE {VISIBLE} AND s.user_id = ?1 AND c.kind = ?2 ORDER BY c.id"
),
(user_id, pim_kind_str(kind)),
|r| {
Ok((
map_pim_collection(r)?,
r.get(10)?,
wire(r, 11, PimShareMode::from_wire)?,
))
},
)
.await
}
/// The owner and kind of a collection.
@@ -2022,18 +1747,15 @@ impl Db {
&self,
id: i64,
) -> DbResult<Option<(i64, PimKind, PimCollection)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_COLLECTION_COLS}, principal_id, kind FROM pim_collections WHERE id = ?1"
))?;
stmt.query_row([id], |r| {
Ok((
r.get(10)?,
PimKind::parse(&r.get::<_, String>(11)?),
map_pim_collection(r)?,
))
})
.optional()
self.row(
&format!(
"SELECT {PIM_COLLECTION_COLS}, c.principal_id, c.kind FROM pim_collections c
WHERE c.id = ?1"
),
[id],
|r| Ok((r.get(10)?, wire(r, 11, pim_kind)?, map_pim_collection(r)?)),
)
.await
}
pub async fn pim_create_link(
@@ -2045,29 +1767,22 @@ impl Db {
password_hash: Option<&str>,
) -> DbResult<PimLink> {
let c = self.conn.lock().await;
let created_at = now();
c.execute(
"INSERT INTO pim_links
(token, collection_id, busy_only, created_at, expires_at, password_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
c.query_row(
&format!(
"INSERT INTO pim_links
(token, collection_id, busy_only, created_at, expires_at, password_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6) RETURNING {PIM_LINK_COLS}"
),
params![
token,
collection_id,
busy_only,
created_at,
now(),
expires_at,
password_hash
],
)?;
Ok(PimLink {
id: c.last_insert_rowid(),
token: token.to_string(),
collection_id,
busy_only,
created_at,
expires_at: expires_at.map(str::to_string),
password_hash: password_hash.map(str::to_string),
})
map_pim_link,
)
}
/// Per collection of `principal_id`: how many loans and feed links it has.
@@ -2075,48 +1790,57 @@ impl Db {
&self,
principal_id: i64,
) -> DbResult<std::collections::HashMap<i64, (usize, usize)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(
"SELECT c.id,
(SELECT COUNT(*) FROM pim_shares s WHERE s.collection_id = c.id),
(SELECT COUNT(*) FROM pim_links l WHERE l.collection_id = c.id)
FROM pim_collections c WHERE c.principal_id = ?1",
)?;
stmt.query_map([principal_id], |r| {
Ok((
r.get(0)?,
(r.get::<_, i64>(1)? as usize, r.get::<_, i64>(2)? as usize),
))
})?
.collect()
let counts = self
.rows(
"SELECT c.id,
(SELECT COUNT(*) FROM pim_shares s WHERE s.collection_id = c.id),
(SELECT COUNT(*) FROM pim_links l WHERE l.collection_id = c.id)
FROM pim_collections c WHERE c.principal_id = ?1",
[principal_id],
|r| {
Ok((
r.get(0)?,
(r.get::<_, i64>(1)? as usize, r.get::<_, i64>(2)? as usize),
))
},
)
.await?;
Ok(counts.into_iter().collect())
}
pub async fn pim_links(&self, collection_id: i64) -> DbResult<Vec<PimLink>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_LINK_COLS} FROM pim_links WHERE collection_id = ?1 ORDER BY id"
))?;
stmt.query_map([collection_id], map_pim_link)?.collect()
self.rows(
&format!("SELECT {PIM_LINK_COLS} FROM pim_links WHERE collection_id = ?1 ORDER BY id"),
[collection_id],
map_pim_link,
)
.await
}
/// `None` also when the owner's account is disabled.
pub async fn pim_link_by_token(&self, token: &str) -> DbResult<Option<PimLink>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_LINK_COLS} FROM pim_links WHERE token = ?1 AND collection_id IN (
SELECT c.id FROM pim_collections c JOIN principals p ON p.id = c.principal_id
LEFT JOIN users u ON u.id = p.user_id WHERE {VISIBLE})"
))?;
stmt.query_row([token], map_pim_link).optional()
self.row(
&format!(
"SELECT {PIM_LINK_COLS} FROM pim_links WHERE token = ?1 AND collection_id IN (
SELECT c.id FROM pim_collections c JOIN principals p ON p.id = c.principal_id
LEFT JOIN users u ON u.id = p.user_id WHERE {VISIBLE})"
),
[token],
map_pim_link,
)
.await
}
/// `false` means no link of that collection had the id.
pub async fn pim_delete_link(&self, collection_id: i64, id: i64) -> DbResult<bool> {
let c = self.conn.lock().await;
Ok(c.execute(
"DELETE FROM pim_links WHERE id = ?1 AND collection_id = ?2",
params![id, collection_id],
)? > 0)
/// Revokes a feed link: one of `collection`, or with `None` whoever made
/// it. `false` means no row matched.
pub async fn pim_delete_link(&self, id: i64, collection: Option<i64>) -> DbResult<bool> {
Ok(self
.exec(
"DELETE FROM pim_links WHERE id = ?1 AND (?2 IS NULL OR collection_id = ?2)",
(id, collection),
)
.await?
> 0)
}
/// `(user id, name, mode)` of everyone a collection is lent to.
@@ -2124,15 +1848,13 @@ impl Db {
&self,
collection_id: i64,
) -> DbResult<Vec<(i64, String, PimShareMode)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(
self.rows(
"SELECT u.id, u.name, s.mode FROM pim_shares s JOIN users u ON u.id = s.user_id
WHERE s.collection_id = ?1 ORDER BY u.name",
)?;
stmt.query_map([collection_id], |r| {
Ok((r.get(0)?, r.get(1)?, r.get::<_, SqlShareMode>(2)?.0))
})?
.collect()
[collection_id],
|r| Ok((r.get(0)?, r.get(1)?, wire(r, 2, PimShareMode::from_wire)?)),
)
.await
}
/// Active accounts a collection could still be lent to: not `me`, not
@@ -2142,15 +1864,15 @@ impl Db {
collection_id: i64,
me: i64,
) -> DbResult<Vec<(String, Option<String>)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(
self.rows(
"SELECT u.name, p.display_name FROM users u JOIN principals p ON p.user_id = u.id
WHERE u.active = 1 AND u.id != ?2
AND u.id NOT IN (SELECT user_id FROM pim_shares WHERE collection_id = ?1)
ORDER BY u.name COLLATE NOCASE",
)?;
stmt.query_map(params![collection_id, me], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect()
(collection_id, me),
|r| Ok((r.get(0)?, r.get(1)?)),
)
.await
}
/// Lends a collection, or changes the mode of an existing loan.
@@ -2160,29 +1882,34 @@ impl Db {
user_id: i64,
mode: PimShareMode,
) -> DbResult<()> {
let c = self.conn.lock().await;
c.execute(
self.exec(
"INSERT INTO pim_shares (collection_id, user_id, mode) VALUES (?1, ?2, ?3)
ON CONFLICT (collection_id, user_id) DO UPDATE SET mode = ?3",
params![collection_id, user_id, mode.as_str()],
)?;
(collection_id, user_id, mode.as_str()),
)
.await?;
Ok(())
}
pub async fn pim_remove_share(&self, collection_id: i64, user_id: i64) -> DbResult<bool> {
let c = self.conn.lock().await;
Ok(c.execute(
"DELETE FROM pim_shares WHERE collection_id = ?1 AND user_id = ?2",
params![collection_id, user_id],
)? > 0)
Ok(self
.exec(
"DELETE FROM pim_shares WHERE collection_id = ?1 AND user_id = ?2",
(collection_id, user_id),
)
.await?
> 0)
}
pub async fn rooms(&self) -> DbResult<Vec<PimPrincipal>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE p.user_id IS NULL ORDER BY p.name"
))?;
stmt.query_map([], map_principal)?.collect()
self.rows(
&format!(
"SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE p.user_id IS NULL ORDER BY p.name"
),
[],
map_principal,
)
.await
}
/// A room or resource with its booking calendar. `None` if the name is
@@ -2221,11 +1948,13 @@ impl Db {
}
pub async fn set_room_display_name(&self, id: i64, display_name: &str) -> DbResult<bool> {
let c = self.conn.lock().await;
Ok(c.execute(
"UPDATE principals SET display_name = ?2 WHERE id = ?1 AND user_id IS NULL",
params![id, display_name],
)? > 0)
Ok(self
.exec(
"UPDATE principals SET display_name = ?2 WHERE id = ?1 AND user_id IS NULL",
(id, display_name),
)
.await?
> 0)
}
/// Deletes a room or resource after `ops`, as [`Self::delete_user`] does.
@@ -2247,11 +1976,12 @@ impl Db {
/// A principal by id, a disabled account's too.
pub async fn pim_principal_by_id(&self, id: i64) -> DbResult<Option<PimPrincipal>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE p.id = ?1"
))?;
stmt.query_row([id], map_principal).optional()
self.row(
&format!("SELECT {PRINCIPAL_COLS} FROM {PRINCIPALS} WHERE p.id = ?1"),
[id],
map_principal,
)
.await
}
/// `(collection id, object, data)` of the calendar objects of other
@@ -2270,7 +2000,7 @@ impl Db {
.map(|i| format!("instr({flat}, ?{}) > 0", i + 2))
.collect();
let mut stmt = c.prepare(&format!(
"SELECT o.collection_id, {PIM_OBJECT_COLS_O}, o.data
"SELECT o.collection_id, {PIM_OBJECT_COLS}, o.data
FROM pim_objects o JOIN pim_collections c ON c.id = o.collection_id
WHERE c.principal_id != ?1 AND c.kind = 'cal' AND ({})",
any.join(" OR ")
@@ -2290,8 +2020,7 @@ impl Db {
&self,
owner: Option<i64>,
) -> DbResult<Vec<PimLinkWithOwner>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(
self.rows(
"SELECT l.id, l.token, l.collection_id, l.busy_only, l.created_at, l.expires_at,
l.password_hash, coalesce(c.displayname, c.slug), c.kind, u.id, u.name,
u.active != 0
@@ -2301,18 +2030,19 @@ impl Db {
JOIN users u ON u.id = p.user_id
WHERE ?1 IS NULL OR u.id = ?1
ORDER BY u.name COLLATE NOCASE, l.id DESC",
)?;
stmt.query_map([owner], |r| {
Ok(PimLinkWithOwner {
link: map_pim_link(r)?,
collection_name: r.get(7)?,
kind: PimKind::parse(&r.get::<_, String>(8)?),
owner_id: r.get(9)?,
owner_name: r.get(10)?,
owner_active: r.get(11)?,
})
})?
.collect()
[owner],
|r| {
Ok(PimLinkWithOwner {
link: map_pim_link(r)?,
collection_name: r.get(7)?,
kind: wire(r, 8, pim_kind)?,
owner_id: r.get(9)?,
owner_name: r.get(10)?,
owner_active: r.get(11)?,
})
},
)
.await
}
/// The loans of the account `owner`'s collections:
@@ -2321,8 +2051,7 @@ impl Db {
&self,
owner: i64,
) -> DbResult<Vec<(i64, String, PimKind, i64, String, PimShareMode)>> {
let c = self.conn.lock().await;
let mut stmt = c.prepare_cached(
self.rows(
"SELECT c.id, coalesce(c.displayname, c.slug), c.kind, u.id, u.name, s.mode
FROM pim_shares s
JOIN pim_collections c ON c.id = s.collection_id
@@ -2330,24 +2059,19 @@ impl Db {
JOIN users u ON u.id = s.user_id
WHERE p.user_id = ?1
ORDER BY coalesce(c.displayname, c.slug) COLLATE NOCASE, u.name COLLATE NOCASE",
)?;
stmt.query_map([owner], |r| {
Ok((
r.get(0)?,
r.get(1)?,
PimKind::parse(&r.get::<_, String>(2)?),
r.get(3)?,
r.get(4)?,
r.get::<_, SqlShareMode>(5)?.0,
))
})?
.collect()
}
/// Revokes a feed link whoever made it.
pub async fn admin_delete_pim_link(&self, id: i64) -> DbResult<bool> {
let c = self.conn.lock().await;
Ok(c.execute("DELETE FROM pim_links WHERE id = ?1", [id])? > 0)
[owner],
|r| {
Ok((
r.get(0)?,
r.get(1)?,
wire(r, 2, pim_kind)?,
r.get(3)?,
r.get(4)?,
wire(r, 5, PimShareMode::from_wire)?,
))
},
)
.await
}
/// Whether users may create writable (read-write) shares. Off by default;
@@ -2390,14 +2114,14 @@ fn commit_if_reachable(tx: rusqlite::Transaction<'_>, user_id: i64) -> DbResult<
let (has_password, mode) = tx.query_row(
"SELECT pass_hash != '', auth_mode FROM users WHERE id = ?1",
[user_id],
|r| Ok((r.get::<_, bool>(0)?, r.get::<_, SqlAuthMode>(1)?)),
|r| Ok((r.get::<_, bool>(0)?, wire(r, 1, AuthMode::from_wire)?)),
)?;
let passkeys: i64 = tx.query_row(
"SELECT COUNT(*) FROM passkeys WHERE user_id = ?1",
[user_id],
|r| r.get(0),
)?;
let reachable = match mode.0 {
let reachable = match mode {
AuthMode::Either => has_password || passkeys > 0,
AuthMode::Both => has_password && passkeys > 0,
};
@@ -2409,7 +2133,7 @@ fn commit_if_reachable(tx: rusqlite::Transaction<'_>, user_id: i64) -> DbResult<
Ok(true)
}
/// Column order matched by [`USER_COLS`] and [`USER_COLS_U`].
/// Column order matched by [`USER_COLS`].
fn map_user(r: &rusqlite::Row) -> DbResult<User> {
Ok(User {
id: r.get(0)?,
@@ -2420,13 +2144,14 @@ fn map_user(r: &rusqlite::Row) -> DbResult<User> {
thumbnails: r.get(5)?,
language: r.get(6)?,
default_root_id: r.get(7)?,
auth_mode: r.get::<_, SqlAuthMode>(8)?.0,
auth_mode: wire(r, 8, AuthMode::from_wire)?,
has_password: r.get(9)?,
week_start: r.get(10)?,
})
}
/// Column order matched by the `passkeys` SELECTs above.
const PASSKEY_COLS: &str = "id, name, created_at, last_used_at, discoverable, passkey";
fn map_passkey(r: &rusqlite::Row) -> DbResult<PasskeyRow> {
Ok(PasskeyRow {
id: r.get(0)?,
@@ -2438,24 +2163,35 @@ fn map_passkey(r: &rusqlite::Row) -> DbResult<PasskeyRow> {
})
}
/// A just-created account: the column defaults of `users`.
fn new_user(id: i64, name: &str, is_admin: bool) -> User {
User {
id,
name: name.to_string(),
is_admin,
active: true,
single_click: false,
thumbnails: true,
language: None,
week_start: 1,
default_root_id: None,
auth_mode: AuthMode::Either,
has_password: true,
fn insert_roots(
tx: &rusqlite::Transaction,
user_id: i64,
roots: &[(String, Mode)],
) -> DbResult<()> {
for (path, mode) in roots {
tx.execute(
"INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
params![user_id, path, mode.as_str()],
)?;
}
Ok(())
}
/// Column order matched by the `app_passwords` SELECTs above.
/// `id, path, mode` of `user_roots` starting at column `at`. `None` when the
/// id is NULL: a user without roots in a LEFT JOIN.
fn map_root_at(r: &rusqlite::Row, at: usize) -> DbResult<Option<RootRow>> {
let Some(id) = r.get(at)? else {
return Ok(None);
};
Ok(Some(RootRow {
id,
path: r.get(at + 1)?,
mode: wire(r, at + 2, Mode::from_wire)?,
}))
}
const APP_PASSWORD_COLS: &str = "id, name, created_at, last_used_at";
fn map_app_password(r: &rusqlite::Row) -> DbResult<AppPasswordInfo> {
Ok(AppPasswordInfo {
id: r.get(0)?,
@@ -2520,16 +2256,14 @@ fn apply_ops(tx: &rusqlite::Transaction, ops: &[PimOp]) -> DbResult<()> {
// would fill it forever; an age limit may suit better.
let old: Vec<String> = tx
.prepare_cached(
"SELECT name FROM pim_objects WHERE collection_id = ?1
ORDER BY id DESC LIMIT -1 OFFSET ?2",
"DELETE FROM pim_objects WHERE collection_id = ?1 AND id NOT IN (
SELECT id FROM pim_objects WHERE collection_id = ?1
ORDER BY id DESC LIMIT ?2)
RETURNING name",
)?
.query_map(params![inbox, INBOX_KEEP], |r| r.get(0))?
.collect::<DbResult<_>>()?;
for name in old {
tx.execute(
"DELETE FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![inbox, name],
)?;
record_pim_change(tx, inbox, &name, true)?;
}
}
@@ -2621,11 +2355,12 @@ fn map_principal(r: &rusqlite::Row) -> DbResult<PimPrincipal> {
user_id: r.get(1)?,
name: r.get(2)?,
display_name: r.get(3)?,
kind: match r.get::<_, String>(4)?.as_str() {
"room" => UserType::Room,
"resource" => UserType::Resource,
_ => UserType::Individual,
},
kind: wire(r, 4, |s| match s {
"person" => Some(UserType::Individual),
"room" => Some(UserType::Room),
"resource" => Some(UserType::Resource),
_ => None,
})?,
active: r.get(5)?,
})
}
@@ -2638,18 +2373,9 @@ fn kind_str(kind: UserType) -> &'static str {
}
}
fn map_shared(r: &rusqlite::Row) -> DbResult<(PimCollection, String, PimShareMode)> {
Ok((
map_pim_collection(r)?,
r.get(10)?,
r.get::<_, SqlShareMode>(11)?.0,
))
}
const PIM_COLLECTION_COLS_C: &str = "c.id, c.slug, c.displayname, c.description, c.color,
/// Read with the table aliased as `c`.
const PIM_COLLECTION_COLS: &str = "c.id, c.slug, c.displayname, c.description, c.color,
c.timezone, c.sort_order, c.components, c.seq, c.transparent";
const PIM_COLLECTION_COLS: &str = "id, slug, displayname, description, color, timezone,
sort_order, components, seq, transparent";
fn write_props(
tx: &rusqlite::Transaction,
@@ -2690,8 +2416,8 @@ fn map_pim_collection(r: &rusqlite::Row) -> DbResult<PimCollection> {
})
}
const PIM_OBJECT_COLS: &str = "name, uid, component, etag, length(data), modified_at, schedule_tag";
const PIM_OBJECT_COLS_O: &str =
/// Read with the table aliased as `o`.
const PIM_OBJECT_COLS: &str =
"o.name, o.uid, o.component, o.etag, length(o.data), o.modified_at, o.schedule_tag";
fn map_pim_object(r: &rusqlite::Row) -> DbResult<PimObject> {
@@ -2726,7 +2452,10 @@ fn map_pim_link(r: &rusqlite::Row) -> DbResult<PimLink> {
})
}
/// Column order matched by the two `shares` SELECTs above.
/// Qualified by the table name, not an alias, so `RETURNING` can use them too.
const SHARE_COLS: &str = "shares.id, shares.token, shares.creator_id, shares.target,
shares.is_file, shares.mode, shares.created_at, shares.expires_at, shares.password_hash";
fn map_share(r: &rusqlite::Row) -> DbResult<ShareRow> {
Ok(ShareRow {
id: r.get(0)?,
@@ -2734,7 +2463,7 @@ fn map_share(r: &rusqlite::Row) -> DbResult<ShareRow> {
creator_id: r.get(2)?,
target: r.get(3)?,
is_file: r.get::<_, i64>(4)? != 0,
mode: r.get::<_, SqlMode>(5)?.0,
mode: wire(r, 5, Mode::from_wire)?,
created_at: r.get(6)?,
expires_at: r.get(7)?,
password_hash: r.get(8)?,
@@ -2781,6 +2510,182 @@ fn now() -> String {
/// Deletion records kept per collection for sync tokens.
const PIM_TOMBSTONES_KEPT: i64 = 1000;
/// The schema, one step per version. Version `n` has run the first `n`.
/// Append only: a database records how many steps it has run.
const MIGRATIONS: &[&str] = &[
SCHEMA_V1,
// A disabled flag so admins can suspend accounts without deleting them.
"ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1",
// Click-to-open mode. Everyone starts on the default (off: single click
// selects, double click opens).
"ALTER TABLE users ADD COLUMN single_click INTEGER NOT NULL DEFAULT 0",
// NULL means "follow the browser".
"ALTER TABLE users ADD COLUMN language TEXT",
// The share list is queried by creator on every shares page.
"CREATE INDEX IF NOT EXISTS idx_shares_creator ON shares(creator_id)",
// Unlocks cascade with their share, which cascades with its creator's
// account.
"ALTER TABLE shares ADD COLUMN password_hash TEXT;
CREATE TABLE IF NOT EXISTS share_unlocks (
token TEXT PRIMARY KEY,
share_id INTEGER NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);",
// `delete_share` cascades into share_unlocks, which is a full scan of
// that table without this.
"CREATE INDEX IF NOT EXISTS idx_share_unlocks_share ON share_unlocks(share_id)",
// On by default, so `--cache` is the only step needed to get thumbnails.
"ALTER TABLE users ADD COLUMN thumbnails INTEGER NOT NULL DEFAULT 1",
// No foreign key on purpose: removing a root must not fail because of
// this column.
"ALTER TABLE users ADD COLUMN default_root_id INTEGER",
// Passkeys, and how they combine with the password.
//
// `webauthn_id` is the WebAuthn user handle: a random uuid the
// authenticator stores inside a discoverable credential and hands back
// at sign-in. It must never change once a passkey exists, or that
// passkey can no longer be traced to its account. Filled in lazily on
// the first registration, so accounts that never use a passkey keep it
// NULL.
"ALTER TABLE users ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'either';
ALTER TABLE users ADD COLUMN webauthn_id TEXT;
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
cred_id BLOB NOT NULL UNIQUE,
passkey TEXT NOT NULL,
name TEXT NOT NULL,
discoverable INTEGER,
created_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_webauthn_id
ON users(webauthn_id) WHERE webauthn_id IS NOT NULL;",
// `secret_hash` is UNIQUE because the lookup keys on it.
"CREATE TABLE IF NOT EXISTS app_passwords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
secret_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_app_passwords_user ON app_passwords(user_id);",
// CalDAV and CardDAV. A principal owns collections: every account has
// one, and rooms and resources are principals without an account, so
// they share the account name space but no account query can return
// them. The trigger gives each new account its principal and refuses a
// name a room already has.
//
// `seq` counts every change to a collection and its members;
// `pim_changes` keeps the latest change per member, deletions included,
// for sync tokens. `pim_shares` lends a collection to another account.
// `schedule_tag` is NULL for objects that schedule nothing. A
// `transparent` calendar adds no busy time to scheduling (RFC 6638
// `schedule-calendar-transp`). `pim_links` are public feeds; not rows of
// `shares`, because every path-based share query would then have to
// skip them. `pim_props` holds the properties clients set that the
// server does not interpret, as XML, per collection, home or principal.
"CREATE TABLE IF NOT EXISTS principals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('person','room','resource')),
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT,
CHECK ((kind = 'person') = (user_id IS NOT NULL))
);
INSERT INTO principals (user_id, kind, name)
SELECT id, 'person', name FROM users;
CREATE TRIGGER IF NOT EXISTS principal_of_user AFTER INSERT ON users
BEGIN
INSERT INTO principals (user_id, kind, name)
VALUES (NEW.id, 'person', NEW.name);
END;
CREATE TABLE IF NOT EXISTS pim_collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
principal_id INTEGER NOT NULL
REFERENCES principals(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('cal','card')),
slug TEXT NOT NULL,
displayname TEXT,
description TEXT,
color TEXT,
timezone TEXT,
sort_order TEXT,
components TEXT NOT NULL DEFAULT '',
transparent INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE (principal_id, kind, slug)
);
CREATE TABLE IF NOT EXISTS pim_objects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
uid TEXT NOT NULL,
component TEXT NOT NULL,
data BLOB NOT NULL,
etag TEXT NOT NULL,
modified_at TEXT NOT NULL,
schedule_tag TEXT,
UNIQUE (collection_id, name),
UNIQUE (collection_id, uid)
);
CREATE TABLE IF NOT EXISTS pim_changes (
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
seq INTEGER NOT NULL,
deleted INTEGER NOT NULL,
PRIMARY KEY (collection_id, name)
);
CREATE INDEX IF NOT EXISTS idx_pim_changes_seq
ON pim_changes(collection_id, seq);
CREATE TABLE IF NOT EXISTS pim_shares (
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mode TEXT NOT NULL CHECK (mode IN ('ro','rw','rw+schedule')),
PRIMARY KEY (collection_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_pim_shares_user ON pim_shares(user_id);
CREATE TABLE IF NOT EXISTS pim_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
collection_id INTEGER NOT NULL
REFERENCES pim_collections(id) ON DELETE CASCADE,
busy_only INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
expires_at TEXT,
password_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_pim_links_collection
ON pim_links(collection_id);
CREATE TABLE IF NOT EXISTS pim_props (
place TEXT NOT NULL,
principal_id INTEGER REFERENCES principals(id) ON DELETE CASCADE,
collection_id INTEGER REFERENCES pim_collections(id) ON DELETE CASCADE,
ns TEXT NOT NULL,
name TEXT NOT NULL,
xml TEXT NOT NULL,
PRIMARY KEY (place, ns, name),
CHECK ((principal_id IS NULL) != (collection_id IS NULL))
);",
// Monday, the ISO 8601 week start.
"ALTER TABLE users ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1",
// The calendar that receives invitations (RFC 6638,
// schedule-default-calendar-URL). NULL: the oldest one.
"ALTER TABLE principals ADD COLUMN default_calendar_id INTEGER
REFERENCES pim_collections(id) ON DELETE SET NULL",
// Sync tokens below this are refused: the tombstones they need are gone.
"ALTER TABLE pim_collections ADD COLUMN pruned_seq INTEGER NOT NULL DEFAULT 0",
// The cascades from a deleted principal or collection.
"CREATE INDEX IF NOT EXISTS idx_pim_props_principal ON pim_props(principal_id);
CREATE INDEX IF NOT EXISTS idx_pim_props_collection ON pim_props(collection_id);",
];
const SCHEMA_V1: &str = r#"
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -3051,13 +2956,15 @@ mod tests {
let (db, admin) = db_with_admin().await;
let p = db.principal_of(admin.id).await.unwrap();
db.pim_ensure_defaults(p).await.unwrap();
let books = db.pim_collections(p, PimKind::AddressBook).await.unwrap();
let books = db.pim_collections(p, PimKind::Addressbook).await.unwrap();
assert_eq!(books.len(), 1);
db.pim_delete_collection(books[0].id).await.unwrap();
db.pim_apply(&[PimOp::DeleteCollection(books[0].id)])
.await
.unwrap();
// Remembered defaults must not hide that one is gone.
db.pim_ensure_defaults(p).await.unwrap();
assert_eq!(
db.pim_collections(p, PimKind::AddressBook)
db.pim_collections(p, PimKind::Addressbook)
.await
.unwrap()
.len(),
@@ -3384,10 +3291,10 @@ mod tests {
assert!(db.user_shares(bob.id).await.unwrap().is_empty());
// Only the creator can delete.
assert!(!db.delete_share(s1.id, bob.id).await.unwrap());
assert!(db.delete_share(s1.id, admin.id).await.unwrap());
assert!(!db.delete_share(s1.id, Some(bob.id)).await.unwrap());
assert!(db.delete_share(s1.id, Some(admin.id)).await.unwrap());
assert!(db.share_by_token("tok-a").await.unwrap().is_none());
assert!(!db.delete_share(s1.id, admin.id).await.unwrap());
assert!(!db.delete_share(s1.id, Some(admin.id)).await.unwrap());
}
/// The sweep decides which timestamps are past, and `expires_at` is
@@ -3493,7 +3400,7 @@ mod tests {
// Deleting the share takes its unlocks with it, so a re-created
// share that happened to reuse the id could not inherit them.
assert!(db.delete_share(a.id, admin.id).await.unwrap());
assert!(db.delete_share(a.id, Some(admin.id)).await.unwrap());
assert!(!db.share_unlock_valid(&unlock, a.id).await.unwrap());
}
▾Mserver/src/error.rs
@@ -29,7 +29,7 @@ pub struct AppState {
pub struct ApiError(
pub StatusCode,
pub String,
pub Option<serde_json::Value>,
pub Option<serde_json::Map<String, serde_json::Value>>,
pub Option<&'static str>,
);
@@ -66,7 +66,7 @@ impl ApiError {
)
}
pub fn with_extra(mut self, extra: serde_json::Value) -> Self {
pub fn with_extra(mut self, extra: serde_json::Map<String, serde_json::Value>) -> Self {
self.2 = Some(extra);
self
}
@@ -74,17 +74,12 @@ impl ApiError {
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let mut body = serde_json::json!({ "error": self.1 });
let mut body = serde_json::Map::new();
body.insert("error".into(), self.1.into());
if let Some(code) = self.3 {
body["code"] = serde_json::json!(code);
}
if let Some(extra) = self.2
&& let (Some(obj), Some(extra)) = (body.as_object_mut(), extra.as_object())
{
for (k, v) in extra {
obj.insert(k.clone(), v.clone());
}
body.insert("code".into(), code.into());
}
body.extend(self.2.unwrap_or_default());
(self.0, axum::Json(body)).into_response()
}
}
@@ -107,7 +102,6 @@ impl From<rusqlite::Error> for ApiError {
mod tests {
use super::*;
use axum::http::StatusCode;
use http_body_util::BodyExt;
fn status_and_body(e: ApiError) -> (StatusCode, String) {
let resp = e.into_response();
@@ -117,7 +111,8 @@ mod tests {
.build()
.unwrap();
let bytes = rt
.block_on(async { resp.into_body().collect().await.unwrap().to_bytes() })
.block_on(axum::body::to_bytes(resp.into_body(), usize::MAX))
.unwrap()
.to_vec();
(status, String::from_utf8(bytes).unwrap())
}
@@ -135,8 +130,12 @@ mod tests {
#[test]
fn extra_object_is_merged_into_body() {
let e = ApiError::new(StatusCode::CONFLICT, "some files already exist")
.with_extra(serde_json::json!({ "skipped": ["a.txt"], "uploaded": 2 }));
let e = ApiError::new(StatusCode::CONFLICT, "some files already exist").with_extra(
serde_json::Map::from_iter([
("skipped".into(), serde_json::json!(["a.txt"])),
("uploaded".into(), serde_json::json!(2)),
]),
);
let (status, body) = status_and_body(e);
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(
@@ -149,17 +148,6 @@ mod tests {
);
}
#[test]
fn non_object_extra_is_ignored() {
let e = ApiError::new(StatusCode::CONFLICT, "conflict")
.with_extra(serde_json::json!(["not", "an", "object"]));
let (_status, body) = status_and_body(e);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap(),
serde_json::json!({ "error": "conflict" })
);
}
#[test]
fn io_error_maps_to_500() {
let e = ApiError::from(std::io::Error::new(
▾Mserver/src/fs.rs
@@ -64,15 +64,25 @@ pub fn resolve_root(server_root: &Path, root_rel: &str) -> Result<PathBuf, FsErr
/// Resolve a requested path (relative to a user root) safely.
pub fn resolve_path(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
let (root_abs, full) = join_under_root(server_root, root_rel, req_rel)?;
let full = canonical(&full)?;
ensure_within(&root_abs, &full)?;
Ok(full)
}
/// The canonical root and the request joined onto it, not yet resolved.
fn join_under_root(
server_root: &Path,
root_rel: &str,
req_rel: &str,
) -> Result<(PathBuf, PathBuf), FsError> {
let root_abs = resolve_root(server_root, root_rel)?;
let req = Path::new(req_rel);
if req.components().any(|c| c == Component::ParentDir) {
return Err(FsError::Forbidden);
}
let full = root_abs.join(req);
let full = canonical(&full)?;
ensure_within(&root_abs, &full)?;
Ok(full)
Ok((root_abs, full))
}
/// Resolve a share target that is a single file (relative to the server root).
@@ -484,12 +494,7 @@ pub(crate) fn resolve_entry(
root_rel: &str,
req_rel: &str,
) -> Result<PathBuf, FsError> {
let root_abs = resolve_root(server_root, root_rel)?;
let req = Path::new(req_rel);
if req.components().any(|c| c == Component::ParentDir) {
return Err(FsError::Forbidden);
}
let full = root_abs.join(req);
let (root_abs, full) = join_under_root(server_root, root_rel, req_rel)?;
// The parent must exist and stay inside the root.
let parent = full
.parent()
@@ -528,9 +533,7 @@ pub fn rename_item(
return Ok(to);
}
// `rename` replaces a file target atomically; no remove-then-rename gap.
if entry_exists(&to) && (!overwrite || entry_is_dir(&to) || entry_is_dir(&from)) {
return Err(FsError::Conflict);
}
check_move_conflict(&to, &from, overwrite)?;
std::fs::rename(&from, &to).map_err(|e| io_err(e, &to))?;
Ok(from)
}
@@ -539,47 +542,32 @@ pub fn rename_item(
/// the caller can revoke shares naming it).
pub fn remove_item(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
let full = resolve_entry(server_root, root_rel, req_rel)?;
// A symlink is unlinked, never followed: deleting it must not delete the
// file it names. A dangling link is deletable for the same reason.
if entry_is_dir(&full) {
std::fs::remove_dir_all(&full).map_err(|e| io_err(e, &full))?;
remove_entry(&full)?;
Ok(full)
}
/// A symlink is unlinked, never followed: deleting it must not delete the
/// file it names. A dangling link is deletable for the same reason.
fn remove_entry(p: &Path) -> Result<(), FsError> {
if entry_is_dir(p) {
std::fs::remove_dir_all(p).map_err(|e| io_err(e, p))
} else {
std::fs::remove_file(&full).map_err(|e| io_err(e, &full))?;
std::fs::remove_file(p).map_err(|e| io_err(e, p))
}
Ok(full)
}
/// Overwrite an existing file's contents (the editor's save path).
/// Overwrite an existing file's contents (the editor's save path). `full`
/// comes from [`resolve_path`], or [`resolve_file`] for a file share.
///
/// The file must already exist and be a regular file. If `expected_mtime`
/// (whole unix seconds) is provided and differs from the file's current mtime,
/// the file changed on disk since it was read → `Conflict` (409). Returns the
/// file's new mtime (unix seconds) after a successful write.
pub fn save_file(
server_root: &Path,
root_rel: &str,
req_rel: &str,
content: &[u8],
expected_mtime: Option<i64>,
) -> Result<i64, FsError> {
let full = resolve_path(server_root, root_rel, req_rel)?; // must exist
write_checked(&full, content, expected_mtime)
}
/// The share-aware variant of [`save_file`]: for a *file* share the root is
/// the file itself, so `target` (relative to `server_root`) points at the
/// file — there is no directory root beneath it.
pub fn save_file_at(
server_root: &Path,
target: &str,
pub fn write_checked(
full: &Path,
content: &[u8],
expected_mtime: Option<i64>,
) -> Result<i64, FsError> {
let full = resolve_file(server_root, target)?; // must exist
write_checked(&full, content, expected_mtime)
}
fn write_checked(full: &Path, content: &[u8], expected_mtime: Option<i64>) -> Result<i64, FsError> {
let meta = std::fs::metadata(full).map_err(|_| FsError::NotFound)?;
if meta.is_dir() {
return Err(FsError::NotADirectory);
@@ -718,12 +706,7 @@ fn rename_or_copy(from: &Path, to: &Path) -> Result<(), FsError> {
return Err(FsError::Forbidden);
}
copy_recursive(from, to)?;
if entry_is_dir(from) {
std::fs::remove_dir_all(from).map_err(|e| io_err(e, from))?;
} else {
std::fs::remove_file(from).map_err(|e| io_err(e, from))?;
}
Ok(())
remove_entry(from)
}
Err(e) => Err(io_err(e, to)),
}
@@ -1332,7 +1315,7 @@ mod tests {
));
}
// ---------- save_file ----------
// ---------- write_checked ----------
fn mtime_of(p: &Path) -> i64 {
std::fs::metadata(p)
@@ -1345,51 +1328,48 @@ mod tests {
}
#[test]
fn save_file_updates_content_and_returns_new_mtime() {
fn write_checked_updates_content_and_returns_new_mtime() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
let before = mtime_of(&root.join("file.txt"));
let file = root.join("file.txt");
let before = mtime_of(&file);
// Sleep so the mtime actually advances (filesystem granularity).
std::thread::sleep(std::time::Duration::from_millis(1100));
let new = save_file(&root, ".", "file.txt", b"brand new", Some(before)).unwrap();
assert_eq!(std::fs::read(root.join("file.txt")).unwrap(), b"brand new");
let new = write_checked(&file, b"brand new", Some(before)).unwrap();
assert_eq!(std::fs::read(&file).unwrap(), b"brand new");
assert!(new >= before);
// A second save with the *returned* mtime succeeds.
let new2 = save_file(&root, ".", "file.txt", b"again", Some(new)).unwrap();
let new2 = write_checked(&file, b"again", Some(new)).unwrap();
assert!(new2 >= new);
// Without an expected mtime, always saves.
let _ = save_file(&root, ".", "file.txt", b"force", None).unwrap();
let _ = write_checked(&file, b"force", None).unwrap();
assert_eq!(std::fs::read(root.join("file.txt")).unwrap(), b"force");
}
#[test]
fn save_file_conflict_on_stale_mtime() {
fn write_checked_conflict_on_stale_mtime() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
// The mtime we pass is older than the file's real mtime → conflict.
assert!(matches!(
save_file(&root, ".", "file.txt", b"x", Some(1)),
write_checked(&root.join("file.txt"), b"x", Some(1)),
Err(FsError::Conflict)
));
}
#[test]
fn save_file_error_cases() {
fn write_checked_error_cases() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
assert!(matches!(
save_file(&root, ".", "nope.txt", b"x", None),
write_checked(&root.join("nope.txt"), b"x", None),
Err(FsError::NotFound)
));
assert!(matches!(
save_file(&root, ".", "docs", b"x", None),
write_checked(&root.join("docs"), b"x", None),
Err(FsError::NotADirectory)
));
assert!(matches!(
save_file(&root, ".", "../evil.txt", b"x", None),
Err(FsError::Forbidden)
));
}
// ---------- move / copy ----------
▾Mserver/src/webauthn.rs
@@ -205,17 +205,14 @@ pub fn put(pending: Pending) -> String {
// Still full of live anonymous entries: drop the oldest of those to make
// room. A visitor whose challenge is evicted here just retries, and a
// half-finished sign-in is never the thing that gets dropped.
while pending.anonymous()
if pending.anonymous()
&& map.values().filter(|(p, _)| p.anonymous()).count() >= MAX_ANONYMOUS
{
let Some(oldest) = map
&& let Some(oldest) = map
.iter()
.filter(|(_, (p, _))| p.anonymous())
.min_by_key(|(_, (_, at))| *at)
.map(|(k, _)| k.clone())
else {
break;
};
{
map.remove(&oldest);
}
map.insert(id.clone(), (pending, Instant::now()));
▾Mserver/tests/api/app_passwords.rs
@@ -9,14 +9,15 @@ const ROUTE: &str = "/api/auth/app-passwords";
/// A `PROPFIND` of the user mount, the way a mount client authenticates.
async fn propfind(env: &Env, name: &str, password: &str) -> Resp {
Client::new(env.app.clone())
.raw(
Method::from_bytes(b"PROPFIND").unwrap(),
"/dav",
&[("depth", "1"), ("authorization", &basic(name, password))],
Vec::new(),
)
.await
req(
env,
"PROPFIND",
"/dav",
&basic(name, password),
&[("depth", "1")],
"",
)
.await
}
/// Create one and return its secret.
▾Mserver/tests/api/auth.rs
@@ -131,7 +131,7 @@ async fn me_requires_valid_session() {
// Bogus session token.
let mut bogus = Client::new(env.app.clone());
bogus.set_cookie("not-a-real-token");
bogus.cookie = Some("not-a-real-token".into());
assert_eq!(
bogus.get("/api/auth/me").await.status,
StatusCode::UNAUTHORIZED
▾Mserver/tests/api/embedded.rs
@@ -3,8 +3,7 @@
//! With `--features embedded` the frontend is baked into the binary by
//! rust-embed at *compile* time from `server/dist` (a copy of `web/dist`),
//! so this test only makes sense after the frontend has been built and
//! staged. Run it via `just e2e`, which does exactly that and then runs
//! the full server suite against the embedded assets.
//! staged. Run it via `just e2e`, which does exactly that.
#![cfg(feature = "embedded")]
use crate::common::*;
▾Mserver/tests/api/files.rs
@@ -862,7 +862,6 @@ async fn exists_check_does_not_follow_symlinked_directories_out_of_the_root() {
/// runs after the first half reached the server and before the second is
/// sent, so the test can change the disk while the upload is in flight.
async fn upload_in_two_halves(
env: &Env,
admin: &Client,
name: &str,
between: impl FnOnce() + Send + 'static,
@@ -896,27 +895,14 @@ async fn upload_in_two_halves(
}
}
});
let req = axum::http::Request::builder()
.method(axum::http::Method::POST)
.uri(root_path(""))
.header("content-type", "multipart/form-data; boundary=B")
.header(
"cookie",
format!("dovenest_session={}", admin.cookie.as_ref().unwrap()),
)
.body(axum::body::Body::from_stream(stream))
.unwrap();
let res = tower::ServiceExt::oneshot(env.app.clone(), req)
.await
.expect("request");
let status = res.status();
let bytes = http_body_util::BodyExt::collect(res.into_body())
.await
.unwrap()
.to_bytes();
let ct = [("content-type", "multipart/form-data; boundary=B")];
let body = axum::body::Body::from_stream(stream);
let r = admin
.raw(axum::http::Method::POST, &root_path(""), &ct, body)
.await;
(
status,
serde_json::from_slice(&bytes).unwrap_or(json!(null)),
r.status,
serde_json::from_slice(&r.body).unwrap_or(json!(null)),
)
}
@@ -931,7 +917,7 @@ async fn upload_does_not_clobber_a_file_created_during_the_transfer() {
assert!(!target.exists());
let t = target.clone();
let (status, body) = upload_in_two_halves(&env, &admin, "raced.txt", move || {
let (status, body) = upload_in_two_halves(&admin, "raced.txt", move || {
std::fs::write(&t, b"someone else").unwrap();
})
.await;
@@ -948,7 +934,7 @@ async fn upload_does_not_clobber_a_file_created_during_the_transfer() {
/// A multipart body that stops inside a part: the headers and part of the
/// payload, then end of stream with no closing boundary. That is what reaches
/// the server when the user closes the tab or the connection drops.
async fn upload_stopped_mid_part(env: &Env, admin: &Client) -> StatusCode {
async fn upload_stopped_mid_part(admin: &Client) -> StatusCode {
let mut body: Vec<u8> = Vec::new();
body.extend_from_slice(
b"--B\r\nContent-Disposition: form-data; name=\"interrupted.txt\"\r\n\r\n",
@@ -966,22 +952,12 @@ async fn upload_stopped_mid_part(env: &Env, admin: &Client) -> StatusCode {
None // EOF: the terminating boundary never arrives
}
});
let req = axum::http::Request::builder()
.method(axum::http::Method::POST)
.uri(root_path(""))
.header("content-type", "multipart/form-data; boundary=B")
.header(
"cookie",
format!("dovenest_session={}", admin.cookie.as_ref().unwrap()),
)
.body(axum::body::Body::from_stream(stream))
.unwrap();
let res = tower::ServiceExt::oneshot(env.app.clone(), req)
let ct = [("content-type", "multipart/form-data; boundary=B")];
let body = axum::body::Body::from_stream(stream);
admin
.raw(axum::http::Method::POST, &root_path(""), &ct, body)
.await
.expect("request");
let status = res.status();
let _ = http_body_util::BodyExt::collect(res.into_body()).await;
status
.status
}
/// Every entry name in the server root, hidden ones included.
@@ -1001,7 +977,7 @@ async fn an_interrupted_upload_leaves_no_scratch_file() {
let env = Env::new().await;
let admin = env.admin().await;
let status = upload_stopped_mid_part(&env, &admin).await;
let status = upload_stopped_mid_part(&admin).await;
assert!(
status.is_client_error(),
"expected a rejection, got {status}"
▾Mserver/tests/api/pim.rs
@@ -16,26 +16,6 @@ async fn setup() -> (Env, String) {
(env, basic(ALICE, PW))
}
async fn req(
env: &Env,
verb: &str,
path: &str,
auth: &str,
extra: &[(&str, &str)],
body: &str,
) -> Resp {
let mut headers = vec![("authorization", auth)];
headers.extend_from_slice(extra);
Client::new(env.app.clone())
.raw(
Method::from_bytes(verb.as_bytes()).unwrap(),
path,
&headers,
body.as_bytes().to_vec(),
)
.await
}
fn propfind_body(props: &[(&str, &str)]) -> String {
let props: String = props
.iter()
@@ -44,33 +24,6 @@ fn propfind_body(props: &[(&str, &str)]) -> String {
format!("<d:propfind xmlns:d=\"DAV:\"><d:prop>{props}</d:prop></d:propfind>")
}
/// `href -> [(status, property element)]` of a multistatus.
fn parse_multistatus(r: &Resp) -> Vec<(String, Vec<(u16, Element)>)> {
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let root = Element::parse(r.body.as_slice()).unwrap();
xml::elements(&root)
.map(|resp| {
let href = xml::text(xml::child(resp, DAV, "href").unwrap());
let props = xml::elements(resp)
.filter(|e| Name::of(e).is(DAV, "propstat"))
.flat_map(|ps| {
let code: u16 = xml::text(xml::child(ps, DAV, "status").unwrap())
.split(' ')
.nth(1)
.unwrap()
.parse()
.unwrap();
let prop = xml::child(ps, DAV, "prop").unwrap();
xml::elements(prop)
.map(move |p| (code, p.clone()))
.collect::<Vec<_>>()
})
.collect();
(href, props)
})
.collect()
}
/// The property of `href` with status 200.
fn prop(
ms: &[(String, Vec<(u16, Element)>)],
@@ -100,12 +53,6 @@ fn hrefs_of(p: &Element) -> Vec<String> {
xml::elements(p).map(xml::text).collect()
}
fn error_condition(r: &Resp) -> Name {
let root = Element::parse(r.body.as_slice()).unwrap_or_else(|_| panic!("{}", r.text()));
assert!(Name::of(&root).is(DAV, "error"), "{}", r.text());
Name::of(xml::elements(&root).next().unwrap())
}
const HOME: &str = "/pim/calendars/alice/";
const CAL: &str = "/pim/calendars/alice/default/";
const BOOK: &str = "/pim/addressbooks/alice/default/";
@@ -678,10 +625,6 @@ fn sync_token_of(r: &Resp) -> String {
xml::text(xml::child(&root, DAV, "sync-token").unwrap())
}
fn ics(body: &str) -> String {
format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{body}END:VCALENDAR\r\n")
}
const LUNCH: &str = "BEGIN:VEVENT\r\nUID:lunch\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T120000Z\r\nDTEND:20260101T130000Z\r\nSUMMARY:Team Lunch\r\nEND:VEVENT\r\n";
const WEEKLY: &str = "BEGIN:VEVENT\r\nUID:weekly\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;TZID=Europe/Berlin:20251201T090000\r\nDTEND;TZID=Europe/Berlin:20251201T093000\r\nRRULE:FREQ=WEEKLY\r\nSUMMARY:Standup\r\nEND:VEVENT\r\n";
const TODO: &str = "BEGIN:VTODO\r\nUID:todo\r\nDTSTAMP:20260101T000000Z\r\nDUE:20260110T170000Z\r\nSUMMARY:Taxes\r\nEND:VTODO\r\n";
@@ -1195,20 +1138,6 @@ async fn two_users() -> (Env, Client, String, String) {
(env, admin, alice, basic(BOB, BOB_PW))
}
/// The id of alice's default calendar, from the JSON API.
async fn calendar_id(alice: &Client) -> i64 {
let r = alice.get("/api/pim/collections").await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
r.json()
.as_array()
.unwrap()
.iter()
.find(|c| c["kind"] == "calendar" && c["mode"].is_null())
.unwrap()["id"]
.as_i64()
.unwrap()
}
fn privilege_names(p: &Element) -> Vec<String> {
xml::elements(p)
.flat_map(xml::elements)
@@ -1220,7 +1149,7 @@ fn privilege_names(p: &Element) -> Vec<String> {
async fn lent_collections() {
let (env, admin, alice_auth, bob) = two_users().await;
let alice = login(&env, ALICE, PW).await;
let id = calendar_id(&alice).await;
let id = collection_id(&alice, CAL).await;
let shares = format!("/api/pim/collections/{id}/shares");
let r = alice
@@ -1676,7 +1605,7 @@ async fn bad_filters_are_refused_by_name() {
async fn other_accounts_get_no_client_properties_or_loans_of_disabled_owners() {
let (env, admin, alice_auth, bob) = two_users().await;
let alice = login(&env, ALICE, PW).await;
let id = calendar_id(&alice).await;
let id = collection_id(&alice, CAL).await;
let r = alice
.post_json(
&format!("/api/pim/collections/{id}/shares"),
@@ -2080,7 +2009,6 @@ async fn of_two_deletes_of_one_collection_only_one_succeeds() {
#[tokio::test]
async fn a_proppatch_whose_collection_goes_while_its_body_arrives_is_not_a_500() {
use tower::ServiceExt;
let (env, auth) = setup().await;
let col = "/pim/calendars/alice/race/";
let r = req(&env, "MKCALENDAR", col, &auth, &[], "").await;
@@ -2092,20 +2020,25 @@ async fn a_proppatch_whose_collection_goes_while_its_body_arrives_is_not_a_500()
arrive.await.ok();
Ok::<_, std::convert::Infallible>(axum::body::Bytes::from(patch))
}));
let request = axum::http::Request::builder()
.method("PROPPATCH")
.uri(col)
.header("host", "files.example.com")
.header("authorization", &auth)
.body(body)
.unwrap();
let pending = tokio::spawn(env.app.clone().oneshot(request));
let client = Client::new(env.app.clone());
let a = auth.clone();
let pending = tokio::spawn(async move {
let headers = [("authorization", a.as_str())];
client
.raw(
Method::from_bytes(b"PROPPATCH").unwrap(),
col,
&headers,
body,
)
.await
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let r = req(&env, "DELETE", col, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
send.send(()).unwrap();
let r = pending.await.unwrap().unwrap();
assert_eq!(r.status(), StatusCode::NOT_FOUND);
let r = pending.await.unwrap();
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
▾Mserver/tests/api/pim_clients.rs
@@ -10,26 +10,6 @@ use xmltree::Element;
const PW: &str = "secret12345";
async fn req(
env: &Env,
verb: &str,
path: &str,
auth: &str,
extra: &[(&str, &str)],
body: &str,
) -> Resp {
let mut headers = vec![("authorization", auth)];
headers.extend_from_slice(extra);
Client::new(env.app.clone())
.raw(
Method::from_bytes(verb.as_bytes()).unwrap(),
path,
&headers,
body.as_bytes().to_vec(),
)
.await
}
async fn setup(names: &[&str]) -> (Env, Client) {
let env = Env::new().await;
let admin = env.admin().await;
@@ -39,27 +19,9 @@ async fn setup(names: &[&str]) -> (Env, Client) {
(env, admin)
}
/// The 200 properties of the single response, and its `<d:error>`.
fn props(r: &Resp) -> (Vec<(u16, Element)>, Option<Name>) {
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let root = Element::parse(r.body.as_slice()).unwrap();
let resp = xml::elements(&root).next().unwrap();
let error = xml::child(resp, DAV, "error").and_then(|e| xml::elements(e).next().map(Name::of));
let props = xml::elements(resp)
.filter(|e| Name::of(e).is(DAV, "propstat"))
.flat_map(|ps| {
let code: u16 = xml::text(xml::child(ps, DAV, "status").unwrap())
.split(' ')
.nth(1)
.unwrap()
.parse()
.unwrap();
xml::elements(xml::child(ps, DAV, "prop").unwrap())
.map(move |p| (code, p.clone()))
.collect::<Vec<_>>()
})
.collect();
(props, error)
/// The properties of the single response.
fn props(r: &Resp) -> Vec<(u16, Element)> {
parse_multistatus(r).remove(0).1
}
fn find<'a>(props: &'a [(u16, Element)], ns: &str, local: &str) -> Option<&'a (u16, Element)> {
@@ -121,15 +83,15 @@ async fn client_properties_are_stored() {
let patch = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:set><d:prop>\
<c:default-alarm-vevent-date>BEGIN:VALARM\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\n</c:default-alarm-vevent-date>\
</d:prop></d:set></d:propertyupdate>";
let (ps, _) = props(&req(&env, "PROPPATCH", home, &alice, &[], patch).await);
let ps = props(&req(&env, "PROPPATCH", home, &alice, &[], patch).await);
assert_eq!(ps[0].0, 200);
let me_card = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:cs=\"http://calendarserver.org/ns/\"><d:set><d:prop>\
<cs:me-card><d:href>/pim/addressbooks/alice/default/me.vcf</d:href></cs:me-card>\
</d:prop></d:set></d:propertyupdate>";
let (ps, _) = props(&req(&env, "PROPPATCH", book_home, &alice, &[], me_card).await);
let ps = props(&req(&env, "PROPPATCH", book_home, &alice, &[], me_card).await);
assert_eq!(ps[0].0, 200);
let custom = "<d:propertyupdate xmlns:d=\"DAV:\"><d:set><d:prop><x:note xmlns:x=\"urn:x\">hi</x:note></d:prop></d:set></d:propertyupdate>";
let (ps, _) = props(
let ps = props(
&req(
&env,
"PROPPATCH",
@@ -154,7 +116,7 @@ async fn client_properties_are_stored() {
&find_body(CALDAV, "default-alarm-vevent-date"),
)
.await;
let (ps, _) = props(&r);
let ps = props(&r);
let (code, p) = find(&ps, CALDAV, "default-alarm-vevent-date").unwrap();
assert_eq!(*code, 200);
// XML parsing made the raw CRLF of the request LF (XML 1.0, 2.11).
@@ -171,7 +133,7 @@ async fn client_properties_are_stored() {
&find_body(CALSERVER, "me-card"),
)
.await;
let (ps, _) = props(&r);
let ps = props(&r);
let href = xml::child(&find(&ps, CALSERVER, "me-card").unwrap().1, DAV, "href").map(xml::text);
assert_eq!(
href.as_deref(),
@@ -186,17 +148,20 @@ async fn client_properties_are_stored() {
"",
)
.await;
let (ps, _) = props(&r);
let ps = props(&r);
assert_eq!(xml::text(&find(&ps, "urn:x", "note").unwrap().1), "hi");
// A computed property is refused by name, and nothing else is stored.
let mixed = "<d:propertyupdate xmlns:d=\"DAV:\"><d:set><d:prop><x:a xmlns:x=\"urn:x\">1</x:a>\
<d:getetag>x</d:getetag></d:prop></d:set></d:propertyupdate>";
let r = req(&env, "PROPPATCH", home, &alice, &[], mixed).await;
let (ps, error) = props(&r);
let codes: Vec<u16> = ps.iter().map(|(c, _)| *c).collect();
let codes: Vec<u16> = props(&r).iter().map(|(c, _)| *c).collect();
assert_eq!(codes, [424, 403]);
assert!(error.unwrap().is(DAV, "cannot-modify-protected-property"));
let root = Element::parse(r.body.as_slice()).unwrap();
let response = xml::elements(&root).next().unwrap();
let error = xml::child(response, DAV, "error").unwrap();
let condition = Name::of(xml::elements(error).next().unwrap());
assert!(condition.is(DAV, "cannot-modify-protected-property"));
let r = req(
&env,
"PROPFIND",
@@ -206,16 +171,16 @@ async fn client_properties_are_stored() {
&find_body("urn:x", "a"),
)
.await;
assert_eq!(find(&props(&r).0, "urn:x", "a").unwrap().0, 404);
assert_eq!(find(&props(&r), "urn:x", "a").unwrap().0, 404);
// Unknown properties no longer fail a collection's PROPPATCH or MKCALENDAR.
let cal = "/pim/calendars/alice/default/";
let patch = "<d:propertyupdate xmlns:d=\"DAV:\" xmlns:i=\"http://apple.com/ns/ical/\"><d:set><d:prop>\
<i:calendar-color>#ff0000</i:calendar-color><x:foo xmlns:x=\"urn:x\">bar</x:foo></d:prop></d:set></d:propertyupdate>";
let (ps, _) = props(&req(&env, "PROPPATCH", cal, &alice, &[], patch).await);
let ps = props(&req(&env, "PROPPATCH", cal, &alice, &[], patch).await);
assert!(ps.iter().all(|(c, _)| *c == 200), "{ps:?}");
let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await;
let (ps, _) = props(&r);
let ps = props(&r);
assert_eq!(
xml::text(
&find(&ps, "http://apple.com/ns/ical/", "calendar-color")
@@ -248,24 +213,24 @@ async fn client_properties_are_stored() {
"",
)
.await;
assert!(find(&props(&r).0, CALDAV, "calendar-free-busy-set").is_some());
assert!(find(&props(&r), CALDAV, "calendar-free-busy-set").is_some());
// Removing works, and an oversized value is refused.
let remove = "<d:propertyupdate xmlns:d=\"DAV:\"><d:remove><d:prop><x:foo xmlns:x=\"urn:x\"/></d:prop></d:remove></d:propertyupdate>";
let (ps, _) = props(&req(&env, "PROPPATCH", cal, &alice, &[], remove).await);
let ps = props(&req(&env, "PROPPATCH", cal, &alice, &[], remove).await);
assert_eq!(ps[0].0, 200);
let r = req(&env, "PROPFIND", cal, &alice, &[("depth", "0")], "").await;
assert!(find(&props(&r).0, "urn:x", "foo").is_none());
assert!(find(&props(&r), "urn:x", "foo").is_none());
let big = format!(
"<d:propertyupdate xmlns:d=\"DAV:\"><d:set><d:prop><x:big xmlns:x=\"urn:x\">{}</x:big></d:prop></d:set></d:propertyupdate>",
"a".repeat(70_000)
);
let (ps, _) = props(&req(&env, "PROPPATCH", cal, &alice, &[], &big).await);
let ps = props(&req(&env, "PROPPATCH", cal, &alice, &[], &big).await);
assert_eq!(ps[0].0, 507);
// A borrower reads the owner's properties and cannot change them.
let cid = alice_calendar_id(&env).await;
let alice_client = login(&env, "alice", PW).await;
let cid = collection_id(&alice_client, cal).await;
let r = alice_client
.post_json(
&format!("/api/pim/collections/{cid}/shares"),
@@ -278,37 +243,19 @@ async fn client_properties_are_stored() {
let r = req(&env, "PROPFIND", &lent, &bob, &[("depth", "0")], "").await;
assert_eq!(
xml::text(
&find(&props(&r).0, "http://apple.com/ns/ical/", "calendar-color")
&find(&props(&r), "http://apple.com/ns/ical/", "calendar-color")
.unwrap()
.1
),
"#ff0000"
);
let (ps, _) = props(&req(&env, "PROPPATCH", &lent, &bob, &[], patch).await);
let ps = props(&req(&env, "PROPPATCH", &lent, &bob, &[], patch).await);
assert!(ps.iter().all(|(code, _)| *code == 403), "{ps:?}");
// And another account's home is not writable.
let r = req(&env, "PROPPATCH", home, &bob, &[], custom).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
}
async fn alice_calendar_id(env: &Env) -> i64 {
let alice = login(env, "alice", PW).await;
let r = alice.get("/api/pim/collections").await;
r.json()
.as_array()
.unwrap()
.iter()
.find(|c| {
c["kind"] == "calendar"
&& c["url"]
.as_str()
.is_some_and(|u| u.ends_with("/calendars/alice/default/"))
})
.unwrap()["id"]
.as_i64()
.unwrap()
}
#[tokio::test]
async fn vcard_three_for_apple() {
let (env, _) = setup(&["alice"]).await;
@@ -316,7 +263,7 @@ async fn vcard_three_for_apple() {
let book = "/pim/addressbooks/alice/default/";
let r = req(&env, "PROPFIND", book, &auth, &[("depth", "0")], "").await;
let (ps, _) = props(&r);
let ps = props(&r);
let data = &find(&ps, CARDDAV, "supported-address-data").unwrap().1;
let versions: Vec<_> = xml::elements(data)
.filter_map(|e| e.attributes.get("version"))
@@ -391,7 +338,7 @@ async fn addresses_and_logins_for_unusual_names() {
Vec::new(),
)
.await;
let (ps, _) = props(&r);
let ps = props(&r);
let set = &find(&ps, CALDAV, "calendar-user-address-set").unwrap().1;
xml::text(xml::elements(set).next().unwrap())
}
▾Mserver/tests/api/pim_derived.rs
@@ -4,7 +4,6 @@
use crate::common::*;
use axum::http::{Method, StatusCode};
use base64::Engine;
use pimdav::xml::{self, DAV};
use serde_json::json;
use xmltree::Element;
@@ -29,15 +28,15 @@ impl Pim {
}
async fn req(&self, user: &str, verb: &str, path: &str, depth: &str, body: &str) -> Resp {
let auth = basic(user, PW);
Client::new(self.env.app.clone())
.raw(
Method::from_bytes(verb.as_bytes()).unwrap(),
path,
&[("authorization", &auth), ("depth", depth)],
body.as_bytes().to_vec(),
)
.await
req(
&self.env,
verb,
path,
&basic(user, PW),
&[("depth", depth)],
body,
)
.await
}
async fn put(&self, user: &str, path: &str, body: &str) {
@@ -50,15 +49,8 @@ impl Pim {
);
}
/// The hrefs PROPFIND lists below a collection.
async fn members(&self, user: &str, collection: &str) -> Vec<String> {
let r = self.req(user, "PROPFIND", collection, "1", "").await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let root = Element::parse(r.body.as_slice()).unwrap();
xml::elements(&root)
.map(|resp| xml::text(xml::child(resp, DAV, "href").unwrap()))
.filter(|h| h != collection)
.collect()
members(&self.env, &basic(user, PW), collection).await
}
async fn sync_token(&self, user: &str, collection: &str) -> String {
@@ -70,22 +62,6 @@ impl Pim {
let prop = xml::child(stat, DAV, "prop").unwrap();
xml::text(xml::child(prop, DAV, "sync-token").unwrap())
}
/// The id of alice's collection with this URL.
async fn id(&self, url: &str) -> i64 {
let list = self.alice.get("/api/pim/collections").await.json();
list.as_array()
.unwrap()
.iter()
.find(|c| c["url"] == url)
.unwrap_or_else(|| panic!("no collection {url}: {list}"))["id"]
.as_i64()
.unwrap()
}
}
fn unfold(s: &str) -> String {
s.replace("\r\n ", "")
}
fn contact(uid: &str, extra: &str) -> String {
@@ -94,12 +70,8 @@ fn contact(uid: &str, extra: &str) -> String {
/// A contact whose PHOTO is a small PNG, inline as vCard 3 does it.
fn contact_with_photo(uid: &str) -> String {
let mut png = Vec::new();
image::RgbImage::from_pixel(8, 8, image::Rgb([200, 30, 30]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
contact(uid, &format!("PHOTO;ENCODING=b;TYPE=PNG:{b64}\r\n"))
let png = "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAEUlEQVR42mM4ISeHFTEMLQkAkL9BAc9woTwAAAAASUVORK5CYII=";
contact(uid, &format!("PHOTO;ENCODING=b;TYPE=PNG:{png}\r\n"))
}
#[tokio::test]
@@ -119,7 +91,7 @@ async fn contact_photos() {
.await;
pim.put("alice", &format!("{BOOK}none.vcf"), &contact("none", ""))
.await;
let id = pim.id(BOOK).await;
let id = collection_id(&pim.alice, BOOK).await;
let photo = |name: &str| format!("/api/pim/collections/{id}/objects/{name}/photo");
let r = pim.alice.get(&photo("anna.vcf")).await;
@@ -163,7 +135,7 @@ async fn contact_photos() {
assert_eq!(bob.get(&photo("anna.vcf")).await.status, StatusCode::OK);
// A calendar holds no photos.
let cal = pim.id("/pim/calendars/alice/default/").await;
let cal = collection_id(&pim.alice, "/pim/calendars/alice/default/").await;
let r = pim
.alice
.get(&format!("/api/pim/collections/{cal}/objects/x.ics/photo"))
@@ -199,7 +171,7 @@ async fn photos_without_a_cache() {
)
.await;
let etag = broken.header("etag").unwrap();
let id = pim.id(BOOK).await;
let id = collection_id(&pim.alice, BOOK).await;
let photo = |name: &str| format!("/api/pim/collections/{id}/objects/{name}/photo");
let r = pim.alice.get(&photo("anna.vcf")).await;
@@ -344,10 +316,6 @@ fn meeting(uid: &str, organizer: &str, attendees: &[&str], start: &str) -> Strin
)
}
fn addr(user: &str) -> String {
format!("mailto:{user}@dovenest.invalid")
}
#[tokio::test]
async fn deleted_principals_are_forgotten() {
let pim = Pim::new(Env::new().await).await;
@@ -454,7 +422,7 @@ async fn deleted_principals_are_forgotten() {
#[tokio::test]
async fn admin_sees_and_revokes_feeds() {
let pim = Pim::new(Env::new().await).await;
let id = pim.id("/pim/calendars/alice/default/").await;
let id = collection_id(&pim.alice, "/pim/calendars/alice/default/").await;
let r = pim
.alice
.post_json(
@@ -500,7 +468,7 @@ async fn admin_sees_and_revokes_feeds() {
async fn own_shares_list_only_mine() {
let pim = Pim::new(Env::new().await).await;
let bob = login(&pim.env, "bob", PW).await;
let cal = pim.id("/pim/calendars/alice/default/").await;
let cal = collection_id(&pim.alice, "/pim/calendars/alice/default/").await;
let r = pim
.alice
.post_json(&format!("/api/pim/collections/{cal}/links"), &json!({}))
▾Mserver/tests/api/pim_io.rs
@@ -23,16 +23,16 @@ impl Io {
}
async fn dav(&self, user: &str, verb: &str, path: &str, body: &str) -> Resp {
let auth = basic(user, PW);
// Without Depth, PROPFIND lists only the collection itself.
Client::new(self.env.app.clone())
.raw(
Method::from_bytes(verb.as_bytes()).unwrap(),
path,
&[("authorization", &auth), ("depth", "1")],
body.as_bytes().to_vec(),
)
.await
req(
&self.env,
verb,
path,
&basic(user, PW),
&[("depth", "1")],
body,
)
.await
}
async fn anon(&self, path: &str, headers: &[(&str, &str)]) -> Resp {
@@ -41,18 +41,6 @@ impl Io {
.await
}
/// The id of alice's collection with this CalDAV/CardDAV URL.
async fn id(&self, url: &str) -> i64 {
let list = self.alice.get("/api/pim/collections").await.json();
list.as_array()
.unwrap()
.iter()
.find(|c| c["url"] == url)
.unwrap_or_else(|| panic!("no collection {url}: {list}"))["id"]
.as_i64()
.unwrap()
}
async fn link(&self, id: i64, body: Value) -> String {
let r = self
.alice
@@ -86,8 +74,8 @@ fn event(uid: &str, summary: &str, extra: &str) -> String {
#[tokio::test]
async fn feeds() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let book = io.id("/pim/addressbooks/alice/default/").await;
let cal = collection_id(&io.alice, CAL).await;
let book = collection_id(&io.alice, "/pim/addressbooks/alice/default/").await;
for uid in ["a", "b"] {
let r = io
.dav(
@@ -228,7 +216,7 @@ async fn feeds() {
.dav("alice", "MKCALENDAR", "/pim/calendars/alice/work/", "")
.await;
assert_eq!(r.status, StatusCode::CREATED);
let work = io.id("/pim/calendars/alice/work/").await;
let work = collection_id(&io.alice, "/pim/calendars/alice/work/").await;
let gone = io.link(work, json!({})).await;
assert_eq!(io.anon(&gone, &[]).await.status, StatusCode::OK);
io.dav("alice", "DELETE", "/pim/calendars/alice/work/", "")
@@ -239,7 +227,7 @@ async fn feeds() {
#[tokio::test]
async fn import_splits_and_updates() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let cal = collection_id(&io.alice, CAL).await;
let file = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\nMETHOD:PUBLISH\r\n\
BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20260105T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20260106T100000Z\r\nDTSTART:20260106T120000Z\r\nEND:VEVENT\r\n\
@@ -282,7 +270,7 @@ async fn import_splits_and_updates() {
#[tokio::test]
async fn an_import_of_meetings_long_over_sends_nothing() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let cal = collection_id(&io.alice, CAL).await;
let people =
"ORGANIZER:mailto:alice@dovenest.invalid\r\nATTENDEE:mailto:bob@dovenest.invalid\r\n";
let r = io.import(cal, &event("old", "Old", people)).await;
@@ -314,7 +302,7 @@ async fn an_import_of_meetings_long_over_sends_nothing() {
#[tokio::test]
async fn import_schedules_like_a_put_and_keeps_uniqueness() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let cal = collection_id(&io.alice, CAL).await;
// A yearly series has not ended, so it schedules.
let meeting = event(
"meet",
@@ -341,7 +329,7 @@ async fn import_schedules_like_a_put_and_keeps_uniqueness() {
// One scheduling object per UID and owner (RFC 6638, 3.2.4.1).
io.dav("alice", "MKCALENDAR", "/pim/calendars/alice/work/", "")
.await;
let work = io.id("/pim/calendars/alice/work/").await;
let work = collection_id(&io.alice, "/pim/calendars/alice/work/").await;
let r = io.import(work, &meeting).await;
assert_eq!(r["created"], 0);
assert_eq!(r["skipped"][0]["uid"], "meet");
@@ -391,7 +379,7 @@ async fn import_schedules_like_a_put_and_keeps_uniqueness() {
#[tokio::test]
async fn export_round_trip() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let cal = collection_id(&io.alice, CAL).await;
io.import(cal, &event("r1", "One", "")).await;
io.import(cal, &event("r2", "Two", "")).await;
@@ -408,12 +396,12 @@ async fn export_round_trip() {
// The export imports back as the same objects.
io.dav("alice", "MKCALENDAR", "/pim/calendars/alice/copy/", "")
.await;
let copy = io.id("/pim/calendars/alice/copy/").await;
let copy = collection_id(&io.alice, "/pim/calendars/alice/copy/").await;
let r = io.import(copy, &exported).await;
assert_eq!(r["created"], 2, "{r}");
// vCards, one without UID.
let book = io.id("/pim/addressbooks/alice/default/").await;
let book = collection_id(&io.alice, "/pim/addressbooks/alice/default/").await;
let cards = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:One\r\nEND:VCARD\r\n\
BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Two\r\nEND:VCARD\r\n";
let r = io.import(book, cards).await;
@@ -421,8 +409,13 @@ async fn export_round_trip() {
let r = io.import(book, cards).await;
assert_eq!(r["updated"], 2, "{r}");
// The system address book has no collection id of its own.
let r = io.alice.get("/api/pim/system/export").await;
// The system address book is collection 0.
let export = format!(
"{}/0{}",
api_types::PIM_COLLECTIONS,
api_types::EXPORT_SUFFIX
);
let r = io.alice.get(&export).await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.header("content-disposition").unwrap().contains(".vcf"));
assert!(r.text().contains("FN:alice"));
@@ -518,7 +511,7 @@ async fn share_candidates_leave_out_owner_borrowers_and_disabled() {
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let id = io.id(CAL).await;
let id = collection_id(&io.alice, CAL).await;
let r = io
.alice
.post_json(
@@ -630,7 +623,7 @@ async fn collection_names_and_descriptions_are_checked() {
#[tokio::test]
async fn a_feed_password_matches_with_edge_spaces() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let cal = collection_id(&io.alice, CAL).await;
let locked = io.link(cal, json!({ "password": " feedpass123 " })).await;
for pw in [" feedpass123 ", "feedpass123"] {
let r = io.anon(&locked, &[("authorization", &basic("", pw))]).await;
@@ -642,7 +635,9 @@ async fn a_feed_password_matches_with_edge_spaces() {
async fn feeds_of_a_disabled_owner_stop() {
let io = Io::new().await;
let admin = login(&io.env, "admin", "admin1234").await;
let feed = io.link(io.id(CAL).await, json!({})).await;
let feed = io
.link(collection_id(&io.alice, CAL).await, json!({}))
.await;
let alice = user_id(&admin, "alice").await;
for (active, status) in [(false, StatusCode::NOT_FOUND), (true, StatusCode::OK)] {
let r = admin
@@ -661,7 +656,7 @@ async fn a_loan_to_a_disabled_user_takes_a_new_mode() {
let io = Io::new().await;
let admin = login(&io.env, "admin", "admin1234").await;
create_user(&admin, "carol", PW, &[]).await;
let id = io.id(CAL).await;
let id = collection_id(&io.alice, CAL).await;
let shares = format!("/api/pim/collections/{id}/shares");
let r = io
.alice
@@ -703,7 +698,10 @@ async fn an_import_racing_the_collection_delete_never_fails_with_500() {
for i in 0..20 {
let r = io.dav("alice", "MKCALENDAR", url, "").await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let path = format!("/api/pim/collections/{}/import", io.id(url).await);
let path = format!(
"/api/pim/collections/{}/import",
collection_id(&io.alice, url).await
);
let (import, delete) = tokio::join!(
io.alice.raw(
Method::POST,
@@ -726,7 +724,7 @@ async fn an_import_racing_the_collection_delete_never_fails_with_500() {
#[tokio::test]
async fn the_web_api_keeps_the_dav_limits() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let cal = collection_id(&io.alice, CAL).await;
// An object over the 10 MiB a PUT takes is skipped.
let big = event(
"big",
▾Mserver/tests/api/pim_schedule.rs
@@ -33,30 +33,11 @@ impl Pim {
extra: &[(&str, &str)],
body: &str,
) -> Resp {
let auth = basic(user, PW);
let mut headers = vec![("authorization", auth.as_str())];
headers.extend_from_slice(extra);
Client::new(self.env.app.clone())
.raw(
Method::from_bytes(verb.as_bytes()).unwrap(),
path,
&headers,
body.as_bytes().to_vec(),
)
.await
req(&self.env, verb, path, &basic(user, PW), extra, body).await
}
/// The hrefs of the members of a collection.
async fn members(&self, user: &str, collection: &str) -> Vec<String> {
let r = self
.req(user, "PROPFIND", collection, &[("depth", "1")], "")
.await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let root = Element::parse(r.body.as_slice()).unwrap();
xml::elements(&root)
.map(|resp| xml::text(xml::child(resp, DAV, "href").unwrap()))
.filter(|h| h != collection)
.collect()
members(&self.env, &basic(user, PW), collection).await
}
/// The only object of a user's default calendar.
@@ -85,14 +66,6 @@ fn cal(user: &str) -> String {
format!("/pim/calendars/{user}/default/")
}
fn addr(user: &str) -> String {
format!("mailto:{user}@dovenest.invalid")
}
fn unfold(s: &str) -> String {
s.replace("\r\n ", "")
}
/// A parameter of the ATTENDEE property of `who`.
fn attendee_param(ics: &str, who: &str, param: &str) -> Option<String> {
let suffix = format!(":{who}");
@@ -135,11 +108,6 @@ async fn invite(pim: &Pim, attendees: &[&str]) -> Resp {
r
}
fn error_condition(r: &Resp) -> Name {
let root = Element::parse(r.body.as_slice()).unwrap_or_else(|_| panic!("{}", r.text()));
Name::of(xml::elements(&root).next().unwrap())
}
#[tokio::test]
async fn discovery_names_inbox_and_outbox() {
let pim = Pim::new().await;
▾Mserver/tests/api/pim_ui.rs
@@ -1,7 +1,7 @@
//! The JSON endpoints behind the calendar and contacts web UI.
use crate::common::*;
use axum::http::{Method, StatusCode};
use axum::http::StatusCode;
use serde_json::{Value, json};
const PW: &str = "secret12345";
@@ -24,14 +24,15 @@ impl Ui {
}
async fn dav(&self, user: &str, verb: &str, path: &str, body: &str) -> Resp {
Client::new(self.env.app.clone())
.raw(
Method::from_bytes(verb.as_bytes()).unwrap(),
path,
&[("authorization", &basic(user, PW)), ("depth", "1")],
body.as_bytes().to_vec(),
)
.await
req(
&self.env,
verb,
path,
&basic(user, PW),
&[("depth", "1")],
body,
)
.await
}
async fn put(&self, user: &str, path: &str, body: &str) {
@@ -49,20 +50,6 @@ impl Ui {
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
r.json().as_array().unwrap().clone()
}
async fn id(&self, who: &Client, url: &str) -> i64 {
self.list(who)
.await
.iter()
.find(|c| c["url"] == url)
.unwrap_or_else(|| panic!("no {url}"))["id"]
.as_i64()
.unwrap()
}
}
fn ics(body: &str) -> String {
format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n{body}END:VCALENDAR\r\n")
}
fn event(uid: &str, props: &str) -> String {
@@ -300,7 +287,7 @@ async fn instances_expand_readable_calendars() {
.count()
};
assert_eq!(count(ui.bob.get(q).await.json()), 0);
let cal = ui.id(&ui.alice, "/pim/calendars/alice/default/").await;
let cal = collection_id(&ui.alice, "/pim/calendars/alice/default/").await;
ui.alice
.post_json(
&format!("/api/pim/collections/{cal}/shares"),
@@ -324,7 +311,7 @@ async fn object_detail_for_events_and_contacts() {
),
)
.await;
let cal = ui.id(&ui.alice, "/pim/calendars/alice/default/").await;
let cal = collection_id(&ui.alice, "/pim/calendars/alice/default/").await;
let r = ui
.alice
.get(&format!("/api/pim/collections/{cal}/objects/m.ics"))
@@ -338,7 +325,6 @@ async fn object_detail_for_events_and_contacts() {
assert_eq!(d["organizer"]["name"], "Alice");
assert_eq!(d["attendees"][1]["role"], "OPT-PARTICIPANT");
assert_eq!(d["attendees"][0]["is_owner"], true);
assert_eq!(d["can_edit"], true);
assert_eq!(d["can_reply"], false);
assert_eq!(d["start"], "2026-01-05T09:00:00Z");
let second = ui
@@ -397,7 +383,7 @@ async fn object_detail_for_events_and_contacts() {
"BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g\r\nFN:Team\r\nKIND:group\r\nMEMBER:urn:uuid:c1\r\nMEMBER:urn:uuid:gone\r\nEND:VCARD\r\n",
)
.await;
let book = ui.id(&ui.alice, "/pim/addressbooks/alice/default/").await;
let book = collection_id(&ui.alice, "/pim/addressbooks/alice/default/").await;
let d = ui
.alice
.get(&format!("/api/pim/collections/{book}/objects/c1.vcf"))
@@ -441,7 +427,6 @@ async fn object_detail_for_events_and_contacts() {
.await
.json();
assert_eq!(d["summary"], "🎂 Carla Díaz");
assert_eq!(d["can_edit"], false);
}
#[tokio::test]
@@ -735,7 +720,7 @@ async fn an_instance_a_range_moved_shows_the_ranges_text() {
),
)
.await;
let id = ui.id(&ui.alice, "/pim/calendars/alice/default/").await;
let id = collection_id(&ui.alice, "/pim/calendars/alice/default/").await;
let r = ui
.alice
.get(&format!(
▾Mserver/tests/common/mod.rs
@@ -11,10 +11,11 @@ use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::http::{HeaderMap, Method, StatusCode, header};
use http_body_util::BodyExt;
use pimdav::xml::{self, DAV, Name};
use server::db::Db;
use server::error::AppState;
use tower::ServiceExt;
use xmltree::Element;
// ---------------------------------------------------------------------------
// Environment
@@ -123,7 +124,7 @@ impl Env {
.await;
assert_eq!(r.status, StatusCode::OK, "{path} as {name}: {}", r.text());
let mut c = Client::new(self.app.clone());
c.set_cookie(&session_cookie(&r).expect("a session cookie"));
c.cookie = Some(session_cookie(&r).expect("a session cookie"));
c
}
}
@@ -171,16 +172,12 @@ impl Client {
Self { app, cookie: None }
}
pub fn set_cookie(&mut self, token: &str) {
self.cookie = Some(token.to_string());
}
pub async fn raw(
&self,
method: Method,
path: &str,
extra_headers: &[(&str, &str)],
body: Vec<u8>,
body: impl Into<Body>,
) -> Resp {
// Every real client sends one (HTTP/1.1 requires it), and the
// passkey routes need it to know which domain they speak for.
@@ -201,7 +198,7 @@ impl Client {
b = b.header(header::COOKIE, format!("dovenest_session={c}"));
}
let req = b
.body(Body::from(body))
.body(body.into())
.unwrap_or_else(|e| panic!("bad request: {e}"));
let res = self
.app
@@ -211,7 +208,9 @@ impl Client {
.unwrap_or_else(|e| panic!("request failed: {e}"));
let status = res.status();
let headers = res.headers().clone();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
Resp {
status,
headers,
@@ -292,9 +291,100 @@ pub fn session_cookie(r: &Resp) -> Option<String> {
/// An `Authorization: Basic` header value.
pub fn basic(name: &str, password: &str) -> String {
use base64::Engine as _;
let raw = base64::engine::general_purpose::STANDARD.encode(format!("{name}:{password}"));
format!("Basic {raw}")
use headers::authorization::Credentials;
let v = headers::Authorization::basic(name, password).0.encode();
v.to_str().unwrap().to_string()
}
/// A DAV request with Basic auth.
pub async fn req(
env: &Env,
verb: &str,
path: &str,
auth: &str,
extra: &[(&str, &str)],
body: &str,
) -> Resp {
let mut headers = vec![("authorization", auth)];
headers.extend_from_slice(extra);
Client::new(env.app.clone())
.raw(
Method::from_bytes(verb.as_bytes()).unwrap(),
path,
&headers,
body.to_string(),
)
.await
}
/// `href -> [(status, property element)]` of a multistatus.
pub fn parse_multistatus(r: &Resp) -> Vec<(String, Vec<(u16, Element)>)> {
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let root = Element::parse(r.body.as_slice()).unwrap();
xml::elements(&root)
.map(|resp| {
let href = xml::text(xml::child(resp, DAV, "href").unwrap());
let props = xml::elements(resp)
.filter(|e| Name::of(e).is(DAV, "propstat"))
.flat_map(|ps| {
let code: u16 = xml::text(xml::child(ps, DAV, "status").unwrap())
.split(' ')
.nth(1)
.unwrap()
.parse()
.unwrap();
let prop = xml::child(ps, DAV, "prop").unwrap();
xml::elements(prop)
.map(move |p| (code, p.clone()))
.collect::<Vec<_>>()
})
.collect();
(href, props)
})
.collect()
}
/// The precondition element of a `<d:error>` body.
pub fn error_condition(r: &Resp) -> Name {
let root = Element::parse(r.body.as_slice()).unwrap_or_else(|_| panic!("{}", r.text()));
assert!(Name::of(&root).is(DAV, "error"), "{}", r.text());
Name::of(xml::elements(&root).next().unwrap())
}
/// The hrefs PROPFIND lists below a collection.
pub async fn members(env: &Env, auth: &str, collection: &str) -> Vec<String> {
let r = req(env, "PROPFIND", collection, auth, &[("depth", "1")], "").await;
parse_multistatus(&r)
.into_iter()
.map(|(href, _)| href)
.filter(|h| h != collection)
.collect()
}
/// The id of the collection with this CalDAV/CardDAV URL, from the JSON API.
pub async fn collection_id(who: &Client, url: &str) -> i64 {
let r = who.get("/api/pim/collections").await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let list = r.json();
list.as_array()
.unwrap()
.iter()
.find(|c| c["url"] == url)
.unwrap_or_else(|| panic!("no collection {url}: {list}"))["id"]
.as_i64()
.unwrap()
}
pub fn ics(body: &str) -> String {
format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{body}END:VCALENDAR\r\n")
}
pub fn unfold(s: &str) -> String {
s.replace("\r\n ", "")
}
pub fn addr(user: &str) -> String {
format!("mailto:{user}@dovenest.invalid")
}
/// POST /api/admin/users helper (used by several test files).
▾Mweb/Cargo.toml
@@ -43,7 +43,6 @@ web-sys = { version = "0.3", features = [
"HtmlDivElement",
"HtmlElement",
"HtmlInputElement",
"HtmlSelectElement",
"KeyboardEvent",
"Location",
"MessageEvent",
▾Mweb/app.css
@@ -64,6 +64,11 @@ body {
font-size: 15px;
}
/* Class rules set `display`, which would beat the UA rule for `hidden`. */
[hidden] {
display: none !important;
}
.muted {
color: var(--muted);
}
@@ -160,7 +165,6 @@ textarea {
/* Square checkboxes — native controls are rounded, the theme is sharp. */
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
margin: 0;
@@ -583,6 +587,7 @@ button:disabled {
cursor: pointer;
color: var(--muted);
padding: 3px 6px;
overflow-wrap: anywhere;
}
.crumb:hover {
@@ -610,6 +615,14 @@ button:disabled {
padding-bottom: 12px;
}
/* Wider screens fit the sort controls by shrinking the label. Wrapping
there would move them to a second line. */
@media (max-width: 360px) {
.toolbar {
flex-wrap: wrap;
}
}
.toolbar-right {
display: flex;
gap: 6px;
@@ -1044,7 +1057,6 @@ button:disabled {
margin-bottom: 16px;
}
.modal-label.error,
.dialog-error {
color: var(--danger);
font-size: 13px;
@@ -2200,6 +2212,27 @@ button:disabled {
height: 14px;
}
/* One row needs more than a phone width. The query and run button stay
first, the scope and the folder get their own rows. */
@media (max-width: 640px) {
.query-bar {
flex-wrap: wrap;
}
.seg,
.folder-pick {
order: 1;
flex: 1 1 100%;
min-width: 0;
max-width: none;
min-height: 38px;
}
.seg button {
flex: 1;
}
}
/* Status line ------------------------------------------------------ */
.status-line {
@@ -2244,10 +2277,10 @@ button:disabled {
color: var(--muted);
}
/* Result group: a `display:flex` (or `none`, from the inline style) flex
item of `.search-view` that re-establishes the column layout for its
label/list/more-row children. */
/* Result group: a flex item of `.search-view` that re-establishes the
column layout for its label/list/more-row children. */
.result-group {
display: flex;
flex-direction: column;
gap: 14px;
}
@@ -2388,8 +2421,10 @@ mark.hl-hit {
color: var(--accent);
}
/* Up to 13 buttons: unwrapped they overflow a phone screen. */
.pf-pages {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
margin-right: auto;
@@ -2561,20 +2596,16 @@ body:has(.page-busy) {
background: var(--bg);
}
.chev .chev-down,
.chev .chev-up {
.chev .chev-mark {
display: block;
width: 10px;
height: 10px;
border-right: 2px solid var(--muted);
border-bottom: 2px solid var(--muted);
}
.chev .chev-down {
transform: rotate(45deg) translate(-1px, -1px);
}
.chev .chev-up {
.chev .chev-mark.up {
transform: rotate(-135deg) translate(-1px, -1px);
}
@@ -2939,6 +2970,16 @@ body:has(.page-busy) {
min-width: 0;
}
.pim-main {
flex: 1;
min-width: 0;
border: 1px solid var(--border);
background: var(--panel);
display: flex;
flex-direction: column;
min-height: 0;
}
.pim-side-head {
display: flex;
align-items: center;
@@ -3048,13 +3089,6 @@ body:has(.page-busy) {
white-space: nowrap;
}
.pim-main {
flex: 1;
min-width: 0;
border: 1px solid var(--border);
background: var(--panel);
}
.pim-modal {
width: 600px;
max-height: calc(100vh - 48px);
@@ -3341,12 +3375,6 @@ select.field-input:focus {
display: none;
}
.pim-main {
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---- Calendar ---------------------------------------------------- */
.cal {
▾Mweb/src/api.rs
@@ -20,8 +20,8 @@ use api_types::{
CreateShare, CreateUser, Credentials, ExistsReq, ExistsResp, FILES, FINISH_SUFFIX, LoginReq,
Mutation, P_ACTION, P_AROUND, P_DESC, P_DIRS, P_FORMAT, P_LIMIT, P_OFFSET, P_OVERWRITE, P_PATH,
P_Q, P_ROOT, P_SCOPE, P_SHARE, P_SORT, PasskeyLoginBegin, PasskeyLoginFinish,
PasskeyRegisterFinish, Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SetAuthMode, Settings,
SortKey, UnlockShare, UpdateUser,
PasskeyRegisterFinish, ProfilePatch, Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
SetAuthMode, Settings, SortKey, UnlockShare, UpdateUser,
};
use api_types::{
ADMIN_PIM_LINKS, ADMIN_ROOMS, CANDIDATES_SUFFIX, CreatePimCollection, CreatePimLink,
@@ -46,7 +46,6 @@ pub enum ApiError {
/// Non-2xx response. `skipped` carries the server's conflict file list
/// when present (upload conflicts).
Http {
#[allow(dead_code)]
status: u16,
message: String,
skipped: Option<Vec<String>>,
@@ -125,38 +124,21 @@ impl ErrBody {
// ---------------------------------------------------------------------------
pub async fn me() -> Result<Me, ApiError> {
let me: Me = request("GET", AUTH_ME.to_string(), None::<()>).await?;
let me: Me = get(AUTH_ME).await?;
crate::router::set_public_url(me.public_url.clone());
Ok(me)
}
/// PUT /api/auth/me — update the signed-in user's profile settings.
/// Omitted fields are left unchanged; `language: Some(None)` means "follow
/// the browser".
#[derive(Serialize, Default)]
struct ProfilePatch {
#[serde(skip_serializing_if = "Option::is_none")]
pub async fn update_profile(
single_click_open: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
thumbnails: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
language: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
default_root_id: Option<Option<i64>>,
#[serde(skip_serializing_if = "Option::is_none")]
week_start: Option<u8>,
}
pub fn update_profile(
single_click_open: Option<bool>,
thumbnails: Option<bool>,
language: Option<Option<String>>,
default_root_id: Option<Option<i64>>,
week_start: Option<u8>,
) -> impl std::future::Future<Output = Result<Me, ApiError>> {
) -> Result<Me, ApiError> {
request(
"PUT",
AUTH_ME.to_string(),
AUTH_ME,
Some(ProfilePatch {
single_click_open,
thumbnails,
@@ -165,6 +147,7 @@ pub fn update_profile(
week_start,
}),
)
.await
}
/// The password leg of signing in.
@@ -172,71 +155,57 @@ pub fn update_profile(
/// `state_id` names a passkey leg that already identified the account, which
/// is how an account requiring both factors finishes when the user starts
/// with the passkey. Then `name` is not needed and is ignored.
pub fn login(
pub async fn login(
name: Option<String>,
password: String,
state_id: Option<String>,
) -> impl std::future::Future<Output = Result<LoginResp, ApiError>> {
) -> Result<LoginResp, ApiError> {
request(
"POST",
AUTH_LOGIN.to_string(),
AUTH_LOGIN,
Some(LoginReq {
name,
password,
state_id,
}),
)
.await
}
// ---------------------------------------------------------------------------
// Credentials: password, sign-in mode, passkeys
// ---------------------------------------------------------------------------
pub fn change_password(
new_password: String,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"POST",
AUTH_PASSWORD.to_string(),
Some(ChangePassword { new_password }),
)
pub async fn change_password(new_password: String) -> Result<OkResp, ApiError> {
request("POST", AUTH_PASSWORD, Some(ChangePassword { new_password })).await
}
pub fn delete_password() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", AUTH_PASSWORD.to_string(), None::<()>)
pub async fn delete_password() -> Result<OkResp, ApiError> {
del(AUTH_PASSWORD).await
}
pub fn set_auth_mode(
mode: AuthMode,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("PUT", AUTH_MODE.to_string(), Some(SetAuthMode { mode }))
pub async fn set_auth_mode(mode: AuthMode) -> Result<OkResp, ApiError> {
request("PUT", AUTH_MODE, Some(SetAuthMode { mode })).await
}
pub fn list_passkeys() -> impl std::future::Future<Output = Result<Vec<PasskeyInfo>, ApiError>> {
request("GET", AUTH_PASSKEYS.to_string(), None::<()>)
pub async fn list_passkeys() -> Result<Vec<PasskeyInfo>, ApiError> {
get(AUTH_PASSKEYS).await
}
pub fn delete_passkey(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{AUTH_PASSKEYS}/{id}"), None::<()>)
pub async fn delete_passkey(id: i64) -> Result<OkResp, ApiError> {
del(&format!("{AUTH_PASSKEYS}/{id}")).await
}
pub fn list_app_passwords()
-> impl std::future::Future<Output = Result<Vec<AppPasswordInfo>, ApiError>> {
request("GET", AUTH_APP_PASSWORDS.to_string(), None::<()>)
pub async fn list_app_passwords() -> Result<Vec<AppPasswordInfo>, ApiError> {
get(AUTH_APP_PASSWORDS).await
}
pub fn create_app_password(
name: String,
) -> impl std::future::Future<Output = Result<NewAppPassword, ApiError>> {
request(
"POST",
AUTH_APP_PASSWORDS.to_string(),
Some(CreateAppPassword { name }),
)
pub async fn create_app_password(name: String) -> Result<NewAppPassword, ApiError> {
request("POST", AUTH_APP_PASSWORDS, Some(CreateAppPassword { name })).await
}
pub fn delete_app_password(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{AUTH_APP_PASSWORDS}/{id}"), None::<()>)
pub async fn delete_app_password(id: i64) -> Result<OkResp, ApiError> {
del(&format!("{AUTH_APP_PASSWORDS}/{id}")).await
}
/// Register a passkey end to end: ask for a challenge, hand it to the
@@ -245,14 +214,13 @@ pub fn delete_app_password(id: i64) -> impl std::future::Future<Output = Result<
/// One function rather than two calls at the view layer, because the two legs
/// are useless apart and the handle between them is not the view's business.
pub async fn add_passkey(name: String) -> Result<PasskeyInfo, ApiError> {
let challenge: PasskeyChallenge =
request("POST", AUTH_PASSKEYS_REGISTER.to_string(), None::<()>).await?;
let challenge: PasskeyChallenge = request("POST", AUTH_PASSKEYS_REGISTER, None::<()>).await?;
let credential = crate::passkey::create(&challenge.options)
.await
.map_err(ApiError::Net)?;
request(
"POST",
format!("{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}"),
&format!("{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}"),
Some(PasskeyRegisterFinish {
state_id: challenge.state_id,
name,
@@ -272,7 +240,7 @@ pub async fn passkey_login(
) -> Result<Option<LoginResp>, ApiError> {
let challenge: PasskeyChallenge = request(
"POST",
AUTH_PASSKEY_LOGIN.to_string(),
AUTH_PASSKEY_LOGIN,
Some(PasskeyLoginBegin { name, conditional }),
)
.await?;
@@ -294,7 +262,7 @@ pub async fn passkey_finish(
};
request(
"POST",
format!("{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}"),
&format!("{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}"),
Some(PasskeyLoginFinish {
state_id: challenge.state_id,
credential,
@@ -304,19 +272,12 @@ pub async fn passkey_finish(
.map(Some)
}
pub fn setup(
name: String,
password: String,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"POST",
AUTH_SETUP.to_string(),
Some(Credentials { name, password }),
)
pub async fn setup(name: String, password: String) -> Result<OkResp, ApiError> {
request("POST", AUTH_SETUP, Some(Credentials { name, password })).await
}
pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("POST", AUTH_LOGOUT.to_string(), Some(()))
pub async fn logout() -> Result<OkResp, ApiError> {
request("POST", AUTH_LOGOUT, Some(())).await
}
// ---------------------------------------------------------------------------
@@ -367,7 +328,7 @@ fn files_url(root_id: i64, path: &str) -> String {
/// One page of a folder, in the given order. With `around`, the page that
/// holds that name.
pub fn list_files(
pub async fn list_files(
root_id: i64,
path: &str,
sort: SortKey,
@@ -375,7 +336,7 @@ pub fn list_files(
offset: usize,
limit: usize,
around: Option<&str>,
) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
) -> Result<FilesResp, ApiError> {
let mut query = format!(
"{P_SORT}={}&{P_DESC}={desc}&{P_OFFSET}={offset}&{P_LIMIT}={limit}",
sort.as_str()
@@ -386,11 +347,7 @@ pub fn list_files(
String::from(js_sys::encode_uri_component(name))
));
}
request(
"GET",
append_query(&files_url(root_id, path), &query),
None::<()>,
)
get(&append_query(&files_url(root_id, path), &query)).await
}
/// One entry of a folder, with its sniffed kind. `None` when it is gone.
@@ -400,48 +357,39 @@ pub async fn find_entry(root_id: i64, dir: &str, name: &str) -> Result<Option<En
}
/// The subfolders of a folder, by name.
pub fn list_dirs(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
pub async fn list_dirs(root_id: i64, path: &str) -> Result<FilesResp, ApiError> {
let url = append_query(&files_url(root_id, path), &format!("{P_DIRS}=true"));
request("GET", url, None::<()>)
get(&url).await
}
/// Create an empty file. `path` is relative to the root (may contain
/// subfolders); the file must not exist yet.
pub fn create_file(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
pub async fn create_file(root_id: i64, path: &str) -> Result<OkResp, ApiError> {
let url = append_query(
&files_url(root_id, path),
&format!("{P_ACTION}={ACTION_CREATE_FILE}"),
);
request("POST", url, None::<()>)
request("POST", &url, None::<()>).await
}
/// Create a folder. `path` is relative to the root (may contain subfolders).
pub fn mkdir(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
pub async fn mkdir(root_id: i64, path: &str) -> Result<OkResp, ApiError> {
let url = append_query(
&files_url(root_id, path),
&format!("{P_ACTION}={ACTION_MKDIR}"),
);
request("POST", url, None::<()>)
request("POST", &url, None::<()>).await
}
pub fn rename_item(
pub async fn rename_item(
root_id: i64,
path: &str,
new_name: String,
overwrite: bool,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
) -> Result<OkResp, ApiError> {
request(
"POST",
files_url(root_id, path),
&files_url(root_id, path),
Some(Mutation {
op: Op::Rename,
new_name: Some(new_name),
@@ -450,20 +398,21 @@ pub fn rename_item(
overwrite,
}),
)
.await
}
/// Move or copy an item into `dst` of `dst_root_id`.
pub fn mutation(
pub async fn mutation(
root_id: i64,
path: &str,
op: Op,
dst_root_id: i64,
dst: &str,
overwrite: bool,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
) -> Result<OkResp, ApiError> {
request(
"POST",
files_url(root_id, path),
&files_url(root_id, path),
Some(Mutation {
op,
new_name: None,
@@ -472,13 +421,11 @@ pub fn mutation(
overwrite,
}),
)
.await
}
pub fn delete_item(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", files_url(root_id, path), None::<()>)
pub async fn delete_item(root_id: i64, path: &str) -> Result<OkResp, ApiError> {
del(&files_url(root_id, path)).await
}
// ---------------------------------------------------------------------------
@@ -533,9 +480,7 @@ pub async fn fetch_content_meta(
root_id: i64,
path: &str,
) -> Result<(String, Option<i64>), ApiError> {
let opts = web_sys::RequestInit::new();
opts.set_method("GET");
opts.set_mode(web_sys::RequestMode::SameOrigin);
let opts = init("GET");
let resp = fetch_checked(&content_url(root_id, path), &opts, "could not read file").await?;
let mtime: Option<i64> = resp
.headers()
@@ -543,13 +488,9 @@ pub async fn fetch_content_meta(
.ok()
.flatten()
.and_then(|s| s.parse::<i64>().ok());
let tp = resp.text().map_err(|e| ApiError::Net(js_msg(&e)))?;
let js = JsFuture::from(tp)
let text = response_text(&resp)
.await
.map_err(|e| ApiError::Net(js_msg(&e)))?;
let text = js
.as_string()
.ok_or_else(|| ApiError::Net("content is not a string".to_string()))?;
.ok_or_else(|| ApiError::Net("could not read the response body".to_string()))?;
Ok((text, mtime))
}
@@ -567,9 +508,7 @@ pub async fn save_content(
force: bool,
) -> Result<i64, ApiError> {
let url = content_url(root_id, path);
let opts = web_sys::RequestInit::new();
opts.set_method("PUT");
opts.set_mode(web_sys::RequestMode::SameOrigin);
let opts = init("PUT");
opts.set_body_opt_str(Some(text));
let headers = web_sys::Headers::new().map_err(|e| ApiError::Net(js_msg(&e)))?;
headers
@@ -697,7 +636,7 @@ pub async fn check_exists(
}
async fn exists_chunk(url: &str, paths: Vec<String>) -> Result<Vec<Existing>, ApiError> {
let resp: ExistsResp = request("POST", url.to_string(), Some(ExistsReq { paths })).await?;
let resp: ExistsResp = request("POST", url, Some(ExistsReq { paths })).await?;
Ok(resp.existing)
}
@@ -789,20 +728,20 @@ pub fn start_upload(
// Shares (milestone 6)
// ---------------------------------------------------------------------------
pub fn list_shares() -> impl std::future::Future<Output = Result<Vec<ShareInfo>, ApiError>> {
request("GET", SHARES.to_string(), None::<()>)
pub async fn list_shares() -> Result<Vec<ShareInfo>, ApiError> {
get(SHARES).await
}
pub fn create_share(
pub async fn create_share(
root_id: i64,
path: &str,
writable: bool,
expires_at: Option<&str>,
password: Option<&str>,
) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
) -> Result<ShareInfo, ApiError> {
request(
"POST",
SHARES.to_string(),
SHARES,
Some(CreateShare {
root_id,
path: path.to_string(),
@@ -811,43 +750,40 @@ pub fn create_share(
password: password.map(|s| s.to_string()),
}),
)
.await
}
pub fn delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{SHARES}/{id}"), None::<()>)
pub async fn delete_share(id: i64) -> Result<OkResp, ApiError> {
del(&format!("{SHARES}/{id}")).await
}
/// Admin only: every share on the server with its creator.
pub fn list_all_shares() -> impl std::future::Future<Output = Result<Vec<AdminShare>, ApiError>> {
request("GET", ADMIN_SHARES.to_string(), None::<()>)
pub async fn list_all_shares() -> Result<Vec<AdminShare>, ApiError> {
get(ADMIN_SHARES).await
}
/// Admin only: end a share whoever created it.
pub fn admin_delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{ADMIN_SHARES}/{id}"), None::<()>)
pub async fn admin_delete_share(id: i64) -> Result<OkResp, ApiError> {
del(&format!("{ADMIN_SHARES}/{id}")).await
}
/// Public: resolve a share (no session required). A password-protected
/// share answers 401 until [`unlock_share`] has run in this browser.
pub fn resolve_share(
token: &str,
) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
request("GET", format!("{SHARE}/{token}"), None::<()>)
pub async fn resolve_share(token: &str) -> Result<ShareInfo, ApiError> {
get(&format!("{SHARE}/{token}")).await
}
/// Public: submit a protected share's password. The proof of the unlock is
/// a cookie the server sets, so nothing has to be kept here.
pub fn unlock_share(
token: &str,
password: &str,
) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
pub async fn unlock_share(token: &str, password: &str) -> Result<ShareInfo, ApiError> {
request(
"POST",
format!("{SHARE}/{token}{SHARE_UNLOCK_SUFFIX}"),
&format!("{SHARE}/{token}{SHARE_UNLOCK_SUFFIX}"),
Some(UnlockShare {
password: password.to_string(),
}),
)
.await
}
// ---------------------------------------------------------------------------
@@ -864,19 +800,19 @@ fn roots_to_bodies(roots: &[(String, Mode)]) -> Vec<Root> {
.collect()
}
pub fn list_admin_users() -> impl std::future::Future<Output = Result<Vec<AdminUser>, ApiError>> {
request("GET", ADMIN_USERS.to_string(), None::<()>)
pub async fn list_admin_users() -> Result<Vec<AdminUser>, ApiError> {
get(ADMIN_USERS).await
}
pub fn create_admin_user(
pub async fn create_admin_user(
name: &str,
password: &str,
is_admin: bool,
roots: &[(String, Mode)],
) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
) -> Result<AdminUser, ApiError> {
request(
"POST",
ADMIN_USERS.to_string(),
ADMIN_USERS,
Some(CreateUser {
name: name.to_string(),
password: password.to_string(),
@@ -884,18 +820,19 @@ pub fn create_admin_user(
roots: roots_to_bodies(roots),
}),
)
.await
}
pub fn update_admin_user(
pub async fn update_admin_user(
id: i64,
password: Option<String>,
is_admin: Option<bool>,
active: Option<bool>,
roots: Option<Vec<(String, Mode)>>,
) -> impl std::future::Future<Output = Result<AdminUser, ApiError>> {
) -> Result<AdminUser, ApiError> {
request(
"PUT",
format!("{ADMIN_USERS}/{id}"),
&format!("{ADMIN_USERS}/{id}"),
Some(UpdateUser {
password,
is_admin,
@@ -903,30 +840,32 @@ pub fn update_admin_user(
roots: roots.map(|r| roots_to_bodies(&r)),
}),
)
.await
}
pub fn delete_admin_user(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{ADMIN_USERS}/{id}"), None::<()>)
pub async fn delete_admin_user(id: i64) -> Result<OkResp, ApiError> {
del(&format!("{ADMIN_USERS}/{id}")).await
}
pub fn get_admin_settings() -> impl std::future::Future<Output = Result<Settings, ApiError>> {
request("GET", ADMIN_SETTINGS.to_string(), None::<()>)
pub async fn get_admin_settings() -> Result<Settings, ApiError> {
get(ADMIN_SETTINGS).await
}
/// PUT replaces the whole settings object, so every setting has to be
/// passed. Omitting one would reset it.
pub fn update_admin_settings(
pub async fn update_admin_settings(
allow_writable_shares: bool,
search_excludes: Vec<String>,
) -> impl std::future::Future<Output = Result<Settings, ApiError>> {
) -> Result<Settings, ApiError> {
request(
"PUT",
ADMIN_SETTINGS.to_string(),
ADMIN_SETTINGS,
Some(Settings {
allow_writable_shares,
search_excludes,
}),
)
.await
}
// ---------------------------------------------------------------------------
@@ -942,8 +881,6 @@ fn random_boundary_suffix() -> String {
s
}
/// Open the native file dialog and run `on_files` with the picked files once
/// the user confirms. `directory` uses webkitdirectory (folder upload).
fn webkit_relative_path(file: &web_sys::File) -> String {
let js: JsValue = file.into();
js_sys::Reflect::get(&js, &JsValue::from_str("webkitRelativePath"))
@@ -953,11 +890,9 @@ fn webkit_relative_path(file: &web_sys::File) -> String {
.unwrap_or_default()
}
pub fn pick_files(
multiple: bool,
directory: bool,
on_files: impl Fn(Vec<(String, web_sys::File)>) + 'static,
) {
/// Open the native file dialog and run `on_files` with the picked files once
/// the user confirms. `directory` uses webkitdirectory (folder upload).
pub fn pick_files(directory: bool, on_files: impl FnOnce(Vec<(String, web_sys::File)>) + 'static) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return;
};
@@ -971,24 +906,15 @@ pub fn pick_files(
// Off screen: the browser renders a bare "Choose files" control for an
// input in the body, and `click()` still works on a hidden one.
let _ = input.set_attribute("hidden", "");
if multiple {
input.set_multiple(true);
}
input.set_multiple(true);
if directory {
let _ = input.set_attribute("webkitdirectory", "");
}
// Known small leak, deliberate: the input holds the listener, the
// listener holds this closure, and the closure captures the input — a
// cycle that nothing frees. `forget()` detaches the Rust side, so the
// element + JS function + closure (~1 KB) survive until reload even
// when the dialog is used (not only when cancelled). Dropping the
// closure from Rust while the JS listener still references it would
// leave a dangling callback, and a closure cannot drop itself; a clean
// fix needs the caller to own it (e.g. a `StoredValue` released in
// `on_cleanup`), which is not worth it at this size.
// A cancelled pick never fires `change`, so the input and this closure
// stay until reload.
let input2 = input.clone();
let closure = Closure::<dyn FnMut()>::new(move || {
let listener = Closure::once_into_js(move || {
let mut files: Vec<(String, web_sys::File)> = Vec::new();
if let Some(list) = input.files() {
for i in 0..list.length() {
@@ -1004,9 +930,7 @@ pub fn pick_files(
on_files(files);
}
});
let listener: &js_sys::Function = closure.as_js_value().unchecked_ref();
let _ = input2.add_event_listener_with_callback("change", listener);
closure.forget();
let _ = input2.add_event_listener_with_callback("change", listener.unchecked_ref());
if let Some(body) = doc.body() {
let _ = body.append_child(&input2);
}
@@ -1186,114 +1110,77 @@ fn enc(s: &str) -> String {
js_sys::encode_uri_component(s).into()
}
pub fn pim_collections()
-> impl std::future::Future<Output = Result<Vec<PimCollectionInfo>, ApiError>> {
request("GET", PIM_COLLECTIONS.to_string(), None::<()>)
pub async fn pim_collections() -> Result<Vec<PimCollectionInfo>, ApiError> {
get(PIM_COLLECTIONS).await
}
pub fn pim_create_collection(
pub async fn pim_create_collection(
body: CreatePimCollection,
) -> impl std::future::Future<Output = Result<PimCollectionInfo, ApiError>> {
request("POST", PIM_COLLECTIONS.to_string(), Some(body))
) -> Result<PimCollectionInfo, ApiError> {
request("POST", PIM_COLLECTIONS, Some(body)).await
}
pub fn pim_update_collection(
pub async fn pim_update_collection(
id: i64,
body: UpdatePimCollection,
) -> impl std::future::Future<Output = Result<PimCollectionInfo, ApiError>> {
request("PUT", format!("{PIM_COLLECTIONS}/{id}"), Some(body))
) -> Result<PimCollectionInfo, ApiError> {
request("PUT", &format!("{PIM_COLLECTIONS}/{id}"), Some(body)).await
}
/// Deletes an own collection, or ends the loan of a lent one.
pub fn pim_delete_collection(
id: i64,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{PIM_COLLECTIONS}/{id}"), None::<()>)
pub async fn pim_delete_collection(id: i64) -> Result<OkResp, ApiError> {
del(&format!("{PIM_COLLECTIONS}/{id}")).await
}
pub fn pim_shares(
id: i64,
) -> impl std::future::Future<Output = Result<Vec<PimShareInfo>, ApiError>> {
request(
"GET",
format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}"),
None::<()>,
)
pub async fn pim_shares(id: i64) -> Result<Vec<PimShareInfo>, ApiError> {
get(&format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}")).await
}
/// The accounts an own collection can still be lent to.
pub fn pim_share_candidates(
id: i64,
) -> impl std::future::Future<Output = Result<Vec<PimShareCandidate>, ApiError>> {
request(
"GET",
format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}{CANDIDATES_SUFFIX}"),
None::<()>,
)
pub async fn pim_share_candidates(id: i64) -> Result<Vec<PimShareCandidate>, ApiError> {
get(&format!(
"{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}{CANDIDATES_SUFFIX}"
))
.await
}
/// Lends a collection, or changes the mode of a loan.
pub fn pim_share(
id: i64,
user: &str,
mode: PimShareMode,
) -> impl std::future::Future<Output = Result<PimShareInfo, ApiError>> {
pub async fn pim_share(id: i64, user: &str, mode: PimShareMode) -> Result<PimShareInfo, ApiError> {
request(
"POST",
format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}"),
&format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}"),
Some(CreatePimShare {
user: user.to_string(),
mode,
}),
)
.await
}
pub fn pim_unshare(
id: i64,
user_id: i64,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"DELETE",
format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}"),
None::<()>,
)
pub async fn pim_unshare(id: i64, user_id: i64) -> Result<OkResp, ApiError> {
del(&format!("{PIM_COLLECTIONS}/{id}{SHARES_SUFFIX}/{user_id}")).await
}
pub fn pim_links(id: i64) -> impl std::future::Future<Output = Result<Vec<PimLinkInfo>, ApiError>> {
request(
"GET",
format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}"),
None::<()>,
)
pub async fn pim_links(id: i64) -> Result<Vec<PimLinkInfo>, ApiError> {
get(&format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}")).await
}
pub fn pim_create_link(
id: i64,
body: CreatePimLink,
) -> impl std::future::Future<Output = Result<PimLinkInfo, ApiError>> {
pub async fn pim_create_link(id: i64, body: CreatePimLink) -> Result<PimLinkInfo, ApiError> {
request(
"POST",
format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}"),
&format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}"),
Some(body),
)
.await
}
pub fn pim_delete_link(
id: i64,
link_id: i64,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"DELETE",
format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}"),
None::<()>,
)
pub async fn pim_delete_link(id: i64, link_id: i64) -> Result<OkResp, ApiError> {
del(&format!("{PIM_COLLECTIONS}/{id}{LINKS_SUFFIX}/{link_id}")).await
}
/// Imports an `.ics` or `.vcf` file into a collection.
pub async fn pim_import(id: i64, file: web_sys::File) -> Result<PimImportResult, ApiError> {
let opts = web_sys::RequestInit::new();
opts.set_method("POST");
opts.set_mode(web_sys::RequestMode::SameOrigin);
let opts = init("POST");
opts.set_body(&file.into());
let url = format!("{PIM_COLLECTIONS}/{id}{IMPORT_SUFFIX}");
let resp = fetch_checked(&url, &opts, "import failed").await?;
@@ -1308,9 +1195,7 @@ pub async fn pim_import_new(
color: &str,
file: web_sys::File,
) -> Result<PimImportNew, ApiError> {
let opts = web_sys::RequestInit::new();
opts.set_method("POST");
opts.set_mode(web_sys::RequestMode::SameOrigin);
let opts = init("POST");
let kind = match kind {
PimCollectionKind::Calendar => "calendar",
PimCollectionKind::Addressbook => "addressbook",
@@ -1333,30 +1218,23 @@ pub fn pim_export_url(id: i64) -> String {
/// The instances of the readable calendars between `from` and `to` (RFC
/// 3339), with dates and floating times read in `tz`.
pub fn pim_instances(
from: &str,
to: &str,
tz: &str,
) -> impl std::future::Future<Output = Result<PimInstances, ApiError>> {
request(
"GET",
format!(
"{PIM_INSTANCES}?{P_FROM}={}&{P_TO}={}&{P_TZ}={}",
enc(from),
enc(to),
enc(tz)
),
None::<()>,
)
pub async fn pim_instances(from: &str, to: &str, tz: &str) -> Result<PimInstances, ApiError> {
get(&format!(
"{PIM_INSTANCES}?{P_FROM}={}&{P_TO}={}&{P_TZ}={}",
enc(from),
enc(to),
enc(tz)
))
.await
}
/// One event or contact. `recurrence_id` picks an instance of a series.
pub fn pim_object(
pub async fn pim_object(
id: i64,
name: &str,
recurrence_id: Option<&str>,
tz: &str,
) -> impl std::future::Future<Output = Result<PimObjectDetail, ApiError>> {
) -> Result<PimObjectDetail, ApiError> {
let mut url = format!(
"{PIM_COLLECTIONS}/{id}{OBJECTS_SUFFIX}/{}?{P_TZ}={}",
enc(name),
@@ -1365,7 +1243,7 @@ pub fn pim_object(
if let Some(rid) = recurrence_id {
url.push_str(&format!("&{P_RECURRENCE_ID}={}", enc(rid)));
}
request("GET", url, None::<()>)
get(&url).await
}
/// A contact's photo as a small WebP.
@@ -1377,106 +1255,104 @@ pub fn pim_photo_url(id: i64, name: &str) -> String {
}
/// The contacts of the address books `collections`.
pub fn pim_contacts(
q: &str,
collections: &[i64],
) -> impl std::future::Future<Output = Result<Vec<PimContact>, ApiError>> {
pub async fn pim_contacts(q: &str, collections: &[i64]) -> Result<Vec<PimContact>, ApiError> {
let ids = collections
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(",");
request(
"GET",
format!(
"{PIM_CONTACTS}?{P_Q}={}&{}={ids}",
enc(q),
api_types::P_COLLECTIONS
),
None::<()>,
)
get(&format!(
"{PIM_CONTACTS}?{P_Q}={}&{}={ids}",
enc(q),
api_types::P_COLLECTIONS
))
.await
}
pub fn pim_invitations(
tz: &str,
) -> impl std::future::Future<Output = Result<Vec<PimInvitation>, ApiError>> {
request(
"GET",
format!("{PIM_INVITATIONS}?{P_TZ}={}", enc(tz)),
None::<()>,
)
pub async fn pim_invitations(tz: &str) -> Result<Vec<PimInvitation>, ApiError> {
get(&format!("{PIM_INVITATIONS}?{P_TZ}={}", enc(tz))).await
}
/// Answers an invitation as the calendar owner.
pub fn pim_reply(body: PimReply) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("POST", PIM_INVITATIONS.to_string(), Some(body))
pub async fn pim_reply(body: PimReply) -> Result<OkResp, ApiError> {
request("POST", PIM_INVITATIONS, Some(body)).await
}
pub fn list_rooms() -> impl std::future::Future<Output = Result<Vec<RoomInfo>, ApiError>> {
request("GET", ADMIN_ROOMS.to_string(), None::<()>)
pub async fn list_rooms() -> Result<Vec<RoomInfo>, ApiError> {
get(ADMIN_ROOMS).await
}
pub fn create_room(
pub async fn create_room(
name: &str,
display_name: &str,
kind: RoomKind,
) -> impl std::future::Future<Output = Result<RoomInfo, ApiError>> {
) -> Result<RoomInfo, ApiError> {
request(
"POST",
ADMIN_ROOMS.to_string(),
ADMIN_ROOMS,
Some(CreateRoom {
name: name.to_string(),
display_name: Some(display_name.to_string()).filter(|d| !d.is_empty()),
kind,
}),
)
.await
}
pub fn update_room(
id: i64,
display_name: &str,
) -> impl std::future::Future<Output = Result<RoomInfo, ApiError>> {
pub async fn update_room(id: i64, display_name: &str) -> Result<RoomInfo, ApiError> {
request(
"PUT",
format!("{ADMIN_ROOMS}/{id}"),
&format!("{ADMIN_ROOMS}/{id}"),
Some(UpdateRoom {
display_name: display_name.to_string(),
}),
)
.await
}
pub fn delete_room(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{ADMIN_ROOMS}/{id}"), None::<()>)
pub async fn delete_room(id: i64) -> Result<OkResp, ApiError> {
del(&format!("{ADMIN_ROOMS}/{id}")).await
}
/// The signed-in user's own feed links and loans.
pub fn pim_own_shares() -> impl std::future::Future<Output = Result<PimOwnShares, ApiError>> {
request("GET", PIM_SHARES.to_string(), None::<()>)
pub async fn pim_own_shares() -> Result<PimOwnShares, ApiError> {
get(PIM_SHARES).await
}
/// Admin only: every public calendar and address book feed.
pub fn admin_pim_links() -> impl std::future::Future<Output = Result<Vec<AdminPimLink>, ApiError>> {
request("GET", ADMIN_PIM_LINKS.to_string(), None::<()>)
pub async fn admin_pim_links() -> Result<Vec<AdminPimLink>, ApiError> {
get(ADMIN_PIM_LINKS).await
}
pub fn admin_delete_pim_link(
id: i64,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{ADMIN_PIM_LINKS}/{id}"), None::<()>)
pub async fn admin_delete_pim_link(id: i64) -> Result<OkResp, ApiError> {
del(&format!("{ADMIN_PIM_LINKS}/{id}")).await
}
// ---------------------------------------------------------------------------
// Low-level request helpers
// ---------------------------------------------------------------------------
fn init(method: &str) -> web_sys::RequestInit {
let opts = web_sys::RequestInit::new();
opts.set_method(method);
opts.set_mode(web_sys::RequestMode::SameOrigin);
opts
}
async fn get<T: DeserializeOwned>(url: &str) -> Result<T, ApiError> {
request("GET", url, None::<()>).await
}
async fn del<T: DeserializeOwned>(url: &str) -> Result<T, ApiError> {
request("DELETE", url, None::<()>).await
}
async fn request<T: DeserializeOwned>(
method: &str,
url: String,
url: &str,
body: Option<impl Serialize>,
) -> Result<T, ApiError> {
let opts = web_sys::RequestInit::new();
opts.set_method(method);
opts.set_mode(web_sys::RequestMode::SameOrigin);
let opts = init(method);
if let Some(body) = body {
let json = serde_json::to_string(&body).map_err(|e| ApiError::Net(e.to_string()))?;
opts.set_body_opt_str(Some(&json));
@@ -1485,7 +1361,7 @@ async fn request<T: DeserializeOwned>(
opts.set_headers_headers(&headers);
}
let resp = fetch_checked(&url, &opts, "request failed").await?;
let resp = fetch_checked(url, &opts, "request failed").await?;
read_json(&resp).await
}
▾Mweb/src/components/icon.rs
@@ -1,5 +1,5 @@
//! The `Icon` component: renders a glyph from the central [`crate::icons`]
//! registry (Material Symbols via Iconify) as inline SVG.
//! Renders a glyph from the central [`crate::icons`] registry (Material
//! Symbols via Iconify) as inline SVG.
use leptos::prelude::*;
@@ -14,11 +14,9 @@ fn icon_class(name: IconName) -> &'static str {
}
}
/// The icon markup, as a plain function rather than a component.
///
/// Prefer this over [`Icon`] inside long lists. A `#[component]` call costs a
/// reactive owner per instance, which is measurable at a thousand rows; a
/// plain function returning a view costs nothing extra.
/// The icon markup, as a plain function rather than a component: a
/// `#[component]` call costs a reactive owner per instance, which is
/// measurable at a thousand rows.
///
/// `inner_html` rather than a `NodeRef` with an `on_load` callback, for the
/// same reason: `on_load` is an effect, so a thousand icons meant a thousand
@@ -42,8 +40,3 @@ pub fn icon_svg(name: IconName, extra: &str) -> impl IntoView + use<> {
/>
}
}
#[component]
pub fn Icon(name: IconName, #[prop(optional)] class: Option<String>) -> impl IntoView {
icon_svg(name, class.as_deref().unwrap_or(""))
}
▾Mweb/src/editor.rs
@@ -14,7 +14,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, ApiError};
use crate::cm;
use crate::components::icon::Icon;
use crate::components::icon::icon_svg;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show};
use crate::i18n;
@@ -22,48 +22,6 @@ use crate::icons::IconName;
use crate::views::dialogs::ConfirmDialog;
use crate::views::file_view::FileView;
/// Read the current document and PUT it, updating the editor's state.
///
/// `force` skips the server's conflict check (used by the "Overwrite" button).
#[allow(clippy::too_many_arguments)] // long signal/JS-handle list
async fn do_save(
target: FileView,
force: bool,
cm_view: StoredValue<Option<JsValue>, LocalStorage>,
loaded_mtime: StoredValue<Option<i64>>,
set_dirty: WriteSignal<bool>,
set_saving: WriteSignal<bool>,
set_status: WriteSignal<Option<String>>,
set_conflict: WriteSignal<bool>,
toast: ToastMsg,
refresh: Callback<()>,
) {
let text = cm_view.with_value(|v| v.as_ref().and_then(cm::get_value));
let Some(text) = text else {
set_status.set(Some("editor not ready".to_string()));
return;
};
let expected = loaded_mtime.get_value();
set_saving.set(true);
set_status.set(None);
match api::save_content(target.root_id, &target.path, &text, expected, force).await {
Ok(new_mtime) => {
loaded_mtime.set_value(Some(new_mtime));
set_dirty.set(false);
set_conflict.set(false);
show(toast, i18n::t(i18n::k::SAVED));
refresh.run(());
}
Err(ApiError::Conflict) => {
set_conflict.set(true);
}
Err(e) => {
set_status.set(Some(e.to_string()));
}
}
set_saving.set(false);
}
/// The full-page editor (no window chrome: the top bar carries the title,
/// save and close).
#[component]
@@ -160,18 +118,29 @@ pub fn Editor(
let make_save = |force: bool| {
let t = target.clone();
Callback::new(move |_| {
spawn_local(do_save(
t.clone(),
force,
cm_view,
loaded_mtime,
set_dirty,
set_saving,
set_status,
set_conflict,
toast,
refresh,
));
let t = t.clone();
spawn_local(async move {
let text = cm_view.with_value(|v| v.as_ref().and_then(cm::get_value));
let Some(text) = text else {
set_status.set(Some("editor not ready".to_string()));
return;
};
let expected = loaded_mtime.get_value();
set_saving.set(true);
set_status.set(None);
match api::save_content(t.root_id, &t.path, &text, expected, force).await {
Ok(new_mtime) => {
loaded_mtime.set_value(Some(new_mtime));
set_dirty.set(false);
set_conflict.set(false);
show(toast, i18n::t(i18n::k::SAVED));
refresh.run(());
}
Err(ApiError::Conflict) => set_conflict.set(true),
Err(e) => set_status.set(Some(e.to_string())),
}
set_saving.set(false);
});
})
};
let save_cb: Callback<()> = make_save(false);
@@ -266,7 +235,7 @@ pub fn Editor(
aria-label={i18n::tr(i18n::k::DISMISS_ERROR)}
on:click=move |_| set_status.set(None)
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
{icon_svg(IconName::Close, "ic-btn")}
</button>
<div class="cm-error-text">{e}</div>
</div>
▾Mweb/src/i18n.rs
@@ -2767,27 +2767,13 @@ mod tests {
}
#[test]
fn tables_are_sorted() {
fn tables_are_sorted_without_duplicates() {
// `find` binary-searches, so an out-of-order entry is unreachable.
for (name, table) in [("EN", EN), ("DE", DE), ("FR", FR)] {
let ks = keys(table);
let mut sorted = ks.clone();
sorted.sort_unstable();
assert_eq!(ks, sorted, "{name} table is not sorted by key");
}
}
#[test]
fn no_duplicate_keys() {
for (name, table) in [("EN", EN), ("DE", DE), ("FR", FR)] {
let mut ks = keys(table);
ks.sort_unstable();
let dups = ks
.windows(2)
.filter(|w| w[0] == w[1])
.map(|w| w[0])
.collect::<Vec<_>>();
assert!(dups.is_empty(), "{name} duplicates: {dups:?}");
for (name, t) in [("EN", EN), ("DE", DE), ("FR", FR)] {
assert!(
t.windows(2).all(|w| w[0].0 < w[1].0),
"{name} table is not strictly sorted by key"
);
}
}
▾Mweb/src/passkey.rs
@@ -81,8 +81,10 @@ export async function passkeyGet(optionsJson, conditional) {
}
"#)]
extern "C" {
/// Whether this browser can do WebAuthn at all. False hides every passkey
/// control rather than offering one that cannot work.
#[wasm_bindgen(js_name = passkeySupported)]
fn js_supported() -> bool;
pub fn supported() -> bool;
#[wasm_bindgen(js_name = conditionalSupported)]
async fn js_conditional_supported() -> JsValue;
@@ -97,12 +99,6 @@ extern "C" {
async fn js_get(options: &str, conditional: bool) -> Result<JsValue, JsValue>;
}
/// Whether this browser can do WebAuthn at all. False hides every passkey
/// control rather than offering one that cannot work.
pub fn supported() -> bool {
js_supported()
}
/// Whether this browser offers passkeys in the autofill dropdown.
pub async fn conditional_supported() -> bool {
js_conditional_supported().await.as_bool().unwrap_or(false)
▾Mweb/src/preview.rs
@@ -6,7 +6,7 @@ use api_types::FileKind;
use leptos::prelude::*;
use crate::api;
use crate::components::icon::Icon;
use crate::components::icon::icon_svg;
use crate::i18n;
use crate::icons::{IconName, icon_for};
use crate::views::file_view::FileView;
@@ -53,7 +53,7 @@ fn NoPreview(target: FileView) -> impl IntoView {
view! {
<div class="file-nopreview">
<div class="empty-state">
<Icon name=icon class="empty-glyph".to_string()/>
{icon_svg(icon, "empty-glyph")}
<h3>{i18n::tr(i18n::k::NO_PREVIEW)}</h3>
<p class="muted">{i18n::t_fmt(i18n::k::NO_PREVIEW_MSG, &name)}</p>
<button
@@ -63,7 +63,7 @@ fn NoPreview(target: FileView) -> impl IntoView {
api::trigger_download(&url, &name);
}
>
<Icon name=IconName::Download class="ic-btn".to_string()/>
{icon_svg(IconName::Download, "ic-btn")}
{i18n::tr(i18n::k::DOWNLOAD)}
</button>
</div>
▾Mweb/src/uploads.rs
@@ -11,11 +11,12 @@
//! ponytail: per-file restart on resume; chunked resumable uploads if
//! multi-GB files matter.
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::collections::{HashSet, VecDeque};
use std::rc::Rc;
use std::time::Duration;
use gloo_timers::future::TimeoutFuture;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
@@ -113,10 +114,7 @@ impl Job {
return 0.0;
}
let n = self.samples.len();
speeds(&[self.samples[n - 2], self.samples[n - 1]])
.last()
.copied()
.unwrap_or(0.0)
rate(self.samples[n - 2], self.samples[n - 1])
}
pub fn is_finished(&self) -> bool {
@@ -127,17 +125,15 @@ impl Job {
/// Bytes per second for each interval between consecutive `(ms, bytes)`
/// samples. One shorter than `samples`, empty for fewer than two.
pub fn speeds(samples: &[(f64, f64)]) -> Vec<f64> {
samples
.windows(2)
.map(|w| {
let ((t0, b0), (t1, b1)) = (w[0], w[1]);
if t1 > t0 {
(b1 - b0) / (t1 - t0) * 1000.0
} else {
0.0
}
})
.collect()
samples.windows(2).map(|w| rate(w[0], w[1])).collect()
}
fn rate((t0, b0): (f64, f64), (t1, b1): (f64, f64)) -> f64 {
if t1 > t0 {
(b1 - b0) / (t1 - t0) * 1000.0
} else {
0.0
}
}
/// Runtime handles the runner and the panel share, outside the signal: the
@@ -162,7 +158,7 @@ thread_local! {
static JOBS: RwSignal<Vec<Job>, LocalStorage> =
ROOT.with(|o| o.with(|| RwSignal::new_local(Vec::new())));
static CONTROLS: RefCell<Vec<(u64, Rc<RefCell<Control>>)>> = const { RefCell::new(Vec::new()) };
static NEXT_ID: RefCell<u64> = const { RefCell::new(1) };
static NEXT_ID: Cell<u64> = const { Cell::new(1) };
/// Overlay collapsed to its header line.
static COLLAPSED: RwSignal<bool> = ROOT.with(|o| o.with(|| RwSignal::new(false)));
/// Called with (root_id, dir) when a job finishes, so the page can
@@ -222,11 +218,7 @@ pub fn any_active() -> bool {
/// Next value of the page-wide counter, for job ids and hook tokens.
fn next_id() -> u64 {
NEXT_ID.with(|n| {
let mut n = n.borrow_mut();
*n += 1;
*n - 1
})
NEXT_ID.with(|n| n.replace(n.get() + 1))
}
fn now_ms() -> f64 {
@@ -354,7 +346,7 @@ pub fn discard(id: u64) {
/// files would otherwise redraw the graph hundreds of times a second.
async fn sample(id: u64) {
loop {
sleep(SAMPLE_MS as u64).await;
TimeoutFuture::new(SAMPLE_MS as u32).await;
let active = read(id, |j| j.state.is_active());
if active != Some(true) {
return;
@@ -460,10 +452,6 @@ pub fn clear_finished() {
// Runner
// ---------------------------------------------------------------------------
async fn sleep(ms: u64) {
gloo_timers::future::sleep(Duration::from_millis(ms)).await;
}
/// Files per request: files are grouped until the group holds
/// `BATCH_BYTES` or `BATCH_FILES`, whichever comes first. A kernel tree
/// (80 000 small files) becomes a few hundred requests instead of 80 000.
@@ -548,7 +536,7 @@ async fn run(
// Pause / abort gate.
// ponytail: 200 ms poll, wake channel if latency matters.
while ctl.borrow().paused && !ctl.borrow().abort {
sleep(200).await;
TimeoutFuture::new(200).await;
}
if ctl.borrow().abort {
finish(id, JobState::Aborted, &dir);
@@ -698,7 +686,7 @@ async fn wait_decision(ctl: &Rc<RefCell<Control>>) -> OnConflict {
if let Some(d) = ctl.borrow_mut().decision.take() {
return d;
}
sleep(200).await;
TimeoutFuture::new(200).await;
}
}
▾Mweb/src/util.rs
@@ -4,8 +4,10 @@ use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen::closure::Closure;
use crate::components::icon::icon_svg;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n;
use crate::icons::IconName;
/// Write `text` to the clipboard and confirm it with `msg`.
///
@@ -23,6 +25,56 @@ pub fn copy_to_clipboard(text: &str, msg: &str, toast: ToastMsg) {
});
}
/// A button that copies `text` and toasts the `msg` key. `title` is a key too.
pub fn copy_button(
class: &'static str,
title: Option<&'static str>,
label: impl IntoView + 'static,
text: String,
msg: &'static str,
toast: ToastMsg,
) -> impl IntoView {
view! {
<button
type="button"
class=class
title=title.map(i18n::tr)
on:click=move |_| copy_to_clipboard(&text, i18n::t(msg), toast)
>
{icon_svg(IconName::Copy, "ic-btn")}
{label}
</button>
}
}
/// A read-only URL with a copy button.
pub fn url_row(label: &'static str, url: String, toast: ToastMsg) -> impl IntoView {
view! {
<div class="share-link-row">
<input
class="share-link-input"
readonly=true
aria-label=i18n::t(label)
value=url.clone()
on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
/>
{copy_button("btn", None, i18n::t(i18n::k::COPY), url, i18n::k::LINK_COPIED, toast)}
</div>
}
}
/// A failed load, with a button to try again.
pub fn retry_card(msg: String, retry: Callback<()>) -> impl IntoView {
view! {
<div class="card error-card">
<span>{msg}</span>
<button class="btn" on:click=move |_| retry.run(())>
{i18n::tr(i18n::k::RETRY)}
</button>
</div>
}
}
/// A window event listener that is removed when the current owner is disposed.
///
/// Leptos' `window_event_listener` returns a `WindowListenerHandle` with no
▾Mweb/src/views/admin.rs
@@ -6,7 +6,7 @@ use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AdminUser, Me, Mode, RoomInfo, RoomKind};
use crate::components::icon::Icon;
use crate::components::icon::icon_svg;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::icons::IconName;
@@ -162,19 +162,12 @@ pub fn SettingsView(
let Some(ws) = week_start.get() else { return };
set_profile_busy.set(true);
spawn_local(async move {
match api::update_profile(Some(v), Some(th), Some(lang.clone()), Some(root), Some(ws))
.await
{
match api::update_profile(Some(v), Some(th), Some(lang), Some(root), Some(ws)).await {
Ok(m) => {
show(toast, i18n::t(i18n::k::PROFILE_SAVED).to_string());
// The response already carries the fresh /me (which also
// flips the UI language via the app-level effect).
set_me.set(Some(m));
set_single_click.set(Some(v));
set_thumbnails.set(Some(th));
set_language.set(Some(lang));
set_default_root.set(Some(root));
set_week_start.set(Some(ws));
}
Err(e) => show(
toast,
@@ -246,9 +239,7 @@ pub fn SettingsView(
show(toast, i18n::t(i18n::k::SETTINGS_SAVED).to_string());
// Re-fetch this session's /me so the share dialog (and
// anything else) sees the new setting without a reload.
if let Ok(m) = api::me().await {
set_me.set(Some(m));
}
crate::views::shell::refresh_me(set_me, toast);
}
Err(e) => show(
toast,
@@ -401,7 +392,7 @@ pub fn SettingsView(
.map(|(i, path)| {
view! {
<div class="exclude-row">
<Icon name=IconName::Folder class="ic-row".to_string()/>
{icon_svg(IconName::Folder, "ic-row")}
<span class="exclude-path">{path.clone()}</span>
<button
class="icon-btn exclude-remove"
@@ -416,7 +407,7 @@ pub fn SettingsView(
set_excl_error.set(None);
}
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
{icon_svg(IconName::Close, "ic-btn")}
</button>
</div>
}
@@ -426,7 +417,7 @@ pub fn SettingsView(
}}
</div>
<button class="btn btn-sm exclude-add" on:click=add_exclude>
<Icon name=IconName::Add class="ic-btn".to_string()/>
{icon_svg(IconName::Add, "ic-btn")}
{i18n::tr(i18n::k::EXCLUDE_FOLDER)}
</button>
{move || excl_error.get().map(|e| view! { <p class="dialog-error">{e}</p> })}
@@ -521,7 +512,7 @@ pub fn UsersView(
<div class="users-header">
<h3 class="admin-section-title">{i18n::tr(i18n::k::USERS)}</h3>
<button class="btn btn-primary" on:click=move |_| set_editing.set(Some(Editing::New))>
<Icon name=IconName::Add class="ic-btn".to_string()/>
{icon_svg(IconName::Add, "ic-btn")}
{i18n::tr(i18n::k::NEW_USER)}
</button>
</div>
@@ -548,7 +539,7 @@ pub fn UsersView(
<div class="user-row-main">
<div class="user-row-name">
{is_admin.then(|| view! {
<span class="ic-admin" title=i18n::tr(i18n::k::ADMINISTRATOR)><Icon name=IconName::Admin/></span>
<span class="ic-admin" title=i18n::tr(i18n::k::ADMINISTRATOR)>{icon_svg(IconName::Admin, "")}</span>
})}
{u.name.clone()}
{is_self.then(|| view! { <span class="muted">" " {i18n::tr(i18n::k::USER_YOU)}</span> })}
@@ -860,7 +851,7 @@ fn UserForm(
set_roots.set(v);
}
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
{icon_svg(IconName::Close, "ic-btn")}
</button>
</div>
}
@@ -868,7 +859,7 @@ fn UserForm(
.collect::<Vec<_>>()
}}
<button type="button" class="btn root-add" on:click=add_root>
<Icon name=IconName::Folder class="ic-btn".to_string()/>
{icon_svg(IconName::Folder, "ic-btn")}
{i18n::tr(i18n::k::ADD_FOLDER)}
</button>
</div>
@@ -937,7 +928,7 @@ pub fn RoomsPanel() -> impl IntoView {
<div class="users-header">
<h3 class="admin-section-title">{i18n::tr(i18n::k::PIM_ROOMS)}</h3>
<button class="btn btn-primary" on:click=move |_| set_editing.set(Some(None))>
<Icon name=IconName::Add class="ic-btn".to_string()/>
{icon_svg(IconName::Add, "ic-btn")}
{i18n::tr(i18n::k::PIM_NEW_ROOM)}
</button>
</div>
@@ -961,7 +952,7 @@ pub fn RoomsPanel() -> impl IntoView {
<div class="user-row">
<div class="user-row-main">
<div class="user-row-name">
<span class="ic-admin"><Icon name=IconName::Room/></span>
<span class="ic-admin">{icon_svg(IconName::Room, "")}</span>
{r.display_name.clone()}
<span class="badge">{kind}</span>
</div>
▾Mweb/src/views/browser.rs
@@ -10,7 +10,7 @@ use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
use crate::api::{self, Entry, Me, RootInfo};
use crate::components::icon::{Icon, icon_svg};
use crate::components::icon::icon_svg;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n;
use crate::icons::{IconName, icon_for};
@@ -18,10 +18,12 @@ use crate::preview::is_media;
use crate::router::{self, Location, navigate};
use crate::uploads::{self, OnConflict};
use crate::util::{
LongPress, SortKey, SortSpec, ViewMode, format_date, format_size, pager, save_page_size,
LongPress, SortKey, SortSpec, ViewMode, format_date, format_size, pager, retry_card,
save_page_size,
};
use crate::views::dialogs::{Dialog, Op};
use crate::views::file_view::FileView;
use crate::views::shell::refresh_me;
/// What a listing fetch asks for. Only a change of it fetches again.
#[derive(Clone, Debug, PartialEq)]
@@ -190,9 +192,9 @@ pub fn Browser(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() != "Escape"
|| ctx.get().is_some()
|| dialog.get().is_some()
|| file_view.get().is_some()
|| ctx.get_untracked().is_some()
|| dialog.get_untracked().is_some()
|| file_view.get_untracked().is_some()
{
return;
}
@@ -295,11 +297,7 @@ pub fn Browser(
&& me_refresh_key.get() != Some((root_id, path_str.clone()))
{
set_me_refresh_key.set(Some((root_id, path_str.clone())));
spawn_local(async move {
if let Ok(m) = api::me().await {
set_me.set(Some(m));
}
});
refresh_me(set_me, toast);
}
set_list_state.set(ListState::Error(e.to_string()));
}
@@ -396,7 +394,7 @@ pub fn Browser(
back(step);
return;
};
let mut files = list.entries.iter().filter(|e| previewable(e));
let mut files = list.entries.iter().filter(|e| is_media(e.kind));
let e = if step.dir > 0 {
files.next()
} else {
@@ -444,146 +442,149 @@ pub fn Browser(
set_browser_refresh.set(Some(fetch));
});
// Left / Right in an open media preview steps to the previous or next
// previewable file, in the listing's current order. Past the edge of the
// page it loads the neighbouring page and continues there.
//
// Only previewable files: the editor needs the arrow keys for its
// caret, and a folder has nothing to preview.
crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
let step: isize = match ev.key().as_str() {
"ArrowLeft" => -1,
"ArrowRight" => 1,
_ => return,
};
// A focused player owns its arrow keys (seek / volume), and a
// modifier means a browser shortcut (Alt+Left is history back).
let on_player = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.is_some_and(|el| matches!(el.tag_name().as_str(), "VIDEO" | "AUDIO"));
if on_player || ev.ctrl_key() || ev.meta_key() || ev.alt_key() || dialog.get().is_some() {
return;
}
let Some(cur) = file_view.get().filter(|v| is_media(v.kind)) else {
return;
};
let ListState::Entries(list) = list_state.get() else {
return;
};
let files: Vec<&Entry> = list.entries.iter().filter(|e| previewable(e)).collect();
let Some(at) = files.iter().position(|e| e.name == cur.name) else {
return;
};
ev.prevent_default();
match at.checked_add_signed(step).and_then(|i| files.get(i)) {
Some(e) => show_preview(e, cur.root_id),
None => start_turn(step, true),
}
});
let acts = Acts {
me,
loc,
open_file,
refresh: fetch,
toast,
dialog,
set_dialog,
owner: ctx_owner,
};
// Delete asks to delete the selection — the same confirm dialog as the
// context menu's Delete. Not while something else owns the keyboard: a
// dialog, the full-page file view, the context menu, or a text field.
// Window-level keys. Not while a dialog owns the keyboard.
{
let owner = ctx_owner.clone();
let acts = acts.clone();
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() != "Delete"
|| ev.ctrl_key()
|| ev.meta_key()
|| ev.alt_key()
|| dialog.get().is_some()
|| file_view.get().is_some()
|| ctx.get().is_some()
{
if dialog.get().is_some() {
return;
}
let in_field = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.is_some_and(|el| matches!(el.tag_name().as_str(), "INPUT" | "TEXTAREA"));
if in_field {
let modifier = ev.ctrl_key() || ev.meta_key() || ev.alt_key();
// Left / Right in an open media preview steps to the previous
// or next previewable file, in the listing's current order. Past
// the edge of the page it loads the neighbouring page and
// continues there.
//
// Only previewable files: the editor needs the arrow keys for
// its caret, and a folder has nothing to preview.
if let Some(cur) = file_view.get() {
let step: isize = match ev.key().as_str() {
"ArrowLeft" => -1,
"ArrowRight" => 1,
_ => return,
};
// A focused player owns its arrow keys (seek / volume), and
// a modifier means a browser shortcut (Alt+Left is history
// back).
let on_player = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.is_some_and(|el| matches!(el.tag_name().as_str(), "VIDEO" | "AUDIO"));
if on_player || modifier || !is_media(cur.kind) {
return;
}
let ListState::Entries(list) = list_state.get() else {
return;
};
let files: Vec<&Entry> =
list.entries.iter().filter(|e| is_media(e.kind)).collect();
let Some(at) = files.iter().position(|e| e.name == cur.name) else {
return;
};
ev.prevent_default();
match at.checked_add_signed(step).and_then(|i| files.get(i)) {
Some(e) => show_preview(e, cur.root_id),
None => start_turn(step, true),
}
return;
}
let loc_now = loc.get();
let (is_rw, root_id) = me.with(|m| {
let root = m.as_ref().and_then(|m| effective_root(&m.roots, &loc_now));
(
root.is_some_and(|r| r.mode.is_writable()),
root.map(|r| r.id),
)
});
if !is_rw {
if ctx.get().is_some() {
return;
}
let action = selected.with(|sel| {
(!sel.is_empty()).then(|| {
action_delete(sel, root_id, loc, fetch, toast, set_dialog, owner.clone())
})
});
if let Some(a) = action {
ev.prevent_default();
a.run(());
match ev.key().as_str() {
// Delete asks to delete the selection — the same confirm
// dialog as the context menu's Delete. Not in a text field.
"Delete" if !modifier => {
let in_field = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.is_some_and(|el| {
matches!(el.tag_name().as_str(), "INPUT" | "TEXTAREA")
});
if in_field {
return;
}
let loc_now = loc.get();
let (is_rw, root_id) = me.with(|m| {
let root = m.as_ref().and_then(|m| effective_root(&m.roots, &loc_now));
(
root.is_some_and(|r| r.mode.is_writable()),
root.map(|r| r.id),
)
});
if !is_rw {
return;
}
let action = selected
.with(|sel| (!sel.is_empty()).then(|| acts.delete(sel, root_id)));
if let Some(a) = action {
ev.prevent_default();
a.run(());
}
}
// Arrow keys while no row has focus (after load, after a
// click on empty space, after a preview closed): continue
// from the first selected entry, or start at the first
// entry. Only when focus is on the page body, so arrows
// inside a focused button, field or player stay theirs.
"ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" => {
let on_body = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.active_element())
.is_none_or(|a| a.tag_name() == "BODY");
if !on_body {
return;
}
let ListState::Entries(all) = list_state.get_untracked() else {
return;
};
let Some(container) = listing_container() else {
return;
};
let anchor = selected.with_untracked(|sel| anchor_index(&all, sel));
match anchor {
Some(idx) => {
let grid = view_mode.get_untracked() == ViewMode::Grid;
arrow_key(
&ev,
&container,
&all,
idx,
grid,
selected,
set_selected,
turn_page,
);
}
None => {
let Some(first) = all.entries.first() else {
return;
};
ev.prevent_default();
set_selected.set(vec![first.clone()]);
focus_item(&container, 0);
}
}
}
_ => {}
}
},
);
}
// Arrow keys while no row has focus (after load, after a click on empty
// space, after a preview closed): continue from the first selected entry,
// or start at the first entry. Only when focus is on the page body, so
// arrows inside a focused button, field or player stay theirs.
crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if !matches!(
ev.key().as_str(),
"ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"
) || dialog.get().is_some()
|| file_view.get().is_some()
|| ctx.get().is_some()
{
return;
}
let on_body = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.active_element())
.is_none_or(|a| a.tag_name() == "BODY");
if !on_body {
return;
}
let ListState::Entries(all) = list_state.get_untracked() else {
return;
};
let Some(container) = listing_container() else {
return;
};
let anchor = selected.with_untracked(|sel| anchor_index(&all, sel));
match anchor {
Some(idx) => {
let grid = view_mode.get_untracked() == ViewMode::Grid;
arrow_key(
&ev,
&container,
&all,
idx,
grid,
selected,
set_selected,
turn_page,
);
}
None => {
let Some(first) = all.entries.first() else {
return;
};
ev.prevent_default();
set_selected.set(vec![first.clone()]);
focus_item(&container, 0);
}
}
});
// Closing the full-page view unmounted the focused row with the
// listing. Put focus back on the selected entry, once it is rendered
// again, so the arrow keys continue from there.
@@ -604,7 +605,7 @@ pub fn Browser(
open
});
let drop_owner = ctx_owner.clone();
let drop_acts = acts.clone();
// A page turn or an open file keeps the rendered listing's frame.
let folder = Memo::new(move |_| loc.with(Location::folder_key));
view! {
@@ -619,8 +620,8 @@ pub fn Browser(
root,
loc,
root.mode.is_writable().then(|| {
let (root_id, dir, owner) =
(root.id, loc_now.path.join("/"), drop_owner.clone());
let (root_id, dir, acts) =
(root.id, loc_now.path.join("/"), drop_acts.clone());
Callback::new(move |ev: web_sys::DragEvent| {
// Read the drop here, not in the task below:
// the `DataTransfer` is dead once this
@@ -634,7 +635,7 @@ pub fn Browser(
// dropped folder takes a while.
let label = upload_label(names.iter().map(String::as_str));
let job = uploads::prepare(root_id, dir.clone(), label);
let (dir, owner) = (dir.clone(), owner.clone());
let (dir, acts) = (dir.clone(), acts.clone());
spawn_local(async move {
let files = api::files_from_drop(dropped).await;
if files.is_empty() {
@@ -645,9 +646,7 @@ pub fn Browser(
job,
upload_label(files.iter().map(|(p, _)| p.as_str())),
);
upload_prepared(
job, root_id, dir, files, toast, dialog, set_dialog, owner,
);
acts.upload_prepared(job, root_id, dir, files);
});
})
}),
@@ -667,19 +666,7 @@ pub fn Browser(
None => no_folder_view(&me_now.roots, &loc_now).into_any(),
})
}}
<CtxMenuView
ctx=ctx
set_ctx=set_ctx
me=me
loc=loc
selected=selected
refresh=fetch
toast=toast
dialog=dialog
set_dialog=set_dialog
open_file=open_file
owner=ctx_owner
/>
<CtxMenuView ctx=ctx set_ctx=set_ctx selected=selected acts=acts/>
</div>
// Outside `.browser`, which is hidden while a preview is open.
<Show when=move || page_step.with(Option::is_some)>
@@ -714,7 +701,7 @@ fn no_folder_view(roots: &[RootInfo], loc: &Location) -> impl IntoView {
};
view! {
<div class="empty-state">
<Icon name=IconName::FolderOff class="empty-glyph".to_string()/>
{icon_svg(IconName::FolderOff, "empty-glyph")}
<h3>{title}</h3>
<p class="muted">{message}</p>
</div>
@@ -828,7 +815,7 @@ fn file_browser(
aria-label=i18n::tr(i18n::k::REFRESH)
on:click=move |_| fetch.run(())
>
<Icon name=IconName::Refresh class="ic-btn".to_string()/>
{icon_svg(IconName::Refresh, "ic-btn")}
</button>
<div class="toolbar-right">
<label class="sort-label" for="sort-key">{i18n::tr(i18n::k::SORT_BY)}</label>
@@ -889,7 +876,7 @@ fn file_browser(
mode.save();
}
>
<Icon name=icon class="ic-btn".to_string()/>
{icon_svg(icon, "ic-btn")}
</button>
})}
</div>
@@ -959,7 +946,7 @@ fn file_browser(
>
<Show when=move || drag_depth.get().is_positive()>
<div class="drop-hint">
<Icon name=IconName::Upload class="ic-tab".to_string()/>
{icon_svg(IconName::Upload, "ic-tab")}
<span>{i18n::tr(i18n::k::UPLOAD_DROP_HINT)}</span>
</div>
</Show>
@@ -978,15 +965,7 @@ fn file_browser(
<p class="muted center-note">{i18n::tr(i18n::k::LOADING)}</p>
}
.into_any(),
ListState::Error(msg) => view! {
<div class="card error-card">
<span>{msg.clone()}</span>
<button class="btn" on:click=move |_| fetch.run(())>
{i18n::tr(i18n::k::RETRY)}
</button>
</div>
}
.into_any(),
ListState::Error(msg) => retry_card(msg, fetch).into_any(),
ListState::Entries(list) => {
if list.entries.is_empty() {
return view! { <p class="muted center-note">{i18n::tr(i18n::k::EMPTY_FOLDER)}</p> }
@@ -1357,9 +1336,7 @@ fn entries_view(
let open_cb = open_entry(e, root_id, loc, is_rw, open_file);
let on_click =
selection_click(all.clone(), i, single_click, selected, set_selected, open_cb);
let thumb_src = (thumbs
&& !e.is_dir
&& matches!(e.kind, FileKind::Image | FileKind::Video))
let thumb_src = (thumbs && matches!(e.kind, FileKind::Image | FileKind::Video))
.then(|| api::thumb_url(root_id, &join_path(&dir, &e.name), &e.mtime));
let all_keys = all.clone();
let item_name = name.clone();
@@ -1416,9 +1393,6 @@ fn entries_view(
{if grid {
view! {
<div class="tile-media">
// `icon_svg`, not `<Icon/>`: no
// reactive owner per item (see its
// doc).
{icon_svg(icon, icon_class)}
// A 404 (no thumbnail for this
// file) removes the element. Left in
@@ -1461,250 +1435,187 @@ fn entries_view(
// Context menu
// ---------------------------------------------------------------------------
/// Build the action list for the selection: the empty area (nothing
/// selected), one entry, or several. Shared by the right-click context menu
/// and the toolbar's selection actions so both always offer the same
/// operations.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn menu_items(
sel: &[Entry],
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
open_file: WriteSignal<Option<FileView>>,
refresh: Callback<()>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Vec<MenuItem> {
let loc_now = loc.get();
// The root that is actually shown. Resolves the single-root default
// location, where `loc.root_id` is `None`. Read in place: cloning `me`
// would copy its whole root list on every selection change.
let (is_rw, root_name, root_id, allow_writable_shares) = me.with(|me_now| {
let root = me_now
.as_ref()
.and_then(|m| effective_root(&m.roots, &loc_now));
(
root.map(|r| r.mode.is_writable()).unwrap_or(false),
root.map(|r| r.name.clone()).unwrap_or_default(),
root.map(|r| r.id),
me_now
/// The context the menu actions run in.
#[derive(Clone)]
pub struct Acts {
pub me: ReadSignal<Option<Me>>,
pub loc: ReadSignal<Location>,
pub open_file: WriteSignal<Option<FileView>>,
pub refresh: Callback<()>,
pub toast: ToastMsg,
pub dialog: ReadSignal<Option<Dialog>>,
pub set_dialog: WriteSignal<Option<Dialog>>,
pub owner: Owner,
}
impl Acts {
/// Build the action list for the selection: the empty area (nothing
/// selected), one entry, or several. Shared by the right-click context
/// menu and the toolbar's selection actions so both always offer the
/// same operations.
fn menu_items(&self, sel: &[Entry]) -> Vec<MenuItem> {
let Self {
me, loc, open_file, ..
} = *self;
let loc_now = loc.get();
// The root that is actually shown. Resolves the single-root default
// location, where `loc.root_id` is `None`. Read in place: cloning `me`
// would copy its whole root list on every selection change.
let (is_rw, root_name, root_id, allow_writable_shares) = me.with(|me_now| {
let root = me_now
.as_ref()
.map(|m| m.allow_writable_shares)
.unwrap_or(false),
)
});
.and_then(|m| effective_root(&m.roots, &loc_now));
(
root.map(|r| r.mode.is_writable()).unwrap_or(false),
root.map(|r| r.name.clone()).unwrap_or_default(),
root.map(|r| r.id),
me_now
.as_ref()
.map(|m| m.allow_writable_shares)
.unwrap_or(false),
)
});
match sel {
[] => {
let mut v = vec![
MenuItem::rw(
IconName::NewFolder,
i18n::t(i18n::k::NEW_FOLDER),
is_rw,
action_name(
NameOp::Mkdir,
root_id,
loc,
refresh,
toast,
set_dialog,
owner.clone(),
),
),
MenuItem::rw(
IconName::NewFile,
i18n::t(i18n::k::NEW_FILE),
is_rw,
action_name(
NameOp::CreateFile,
root_id,
loc,
refresh,
toast,
set_dialog,
owner.clone(),
),
),
MenuItem::rw(
IconName::Upload,
i18n::t(i18n::k::UPLOAD_FILES),
is_rw,
action_upload(
true,
false,
root_id,
loc,
toast,
dialog,
set_dialog,
owner.clone(),
match sel {
[] => {
let mut v = vec![
MenuItem::rw(
IconName::NewFolder,
i18n::t(i18n::k::NEW_FOLDER),
is_rw,
self.name(NameOp::Mkdir, root_id),
),
),
MenuItem::rw(
IconName::UploadFolder,
i18n::t(i18n::k::UPLOAD_FOLDER),
is_rw,
action_upload(
true,
true,
root_id,
loc,
toast,
dialog,
set_dialog,
owner.clone(),
MenuItem::rw(
IconName::NewFile,
i18n::t(i18n::k::NEW_FILE),
is_rw,
self.name(NameOp::CreateFile, root_id),
),
),
];
// Sharing and search are signed-in features; a share visitor has
// neither, and `SessionUser` refuses a share token outright.
if loc_now.share_token.is_none() {
v.push(MenuItem::new(
IconName::Share,
i18n::t(i18n::k::SHARE),
action_share(
None,
root_id,
loc,
root_name.clone(),
allow_writable_shares,
MenuItem::rw(
IconName::Upload,
i18n::t(i18n::k::UPLOAD_FILES),
is_rw,
set_dialog,
owner.clone(),
self.upload(false, root_id),
),
));
}
if let Some(rid) = root_id
&& loc_now.share_token.is_none()
{
let dir = loc_now.path.join("/");
v.push(MenuItem::new(
IconName::Search,
i18n::t(i18n::k::SEARCH_IN_FOLDER),
owner.with(|| {
Callback::new(move |_| crate::views::search::open_search_in(rid, &dir))
}),
));
}
v
}
// One entry gets every action; several get only the ones that take
// a set (move, copy, delete).
_ => {
let one = if let [e] = sel { Some(e) } else { None };
let mut v = vec![];
if let Some(e) = one {
// Exactly what left-clicking the entry does: descend into a
// folder, open a text file in the editor (read-only in
// read-only folders), or preview media.
if let Some(rid) = root_id {
v.push(MenuItem::new(
if e.is_dir {
IconName::FolderOpen
} else {
IconName::Open
},
i18n::t(i18n::k::OPEN),
open_entry(e, rid, loc, is_rw, open_file),
));
}
if !e.is_dir {
// Serve the file itself in a new tab. HTML and SVG render
// as real pages there (sandboxed by the server), which is
// how you share a small site or an HTML report.
v.push(MenuItem::new(
IconName::OpenInNew,
i18n::t(i18n::k::OPEN_IN_NEW_TAB),
action_open_tab(e, root_id, loc, owner.clone()),
));
}
v.push(MenuItem::new(
IconName::Download,
i18n::t(i18n::k::DOWNLOAD),
action_download(e, root_id, loc, set_dialog, toast, owner.clone()),
));
v.push(MenuItem::rw(
IconName::Rename,
i18n::t(i18n::k::RENAME),
is_rw,
action_name(
NameOp::Rename(e.name.clone()),
root_id,
loc,
refresh,
toast,
set_dialog,
owner.clone(),
MenuItem::rw(
IconName::UploadFolder,
i18n::t(i18n::k::UPLOAD_FOLDER),
is_rw,
self.upload(true, root_id),
),
));
}
v.push(MenuItem::rw(
IconName::Move,
i18n::t(i18n::k::MOVE),
is_rw,
action_move_copy(
Op::Move,
sel,
root_id,
me,
loc,
refresh,
toast,
dialog,
set_dialog,
owner.clone(),
),
));
// Copying only writes at the destination, so a read-only source
// folder is fine.
v.push(MenuItem::new(
IconName::Copy,
i18n::t(i18n::k::COPY),
action_move_copy(
Op::Copy,
sel,
root_id,
me,
loc,
refresh,
toast,
dialog,
set_dialog,
owner.clone(),
),
));
if let Some(e) = one {
];
// Sharing and search are signed-in features; a share visitor has
// neither, and `SessionUser` refuses a share token outright.
if loc_now.share_token.is_none() {
v.push(MenuItem::new(
IconName::Share,
i18n::t(i18n::k::SHARE),
action_share(
Some(e),
self.share(
None,
root_id,
loc,
root_name.clone(),
allow_writable_shares,
is_rw,
set_dialog,
owner.clone(),
),
));
}
if let Some(rid) = root_id
&& loc_now.share_token.is_none()
{
let dir = loc_now.path.join("/");
v.push(MenuItem::new(
IconName::Search,
i18n::t(i18n::k::SEARCH_IN_FOLDER),
self.owner.with(|| {
Callback::new(move |_| crate::views::search::open_search_in(rid, &dir))
}),
));
}
v
}
// One entry gets every action; several get only the ones that take
// a set (move, copy, delete).
_ => {
let one = if let [e] = sel { Some(e) } else { None };
let mut v = vec![];
if let Some(e) = one {
// Exactly what left-clicking the entry does: descend into a
// folder, open a text file in the editor (read-only in
// read-only folders), or preview media.
if let Some(rid) = root_id {
v.push(MenuItem::new(
if e.is_dir {
IconName::FolderOpen
} else {
IconName::Open
},
i18n::t(i18n::k::OPEN),
open_entry(e, rid, loc, is_rw, open_file),
));
}
if !e.is_dir {
// Serve the file itself in a new tab. HTML and SVG render
// as real pages there (sandboxed by the server), which is
// how you share a small site or an HTML report.
v.push(MenuItem::new(
IconName::OpenInNew,
i18n::t(i18n::k::OPEN_IN_NEW_TAB),
self.open_tab(e, root_id),
));
}
v.push(MenuItem::new(
IconName::Download,
i18n::t(i18n::k::DOWNLOAD),
self.download(e, root_id),
));
v.push(MenuItem::rw(
IconName::Rename,
i18n::t(i18n::k::RENAME),
is_rw,
self.name(NameOp::Rename(e.name.clone()), root_id),
));
}
v.push(MenuItem::rw(
IconName::Move,
i18n::t(i18n::k::MOVE),
is_rw,
self.move_copy(Op::Move, sel, root_id),
));
// Copying only writes at the destination, so a read-only source
// folder is fine.
v.push(MenuItem::new(
IconName::Info,
i18n::t(i18n::k::INFO),
action_info(e, loc, root_name, set_dialog, owner.clone()),
IconName::Copy,
i18n::t(i18n::k::COPY),
self.move_copy(Op::Copy, sel, root_id),
));
if let Some(e) = one {
if loc_now.share_token.is_none() {
v.push(MenuItem::new(
IconName::Share,
i18n::t(i18n::k::SHARE),
self.share(
Some(e),
root_id,
root_name.clone(),
allow_writable_shares,
is_rw,
),
));
}
v.push(MenuItem::new(
IconName::Info,
i18n::t(i18n::k::INFO),
self.info(e, root_name),
));
}
v.push(MenuItem::rw(
IconName::Delete,
i18n::t(i18n::k::DELETE),
is_rw,
self.delete(sel, root_id),
));
v
}
v.push(MenuItem::rw(
IconName::Delete,
i18n::t(i18n::k::DELETE),
is_rw,
action_delete(sel, root_id, loc, refresh, toast, set_dialog, owner.clone()),
));
v
}
}
}
@@ -1713,15 +1624,8 @@ fn menu_items(
fn CtxMenuView(
ctx: ReadSignal<Option<CtxMenu>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
selected: ReadSignal<Vec<Entry>>,
refresh: Callback<()>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
open_file: WriteSignal<Option<FileView>>,
owner: Owner,
acts: Acts,
) -> impl IntoView {
// Close on any click or Escape.
{
@@ -1772,9 +1676,7 @@ fn CtxMenuView(
view! {
{move || {
let (mx, my) = ctx.get()?;
let items = selected.with(|sel| {
menu_items(sel, me, loc, open_file, refresh, toast, dialog, set_dialog, owner.clone())
});
let items = selected.with(|sel| acts.menu_items(sel));
Some(view! {
<div
@@ -1834,7 +1736,8 @@ fn CtxItem(item: MenuItem) -> impl IntoView {
let hint = hint.unwrap_or_default();
view! {
<div
class=move || if enabled { "ctx-item".to_string() } else { "ctx-item disabled".to_string() }
class="ctx-item"
class:disabled=!enabled
title=move || if enabled { "".to_string() } else { hint.to_string() }
on:click=move |_| {
if let Some(a) = &action {
@@ -1842,7 +1745,7 @@ fn CtxItem(item: MenuItem) -> impl IntoView {
}
}
>
<Icon name=icon class="ic-ctx".to_string()/>
{icon_svg(icon, "ic-ctx")}
<span>{label}</span>
</div>
}
@@ -1858,17 +1761,11 @@ fn CtxItem(item: MenuItem) -> impl IntoView {
/// right). Buttons that don't fit collapse into a three-dot overflow menu.
#[component]
pub fn SelectionActions(
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
selected: ReadSignal<Vec<Entry>>,
open_file: WriteSignal<Option<FileView>>,
refresh: Callback<()>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
acts: Acts,
topbar_ref: NodeRef<leptos::html::Header>,
) -> impl IntoView {
let Acts { me, loc, .. } = acts;
// How many buttons are shown before the overflow kicks in. Starts small
// so the first paint never overflows; the effect below widens it as soon
// as the real widths are measurable.
@@ -1940,9 +1837,7 @@ pub fn SelectionActions(
if !has_root {
return None;
}
let items = selected.with(|sel| {
menu_items(sel, me, loc, open_file, refresh, toast, dialog, set_dialog, owner.clone())
});
let items = selected.with(|sel| acts.menu_items(sel));
let fit = visible_n.get();
let n = if fit >= items.len() {
items.len()
@@ -1988,7 +1883,7 @@ pub fn SelectionActions(
set_at.set((r.bottom() + 6.0, f64::from(doc.client_width()) - r.right()));
}
>
<Icon name=IconName::MoreVert class="ic-btn".to_string()/>
{icon_svg(IconName::MoreVert, "ic-btn")}
</button>
<div
id="sel-more-menu"
@@ -2037,7 +1932,7 @@ fn SelButton(item: MenuItem) -> impl IntoView {
}
}
>
<Icon name=item.icon class="ic-btn".to_string()/>
{icon_svg(item.icon, "ic-btn")}
</button>
}
}
@@ -2046,10 +1941,6 @@ fn SelButton(item: MenuItem) -> impl IntoView {
// Menu actions
// ---------------------------------------------------------------------------
fn previewable(e: &Entry) -> bool {
!e.is_dir && is_media(e.kind)
}
fn join_path(dir: &[String], name: &str) -> String {
if dir.is_empty() {
name.to_string()
@@ -2058,10 +1949,6 @@ fn join_path(dir: &[String], name: &str) -> String {
}
}
fn is_conflict(e: &api::ApiError) -> bool {
matches!(e, api::ApiError::Http { status: 409, .. })
}
/// What a name prompt in the current folder does with the entered name.
#[derive(Clone)]
enum NameOp {
@@ -2071,161 +1958,6 @@ enum NameOp {
Rename(String),
}
fn action_name(
op: NameOp,
root_id: Option<i64>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
use i18n::k;
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let dir = loc.get().path;
let (title, label, initial, submit, ok) = match &op {
NameOp::Mkdir => (
i18n::t(k::NEW_FOLDER).to_string(),
k::FOLDER_NAME,
String::new(),
k::CREATE,
k::FOLDER_CREATED,
),
NameOp::CreateFile => (
i18n::t(k::NEW_FILE).to_string(),
k::FILE_NAME,
String::new(),
k::CREATE,
k::FILE_CREATED,
),
NameOp::Rename(old) => (
i18n::t_fmt(k::RENAME_TITLE, old),
k::NEW_NAME,
old.clone(),
k::RENAME,
k::RENAMED,
),
};
let op = op.clone();
set_dialog.set(Some(Dialog::PromptOp {
title,
label: i18n::t(label).to_string(),
initial,
submit: i18n::t(submit).to_string(),
run: owner.with(|| {
Callback::new(move |name: String| {
let (op, dir) = (op.clone(), dir.clone());
let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
match op {
NameOp::Mkdir => api::mkdir(root_id, &join_path(&dir, &name)).await,
NameOp::CreateFile => {
api::create_file(root_id, &join_path(&dir, &name)).await
}
NameOp::Rename(old) => {
api::rename_item(root_id, &join_path(&dir, &old), name, false).await
}
}
.map(|_| ())
.map_err(|e| e.to_string())
});
fut
})
}),
on_ok: owner.with(|| {
Callback::new(move |_| {
show(toast, i18n::t(ok));
refresh.run(());
})
}),
}));
})
}
/// Delete the entries after one confirmation. Several are deleted one by
/// one; the first failure stops and reports.
fn action_delete(
entries: &[Entry],
root_id: Option<i64>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
let items: Vec<(String, bool)> = entries.iter().map(|e| (e.name.clone(), e.is_dir)).collect();
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let dir = loc.get().path;
let message = match items.as_slice() {
[(name, true)] => i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, name),
[(name, false)] => i18n::t_fmt(i18n::k::DELETE_MSG, name),
many => i18n::t_fmt(i18n::k::DELETE_N_MSG, &many.len().to_string()),
};
let fulls: Vec<String> = items.iter().map(|(n, _)| join_path(&dir, n)).collect();
set_dialog.set(Some(Dialog::Confirm {
title: i18n::t(i18n::k::DELETE).to_string(),
message,
submit: i18n::t(i18n::k::DELETE).to_string(),
danger: true,
on_submit: owner.with(|| {
Callback::new(move |_| {
set_dialog.set(None);
let fulls = fulls.clone();
spawn_local(async move {
for f in &fulls {
if let Err(e) = api::delete_item(root_id, f).await {
show_error(toast, e.to_string());
refresh.run(());
return;
}
}
show(toast, i18n::t(i18n::k::DELETED));
refresh.run(());
});
})
}),
}));
})
}
/// Pick files (or a folder) and upload them into the current directory.
///
/// Flow: pick → ask the server which targets exist → if any, the conflict
/// dialog (overwrite / skip / cancel, "apply to all") → start a job. The
/// job runs in `uploads`; its progress shows in the panel, not in a toast.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn action_upload(
multiple: bool,
directory: bool,
root_id: Option<i64>,
loc: ReadSignal<Location>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let dir = loc.get().path.join("/");
let owner = owner.clone();
api::pick_files(multiple, directory, move |files| {
let label = upload_label(files.iter().map(|(p, _)| p.as_str()));
let job = uploads::prepare(root_id, dir.clone(), label);
upload_prepared(
job,
root_id,
dir.clone(),
files,
toast,
dialog,
set_dialog,
owner.clone(),
);
});
})
}
/// The job label: the one name for a single item, the top folder when every
/// path carries a slash (a folder pick), else "N files". Takes relative
/// paths after a pick, top-level names after a drop.
@@ -2241,308 +1973,448 @@ fn upload_label<'a>(names: impl Iterator<Item = &'a str>) -> String {
}
}
/// Upload picked or dropped files into `dir`: existence check, conflict
/// dialog, transfer. The job already shows in the panel as "preparing".
#[allow(clippy::too_many_arguments)] // explicit signal props
fn upload_prepared(
job: u64,
root_id: i64,
dir: String,
files: Vec<(String, web_sys::File)>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) {
spawn_local(async move {
let paths: Vec<String> = files.iter().map(|(rel, _)| rel.clone()).collect();
let existing = match api::check_exists(root_id, &dir, paths).await {
Ok(e) => e,
Err(e) => {
uploads::discard(job);
show_error(
toast,
i18n::t_fmt(i18n::k::UPLOAD_CHECK_FAILED, &e.to_string()),
);
return;
}
};
// A folder where a file would go can never be written.
// Report it in the job and leave it out of the upload.
let dirs: std::collections::HashSet<String> = existing
.iter()
.filter(|e| e.is_dir)
.map(|e| e.path.clone())
.collect();
let conflicts: Vec<String> = existing
.iter()
.filter(|e| !e.is_dir)
.map(|e| e.path.clone())
.collect();
let errors: Vec<(String, String)> = dirs
.iter()
.map(|p| (p.clone(), i18n::t(i18n::k::ERR_FOLDER_EXISTS).to_string()))
.collect();
let files: Vec<(String, web_sys::File)> = files
.into_iter()
.filter(|(rel, _)| !dirs.contains(rel.as_str()))
.collect();
let start = move |on_conflict: OnConflict, decided: Vec<String>, overwrite: bool| {
uploads::begin(
job,
files.clone(),
errors.clone(),
on_conflict,
decided,
overwrite,
);
};
if conflicts.is_empty() {
start(OnConflict::Ask, Vec::new(), false);
return;
}
// Callbacks made inside a short-lived async scope die with
// it; the browser's owner keeps this one alive.
let on_decide = owner.with(|| {
let conflicts = conflicts.clone();
let start = std::sync::Arc::new(std::sync::Mutex::new(Some(start)));
Callback::new(move |answer: Option<(bool, bool)>| {
let Some(start) = start.lock().unwrap().take() else {
return;
};
let Some((overwrite, apply_all)) = answer else {
uploads::discard(job);
return;
};
// The answer covers the listed files. "Apply to all" extends
// it to files that appear during the transfer.
let rule = match (apply_all, overwrite) {
(false, _) => OnConflict::Ask,
(true, true) => OnConflict::Overwrite,
(true, false) => OnConflict::Skip,
};
start(rule, conflicts.clone(), overwrite);
})
});
// The job sits in the panel as "preparing" while it waits.
crate::views::dialogs::claim_async_dialog(dialog).await;
set_dialog.set(Some(Dialog::UploadConflict {
files: conflicts,
on_decide,
}));
});
}
/// Move or copy the entries to a folder chosen in the picker. Several are
/// processed one by one; name collisions are collected and asked about once.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn action_move_copy(
op: Op,
entries: &[Entry],
root_id: Option<i64>,
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
toast: ToastMsg,
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
Callback::new(move |_| {
let loc = loc.get();
let Some(root_id) = root_id else { return };
let roots = me.get().map(|m| m.roots).unwrap_or_default();
let dir = loc.path.clone();
let title = match names.as_slice() {
[one] => i18n::t_fmt(op.title_key(1), one),
many => i18n::t_fmt(op.title_key(many.len()), &many.len().to_string()),
};
// (name, path relative to the root) per item.
let items: Vec<(String, String)> = names
.iter()
.map(|n| (n.clone(), join_path(&dir, n)))
.collect();
set_dialog.set(Some(Dialog::Picker {
title,
confirm: i18n::t(op.here_key()).to_string(),
roots,
root: root_id,
dir: dir.join("/"),
for_write: true,
on_pick: owner.with(|| {
Callback::new(move |(dst_root, dst_dir): (i64, String)| {
let wire = match op {
Op::Move => api_types::Op::Move,
Op::Copy => api_types::Op::Copy,
};
let ok_msg = i18n::t(if op == Op::Move {
i18n::k::MOVED
} else {
i18n::k::COPIED
});
// Runs the items and returns the ones that collided.
let run = move |items: Vec<(String, String)>,
overwrite: bool,
dst_dir: String| async move {
let mut conflicts = Vec::new();
for (name, full) in items {
match api::mutation(root_id, &full, wire, dst_root, &dst_dir, overwrite)
.await
{
Ok(_) => {}
Err(e) if is_conflict(&e) => conflicts.push((name, full)),
Err(e) => return Err(e.to_string()),
}
}
Ok(conflicts)
};
let items = items.clone();
spawn_local(async move {
let conflicts = match run(items, false, dst_dir.clone()).await {
Ok(c) if c.is_empty() => {
show(toast, ok_msg);
refresh.run(());
return;
}
Ok(c) => c,
Err(e) => return show_error(toast, e),
};
crate::views::dialogs::claim_async_dialog(dialog).await;
set_dialog.set(Some(Dialog::Conflict {
title: i18n::t(i18n::k::NAME_TAKEN).to_string(),
files: conflicts.iter().map(|(n, _)| n.clone()).collect(),
// The retry re-runs only the items that collided.
on_submit: Callback::new(move |_| {
let (conflicts, dst_dir) = (conflicts.clone(), dst_dir.clone());
spawn_local(async move {
match run(conflicts, true, dst_dir).await {
Ok(c) if c.is_empty() => {
show(toast, ok_msg);
refresh.run(());
}
Ok(_) => show_error(toast, i18n::t(i18n::k::NAME_TAKEN)),
Err(e) => show_error(toast, e),
}
});
}),
}));
});
})
}),
}));
})
}
fn action_download(
entry: &Entry,
root_id: Option<i64>,
loc: ReadSignal<Location>,
set_dialog: WriteSignal<Option<Dialog>>,
toast: ToastMsg,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
let is_dir = entry.is_dir;
owner.with(|| {
impl Acts {
fn name(&self, op: NameOp, root_id: Option<i64>) -> Callback<()> {
use i18n::k;
let Self {
loc,
refresh,
toast,
set_dialog,
..
} = *self;
let owner = self.owner.clone();
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let full = join_path(&loc.path, &name);
if is_dir {
set_dialog.set(Some(Dialog::DownloadFormat {
name: name.clone(),
root_id,
path: full,
}));
} else {
let url = api::download_url(root_id, &full, None);
api::trigger_download(&url, &name);
show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
}
let dir = loc.get().path;
let (title, label, initial, submit, ok) = match &op {
NameOp::Mkdir => (
i18n::t(k::NEW_FOLDER).to_string(),
k::FOLDER_NAME,
String::new(),
k::CREATE,
k::FOLDER_CREATED,
),
NameOp::CreateFile => (
i18n::t(k::NEW_FILE).to_string(),
k::FILE_NAME,
String::new(),
k::CREATE,
k::FILE_CREATED,
),
NameOp::Rename(old) => (
i18n::t_fmt(k::RENAME_TITLE, old),
k::NEW_NAME,
old.clone(),
k::RENAME,
k::RENAMED,
),
};
let op = op.clone();
set_dialog.set(Some(Dialog::PromptOp {
title,
label: i18n::t(label).to_string(),
initial,
submit: i18n::t(submit).to_string(),
run: owner.with(|| {
Callback::new(move |name: String| {
let (op, dir) = (op.clone(), dir.clone());
let fut: crate::views::dialogs::PromptOpFuture = Box::pin(async move {
match op {
NameOp::Mkdir => api::mkdir(root_id, &join_path(&dir, &name)).await,
NameOp::CreateFile => {
api::create_file(root_id, &join_path(&dir, &name)).await
}
NameOp::Rename(old) => {
api::rename_item(root_id, &join_path(&dir, &old), name, false)
.await
}
}
.map(|_| ())
.map_err(|e| e.to_string())
});
fut
})
}),
on_ok: owner.with(|| {
Callback::new(move |_| {
show(toast, i18n::t(ok));
refresh.run(());
})
}),
}));
})
})
}
}
/// Open the file itself in a new tab, via the `?action=preview` endpoint —
/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
fn action_open_tab(
entry: &Entry,
root_id: Option<i64>,
loc: ReadSignal<Location>,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
owner.with(|| {
/// Delete the entries after one confirmation. Several are deleted one by
/// one; the first failure stops and reports.
fn delete(&self, entries: &[Entry], root_id: Option<i64>) -> Callback<()> {
let Self {
loc,
refresh,
toast,
set_dialog,
..
} = *self;
let owner = self.owner.clone();
let items: Vec<(String, bool)> =
entries.iter().map(|e| (e.name.clone(), e.is_dir)).collect();
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let full = join_path(&loc.get().path, &name);
api::open_in_new_tab(&api::preview_url(root_id, &full));
let dir = loc.get().path;
let message = match items.as_slice() {
[(name, true)] => i18n::t_fmt(i18n::k::DELETE_FOLDER_MSG, name),
[(name, false)] => i18n::t_fmt(i18n::k::DELETE_MSG, name),
many => i18n::t_fmt(i18n::k::DELETE_N_MSG, &many.len().to_string()),
};
let fulls: Vec<String> = items.iter().map(|(n, _)| join_path(&dir, n)).collect();
set_dialog.set(Some(Dialog::Confirm {
title: i18n::t(i18n::k::DELETE).to_string(),
message,
submit: i18n::t(i18n::k::DELETE).to_string(),
danger: true,
on_submit: owner.with(|| {
Callback::new(move |_| {
set_dialog.set(None);
let fulls = fulls.clone();
spawn_local(async move {
for f in &fulls {
if let Err(e) = api::delete_item(root_id, f).await {
show_error(toast, e.to_string());
refresh.run(());
return;
}
}
show(toast, i18n::t(i18n::k::DELETED));
refresh.run(());
});
})
}),
}));
})
})
}
}
/// Open the share dialog for an item (milestone 6).
///
/// `entry` is the item to share, or `None` for the folder currently open.
/// The folder case sends an empty path, which the server reads as the root of
/// the location. At the top of a root that shares the root itself.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn action_share(
entry: Option<&Entry>,
root_id: Option<i64>,
loc: ReadSignal<Location>,
root_name: String,
allow_writable: bool,
root_writable: bool,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
let entry_name = entry.map(|e| e.name.clone());
owner.with(|| {
/// Pick files (or a folder) and upload them into the current directory.
///
/// Flow: pick → ask the server which targets exist → if any, the conflict
/// dialog (overwrite / skip / cancel, "apply to all") → start a job. The
/// job runs in `uploads`; its progress shows in the panel, not in a toast.
fn upload(&self, directory: bool, root_id: Option<i64>) -> Callback<()> {
let acts = self.clone();
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let (name, path) = match &entry_name {
Some(n) => (n.clone(), join_path(&loc.path, n)),
// The last path segment names the folder; at the top of a
// root there is none, so the root's own name stands in.
None => (
loc.path
.last()
.cloned()
.unwrap_or_else(|| root_name.clone()),
loc.path.join("/"),
),
let dir = acts.loc.get().path.join("/");
let acts = acts.clone();
api::pick_files(directory, move |files| {
let label = upload_label(files.iter().map(|(p, _)| p.as_str()));
let job = uploads::prepare(root_id, dir.clone(), label);
acts.upload_prepared(job, root_id, dir, files);
});
})
}
/// Upload picked or dropped files into `dir`: existence check, conflict
/// dialog, transfer. The job already shows in the panel as "preparing".
fn upload_prepared(
&self,
job: u64,
root_id: i64,
dir: String,
files: Vec<(String, web_sys::File)>,
) {
let Self {
toast,
dialog,
set_dialog,
..
} = *self;
let owner = self.owner.clone();
spawn_local(async move {
let paths: Vec<String> = files.iter().map(|(rel, _)| rel.clone()).collect();
let existing = match api::check_exists(root_id, &dir, paths).await {
Ok(e) => e,
Err(e) => {
uploads::discard(job);
show_error(
toast,
i18n::t_fmt(i18n::k::UPLOAD_CHECK_FAILED, &e.to_string()),
);
return;
}
};
set_dialog.set(Some(Dialog::Share {
name,
root_id,
path,
allow_writable,
root_writable,
// A folder where a file would go can never be written.
// Report it in the job and leave it out of the upload.
let dirs: std::collections::HashSet<String> = existing
.iter()
.filter(|e| e.is_dir)
.map(|e| e.path.clone())
.collect();
let conflicts: Vec<String> = existing
.iter()
.filter(|e| !e.is_dir)
.map(|e| e.path.clone())
.collect();
let errors: Vec<(String, String)> = dirs
.iter()
.map(|p| (p.clone(), i18n::t(i18n::k::ERR_FOLDER_EXISTS).to_string()))
.collect();
let files: Vec<(String, web_sys::File)> = files
.into_iter()
.filter(|(rel, _)| !dirs.contains(rel.as_str()))
.collect();
let start = move |on_conflict: OnConflict, decided: Vec<String>, overwrite: bool| {
uploads::begin(
job,
files.clone(),
errors.clone(),
on_conflict,
decided,
overwrite,
);
};
if conflicts.is_empty() {
start(OnConflict::Ask, Vec::new(), false);
return;
}
// Callbacks made inside a short-lived async scope die with
// it; the browser's owner keeps this one alive.
let on_decide = owner.with(|| {
let conflicts = conflicts.clone();
let start = std::sync::Arc::new(std::sync::Mutex::new(Some(start)));
Callback::new(move |answer: Option<(bool, bool)>| {
let Some(start) = start.lock().unwrap().take() else {
return;
};
let Some((overwrite, apply_all)) = answer else {
uploads::discard(job);
return;
};
// The answer covers the listed files. "Apply to all" extends
// it to files that appear during the transfer.
let rule = match (apply_all, overwrite) {
(false, _) => OnConflict::Ask,
(true, true) => OnConflict::Overwrite,
(true, false) => OnConflict::Skip,
};
start(rule, conflicts.clone(), overwrite);
})
});
// The job sits in the panel as "preparing" while it waits.
crate::views::dialogs::claim_async_dialog(dialog).await;
set_dialog.set(Some(Dialog::UploadConflict {
files: conflicts,
on_decide,
}));
})
})
}
});
}
fn action_info(
entry: &Entry,
loc: ReadSignal<Location>,
root_name: String,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
let entry2 = entry.clone();
owner.with(|| {
/// Move or copy the entries to a folder chosen in the picker. Several are
/// processed one by one; name collisions are collected and asked about once.
fn move_copy(&self, op: Op, entries: &[Entry], root_id: Option<i64>) -> Callback<()> {
let Self {
me,
loc,
refresh,
toast,
dialog,
set_dialog,
..
} = *self;
let owner = self.owner.clone();
let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
Callback::new(move |_| {
let loc = loc.get();
let full = join_path(&loc.path, &entry2.name);
set_dialog.set(Some(Dialog::Info {
entry: entry2.clone(),
root_name: root_name.clone(),
rel_path: full,
let Some(root_id) = root_id else { return };
let roots = me.get().map(|m| m.roots).unwrap_or_default();
let dir = loc.path.clone();
let title = match names.as_slice() {
[one] => i18n::t_fmt(op.title_key(1), one),
many => i18n::t_fmt(op.title_key(many.len()), &many.len().to_string()),
};
// (name, path relative to the root) per item.
let items: Vec<(String, String)> = names
.iter()
.map(|n| (n.clone(), join_path(&dir, n)))
.collect();
set_dialog.set(Some(Dialog::Picker {
title,
confirm: i18n::t(op.here_key()).to_string(),
roots,
root: root_id,
dir: dir.join("/"),
for_write: true,
on_pick: owner.with(|| {
Callback::new(move |(dst_root, dst_dir): (i64, String)| {
let wire = match op {
Op::Move => api_types::Op::Move,
Op::Copy => api_types::Op::Copy,
};
let ok_msg = i18n::t(if op == Op::Move {
i18n::k::MOVED
} else {
i18n::k::COPIED
});
// Runs the items and returns the ones that collided.
let run = move |items: Vec<(String, String)>,
overwrite: bool,
dst_dir: String| async move {
let mut conflicts = Vec::new();
for (name, full) in items {
match api::mutation(
root_id, &full, wire, dst_root, &dst_dir, overwrite,
)
.await
{
Ok(_) => {}
Err(e) if e.status() == Some(409) => {
conflicts.push((name, full))
}
Err(e) => return Err(e.to_string()),
}
}
Ok(conflicts)
};
let items = items.clone();
spawn_local(async move {
let conflicts = match run(items, false, dst_dir.clone()).await {
Ok(c) if c.is_empty() => {
show(toast, ok_msg);
refresh.run(());
return;
}
Ok(c) => c,
Err(e) => return show_error(toast, e),
};
crate::views::dialogs::claim_async_dialog(dialog).await;
set_dialog.set(Some(Dialog::Conflict {
title: i18n::t(i18n::k::NAME_TAKEN).to_string(),
files: conflicts.iter().map(|(n, _)| n.clone()).collect(),
// The retry re-runs only the items that collided.
on_submit: Callback::new(move |_| {
let (conflicts, dst_dir) = (conflicts.clone(), dst_dir.clone());
spawn_local(async move {
match run(conflicts, true, dst_dir).await {
Ok(c) if c.is_empty() => {
show(toast, ok_msg);
refresh.run(());
}
Ok(_) => {
show_error(toast, i18n::t(i18n::k::NAME_TAKEN))
}
Err(e) => show_error(toast, e),
}
});
}),
}));
});
})
}),
}));
})
})
}
fn download(&self, entry: &Entry, root_id: Option<i64>) -> Callback<()> {
let Self {
loc,
set_dialog,
toast,
..
} = *self;
let name = entry.name.clone();
let is_dir = entry.is_dir;
self.owner.with(|| {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let full = join_path(&loc.path, &name);
if is_dir {
set_dialog.set(Some(Dialog::DownloadFormat {
name: name.clone(),
root_id,
path: full,
}));
} else {
let url = api::download_url(root_id, &full, None);
api::trigger_download(&url, &name);
show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
}
})
})
}
/// Open the file itself in a new tab, via the `?action=preview` endpoint —
/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
fn open_tab(&self, entry: &Entry, root_id: Option<i64>) -> Callback<()> {
let loc = self.loc;
let name = entry.name.clone();
self.owner.with(|| {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let full = join_path(&loc.get().path, &name);
api::open_in_new_tab(&api::preview_url(root_id, &full));
})
})
}
/// Open the share dialog for an item (milestone 6).
///
/// `entry` is the item to share, or `None` for the folder currently open.
/// The folder case sends an empty path, which the server reads as the root of
/// the location. At the top of a root that shares the root itself.
fn share(
&self,
entry: Option<&Entry>,
root_id: Option<i64>,
root_name: String,
allow_writable: bool,
root_writable: bool,
) -> Callback<()> {
let Self {
loc, set_dialog, ..
} = *self;
let entry_name = entry.map(|e| e.name.clone());
self.owner.with(|| {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let (name, path) = match &entry_name {
Some(n) => (n.clone(), join_path(&loc.path, n)),
// The last path segment names the folder; at the top of a
// root there is none, so the root's own name stands in.
None => (
loc.path
.last()
.cloned()
.unwrap_or_else(|| root_name.clone()),
loc.path.join("/"),
),
};
set_dialog.set(Some(Dialog::Share {
name,
root_id,
path,
allow_writable,
root_writable,
}));
})
})
}
fn info(&self, entry: &Entry, root_name: String) -> Callback<()> {
let Self {
loc, set_dialog, ..
} = *self;
let entry2 = entry.clone();
self.owner.with(|| {
Callback::new(move |_| {
let loc = loc.get();
let full = join_path(&loc.path, &entry2.name);
set_dialog.set(Some(Dialog::Info {
entry: entry2.clone(),
root_name: root_name.clone(),
rel_path: full,
}));
})
})
}
}
▾Mweb/src/views/calendar.rs
@@ -19,7 +19,7 @@ use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n::{self, k};
use crate::icons::IconName;
use crate::router::{self, Location, Section, navigate};
use crate::util::{intl, owned_window_listener};
use crate::util::{intl, owned_window_listener, retry_card};
use crate::views::pim::{browser_tz, display_name, safe_color};
// ---------------------------------------------------------------------------
@@ -340,25 +340,14 @@ fn fmt_when(w: When, day: i64) -> String {
}
}
fn fmt_day_long(day: i64) -> String {
/// `w` is the width of the weekday and month names: "long" or "short".
fn fmt_day(day: i64, w: &str) -> String {
intl(
&day_date(day),
&[
("weekday", "long"),
("weekday", w),
("day", "numeric"),
("month", "long"),
("year", "numeric"),
],
)
}
fn fmt_day_short(day: i64) -> String {
intl(
&day_date(day),
&[
("weekday", "short"),
("day", "numeric"),
("month", "short"),
("month", w),
("year", "numeric"),
],
)
@@ -367,20 +356,24 @@ fn fmt_day_short(day: i64) -> String {
/// The full time of an instance, for its details.
fn fmt_span(s: &Span) -> String {
match (s.all_day, s.first == s.last) {
(true, true) => fmt_day_short(s.first),
(true, false) => format!("{} – {}", fmt_day_short(s.first), fmt_day_short(s.last)),
(true, true) => fmt_day(s.first, "short"),
(true, false) => format!(
"{} – {}",
fmt_day(s.first, "short"),
fmt_day(s.last, "short")
),
(false, true) => format!(
"{} · {}",
fmt_day_short(s.first),
fmt_day(s.first, "short"),
fmt_when(when_on(s, s.first), s.first)
),
(false, false) => {
let (day, min) = s.end_at();
format!(
"{} {} – {} {}",
fmt_day_short(s.first),
fmt_day(s.first, "short"),
fmt_min(s.first, s.start_min),
fmt_day_short(day),
fmt_day(day, "short"),
fmt_min(day, min)
)
}
@@ -492,27 +485,6 @@ impl Opened {
}
}
/// An invitation as the instance it names, for the day and time helpers.
fn inv_instance(v: &PimInvitation) -> PimInstance {
PimInstance {
collection_id: v.collection_id,
name: v.name.clone(),
uid: v.uid.clone(),
recurrence_id: v.recurrence_id.clone(),
start: v.start.clone(),
end: v.end.clone(),
all_day: v.all_day,
component: "VEVENT".into(),
summary: v.summary.clone(),
location: v.location.clone(),
status: None,
transparent: false,
has_attendees: true,
partstat: None,
organizer: None,
}
}
/// An instance with what the views derive from it.
#[derive(Clone)]
struct Shown {
@@ -852,13 +824,7 @@ fn loading_or_error(
) -> Result<api::PimInstances, AnyView> {
match data {
None => Err(view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any()),
Some(Err(e)) => Err(view! {
<div class="card error-card">
<span>{e.clone()}</span>
<button class="btn" on:click=move |_| retry.run(())>{i18n::t(k::RETRY)}</button>
</div>
}
.into_any()),
Some(Err(e)) => Err(retry_card(e.clone(), retry).into_any()),
Some(Ok(list)) => Ok(list.clone()),
}
}
@@ -950,11 +916,11 @@ fn MonthView(
let dots: Vec<String> =
on_day.take(3).map(|s| s.color.clone()).collect();
let label = match n {
0 => fmt_day_long(day),
1 => format!("{}, {}", fmt_day_long(day), i18n::t(k::PIM_EVENTS_ONE)),
0 => fmt_day(day, "long"),
1 => format!("{}, {}", fmt_day(day, "long"), i18n::t(k::PIM_EVENTS_ONE)),
n => format!(
"{}, {}",
fmt_day_long(day),
fmt_day(day, "long"),
i18n::t_fmt(k::PIM_EVENTS_N, &n.to_string())
),
};
@@ -1057,7 +1023,7 @@ fn MonthView(
.map(|s| day_row(s, day))
.collect();
view! {
<h3 class="cal-day-title">{fmt_day_long(day)}</h3>
<h3 class="cal-day-title">{fmt_day(day, "long")}</h3>
{if rows.is_empty() {
view! { <p class="muted cal-pad">{i18n::t(k::PIM_NO_EVENTS_DAY)}</p> }.into_any()
} else {
@@ -1104,7 +1070,7 @@ fn AgendaView(
(!rows.is_empty()).then(|| view! {
<section class="cal-agenda-day">
<h3 class="cal-day-title" class:cal-day-today=day == from>
{fmt_day_long(day)}
{fmt_day(day, "long")}
</h3>
<div class="cal-rows">{rows}</div>
</section>
@@ -1206,13 +1172,7 @@ fn InvitationsView(
<div class="cal-invitations">
{move || match data.get() {
None => view! { <p class="muted cal-pad">{i18n::t(k::LOADING)}</p> }.into_any(),
Some(Err(e)) => view! {
<div class="card error-card">
<span>{e}</span>
<button class="btn" on:click=move |_| on_replied.run(())>{i18n::t(k::RETRY)}</button>
</div>
}
.into_any(),
Some(Err(e)) => retry_card(e, on_replied).into_any(),
Some(Ok(list)) if list.is_empty() => view! {
<div class="empty-state">
{icon_svg(IconName::Calendar, "empty-glyph")}
@@ -1235,7 +1195,7 @@ fn InvitationsView(
.or_else(|| inv.recurring.then(|| inv.start.clone())),
name: inv.name.clone(),
};
let span = span_of(&inv_instance(&inv));
let span = span_at(&inv.start, &inv.end, inv.all_day);
let series = inv.recurring && inv.recurrence_id.is_none();
let mut meta = vec![fmt_span(&span)];
match (&inv.rrule, &inv.recurrence_id) {
▾Mweb/src/views/contacts.rs
@@ -66,21 +66,14 @@ fn fmt_vcard_date(s: &str) -> String {
// leap year, so February 29 exists when the year is unknown.
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(0.0));
date.set_utc_full_year_with_month_date(year.unwrap_or(2000) as u32, m as i32 - 1, d as i32);
match year {
Some(_) => intl(
&date,
&[
("timeZone", "UTC"),
("day", "numeric"),
("month", "long"),
("year", "numeric"),
],
),
None => intl(
&date,
&[("timeZone", "UTC"), ("day", "numeric"), ("month", "long")],
),
}
let opts = [
("timeZone", "UTC"),
("day", "numeric"),
("month", "long"),
("year", "numeric"),
];
let n = if year.is_some() { 4 } else { 3 };
intl(&date, &opts[..n])
}
/// vCard TYPE values in the UI language. Unknown ones stay as written.
▾Mweb/src/views/dialogs.rs
@@ -7,7 +7,7 @@ use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Entry, RootInfo};
use crate::components::icon::Icon;
use crate::components::icon::icon_svg;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n;
@@ -392,9 +392,9 @@ fn conflict_list(files: &[String]) -> impl IntoView + use<> {
view! {
<ul class="conflict-list">
{shown.into_iter().map(|f| view! { <li>{f}</li> }).collect::<Vec<_>>()}
<Show when=move || { extra > 0 }>
{(extra > 0).then(|| view! {
<li class="muted">{move || i18n::t_fmt(i18n::k::AND_MORE, &extra.to_string())}</li>
</Show>
})}
</ul>
}
}
@@ -556,7 +556,7 @@ fn PickerDialog(
set_sel_dir.set(String::new());
}
>
<Icon name=IconName::Folder class="picker-root-ic".to_string()/>
{icon_svg(IconName::Folder, "picker-root-ic")}
<span class="picker-root-name">{name}</span>
</button>
}
@@ -608,7 +608,7 @@ fn PickerDialog(
if es.is_empty() {
return view! {
<div class="picker-empty muted">
<Icon name=IconName::FolderOff class="picker-empty-ic".to_string()/>
{icon_svg(IconName::FolderOff, "picker-empty-ic")}
<span>{i18n::tr(i18n::k::NO_SUBFOLDERS)}</span>
</div>
}
@@ -620,7 +620,7 @@ fn PickerDialog(
let name2 = name.clone();
view! {
<div class="picker-row" on:click=move |_| descend(name2.clone())>
<Icon name=IconName::Folder class="row-icon".to_string()/>
{icon_svg(IconName::Folder, "row-icon")}
<span class="row-name">{name}</span>
<span class="picker-chev"></span>
</div>
@@ -730,11 +730,7 @@ fn InfoDialog(
};
let modified = crate::util::format_date(&entry.mtime);
let parent = rel_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
let location = if parent.is_empty() {
format!("{root_name}/")
} else {
format!("{root_name}/{parent}")
};
let location = format!("{root_name}/{parent}");
view! {
<Modal class="card" on_close=on_close>
<h2 class="modal-title">{i18n::tr(i18n::k::FILE_INFO)}</h2>
▾Mweb/src/views/file_view.rs
@@ -9,11 +9,9 @@ use api_types::FileKind;
use leptos::prelude::*;
use crate::api;
use crate::components::icon::Icon;
use crate::components::icon::icon_svg;
use crate::components::toast::{ToastMsg, show};
use crate::editor::Editor;
use crate::icons::{IconName, icon_for};
use crate::preview::Preview;
/// A file that is open in the full-page view.
#[derive(Clone)]
@@ -38,7 +36,7 @@ pub fn FileViewTitle(view: FileView, dirty: ReadSignal<bool>) -> impl IntoView {
let name = view.name;
view! {
<div class="file-title">
<Icon name=icon class="file-title-icon".to_string()/>
{icon_svg(icon, "file-title-icon")}
<span class="file-title-name">
{move || {
let dot = if dirty.get() { " •" } else { "" };
@@ -85,7 +83,7 @@ pub fn FileViewActions(
show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
}
>
<Icon name=IconName::Download class="ic-btn".to_string()/>
{icon_svg(IconName::Download, "ic-btn")}
</button>
}
};
@@ -107,7 +105,7 @@ pub fn FileViewActions(
</button>
})}
{download_btn}
<Show when=move || show_close>
{show_close.then(|| view! {
<button
class="icon-btn file-close"
title={i18n::tr(i18n::k::CLOSE_ESC)}
@@ -120,44 +118,9 @@ pub fn FileViewActions(
}
}
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
{icon_svg(IconName::Close, "ic-btn")}
</button>
</Show>
})}
</div>
}
}
/// The full-page content: preview, editor, or the "no preview" fallback.
#[component]
pub fn FileViewContent(
view: FileView,
register_save: WriteSignal<Option<Callback<()>>>,
register_close: WriteSignal<Option<Callback<()>>>,
dirty: ReadSignal<bool>,
set_dirty: WriteSignal<bool>,
set_saving: WriteSignal<bool>,
close: Callback<()>,
toast: ToastMsg,
/// Re-fetch the (hidden, still mounted) browser after a save.
refresh: Callback<()>,
) -> impl IntoView {
let content = if view.kind == FileKind::Text {
view! {
<Editor
target=view
register_save=register_save
register_close=register_close
dirty=dirty
set_dirty=set_dirty
set_saving=set_saving
close=close
toast=toast
refresh=refresh
/>
}
.into_any()
} else {
view! { <Preview target=view/> }.into_any()
};
view! { <div class="file-view">{content}</div> }
}
▾Mweb/src/views/pim.rs
@@ -13,13 +13,13 @@ use crate::api::{
self, Me, PimCollectionInfo, PimCollectionKind, PimImportResult, PimLinkInfo,
PimShareCandidate, PimShareInfo, PimShareMode,
};
use crate::components::icon::{Icon, icon_svg};
use crate::components::icon::icon_svg;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n::{self, k};
use crate::icons::IconName;
use crate::router::{Location, Section, navigate, server_url};
use crate::util::{copy_to_clipboard, local_date_time, storage_get, storage_set};
use crate::util::{copy_button, local_date_time, retry_card, storage_get, storage_set, url_row};
use crate::views::calendar::CalendarMain;
use crate::views::contacts::ContactsMain;
use crate::views::dialogs::ConfirmDialog;
@@ -327,7 +327,7 @@ fn CollectionList(
aria-label=move || i18n::t_fmt(k::PIM_MANAGE_OF, &name)
on:click=move |_| set_managing.set(Some(info.clone()))
>
<Icon name=IconName::Settings class="ic-btn".to_string()/>
{icon_svg(IconName::Settings, "ic-btn")}
</button>
</div>
}
@@ -342,7 +342,7 @@ fn CollectionList(
aria-label=i18n::tr(k::PIM_CONNECT)
on:click=move |_| set_connecting.set(true)
>
<Icon name=IconName::Devices class="ic-btn".to_string()/>
{icon_svg(IconName::Devices, "ic-btn")}
</button>
<button
class="icon-btn icon-btn-sm"
@@ -350,7 +350,7 @@ fn CollectionList(
aria-label=i18n::tr(k::PIM_NEW_FROM_FILE)
on:click=move |_| set_importing.set(true)
>
<Icon name=IconName::Upload class="ic-btn".to_string()/>
{icon_svg(IconName::Upload, "ic-btn")}
</button>
<button
class="icon-btn icon-btn-sm"
@@ -358,20 +358,12 @@ fn CollectionList(
aria-label=i18n::tr(new_label)
on:click=move |_| set_creating.set(true)
>
<Icon name=IconName::Add class="ic-btn".to_string()/>
{icon_svg(IconName::Add, "ic-btn")}
</button>
</div>
{move || match collections.get() {
None => match load_err.get() {
Some(msg) => view! {
<div class="card error-card">
<span>{msg}</span>
<button class="btn" on:click=move |_| reload.run(())>
{i18n::tr(k::RETRY)}
</button>
</div>
}
.into_any(),
Some(msg) => retry_card(msg, reload).into_any(),
None => view! { <p class="muted">{i18n::tr(k::LOADING)}</p> }.into_any(),
},
Some(list) => {
@@ -509,11 +501,11 @@ fn NewCollectionDialog(
on_created: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (name, set_name) = signal(String::new());
let name = RwSignal::new(String::new());
let color = RwSignal::new(Some(
collections.with_untracked(|c| next_color(c.as_deref().unwrap_or_default())),
));
let (description, set_description) = signal(String::new());
let description = RwSignal::new(String::new());
let (busy, set_busy) = signal(false);
let title = match kind {
PimCollectionKind::Calendar => k::PIM_NEW_CALENDAR,
@@ -551,31 +543,7 @@ fn NewCollectionDialog(
<Modal class="pim-modal" on_close=on_close>
<form on:submit=submit>
<h2 class="modal-title">{i18n::tr(title)}</h2>
<label class="field">
<span class="field-label">{i18n::tr(k::NAME)}</span>
<input
class="field-input"
required=true
maxlength="200"
autofocus=true
prop:value=move || name.get()
on:input=move |ev| set_name.set(event_target_value(&ev))
/>
</label>
<div class="field">
<span class="field-label">{i18n::tr(k::PIM_COLOR)}</span>
<ColorPicker color=color/>
</div>
<label class="field">
<span class="field-label">{i18n::tr(k::PIM_DESCRIPTION)}</span>
<textarea
class="field-input"
rows="2"
maxlength="1024"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</label>
{collection_fields(name, color, description, true)}
<div class="modal-actions">
<button type="button" class="btn" on:click=move |_| on_close.run(())>
{i18n::tr(k::CANCEL)}
@@ -589,27 +557,39 @@ fn NewCollectionDialog(
}
}
/// A read-only URL with a copy button.
fn url_row(label: &'static str, url: String, toast: ToastMsg) -> impl IntoView {
let copy = url.clone();
/// The name, color and description fields of a collection form.
fn collection_fields(
name: RwSignal<String>,
color: RwSignal<Option<String>>,
description: RwSignal<String>,
autofocus: bool,
) -> impl IntoView {
view! {
<div class="share-link-row">
<label class="field">
<span class="field-label">{i18n::tr(k::NAME)}</span>
<input
class="share-link-input"
readonly=true
aria-label=i18n::t(label)
value=url
on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
class="field-input"
required=true
maxlength="200"
autofocus=autofocus
prop:value=move || name.get()
on:input=move |ev| name.set(event_target_value(&ev))
/>
<button
type="button"
class="btn"
on:click=move |_| copy_to_clipboard(&copy, i18n::t(k::LINK_COPIED), toast)
>
{icon_svg(IconName::Copy, "ic-btn")}
{i18n::t(k::COPY)}
</button>
</label>
<div class="field">
<span class="field-label">{i18n::tr(k::PIM_COLOR)}</span>
<ColorPicker color=color/>
</div>
<label class="field">
<span class="field-label">{i18n::tr(k::PIM_DESCRIPTION)}</span>
<textarea
class="field-input"
rows="2"
maxlength="1024"
prop:value=move || description.get()
on:input=move |ev| description.set(event_target_value(&ev))
></textarea>
</label>
}
}
@@ -675,9 +655,9 @@ fn GeneralSection(
let toast = use_context::<ToastMsg>().expect("toast context");
let id = info.id;
let calendar = info.kind == PimCollectionKind::Calendar;
let (name, set_name) = signal(info.name.clone());
let name = RwSignal::new(info.name.clone());
let color = RwSignal::new(info.color.clone());
let (description, set_description) = signal(info.description.clone().unwrap_or_default());
let description = RwSignal::new(info.description.clone().unwrap_or_default());
// Sent only when changed: one a client stored may fail the server's check.
let saved_description = StoredValue::new(description.get_untracked());
let (transparent, set_transparent) = signal(info.transparent);
@@ -721,30 +701,7 @@ fn GeneralSection(
view! {
<form class="pim-section" on:submit=save>
<h3 class="pim-section-title">{i18n::tr(k::PIM_GENERAL)}</h3>
<label class="field">
<span class="field-label">{i18n::tr(k::NAME)}</span>
<input
class="field-input"
required=true
maxlength="200"
prop:value=move || name.get()
on:input=move |ev| set_name.set(event_target_value(&ev))
/>
</label>
<div class="field">
<span class="field-label">{i18n::tr(k::PIM_COLOR)}</span>
<ColorPicker color=color/>
</div>
<label class="field">
<span class="field-label">{i18n::tr(k::PIM_DESCRIPTION)}</span>
<textarea
class="field-input"
rows="2"
maxlength="1024"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</label>
{collection_fields(name, color, description, false)}
{calendar.then(|| view! {
<label class="check-row">
<input
@@ -896,7 +853,7 @@ fn SharingSection(info: PimCollectionInfo) -> impl IntoView {
});
}
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
{icon_svg(IconName::Close, "ic-btn")}
</button>
</div>
}
@@ -1035,14 +992,7 @@ fn LinksSection(info: PimCollectionInfo) -> impl IntoView {
<span class="muted pim-link-meta">{meta.join(" · ")}</span>
</div>
<div class="pim-link-actions">
<button
type="button"
class="btn btn-sm"
on:click=move |_| copy_to_clipboard(&copy, i18n::t(k::LINK_COPIED), toast)
>
{icon_svg(IconName::Copy, "ic-btn")}
{i18n::t(k::COPY)}
</button>
{copy_button("btn btn-sm", None, i18n::t(k::COPY), copy, k::LINK_COPIED, toast)}
{calendar.then(|| webcal.map(|w| view! {
<a class="btn btn-sm" href=w>{i18n::t(k::PIM_SUBSCRIBE)}</a>
}))}
@@ -1505,14 +1455,7 @@ fn ConnectDialog(
<div class="pim-item">
<span class="pim-swatch" style=format!("--swatch: {}", safe_color(c.color.as_deref()))></span>
<span class="pim-item-name" title=url.clone()>{display_name(&c)}</span>
<button
type="button"
class="btn btn-sm"
on:click=move |_| copy_to_clipboard(&copy, i18n::t(k::LINK_COPIED), toast)
>
{icon_svg(IconName::Copy, "ic-btn")}
{i18n::t(k::COPY)}
</button>
{copy_button("btn btn-sm", None, i18n::t(k::COPY), copy, k::LINK_COPIED, toast)}
</div>
}
})
▾Mweb/src/views/search.rs
@@ -19,7 +19,7 @@ use leptos::prelude::*;
use wasm_bindgen::JsCast;
use crate::api;
use crate::components::icon::{Icon, icon_svg};
use crate::components::icon::icon_svg;
use crate::components::toast::{ToastMsg, show_error};
use crate::i18n::{self, k};
use crate::icons::{IconName, icon_for};
@@ -267,9 +267,7 @@ fn push_escaped(out: &mut String, s: &str) {
/// Returns the query, the scope and the start folder `(root, dir)` when a
/// root is given.
fn parse_search_url() -> (String, Scope, Option<(i64, String)>) {
let hash = web_sys::window()
.and_then(|w| w.location().hash().ok())
.unwrap_or_default();
let hash = router::current_hash();
let params = hash
.split_once('?')
.and_then(|(_, qs)| web_sys::UrlSearchParams::new_with_str(qs).ok());
@@ -348,11 +346,7 @@ pub fn SearchView(
let words = StoredValue::new(Vec::<String>::new());
// The folder a search starts in: `(root id, dir relative to the root)`.
// Defaults to the top of the caller's first root.
let (folder, set_folder) = RwSignal::<Option<(i64, String)>>::new(
me.get_untracked()
.and_then(|m| m.roots.first().map(|r| (r.id, String::new()))),
)
.split();
let (folder, set_folder) = RwSignal::<Option<(i64, String)>>::new(first_root(me)).split();
let status = RwSignal::new(Status::Idle);
let files_sig = RwSignal::<Vec<FileHit>>::new(Vec::new());
let (files_r, files_w) = files_sig.split();
@@ -489,10 +483,7 @@ pub fn SearchView(
// Exactly one start folder. Falls back to the first root when
// nothing is selected yet (a deep link with an unknown id, or
// `me` not loaded when the view was built).
let Some((root, dir)) = folder.get_untracked().or_else(|| {
me.get_untracked()
.and_then(|m| m.roots.first().map(|r| (r.id, String::new())))
}) else {
let Some((root, dir)) = folder.get_untracked().or_else(|| first_root(me)) else {
return;
};
set_folder.set(Some((root, dir.clone())));
@@ -674,10 +665,7 @@ pub fn SearchView(
let Some(m) = me.get_untracked() else {
return;
};
let Some((root, dir)) = folder
.get_untracked()
.or_else(|| m.roots.first().map(|r| (r.id, String::new())))
else {
let Some((root, dir)) = folder.get_untracked().or_else(|| first_root(me)) else {
return;
};
set_dialog.set(Some(Dialog::Picker {
@@ -759,30 +747,23 @@ pub fn SearchView(
navigate(&Location::folder(Some(root_id), None, dir_segs(&path)));
return;
}
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
saved_scroll.set_value(scroll_y());
open_file.set(Some(FileView {
root_id,
path,
name,
kind: hit.kind,
readonly: !is_writable(me, root_id),
}));
open_hit(me, open_file, root_id, path, hit.kind);
};
// ---- render ------------------------------------------------------------
view! {
// Hidden with `display` rather than unmounted while a file is open
// Hidden rather than unmounted while a file is open
// (the same treatment the browser view gets). Unmounting would throw
// away every rendered row and rebuild it on close, and the document
// would lose its scroll position — see `keep_scroll` above.
<div
class="search-view"
style:display=move || if file_view.get().is_some() { "none" } else { "flex" }
hidden=move || file_view.get().is_some()
>
<div class="query-bar">
<div class="query-input">
<Icon name=IconName::Search class="qi-icon".to_string()/>
{icon_svg(IconName::Search, "qi-icon")}
<input
type="text"
placeholder=move || i18n::t(k::SEARCH_PLACEHOLDER).to_string()
@@ -811,7 +792,7 @@ pub fn SearchView(
title=move || i18n::t(k::SEARCH_IN).to_string()
on:click=pick_folder
>
<Icon name=IconName::Folder class="fp-icon".to_string()/>
{icon_svg(IconName::Folder, "fp-icon")}
<span class="fp-path">
{move || {
let Some((root, dir)) = folder.get() else {
@@ -830,7 +811,7 @@ pub fn SearchView(
if status.get() == Status::Searching {
view! {
<button class="btn-stop" on:click=move |_| stop_search.run(())>
<Icon name=IconName::Stop class="ic-stop".to_string()/>
{icon_svg(IconName::Stop, "ic-stop")}
{i18n::t(k::SEARCH_STOP)}
</button>
}
@@ -903,24 +884,19 @@ pub fn SearchView(
}
}}
// The groups are hidden with `display` instead of unmounted:
// The groups are hidden instead of unmounted:
// toggling scope must not rebuild the rows/cards (and the cards'
// line signals live in the registry, but keeping the DOM stable
// makes the toggle a single attribute write).
<div
class="result-group"
style:display=move || {
let show = matches!(scope.get(), Scope::Name | Scope::Both)
&& (files_total.get() > 0 || status.get() != Status::Idle);
if show {
"flex"
} else {
"none"
}
hidden=move || {
!(matches!(scope.get(), Scope::Name | Scope::Both)
&& (files_total.get() > 0 || status.get() != Status::Idle))
}
>
<div class="group-label">
<Icon name=IconName::File class="gl-icon".to_string()/>
{icon_svg(IconName::File, "gl-icon")}
<span>{i18n::tr(k::FILES)}</span>
<span class="n">{"· "}{files_total}</span>
</div>
@@ -936,7 +912,7 @@ pub fn SearchView(
children=move |h: FileHit| {
// Note: only the first `shown_files` hits reach
// here; `each` above slices before cloning.
let (dir, name) = split_name(&h.path);
let (dir, name) = h.path.rsplit_once('/').unwrap_or(("", &h.path));
let icon = if h.is_dir {
IconName::Folder
} else {
@@ -953,10 +929,9 @@ pub fn SearchView(
} else {
format_size(h.size)
};
// `icon_svg`, not `<Icon/>`: no component owner
// per row. Click handling is delegated to the
// list (see `on_row_click`), so the row only
// carries data attributes.
// Click handling is delegated to the list (see
// `on_row_click`), so the row only carries data
// attributes.
view! {
<div
class="srow"
@@ -995,18 +970,13 @@ pub fn SearchView(
<div
class="result-group"
style:display=move || {
let show = matches!(scope.get(), Scope::Content | Scope::Both)
&& (match_files_r.with(|v| !v.is_empty()) || status.get() != Status::Idle);
if show {
"flex"
} else {
"none"
}
hidden=move || {
!(matches!(scope.get(), Scope::Content | Scope::Both)
&& (match_files_r.with(|v| !v.is_empty()) || status.get() != Status::Idle))
}
>
<div class="group-label">
<Icon name=IconName::Text class="gl-icon".to_string()/>
{icon_svg(IconName::Text, "gl-icon")}
<span>{i18n::tr(k::SEARCH_CONTENT)}</span>
// Closures, not bare calls: these have to re-read the
// totals as the search streams in.
@@ -1063,7 +1033,7 @@ pub fn SearchView(
let collapsed = st.collapsed;
let key = f.path.clone();
let root_id = f.root_id;
let (dir, name) = split_name(&f.path);
let (dir, name) = f.path.rsplit_once('/').unwrap_or(("", &f.path));
// A content match came out of the grep, which only
// reads text files, so the kind is known without
// asking the server.
@@ -1125,24 +1095,12 @@ pub fn SearchView(
});
}
>
<span class=move || {
if collapsed.get() {
"chev chev-up".to_string()
} else {
"chev chev-down".to_string()
}
}></span>
<span class="chev chev-mark" class:up=move || collapsed.get()></span>
</button>
</div>
<div
class="match-lines"
style:display=move || {
if collapsed.get() {
"none"
} else {
"block"
}
}
hidden=move || collapsed.get()
>
<For
each=move || lines_r.get().into_iter().enumerate()
@@ -1443,6 +1401,28 @@ fn is_writable(me: ReadSignal<Option<api_types::Me>>, root_id: i64) -> bool {
})
}
/// The top of the caller's first root: the start folder when none is set.
fn first_root(me: ReadSignal<Option<api_types::Me>>) -> Option<(i64, String)> {
me.with_untracked(|m| m.as_ref()?.roots.first().map(|r| (r.id, String::new())))
}
fn open_hit(
me: ReadSignal<Option<api_types::Me>>,
open_file: WriteSignal<Option<FileView>>,
root_id: i64,
path: String,
kind: FileKind,
) {
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
open_file.set(Some(FileView {
root_id,
path,
name,
kind,
readonly: !is_writable(me, root_id),
}));
}
/// Open a content match: always a text file (the grep only reads those). One
/// callback per card, shared by all of its lines. Free function rather than a
/// component closure so the card's `<For>` children closure only captures
@@ -1454,16 +1434,7 @@ fn open_match_cb(
me: ReadSignal<Option<api_types::Me>>,
open_file: WriteSignal<Option<FileView>>,
) -> Callback<()> {
Callback::new(move |_| {
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
open_file.set(Some(FileView {
root_id,
path: path.to_string(),
name,
kind: FileKind::Text,
readonly: !is_writable(me, root_id),
}));
})
Callback::new(move |_| open_hit(me, open_file, root_id, path.to_string(), FileKind::Text))
}
/// Lowercased whitespace-split words of the query.
@@ -1471,14 +1442,6 @@ fn query_words(query: &str) -> Vec<String> {
query.split_whitespace().map(lower).collect()
}
/// `("docs/notes", "runbook.md")` from a relative path.
fn split_name(path: &str) -> (&str, &str) {
match path.rfind('/') {
Some(i) if i > 0 => (&path[..i], &path[i + 1..]),
_ => ("", path),
}
}
#[cfg(test)]
mod tests {
use super::*;
▾Mweb/src/views/security.rs
@@ -11,17 +11,11 @@ use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AppPasswordInfo, AuthMode, Me, PasskeyInfo, input_value};
use crate::components::icon::Icon;
use crate::components::icon::icon_svg;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n;
use crate::icons::IconName;
use crate::util::copy_to_clipboard;
/// Trim an RFC 3339 timestamp to its date. The exact minute a passkey was
/// registered is noise in a list.
fn day(ts: &str) -> String {
ts.split('T').next().unwrap_or(ts).to_string()
}
use crate::util::{copy_button, format_date};
#[component]
pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) -> impl IntoView {
@@ -54,13 +48,7 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
// Re-reading `/me` after a credential change keeps the rest of the app
// (and this view's own buttons) in step with what the account now needs.
let refresh_me = move || {
spawn_local(async move {
if let Ok(m) = api::me().await {
set_me.set(Some(m));
}
});
};
let refresh_me = move || crate::views::shell::refresh_me(set_me, toast);
// --- password and sign-in requirement, in one save --------------------
let save = move |_| {
@@ -342,7 +330,7 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
</label>
<div class="modal-actions">
<button class="btn" disabled=move || busy.get() on:click=add_passkey>
<Icon name=IconName::Add class="ic-btn".to_string()/>
{icon_svg(IconName::Add, "ic-btn")}
{i18n::tr(i18n::k::ADD_PASSKEY)}
</button>
</div>
@@ -378,26 +366,15 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
</div>
{move || {
let value = secret.get()?;
let copy = value.clone();
Some(view! {
<div class="share-link-row">
<input
class="share-link-input"
readonly=true
value=value
value=value.clone()
on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
/>
<button
class="btn"
on:click=move |_| copy_to_clipboard(
&copy,
i18n::t(i18n::k::COPIED),
toast,
)
>
<Icon name=IconName::Copy class="ic-btn".to_string()/>
{i18n::tr(i18n::k::COPY)}
</button>
{copy_button("btn", None, i18n::tr(i18n::k::COPY), value, i18n::k::COPIED, toast)}
</div>
<p class="setting-desc">{i18n::tr(i18n::k::APP_PASSWORD_SECRET_ONCE)}</p>
})
@@ -409,7 +386,7 @@ pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>)
</label>
<div class="modal-actions">
<button class="btn" disabled=move || busy.get() on:click=create_app_password>
<Icon name=IconName::Add class="ic-btn".to_string()/>
{icon_svg(IconName::Add, "ic-btn")}
{i18n::tr(i18n::k::ADD_APP_PASSWORD)}
</button>
</div>
@@ -431,16 +408,16 @@ fn CredentialRow(
on_remove: Callback<i64>,
) -> impl IntoView {
let used = match &last_used_at {
Some(t) => i18n::t_fmt(i18n::k::PASSKEY_LAST_USED, &day(t)),
Some(t) => i18n::t_fmt(i18n::k::PASSKEY_LAST_USED, &format_date(t)),
None => i18n::t(i18n::k::PASSKEY_NEVER_USED).to_string(),
};
view! {
<div class="passkey-row">
<Icon name=icon class="ic-row".to_string()/>
{icon_svg(icon, "ic-row")}
<span class="passkey-main">
<span class="passkey-name">{name}</span>
<span class="setting-desc">
{i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &day(&created_at))}
{i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &format_date(&created_at))}
" · " {used}
</span>
{warn.then(|| view! {
@@ -455,7 +432,7 @@ fn CredentialRow(
aria-label=i18n::t(i18n::k::DELETE)
on:click=move |_| on_remove.run(id)
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
{icon_svg(IconName::Close, "ic-btn")}
</button>
</div>
}
▾Mweb/src/views/share_page.rs
@@ -220,9 +220,8 @@ fn UnlockForm(token: String, accept: Callback<ShareInfo>) -> impl IntoView {
let (error, set_error) = signal(Option::<String>::None);
let (busy, set_busy) = signal(false);
// A Callback, not a plain closure: both the button and Enter need it,
// and a closure would be moved into whichever handler came first.
let submit = Callback::new(move |_: ()| {
let submit = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
let pw = password.get();
if pw.is_empty() || busy.get() {
return;
@@ -239,11 +238,11 @@ fn UnlockForm(token: String, accept: Callback<ShareInfo>) -> impl IntoView {
}
}
});
});
};
view! {
<div class="center-screen">
<div class="card unlock-card">
<form class="card unlock-card" on:submit=submit>
<h2>{i18n::tr(i18n::k::SHARE_LOCKED_TITLE)}</h2>
<p class="muted">{i18n::tr(i18n::k::ERR_SHARE_LOCKED)}</p>
<input
@@ -255,22 +254,16 @@ fn UnlockForm(token: String, accept: Callback<ShareInfo>) -> impl IntoView {
set_password.set(event_target_value(&ev));
set_error.set(None);
}
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" {
ev.prevent_default();
submit.run(());
}
}
/>
{move || error.get().map(|e| view! { <p class="dialog-error">{e}</p> })}
<button
type="submit"
class="btn btn-primary unlock-btn"
disabled=move || busy.get() || password.get().is_empty()
on:click=move |_| submit.run(())
>
{i18n::tr(i18n::k::UNLOCK)}
</button>
</div>
</form>
</div>
}
}
▾Mweb/src/views/shares.rs
@@ -16,8 +16,7 @@ use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n;
use crate::icons::IconName;
use crate::router::{dav_share_url, share_url};
use crate::util::copy_to_clipboard;
use crate::util::{format_date, local_date_time};
use crate::util::{copy_button, format_date, local_date_time, retry_card};
use crate::views::dialogs::ConfirmDialog;
use crate::views::pim::{mode_label, webcal};
@@ -126,14 +125,14 @@ pub fn ShareDialog(
value=link.clone()
on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
/>
<button
class="btn"
title={i18n::tr(i18n::k::COPY_LINK)}
on:click=move |_| copy_to_clipboard(&link, i18n::t(i18n::k::LINK_COPIED), toast)
>
{icon_svg(IconName::Copy, "ic-btn")}
{i18n::t(i18n::k::COPY)}
</button>
{copy_button(
"btn",
Some(i18n::k::COPY_LINK),
i18n::t(i18n::k::COPY),
link,
i18n::k::LINK_COPIED,
toast,
)}
</div>
// Folder shares only: a file share has no
// collection to mount.
@@ -146,14 +145,14 @@ pub fn ShareDialog(
title={i18n::tr(i18n::k::COPY_DAV_LINK)}
on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
/>
<button
class="btn"
title={i18n::tr(i18n::k::COPY_DAV_LINK)}
on:click=move |_| copy_to_clipboard(&dav_link, i18n::t(i18n::k::LINK_COPIED), toast)
>
{icon_svg(IconName::Copy, "ic-btn")}
"WebDAV"
</button>
{copy_button(
"btn",
Some(i18n::k::COPY_DAV_LINK),
"WebDAV",
dav_link,
i18n::k::LINK_COPIED,
toast,
)}
</div>
})}
<p class="muted share-expiry-note">
@@ -368,28 +367,23 @@ fn share_row(s: &ShareInfo, toast: ToastMsg, del: Callback<i64>) -> AnyView {
<div class="share-item-meta muted">{meta}</div>
</div>
<div class="share-item-actions">
<button
class="btn btn-sm"
title={i18n::tr(i18n::k::COPY_LINK)}
on:click=move |_| copy_to_clipboard(&link, i18n::t(i18n::k::LINK_COPIED), toast)
>
{icon_svg(IconName::Copy, "ic-btn")}
{i18n::t(i18n::k::COPY)}
</button>
{copy_button(
"btn btn-sm",
Some(i18n::k::COPY_LINK),
i18n::t(i18n::k::COPY),
link,
i18n::k::LINK_COPIED,
toast,
)}
// Folder shares only: a file share has no collection to mount.
<Show when=move || !is_file>
<button
class="btn btn-sm"
title={i18n::tr(i18n::k::COPY_DAV_LINK)}
on:click={
let dav = dav_link.clone();
move |_| copy_to_clipboard(&dav, i18n::t(i18n::k::LINK_COPIED), toast)
}
>
{icon_svg(IconName::Copy, "ic-btn")}
"WebDAV"
</button>
</Show>
{(!is_file).then(|| copy_button(
"btn btn-sm",
Some(i18n::k::COPY_DAV_LINK),
"WebDAV",
dav_link,
i18n::k::LINK_COPIED,
toast,
))}
<button
class="btn btn-sm btn-danger"
title={i18n::tr(i18n::k::DELETE_SHARE)}
@@ -404,6 +398,49 @@ fn share_row(s: &ShareInfo, toast: ToastMsg, del: Callback<i64>) -> AnyView {
.into_any()
}
/// One collapsible section of share rows. `open` keeps each section's state
/// by `gid`; `default_open` holds until the user toggles it.
fn share_group(
open: RwSignal<HashMap<i64, bool>>,
gid: i64,
default_open: bool,
title: impl IntoView + 'static,
warn: bool,
extra: impl IntoView + 'static,
rows: Vec<AnyView>,
) -> impl IntoView {
let n = rows.len();
view! {
<details
class="share-group"
prop:open=move || open.with(|m| m.get(&gid).copied().unwrap_or(default_open))
// `<details>` announces every toggle the user makes, already in
// its new state.
on:toggle=move |ev: web_sys::Event| {
let on = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlDetailsElement>().ok())
.is_some_and(|d| d.open());
open.update(|m| {
m.insert(gid, on);
});
}
>
<summary class="share-group-head">
{icon_svg(IconName::ChevronRight, "share-caret")}
<span class="share-group-title">{title}</span>
// Visible while collapsed: the case the admin view exists for.
{warn.then(|| view! {
<span class="share-group-warn">{i18n::tr(i18n::k::SHARE_OWNER_DISABLED)}</span>
})}
<span class="share-group-count muted">{n}</span>
{extra}
</summary>
<div class="share-list">{rows}</div>
</details>
}
}
#[component]
pub fn SharesView(me: ReadSignal<Option<Me>>) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
@@ -426,7 +463,7 @@ pub fn SharesView(me: ReadSignal<Option<Me>>) -> impl IntoView {
let (busy, set_busy) = signal(false);
// Which sections are open, by account. Kept out of the DOM: the listing is
// rebuilt on every delete, and a section must not snap shut under the user.
let (open, set_open) = signal(HashMap::<i64, bool>::new());
let open = RwSignal::new(HashMap::<i64, bool>::new());
let load = move || {
set_load_err.set(None);
@@ -572,15 +609,7 @@ pub fn SharesView(me: ReadSignal<Option<Me>>) -> impl IntoView {
</div>
{move || match rows.get() {
None => match load_err.get() {
Some(msg) => view! {
<div class="card error-card">
<span>{msg}</span>
<button class="btn" on:click=move |_| load()>
{i18n::tr(i18n::k::RETRY)}
</button>
</div>
}
.into_any(),
Some(msg) => retry_card(msg, Callback::new(move |_| load())).into_any(),
None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
.into_any(),
},
@@ -607,97 +636,46 @@ pub fn SharesView(me: ReadSignal<Option<Me>>) -> impl IntoView {
.map(|s| share_row(s, toast, delete_one))
.collect::<Vec<_>>();
let ids: Vec<i64> = g.shares.iter().map(|s| s.id).collect();
let n = g.shares.len();
let gid = g.id;
let inactive = !g.active;
view! {
// Own links open, everyone else's collapsed: an
// admin lands on their own links, not on a wall
// of other people's tokens.
<details
class="share-group"
prop:open=move || {
open.with(|m| m.get(&gid).copied().unwrap_or(own))
}
// `<details>` announces every toggle the user
// makes, already in its new state.
on:toggle=move |ev: web_sys::Event| {
let on = ev
.target()
.and_then(|t| {
t.dyn_into::<web_sys::HtmlDetailsElement>().ok()
})
.is_some_and(|d| d.open());
set_open.update(|m| {
m.insert(gid, on);
});
let title = {
let name = name.clone();
move || {
if own {
i18n::t(i18n::k::YOUR_SHARES).to_string()
} else {
name.to_string()
}
>
<summary class="share-group-head">
{icon_svg(IconName::ChevronRight, "share-caret")}
<span class="share-group-title">
{
let name = name.clone();
move || {
if own {
i18n::t(i18n::k::YOUR_SHARES).to_string()
} else {
name.to_string()
}
}
}
};
// A non-admin has one section, and the heading
// button already deletes all of it.
let del_all = is_admin.then(|| {
view! {
<button
class="btn btn-sm btn-danger share-group-del"
title={i18n::tr(i18n::k::DELETE_ALL_TITLE)}
disabled=move || busy.get()
on:click=move |ev: web_sys::MouseEvent| {
// Otherwise this click toggles the
// section it sits in.
ev.prevent_default();
if ids.is_empty() {
return;
}
</span>
// Visible while collapsed: the case the
// admin view exists for.
{
(inactive)
.then(|| {
view! {
<span class="share-group-warn">
{i18n::tr(i18n::k::SHARE_OWNER_DISABLED)}
</span>
}
})
.into_any()
}
<span class="share-group-count muted">{n}</span>
{
// A non-admin has one section, and the heading
// button already deletes all of it.
let (ids, name) = (ids.clone(), name.clone());
is_admin
.then(|| {
view! {
<button
class="btn btn-sm btn-danger share-group-del"
title={i18n::tr(i18n::k::DELETE_ALL_TITLE)}
disabled=move || busy.get()
on:click=move |ev: web_sys::MouseEvent| {
// Otherwise this click toggles the
// section it sits in.
ev.prevent_default();
if ids.is_empty() {
return;
}
set_confirm.set(Some(Pending {
ids: ids.clone(),
who: (!own).then(|| name.to_string()),
}));
}
>
{
icon_svg(IconName::Delete, "ic-btn")
}
{i18n::t(i18n::k::DELETE_ALL)}
</button>
}
})
.into_any()
set_confirm.set(Some(Pending {
ids: ids.clone(),
who: (!own).then(|| name.to_string()),
}));
}
</summary>
<div class="share-list">{items}</div>
</details>
}
>
{icon_svg(IconName::Delete, "ic-btn")}
{i18n::t(i18n::k::DELETE_ALL)}
</button>
}
});
// Own links open, everyone else's collapsed: an
// admin lands on their own links, not on a wall
// of other people's tokens.
share_group(open, g.id, own, title, !g.active, del_all, items)
})
.collect::<Vec<_>>();
view! { <div class="share-groups">{sections}</div> }
@@ -744,7 +722,7 @@ fn PimShares(admin: bool, my_id: i64) -> impl IntoView {
let (others, set_others) = signal(Vec::<AdminPimLink>::new());
// Open sections, kept across reloads like the file shares'. Own groups
// use the ids below, other owners their account id.
let (open, set_open) = signal(HashMap::<i64, bool>::new());
let open = RwSignal::new(HashMap::<i64, bool>::new());
const OWN_LINKS: i64 = -1;
const OWN_LENDS: i64 = -2;
let (load_err, set_load_err) = signal(Option::<String>::None);
@@ -800,7 +778,6 @@ fn PimShares(admin: bool, my_id: i64) -> impl IntoView {
// the admin route.
let feed_row = move |f: &AdminPimLink| {
let url = crate::router::server_url(&f.link.path);
let copy = url.clone();
let calendar = f.kind == api::PimCollectionKind::Calendar;
let subscribe = calendar.then(|| webcal(&url)).flatten();
let (id, cid, mine) = (f.link.id, f.collection_id, f.owner_id == my_id);
@@ -833,13 +810,14 @@ fn PimShares(admin: bool, my_id: i64) -> impl IntoView {
<div class="share-item-meta muted">{meta.join(" · ")}</div>
</div>
<div class="share-item-actions">
<button
class="btn btn-sm"
on:click=move |_| copy_to_clipboard(&copy, i18n::t(i18n::k::LINK_COPIED), toast)
>
{icon_svg(IconName::Copy, "ic-btn")}
{i18n::t(i18n::k::COPY)}
</button>
{copy_button(
"btn btn-sm",
None,
i18n::t(i18n::k::COPY),
url,
i18n::k::LINK_COPIED,
toast,
)}
{subscribe.map(|w| view! {
<a class="btn btn-sm" href=w>{i18n::t(i18n::k::PIM_SUBSCRIBE)}</a>
})}
@@ -949,32 +927,7 @@ fn PimShares(admin: bool, my_id: i64) -> impl IntoView {
.into_any()
};
let group = move |gid: i64, title: String, warn: bool, rows: Vec<AnyView>| {
let n = rows.len();
view! {
<details
class="share-group"
prop:open=move || open.with(|m| m.get(&gid).copied().unwrap_or(gid < 0))
on:toggle=move |ev: web_sys::Event| {
let on = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlDetailsElement>().ok())
.is_some_and(|d| d.open());
set_open.update(|m| {
m.insert(gid, on);
});
}
>
<summary class="share-group-head">
{icon_svg(IconName::ChevronRight, "share-caret")}
<span class="share-group-title">{title}</span>
{warn.then(|| view! {
<span class="share-group-warn">{i18n::tr(i18n::k::SHARE_OWNER_DISABLED)}</span>
})}
<span class="share-group-count muted">{n}</span>
</summary>
<div class="share-list">{rows}</div>
</details>
}
share_group(open, gid, gid < 0, title, warn, (), rows)
};
view! {
<div class="view-header feeds-header">
@@ -984,27 +937,14 @@ fn PimShares(admin: bool, my_id: i64) -> impl IntoView {
{move || {
let Some(own) = own.get() else {
return match load_err.get() {
Some(msg) => view! {
<div class="card error-card">
<span>{msg}</span>
<button class="btn" on:click=move |_| load()>
{i18n::tr(i18n::k::RETRY)}
</button>
</div>
}
.into_any(),
Some(msg) => retry_card(msg, Callback::new(move |_| load())).into_any(),
None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }.into_any(),
};
};
let others = others.get();
let others_err = others_err.get().map(|msg| view! {
<div class="card error-card">
<span>{msg}</span>
<button class="btn" on:click=move |_| load()>
{i18n::tr(i18n::k::RETRY)}
</button>
</div>
});
let others_err = others_err
.get()
.map(|msg| retry_card(msg, Callback::new(move |_| load())));
if own.links.is_empty() && own.lends.is_empty() && others.is_empty() {
return view! {
{others_err}
▾Mweb/src/views/shell.rs
@@ -6,7 +6,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::PimCollectionKind;
use crate::api::{self, Me};
use crate::components::icon::Icon;
use crate::components::icon::icon_svg;
use crate::components::toast::{ToastMsg, provide_toast, show_error};
use crate::icons::IconName;
use crate::router::{Location, Section, navigate};
@@ -103,11 +103,7 @@ pub fn ShellView(
// files view so the URL, the content fallback, and the sidebar highlight
// all agree.
Effect::new(move |_| {
let Some(m) = me.get() else {
return;
};
let admin = m.user.as_ref().is_some_and(|u| u.is_admin);
if !admin && loc.get().section == Section::Users {
if me.with(Option::is_some) && !admin.get() && section.get() == Section::Users {
navigate(&Location::root());
}
});
@@ -148,15 +144,7 @@ pub fn ShellView(
};
view! {
<div
class=move || {
if file_view.get().is_some() {
"layout file-mode".to_string()
} else {
"layout".to_string()
}
}
>
<div class="layout" class:file-mode=move || file_view.get().is_some()>
<header class="topbar" node_ref=topbar_ref>
<div class="topbar-left">
<button
@@ -165,7 +153,7 @@ pub fn ShellView(
aria-label={i18n::tr(i18n::k::OPEN_MENU)}
on:click=move |_| set_nav_open.update(|v| *v = !*v)
>
<Icon name=IconName::Menu class="ic-btn".to_string()/>
{icon_svg(IconName::Menu, "ic-btn")}
</button>
{topbar_left}
</div>
@@ -195,7 +183,7 @@ pub fn ShellView(
title={i18n::tr(i18n::k::REFRESH_FOLDERS)}
on:click=move |_| refresh_me(set_me, toast)
>
<Icon name=IconName::Refresh class="ic-btn".to_string()/>
{icon_svg(IconName::Refresh, "ic-btn")}
</button>
</div>
<button
@@ -203,7 +191,7 @@ pub fn ShellView(
class:active=move || section.get() == Section::Search
on:click=move |_| open_section(Section::Search)
>
<Icon name=IconName::Search class="ic-tab".to_string()/>
{icon_svg(IconName::Search, "ic-tab")}
<span class="nav-tab-name">{i18n::tr(i18n::k::SEARCH)}</span>
</button>
{move || {
@@ -239,7 +227,7 @@ pub fn ShellView(
));
}
>
<Icon name=IconName::Folder class="ic-tab".to_string()/>
{icon_svg(IconName::Folder, "ic-tab")}
<span class="nav-tab-name">{name}</span>
{ro.then(|| view! { <span class="nav-tab-note">"ro"</span> })}
</button>
@@ -260,7 +248,7 @@ pub fn ShellView(
class:active=move || section.get() == sec
on:click=move |_| open_section(sec)
>
<Icon name=icon class="ic-tab".to_string()/>
{icon_svg(icon, "ic-tab")}
<span class="nav-tab-name">{i18n::tr(label)}</span>
</button>
})}
@@ -280,7 +268,7 @@ pub fn ShellView(
class:active=move || section.get() == sec
on:click=move |_| open_section(sec)
>
<Icon name=icon class="ic-tab".to_string()/>
{icon_svg(icon, "ic-tab")}
<span class="nav-tab-name">{i18n::tr(label)}</span>
</button>
</Show>
@@ -298,19 +286,15 @@ pub fn ShellView(
next.apply();
}
>
{move || {
view! {
<Icon name=theme.get().icon() class="ic-tab".to_string()/>
}
}}
{move || icon_svg(theme.get().icon(), "ic-tab")}
<span class="nav-tab-name">{move || i18n::t(theme.get().name_key()).to_string()}</span>
</button>
<button
class="nav-tab sidebar-action logout"
title={i18n::tr(i18n::k::LOG_OUT)}
on:click=move |_| on_logout(())
on:click=on_logout
>
<Icon name=IconName::Logout class="ic-tab".to_string()/>
{icon_svg(IconName::Logout, "ic-tab")}
<span class="nav-tab-name">{i18n::tr(i18n::k::LOG_OUT)}</span>
</button>
<div class="sidebar-version">
▾Mweb/src/views/uploads.rs
@@ -3,7 +3,7 @@
use leptos::prelude::*;
use crate::components::icon::Icon;
use crate::components::icon::icon_svg;
use crate::i18n;
use crate::icons::IconName;
use crate::uploads::{self, Job, JobState, OnConflict, format_speed, speeds};
@@ -46,7 +46,7 @@ pub fn UploadPanel() -> impl IntoView {
<Show when=move || !shown.get().is_empty()>
<div class="upload-panel" class:collapsed=move || collapsed.get()>
<div class="upload-panel-head">
<Icon name=IconName::Upload class="ic-btn".to_string()/>
{icon_svg(IconName::Upload, "ic-btn")}
<span class="upload-panel-title">{i18n::tr(i18n::k::UPLOADS)}</span>
<span class="muted upload-panel-summary">{summary}</span>
<button
@@ -58,12 +58,7 @@ pub fn UploadPanel() -> impl IntoView {
}
on:click=move |_| collapsed.update(|c| *c = !*c)
>
{move || view! {
<Icon
name=if collapsed.get() { IconName::ExpandLess } else { IconName::ExpandMore }
class="ic-btn".to_string()
/>
}}
{move || icon_svg(if collapsed.get() { IconName::ExpandLess } else { IconName::ExpandMore }, "ic-btn")}
</button>
</div>
<Show when=move || !collapsed.get()>
@@ -99,7 +94,7 @@ pub fn UploadsView() -> impl IntoView {
when=move || !ids.get().is_empty()
fallback=move || view! {
<div class="empty-state">
<Icon name=IconName::Upload class="empty-glyph".to_string()/>
{icon_svg(IconName::Upload, "empty-glyph")}
<h3>{i18n::tr(i18n::k::UPLOADS_EMPTY)}</h3>
</div>
}
@@ -190,22 +185,22 @@ fn JobRow(id: u64, in_log: bool) -> impl IntoView {
<span class="upload-job-pct">{move || with(&|j| format!("{:.0}%", j.percent()))}</span>
<Show when=move || is(|s| *s == JobState::Running)>
<button class="icon-btn icon-btn-sm" title={i18n::tr(i18n::k::UPLOAD_PAUSE)} on:click=move |_| uploads::pause(id)>
<Icon name=IconName::Pause class="ic-btn".to_string()/>
{icon_svg(IconName::Pause, "ic-btn")}
</button>
</Show>
<Show when=move || is(|s| *s == JobState::Paused)>
<button class="icon-btn icon-btn-sm" title={i18n::tr(i18n::k::UPLOAD_RESUME)} on:click=move |_| uploads::resume(id)>
<Icon name=IconName::Play class="ic-btn".to_string()/>
{icon_svg(IconName::Play, "ic-btn")}
</button>
</Show>
<Show when=move || is(JobState::is_active)>
<button class="icon-btn icon-btn-sm" title={i18n::tr(i18n::k::UPLOAD_ABORT)} on:click=move |_| uploads::abort(id)>
<Icon name=IconName::Stop class="ic-btn".to_string()/>
{icon_svg(IconName::Stop, "ic-btn")}
</button>
</Show>
<Show when=move || in_log && !is(JobState::is_active)>
<button class="icon-btn icon-btn-sm" title={i18n::tr(i18n::k::UPLOAD_CLEAR)} on:click=move |_| uploads::clear(id)>
<Icon name=IconName::Close class="ic-btn".to_string()/>
{icon_svg(IconName::Close, "ic-btn")}
</button>
</Show>
<button
@@ -213,12 +208,7 @@ fn JobRow(id: u64, in_log: bool) -> impl IntoView {
title={i18n::tr(i18n::k::UPLOAD_SHOW_GRAPH)}
on:click=move |_| uploads::toggle_expanded(id)
>
{move || view! {
<Icon
name=if expanded() { IconName::ExpandLess } else { IconName::ExpandMore }
class="ic-btn".to_string()
/>
}}
{move || icon_svg(if expanded() { IconName::ExpandLess } else { IconName::ExpandMore }, "ic-btn")}
</button>
</div>
<div class="upload-bar" class:indeterminate=move || is(|s| *s == JobState::Preparing)>
▾Mweb/src/views/workspace.rs
@@ -16,12 +16,14 @@ use web_sys::KeyboardEvent;
use crate::api::{Entry, Me};
use crate::components::logo::Logo;
use crate::components::toast::{ToastMsg, ToastView};
use crate::editor::Editor;
use crate::preview::Preview;
use crate::router::{self, Location, Section};
use crate::uploads;
use crate::util::owned_window_listener;
use crate::views::browser::SelectionActions;
use crate::views::browser::{Acts, SelectionActions};
use crate::views::dialogs::{Dialog, DialogView};
use crate::views::file_view::{FileView, FileViewActions, FileViewContent, FileViewTitle};
use crate::views::file_view::{FileView, FileViewActions, FileViewTitle};
use crate::views::uploads::UploadPanel;
/// The shared state and behavior around the full-page file view.
@@ -349,20 +351,17 @@ impl FileWorkspace {
let refresh = browser_refresh
.get()
.unwrap_or_else(|| Callback::new(move |_| {}));
view! {
<SelectionActions
me=me
loc=loc
selected=selected
open_file=open_file
refresh=refresh
toast=toast
dialog=dialog
set_dialog=set_dialog
owner=owner.clone()
topbar_ref=topbar_ref
/>
}
let acts = Acts {
me,
loc,
open_file,
refresh,
toast,
dialog,
set_dialog,
owner: owner.clone(),
};
view! { <SelectionActions selected=selected acts=acts topbar_ref=topbar_ref/> }
.into_any()
} else {
view! {}.into_any()
@@ -388,19 +387,25 @@ impl FileWorkspace {
let refresh = browser_refresh
.get()
.unwrap_or_else(|| Callback::new(move |_| {}));
view! {
<FileViewContent
view=v
register_save=set_save_cb
register_close=set_close_cb
dirty=dirty
set_dirty=set_dirty
set_saving=set_saving
close=close_file
toast=toast
refresh=refresh
/>
}
let content = if v.kind == api_types::FileKind::Text {
view! {
<Editor
target=v
register_save=set_save_cb
register_close=set_close_cb
dirty=dirty
set_dirty=set_dirty
set_saving=set_saving
close=close_file
toast=toast
refresh=refresh
/>
}
.into_any()
} else {
view! { <Preview target=v/> }.into_any()
};
view! { <div class="file-view">{content}</div> }
})}
}
}