Merge upstream/main into feat/external-file-mounts
Resolve conflicts between the external-file-mounts feature and upstream's D5/D7 refactor (per-file provenance, keyset pagination, cross-drive move gates, resource-access hook, folder-cascade lifecycle hook). Key resolutions: - FolderService::new now takes (repo, authz, file_lifecycle, mount_router); all callers + DI updated. - FileRetrievalService / FileManagementService keep both the mount_router and the new resource_access_hook / drive_repo / storage_usage wiring. - list_files_batch_with_perms: adapt the mount branch from offset- to keyset (after_name) pagination, mirroring paginate_mount_entries. - download_file_impl: keep upstream's &HeaderMap + `impl IntoResponse + use<>` signature, retain the mount-download branch. - Mount DTOs: the retired `owner_id` field maps onto created_by/updated_by (the mount owner) — the fields the frontend now uses for owner display. - admin/+page.svelte: keep upstream's user-delete modal + the 'mounts' tab. - Bump memmap2 0.9.10 -> 0.9.11 (RUSTSEC critical advisory fix) and regenerate Cargo.lock against the merged Cargo.toml.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,26 @@ mod tests {
|
||||
all_day: false,
|
||||
rrule: None,
|
||||
ical_uid: "uid-evt-001@oxicloud".to_string(),
|
||||
recurrence_id: None,
|
||||
// Post-phase-4 the emitter serves stored ical_data
|
||||
// verbatim (folded per UID) instead of regenerating
|
||||
// from DTO fields. The fixture must therefore carry
|
||||
// a valid single-VEVENT VCALENDAR body — this is what
|
||||
// create_event_from_ical stores per row.
|
||||
ical_data: "BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//OxiCloud test//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:uid-evt-001@oxicloud\r\n\
|
||||
DTSTAMP:20250601T090000Z\r\n\
|
||||
DTSTART:20250615T100000Z\r\n\
|
||||
DTEND:20250615T110000Z\r\n\
|
||||
SUMMARY:Team Meeting\r\n\
|
||||
DESCRIPTION:Weekly team sync\r\n\
|
||||
LOCATION:Conference Room A\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n"
|
||||
.to_string(),
|
||||
created_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
updated_at: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
|
||||
}
|
||||
|
||||
@@ -17,6 +17,21 @@ use crate::application::adapters::webdav_adapter::{
|
||||
use crate::application::dtos::address_book_dto::AddressBookDto;
|
||||
use crate::application::dtos::contact_dto::ContactDto;
|
||||
|
||||
/// Emit a WebDAV `getetag` value as `"…"` with the surrounding quotes written
|
||||
/// as borrowed pre-escaped `"` text events around the escaped etag body.
|
||||
///
|
||||
/// Byte-identical to escaping the whole `"{etag}"` String — `quick_xml` escapes
|
||||
/// a literal `"` to `"`, so the one-String form re-allocated an owned `Cow`
|
||||
/// on write — but with **0 heap allocs per contact** (the NextCloud
|
||||
/// `write_etag_element` pattern, benches/ROUND20.md §C1). Called per contact on
|
||||
/// the CardDAV multiget/PROPFIND emit path.
|
||||
fn write_quoted_etag<W: Write>(xml_writer: &mut Writer<W>, etag: &str) -> Result<()> {
|
||||
xml_writer.write_event(Event::Text(BytesText::from_escaped(""")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(etag)))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::from_escaped(""")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a requested property as a namespaced response element name, mapping
|
||||
/// the known namespaces to their response prefixes (`D:` for DAV, `CR:` for
|
||||
/// CardDAV). Used for the catch-all arms of the requested-property writers so
|
||||
@@ -278,6 +293,27 @@ impl CardDavAdapter {
|
||||
) -> Result<()> {
|
||||
let mut xml_writer = Writer::new(writer);
|
||||
|
||||
Self::write_collection_head(&mut xml_writer, address_book, request, base_href)?;
|
||||
|
||||
// Write contacts if depth > 0
|
||||
if depth != "0" {
|
||||
Self::write_collection_contact_page(&mut xml_writer, contacts, base_href)?;
|
||||
}
|
||||
|
||||
Self::write_carddav_multistatus_end(&mut xml_writer)
|
||||
}
|
||||
|
||||
/// Multistatus opening (DAV + CardDAV + CalendarServer namespaces)
|
||||
/// plus the address book's own `D:response` — the head of a depth-1
|
||||
/// collection PROPFIND. Streaming emitters call this once, then
|
||||
/// [`Self::write_collection_contact_page`] per cursor page, then
|
||||
/// [`Self::write_carddav_multistatus_end`].
|
||||
pub fn write_collection_head<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
address_book: &AddressBookDto,
|
||||
request: &PropFindRequest,
|
||||
base_href: &str,
|
||||
) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(
|
||||
BytesStart::new("D:multistatus").with_attributes([
|
||||
("xmlns:D", "DAV:"),
|
||||
@@ -285,19 +321,25 @@ impl CardDavAdapter {
|
||||
("xmlns:CS", "http://calendarserver.org/ns/"),
|
||||
]),
|
||||
))?;
|
||||
Self::write_addressbook_response(xml_writer, address_book, request, base_href)
|
||||
}
|
||||
|
||||
// Write the address book itself
|
||||
Self::write_addressbook_response(&mut xml_writer, address_book, request, base_href)?;
|
||||
|
||||
// Write contacts if depth > 0
|
||||
if depth != "0" {
|
||||
for contact in contacts {
|
||||
let contact_href = format!("{}{}.vcf", base_href, contact.uid);
|
||||
Self::write_contact_response(&mut xml_writer, contact, &[], &contact_href)?;
|
||||
}
|
||||
/// One depth-1 collection page of contact entries (standard props;
|
||||
/// href buffer reused across the page).
|
||||
pub fn write_collection_contact_page<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
contacts: &[ContactDto],
|
||||
base_href: &str,
|
||||
) -> Result<()> {
|
||||
let mut href = String::with_capacity(base_href.len() + 48);
|
||||
for contact in contacts {
|
||||
href.clear();
|
||||
let _ = std::fmt::Write::write_fmt(
|
||||
&mut href,
|
||||
format_args!("{}{}.vcf", base_href, contact.uid),
|
||||
);
|
||||
Self::write_contact_response(xml_writer, contact, &[], &href)?;
|
||||
}
|
||||
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -359,7 +401,7 @@ impl CardDavAdapter {
|
||||
|
||||
// getetag
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!("\"{}\"", book.id))))?;
|
||||
write_quoted_etag(xml_writer, &book.id)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
|
||||
// getcontenttype
|
||||
@@ -441,8 +483,7 @@ impl CardDavAdapter {
|
||||
}
|
||||
("DAV:", "getetag") => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer
|
||||
.write_event(Event::Text(BytesText::new(&format!("\"{}\"", book.id))))?;
|
||||
write_quoted_etag(xml_writer, &book.id)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
}
|
||||
("DAV:", "getcontenttype") => {
|
||||
@@ -648,47 +689,65 @@ impl CardDavAdapter {
|
||||
}
|
||||
|
||||
/// Generate response for contacts (for REPORT)
|
||||
pub fn generate_contacts_response<W: Write>(
|
||||
writer: W,
|
||||
contacts: &[ContactDto],
|
||||
vcards: &[(String, String)], // (uid, vcard_data)
|
||||
report: &CardDavReportType,
|
||||
base_href: &str,
|
||||
) -> Result<()> {
|
||||
let mut xml_writer = Writer::new(writer);
|
||||
|
||||
/// REPORT `<D:multistatus>` opening tag (DAV + CardDAV namespaces).
|
||||
/// Streaming emitters call this once, then
|
||||
/// [`Self::write_contacts_report_page`] per cursor page, then
|
||||
/// [`Self::write_carddav_multistatus_end`].
|
||||
pub fn write_report_multistatus_start<W: Write>(xml_writer: &mut Writer<W>) -> Result<()> {
|
||||
xml_writer.write_event(Event::Start(
|
||||
BytesStart::new("D:multistatus").with_attributes([
|
||||
("xmlns:D", "DAV:"),
|
||||
("xmlns:CR", "urn:ietf:params:xml:ns:carddav"),
|
||||
]),
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let props = match report {
|
||||
CardDavReportType::AddressbookQuery { props } => props.clone(),
|
||||
CardDavReportType::AddressbookMultiget { props, .. } => props.clone(),
|
||||
CardDavReportType::SyncCollection { props, .. } => props.clone(),
|
||||
};
|
||||
|
||||
for contact in contacts {
|
||||
let href = format!("{}{}.vcf", base_href, contact.uid);
|
||||
let vcard = vcards
|
||||
.iter()
|
||||
.find(|(uid, _)| *uid == contact.uid)
|
||||
.map(|(_, data)| data.as_str())
|
||||
.unwrap_or("");
|
||||
Self::write_contact_response(&mut xml_writer, contact, &props, &href)?;
|
||||
// If address-data is requested, include vcard
|
||||
if props.iter().any(|p| p.name == "address-data") || props.is_empty() {
|
||||
// Already handled in write_contact_response
|
||||
}
|
||||
let _ = vcard; // suppress warning - used via contact_to_vcard fallback
|
||||
}
|
||||
|
||||
/// Close a multistatus opened by either start writer.
|
||||
pub fn write_carddav_multistatus_end<W: Write>(xml_writer: &mut Writer<W>) -> Result<()> {
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One REPORT page of contact responses. Props are borrowed from
|
||||
/// the request; one href buffer is reused across the page.
|
||||
pub fn write_contacts_report_page<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
contacts: &[ContactDto],
|
||||
report: &CardDavReportType,
|
||||
base_href: &str,
|
||||
) -> Result<()> {
|
||||
let props = match report {
|
||||
CardDavReportType::AddressbookQuery { props } => props,
|
||||
CardDavReportType::AddressbookMultiget { props, .. } => props,
|
||||
CardDavReportType::SyncCollection { props, .. } => props,
|
||||
};
|
||||
let mut href = String::with_capacity(base_href.len() + 48);
|
||||
for contact in contacts {
|
||||
href.clear();
|
||||
let _ = std::fmt::Write::write_fmt(
|
||||
&mut href,
|
||||
format_args!("{}{}.vcf", base_href, contact.uid),
|
||||
);
|
||||
// `write_contact_response` generates the vCard on demand when (and
|
||||
// only when) address-data is actually requested.
|
||||
Self::write_contact_response(xml_writer, contact, props, &href)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn generate_contacts_response<W: Write>(
|
||||
writer: W,
|
||||
contacts: &[ContactDto],
|
||||
report: &CardDavReportType,
|
||||
base_href: &str,
|
||||
) -> Result<()> {
|
||||
let mut xml_writer = Writer::new(writer);
|
||||
Self::write_report_multistatus_start(&mut xml_writer)?;
|
||||
Self::write_contacts_report_page(&mut xml_writer, contacts, report, base_href)?;
|
||||
Self::write_carddav_multistatus_end(&mut xml_writer)
|
||||
}
|
||||
|
||||
/// Write a single contact response element
|
||||
fn write_contact_response<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
@@ -710,10 +769,7 @@ impl CardDavAdapter {
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"\"{}\"",
|
||||
contact.etag
|
||||
))))?;
|
||||
write_quoted_etag(xml_writer, &contact.etag)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
|
||||
@@ -733,10 +789,7 @@ impl CardDavAdapter {
|
||||
}
|
||||
("DAV:", "getetag") => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:getetag")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"\"{}\"",
|
||||
contact.etag
|
||||
))))?;
|
||||
write_quoted_etag(xml_writer, &contact.etag)?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getetag")))?;
|
||||
}
|
||||
("DAV:", "getcontenttype") => {
|
||||
@@ -750,9 +803,24 @@ impl CardDavAdapter {
|
||||
("DAV:", "getlastmodified") => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(
|
||||
&contact.updated_at.to_rfc2822(),
|
||||
)))?;
|
||||
// Stack render (ROUND10 §13, byte-identical to
|
||||
// chrono) with the chrono fallback for
|
||||
// out-of-range timestamps — per-contact on the
|
||||
// multiget/PROPFIND path.
|
||||
let mut lm_buf = [0u8; 31];
|
||||
match crate::common::fmt::rfc2822_utc(
|
||||
&mut lm_buf,
|
||||
contact.updated_at.timestamp(),
|
||||
) {
|
||||
Some(s) => {
|
||||
xml_writer.write_event(Event::Text(BytesText::new(s)))?;
|
||||
}
|
||||
None => {
|
||||
xml_writer.write_event(Event::Text(BytesText::new(
|
||||
&contact.updated_at.to_rfc2822(),
|
||||
)))?;
|
||||
}
|
||||
}
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
|
||||
}
|
||||
("urn:ietf:params:xml:ns:carddav", "address-data") => {
|
||||
@@ -868,92 +936,133 @@ impl CardDavAdapter {
|
||||
|
||||
/// Convert a ContactDto to vCard 3.0 format
|
||||
pub fn contact_to_vcard(contact: &ContactDto) -> String {
|
||||
// `write!` into a String is infallible; `let _ =` discards the Ok(()).
|
||||
// Formatting straight into the buffer avoids one temporary String per
|
||||
// vCard line compared to `push_str(&format!(…))`.
|
||||
use std::fmt::Write as _;
|
||||
|
||||
let mut vcard = String::from("BEGIN:VCARD\r\nVERSION:3.0\r\n");
|
||||
|
||||
vcard.push_str(&format!("UID:{}\r\n", contact.uid));
|
||||
let _ = write!(vcard, "UID:{}\r\n", contact.uid);
|
||||
|
||||
if let (Some(last), Some(first)) = (&contact.last_name, &contact.first_name) {
|
||||
vcard.push_str(&format!("N:{};{};;;\r\n", last, first));
|
||||
let _ = write!(vcard, "N:{};{};;;\r\n", last, first);
|
||||
} else if let Some(last) = &contact.last_name {
|
||||
vcard.push_str(&format!("N:{};;;;\r\n", last));
|
||||
let _ = write!(vcard, "N:{};;;;\r\n", last);
|
||||
} else if let Some(first) = &contact.first_name {
|
||||
vcard.push_str(&format!("N:;{};;;\r\n", first));
|
||||
let _ = write!(vcard, "N:;{};;;\r\n", first);
|
||||
}
|
||||
|
||||
if let Some(fn_name) = &contact.full_name {
|
||||
vcard.push_str(&format!("FN:{}\r\n", fn_name));
|
||||
let _ = write!(vcard, "FN:{}\r\n", fn_name);
|
||||
} else {
|
||||
// FN is mandatory in vCard 3.0
|
||||
// FN is mandatory in vCard 3.0. Write the borrowed trim slice directly
|
||||
// instead of copying it into a second owned String (benches/ROUND19.md §V1).
|
||||
let fn_name = format!(
|
||||
"{} {}",
|
||||
contact.first_name.as_deref().unwrap_or(""),
|
||||
contact.last_name.as_deref().unwrap_or(""),
|
||||
)
|
||||
.trim()
|
||||
.to_string();
|
||||
if !fn_name.is_empty() {
|
||||
vcard.push_str(&format!("FN:{}\r\n", fn_name));
|
||||
);
|
||||
let trimmed = fn_name.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let _ = write!(vcard, "FN:{}\r\n", trimmed);
|
||||
} else {
|
||||
vcard.push_str("FN:Unknown\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(nickname) = &contact.nickname {
|
||||
vcard.push_str(&format!("NICKNAME:{}\r\n", nickname));
|
||||
let _ = write!(vcard, "NICKNAME:{}\r\n", nickname);
|
||||
}
|
||||
|
||||
for email in &contact.email {
|
||||
vcard.push_str(&format!(
|
||||
"EMAIL;TYPE={}:{}\r\n",
|
||||
email.r#type.to_uppercase(),
|
||||
email.email
|
||||
));
|
||||
vcard.push_str("EMAIL;TYPE=");
|
||||
crate::common::fmt::push_upper(&mut vcard, &email.r#type);
|
||||
vcard.push(':');
|
||||
vcard.push_str(&email.email);
|
||||
vcard.push_str("\r\n");
|
||||
}
|
||||
|
||||
for phone in &contact.phone {
|
||||
vcard.push_str(&format!(
|
||||
"TEL;TYPE={}:{}\r\n",
|
||||
phone.r#type.to_uppercase(),
|
||||
phone.number
|
||||
));
|
||||
vcard.push_str("TEL;TYPE=");
|
||||
crate::common::fmt::push_upper(&mut vcard, &phone.r#type);
|
||||
vcard.push(':');
|
||||
vcard.push_str(&phone.number);
|
||||
vcard.push_str("\r\n");
|
||||
}
|
||||
|
||||
for addr in &contact.address {
|
||||
let adr = format!(
|
||||
";;{};{};{};{};{}",
|
||||
vcard.push_str("ADR;TYPE=");
|
||||
crate::common::fmt::push_upper(&mut vcard, &addr.r#type);
|
||||
let _ = write!(
|
||||
vcard,
|
||||
":;;{};{};{};{};{}\r\n",
|
||||
addr.street.as_deref().unwrap_or(""),
|
||||
addr.city.as_deref().unwrap_or(""),
|
||||
addr.state.as_deref().unwrap_or(""),
|
||||
addr.postal_code.as_deref().unwrap_or(""),
|
||||
addr.country.as_deref().unwrap_or(""),
|
||||
);
|
||||
vcard.push_str(&format!(
|
||||
"ADR;TYPE={}:{}\r\n",
|
||||
addr.r#type.to_uppercase(),
|
||||
adr
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(org) = &contact.organization {
|
||||
vcard.push_str(&format!("ORG:{}\r\n", org));
|
||||
let _ = write!(vcard, "ORG:{}\r\n", org);
|
||||
}
|
||||
if let Some(title) = &contact.title {
|
||||
vcard.push_str(&format!("TITLE:{}\r\n", title));
|
||||
let _ = write!(vcard, "TITLE:{}\r\n", title);
|
||||
}
|
||||
if let Some(notes) = &contact.notes {
|
||||
vcard.push_str(&format!("NOTE:{}\r\n", notes.replace('\n', "\\n")));
|
||||
// Only a multi-line note needs the escaping copy; a note with no newline
|
||||
// writes its borrowed slice directly (benches/ROUND19.md §V1).
|
||||
if notes.contains('\n') {
|
||||
let _ = write!(vcard, "NOTE:{}\r\n", notes.replace('\n', "\\n"));
|
||||
} else {
|
||||
vcard.push_str("NOTE:");
|
||||
vcard.push_str(notes);
|
||||
vcard.push_str("\r\n");
|
||||
}
|
||||
}
|
||||
if let Some(bday) = &contact.birthday {
|
||||
vcard.push_str(&format!("BDAY:{}\r\n", bday.format("%Y-%m-%d")));
|
||||
// Stack render (byte-identical to chrono's `%Y-%m-%d`) with the chrono
|
||||
// fallback for out-of-range years — drops the strftime interpreter + a
|
||||
// heap alloc per contact-with-birthday (fmt::compact_date is the
|
||||
// date-only companion to the §V2 REV renderer above).
|
||||
use chrono::Datelike as _;
|
||||
let mut bday_buf = [0u8; 10];
|
||||
match crate::common::fmt::compact_date(&mut bday_buf, bday.year(), bday.month(), bday.day())
|
||||
{
|
||||
Some(s) => {
|
||||
vcard.push_str("BDAY:");
|
||||
vcard.push_str(s);
|
||||
vcard.push_str("\r\n");
|
||||
}
|
||||
None => {
|
||||
let _ = write!(vcard, "BDAY:{}\r\n", bday.format("%Y-%m-%d"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(photo) = &contact.photo_url {
|
||||
vcard.push_str(&format!("PHOTO;VALUE=URI:{}\r\n", photo));
|
||||
let _ = write!(vcard, "PHOTO;VALUE=URI:{}\r\n", photo);
|
||||
}
|
||||
|
||||
vcard.push_str(&format!(
|
||||
"REV:{}\r\n",
|
||||
contact.updated_at.format("%Y%m%dT%H%M%SZ")
|
||||
));
|
||||
// REV via the stack renderer — chrono's `.format("%Y%m%dT%H%M%SZ")` runs the
|
||||
// strftime interpreter and allocates per contact (benches/ROUND19.md §V2:
|
||||
// 11.8× faster, 3→0 allocs). Out-of-range falls back to chrono.
|
||||
let mut rev_buf = [0u8; 16];
|
||||
match crate::common::fmt::compact_ical_utc(&mut rev_buf, contact.updated_at.timestamp()) {
|
||||
Some(rev) => {
|
||||
vcard.push_str("REV:");
|
||||
vcard.push_str(rev);
|
||||
vcard.push_str("\r\n");
|
||||
}
|
||||
None => {
|
||||
let _ = write!(
|
||||
vcard,
|
||||
"REV:{}\r\n",
|
||||
contact.updated_at.format("%Y%m%dT%H%M%SZ")
|
||||
);
|
||||
}
|
||||
}
|
||||
vcard.push_str("END:VCARD\r\n");
|
||||
|
||||
vcard
|
||||
|
||||
@@ -484,10 +484,6 @@ mod tests {
|
||||
#[test]
|
||||
fn test_generate_contacts_response() {
|
||||
let contacts = vec![sample_contact()];
|
||||
let vcards = vec![(
|
||||
"contact-001".to_string(),
|
||||
contact_to_vcard(&sample_contact()),
|
||||
)];
|
||||
let report = CardDavReportType::AddressbookQuery {
|
||||
props: vec![
|
||||
QualifiedName {
|
||||
@@ -505,7 +501,6 @@ mod tests {
|
||||
let result = CardDavAdapter::generate_contacts_response(
|
||||
&mut output,
|
||||
&contacts,
|
||||
&vcards,
|
||||
&report,
|
||||
"/carddav/ab-001",
|
||||
);
|
||||
@@ -528,14 +523,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_generate_empty_contacts_response() {
|
||||
let contacts: Vec<ContactDto> = vec![];
|
||||
let vcards: Vec<(String, String)> = vec![];
|
||||
let report = CardDavReportType::AddressbookQuery { props: vec![] };
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CardDavAdapter::generate_contacts_response(
|
||||
&mut output,
|
||||
&contacts,
|
||||
&vcards,
|
||||
&report,
|
||||
"/carddav/ab-001",
|
||||
);
|
||||
|
||||
@@ -61,7 +61,10 @@ impl PluginLifecycleHook {
|
||||
|
||||
dispatch.dispatch(PluginEvent {
|
||||
name: EVENT_FILE_UPLOADED,
|
||||
user_id: dto.owner_id,
|
||||
// Post-D7 the wire DTO no longer carries `owner_id`;
|
||||
// §14 `created_by` provenance is the equivalent signal
|
||||
// (who put the file in the system).
|
||||
user_id: dto.created_by.map(|u| u.to_string()),
|
||||
invocation_id: Uuid::new_v4().to_string(),
|
||||
payload: serde_json::json!({
|
||||
"path": dto.path,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,15 +31,18 @@ impl Default for AddressBookDto {
|
||||
|
||||
impl From<AddressBook> for AddressBookDto {
|
||||
fn from(book: AddressBook) -> Self {
|
||||
// Owned entity → move the owned fields instead of cloning through the
|
||||
// borrowing accessors (benches/ROUND20.md §A4).
|
||||
let p = book.into_parts();
|
||||
Self {
|
||||
id: book.id().to_string(),
|
||||
name: book.name().to_string(),
|
||||
owner_id: book.owner_id().to_string(),
|
||||
description: book.description().map(|s| s.to_string()),
|
||||
color: book.color().map(|s| s.to_string()),
|
||||
is_public: book.is_public(),
|
||||
created_at: *book.created_at(),
|
||||
updated_at: *book.updated_at(),
|
||||
id: p.id.to_string(),
|
||||
name: p.name,
|
||||
owner_id: p.owner_id,
|
||||
description: p.description,
|
||||
color: p.color,
|
||||
is_public: p.is_public,
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,16 +64,3 @@ pub struct UpdateAddressBookDto {
|
||||
pub is_public: Option<bool>,
|
||||
pub user_id: String, // Current user making the update
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShareAddressBookDto {
|
||||
pub address_book_id: String,
|
||||
pub user_id: String,
|
||||
pub can_write: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UnshareAddressBookDto {
|
||||
pub address_book_id: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
@@ -36,16 +36,20 @@ impl Default for CalendarDto {
|
||||
|
||||
impl From<Calendar> for CalendarDto {
|
||||
fn from(calendar: Calendar) -> Self {
|
||||
// `calendar` is owned and dropped here — move the heap fields (notably
|
||||
// the `custom_properties` HashMap) instead of cloning them through the
|
||||
// borrowing accessors (benches/ROUND20.md §A4).
|
||||
let p = calendar.into_parts();
|
||||
Self {
|
||||
id: calendar.id().to_string(),
|
||||
name: calendar.name().to_string(),
|
||||
owner_id: calendar.owner_id().to_string(),
|
||||
description: calendar.description().map(|s| s.to_string()),
|
||||
color: calendar.color().map(|s| s.to_string()),
|
||||
id: p.id.to_string(),
|
||||
name: p.name,
|
||||
owner_id: p.owner_id.to_string(),
|
||||
description: p.description,
|
||||
color: p.color,
|
||||
is_public: false, // This needs to be set separately as it's not part of the domain entity
|
||||
created_at: *calendar.created_at(),
|
||||
updated_at: *calendar.updated_at(),
|
||||
custom_properties: calendar.custom_properties().clone(),
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
custom_properties: p.custom_properties,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +93,22 @@ pub struct CalendarEventDto {
|
||||
pub all_day: bool,
|
||||
pub rrule: Option<String>,
|
||||
pub ical_uid: String,
|
||||
/// RFC 5545 §3.8.4.4 RECURRENCE-ID. `None` on masters and on
|
||||
/// non-recurring events; `Some` on per-instance exception
|
||||
/// overrides. Two rows sharing (`calendar_id`, `ical_uid`) but
|
||||
/// distinguished by this field represent a recurring master and
|
||||
/// its modified occurrence(s) respectively (see #528).
|
||||
pub recurrence_id: Option<DateTime<Utc>>,
|
||||
/// Full stored iCalendar body for this row — one VCALENDAR
|
||||
/// containing exactly one VEVENT. Populated at every read
|
||||
/// path from the entity's `ical_data()`. The CalDAV read
|
||||
/// emitters serve this verbatim (extracted + bundled per
|
||||
/// UID) instead of regenerating from the other DTO fields,
|
||||
/// so properties beyond the structured columns
|
||||
/// (ATTENDEE, VALARM, CATEGORIES, RECURRENCE-ID, X-*)
|
||||
/// survive PUT → GET round-trips. See phase-4 read-side
|
||||
/// unification.
|
||||
pub ical_data: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -106,6 +126,8 @@ impl Default for CalendarEventDto {
|
||||
all_day: false,
|
||||
rrule: None,
|
||||
ical_uid: String::new(),
|
||||
recurrence_id: None,
|
||||
ical_data: String::new(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
@@ -114,19 +136,26 @@ impl Default for CalendarEventDto {
|
||||
|
||||
impl From<CalendarEvent> for CalendarEventDto {
|
||||
fn from(event: CalendarEvent) -> Self {
|
||||
// Move every owned field out of the consumed entity — the old
|
||||
// getter-clone shape deep-copied 6 Strings per event, dominated by
|
||||
// the ~11 KB `ical_data` blob, on every CalDAV listing row
|
||||
// (benches/ROUND11.md §19: 1.45x + the 11 KB memcpy gone).
|
||||
let parts = event.into_parts();
|
||||
Self {
|
||||
id: event.id().to_string(),
|
||||
calendar_id: event.calendar_id().to_string(),
|
||||
summary: event.summary().to_string(),
|
||||
description: event.description().map(|s| s.to_string()),
|
||||
location: event.location().map(|s| s.to_string()),
|
||||
start_time: *event.start_time(),
|
||||
end_time: *event.end_time(),
|
||||
all_day: event.all_day(),
|
||||
rrule: event.rrule().map(|s| s.to_string()),
|
||||
ical_uid: event.ical_uid().to_string(),
|
||||
created_at: *event.created_at(),
|
||||
updated_at: *event.updated_at(),
|
||||
id: parts.id.to_string(),
|
||||
calendar_id: parts.calendar_id.to_string(),
|
||||
summary: parts.summary,
|
||||
description: parts.description,
|
||||
location: parts.location,
|
||||
start_time: parts.start_time,
|
||||
end_time: parts.end_time,
|
||||
all_day: parts.all_day,
|
||||
rrule: parts.rrule,
|
||||
ical_uid: parts.ical_uid,
|
||||
recurrence_id: parts.recurrence_id,
|
||||
ical_data: parts.ical_data,
|
||||
created_at: parts.created_at,
|
||||
updated_at: parts.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,175 @@
|
||||
//! then fall back to the file extension when the MIME is generic
|
||||
//! (`application/octet-stream` or empty).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
// ─── Arc<str> interning for closed-set display values ────────────────
|
||||
//
|
||||
// `FileDto` / `FolderDto` store their display fields as `Arc<str>` so DTO
|
||||
// clones are O(1). But `Arc::<str>::from(&str)` always allocates + copies,
|
||||
// so building the DTO paid 3-4 heap allocations per row even though the
|
||||
// value space is a small closed set. Interning turns each conversion into
|
||||
// a HashMap lookup + refcount bump.
|
||||
|
||||
/// Every `&'static str` that [`icon_class_for`], [`icon_special_class_for`]
|
||||
/// and [`category_for`] can return, plus the folder-DTO constants.
|
||||
///
|
||||
/// Keep this table in sync when adding a value to those functions — a
|
||||
/// missing entry is not a bug (callers fall back to `Arc::from`, same
|
||||
/// bytes, one extra allocation), just a lost optimization.
|
||||
static DISPLAY_INTERN: LazyLock<HashMap<&'static str, Arc<str>>> = LazyLock::new(|| {
|
||||
const CLOSED_SET: &[&str] = &[
|
||||
// icon_class_for
|
||||
"fas fa-file-pdf",
|
||||
"fas fa-file-word",
|
||||
"fas fa-file-excel",
|
||||
"fas fa-file-powerpoint",
|
||||
"fas fa-file-archive",
|
||||
"fas fa-file-code",
|
||||
"fas fa-hdd",
|
||||
"fas fa-file-image",
|
||||
"fas fa-file-video",
|
||||
"fas fa-file-audio",
|
||||
"fas fa-file-alt",
|
||||
"fas fa-terminal",
|
||||
"fas fa-file",
|
||||
// icon_special_class_for
|
||||
"pdf-icon",
|
||||
"doc-icon",
|
||||
"spreadsheet-icon",
|
||||
"presentation-icon",
|
||||
"archive-icon",
|
||||
"code-icon json-icon",
|
||||
"code-icon js-icon",
|
||||
"code-icon ts-icon",
|
||||
"code-icon html-icon",
|
||||
"code-icon sql-icon",
|
||||
"code-icon config-icon",
|
||||
"code-icon php-icon",
|
||||
"script-icon",
|
||||
"installer-icon",
|
||||
"image-icon",
|
||||
"video-icon",
|
||||
"audio-icon",
|
||||
"code-icon py-icon",
|
||||
"code-icon rust-icon",
|
||||
"code-icon",
|
||||
"code-icon go-icon",
|
||||
"code-icon ruby-icon",
|
||||
"code-icon md-icon",
|
||||
"code-icon css-icon",
|
||||
"code-icon java-icon",
|
||||
"code-icon c-icon",
|
||||
"code-icon cs-icon",
|
||||
"code-icon swift-icon",
|
||||
"",
|
||||
// category_for
|
||||
"PDF",
|
||||
"Document",
|
||||
"Spreadsheet",
|
||||
"Presentation",
|
||||
"Archive",
|
||||
"Code",
|
||||
"Installer",
|
||||
"Image",
|
||||
"Video",
|
||||
"Audio",
|
||||
"Markdown",
|
||||
"Text",
|
||||
// FolderDto constants
|
||||
"fas fa-folder",
|
||||
"folder-icon",
|
||||
"Folder",
|
||||
];
|
||||
CLOSED_SET.iter().map(|s| (*s, Arc::from(*s))).collect()
|
||||
});
|
||||
|
||||
/// Returns a shared `Arc<str>` for a display value from the closed sets
|
||||
/// above (icon class, icon special class, category). Lookup + refcount
|
||||
/// bump instead of alloc + copy; unknown values (future additions not
|
||||
/// yet in the table) fall back to `Arc::from` with identical bytes.
|
||||
pub fn intern_display(s: &'static str) -> Arc<str> {
|
||||
DISPLAY_INTERN
|
||||
.get(s)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Arc::from(s))
|
||||
}
|
||||
|
||||
/// The MIME types that dominate real storage rows. Exotic types fall back
|
||||
/// to a per-row `Arc::from` — correctness is unaffected, only the alloc is.
|
||||
static MIME_INTERN: LazyLock<HashMap<&'static str, Arc<str>>> = LazyLock::new(|| {
|
||||
const COMMON_MIMES: &[&str] = &[
|
||||
"",
|
||||
"directory",
|
||||
"application/octet-stream",
|
||||
// Images
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"image/avif",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"image/x-icon",
|
||||
// Video
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
"video/x-matroska",
|
||||
"video/x-msvideo",
|
||||
// Audio
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
"audio/ogg",
|
||||
"audio/flac",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/aac",
|
||||
// Documents
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
// Text / code
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
"text/html",
|
||||
"text/css",
|
||||
"text/markdown",
|
||||
"text/xml",
|
||||
"application/json",
|
||||
"application/javascript",
|
||||
"application/xml",
|
||||
"application/x-yaml",
|
||||
// Archives
|
||||
"application/zip",
|
||||
"application/gzip",
|
||||
"application/x-tar",
|
||||
"application/x-7z-compressed",
|
||||
"application/x-rar-compressed",
|
||||
];
|
||||
COMMON_MIMES.iter().map(|s| (*s, Arc::from(*s))).collect()
|
||||
});
|
||||
|
||||
/// Returns a shared `Arc<str>` for the given MIME type. Common types hit
|
||||
/// the intern table (refcount bump); exotic ones allocate as before.
|
||||
pub fn intern_mime(mime: &str) -> Arc<str> {
|
||||
MIME_INTERN
|
||||
.get(mime)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Arc::from(mime))
|
||||
}
|
||||
|
||||
// ─── Private: extract lowercase extension from a filename ────────────
|
||||
fn ext_of(name: &str) -> Option<&str> {
|
||||
let name = name.rsplit('/').next().unwrap_or(name); // strip path
|
||||
@@ -19,6 +188,50 @@ fn ext_of(name: &str) -> Option<&str> {
|
||||
Some(after_dot)
|
||||
}
|
||||
|
||||
/// Longest extension any classifier table matches ("appimage", "markdown"
|
||||
/// — 8 bytes). Longer extensions can only ever hit the `_` arms, so they
|
||||
/// skip the buffer entirely.
|
||||
const MAX_CLASSIFIED_EXT: usize = 16;
|
||||
|
||||
/// Lowercase `ext` into `buf` without heap allocation. Returns `None` for
|
||||
/// extensions longer than any table entry — the caller must then take the
|
||||
/// same default arm `ext.to_ascii_lowercase()` would have fallen into.
|
||||
fn lower_ext_into<'b>(ext: &str, buf: &'b mut [u8; MAX_CLASSIFIED_EXT]) -> Option<&'b str> {
|
||||
let bytes = ext.as_bytes();
|
||||
if bytes.len() > MAX_CLASSIFIED_EXT {
|
||||
return None;
|
||||
}
|
||||
for (i, b) in bytes.iter().enumerate() {
|
||||
buf[i] = b.to_ascii_lowercase();
|
||||
}
|
||||
// ASCII-lowercasing bytes keeps UTF-8 validity (non-ASCII bytes pass
|
||||
// through untouched).
|
||||
std::str::from_utf8(&buf[..bytes.len()]).ok()
|
||||
}
|
||||
|
||||
/// The three display classifications for one `(name, mime)` pair.
|
||||
pub struct DisplayClass {
|
||||
pub icon_class: &'static str,
|
||||
pub icon_special_class: &'static str,
|
||||
pub category: &'static str,
|
||||
}
|
||||
|
||||
/// Run all three classifiers over one `(name, mime)` pair, lowering the
|
||||
/// extension **once into a stack buffer** instead of each classifier
|
||||
/// allocating its own `to_ascii_lowercase()` String on the fallback path
|
||||
/// (generic/empty MIME rows — common for code and unknown types). Each
|
||||
/// decision tree is byte-for-byte the classifier it replaces
|
||||
/// (benches/ROUND11.md §21 gates the equivalence over a corpus).
|
||||
pub fn classify_display(name: &str, mime: &str) -> DisplayClass {
|
||||
let mut buf = [0u8; MAX_CLASSIFIED_EXT];
|
||||
let ext = ext_of(name).and_then(|e| lower_ext_into(e, &mut buf));
|
||||
DisplayClass {
|
||||
icon_class: icon_class_with_ext(mime, ext),
|
||||
icon_special_class: icon_special_class_with_ext(mime, ext),
|
||||
category: category_with_ext(mime, ext),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Icon class (FontAwesome) ────────────────────────────────────────
|
||||
|
||||
/// Returns the FontAwesome icon class for a file, considering both MIME
|
||||
@@ -27,6 +240,12 @@ fn ext_of(name: &str) -> Option<&str> {
|
||||
/// Use this instead of the old `mime_to_icon_class` whenever the filename
|
||||
/// is available.
|
||||
pub fn icon_class_for(name: &str, mime: &str) -> &'static str {
|
||||
let mut buf = [0u8; MAX_CLASSIFIED_EXT];
|
||||
let ext = ext_of(name).and_then(|e| lower_ext_into(e, &mut buf));
|
||||
icon_class_with_ext(mime, ext)
|
||||
}
|
||||
|
||||
fn icon_class_with_ext(mime: &str, ext: Option<&str>) -> &'static str {
|
||||
// 1. Try specific MIME matches first
|
||||
match mime {
|
||||
"application/pdf" => return "fas fa-file-pdf",
|
||||
@@ -104,8 +323,8 @@ pub fn icon_class_for(name: &str, mime: &str) -> &'static str {
|
||||
}
|
||||
|
||||
// 3. Extension-based fallback (for application/octet-stream, empty, etc.)
|
||||
if let Some(ext) = ext_of(name) {
|
||||
return match ext.to_ascii_lowercase().as_str() {
|
||||
if let Some(ext) = ext {
|
||||
return match ext {
|
||||
"pdf" => "fas fa-file-pdf",
|
||||
"doc" | "docx" | "odt" | "rtf" => "fas fa-file-word",
|
||||
"xls" | "xlsx" | "ods" | "csv" => "fas fa-file-excel",
|
||||
@@ -142,6 +361,12 @@ pub fn icon_class_for(name: &str, mime: &str) -> &'static str {
|
||||
/// The returned class maps to CSS rules in `style.css` that set colours,
|
||||
/// backgrounds and decorative pseudo-elements per file type.
|
||||
pub fn icon_special_class_for(name: &str, mime: &str) -> &'static str {
|
||||
let mut buf = [0u8; MAX_CLASSIFIED_EXT];
|
||||
let ext = ext_of(name).and_then(|e| lower_ext_into(e, &mut buf));
|
||||
icon_special_class_with_ext(mime, ext)
|
||||
}
|
||||
|
||||
fn icon_special_class_with_ext(mime: &str, ext: Option<&str>) -> &'static str {
|
||||
// 1. Specific MIME matches
|
||||
match mime {
|
||||
"application/pdf" => return "pdf-icon",
|
||||
@@ -221,8 +446,8 @@ pub fn icon_special_class_for(name: &str, mime: &str) -> &'static str {
|
||||
}
|
||||
|
||||
// 3. Extension-based fallback
|
||||
if let Some(ext) = ext_of(name) {
|
||||
return match ext.to_ascii_lowercase().as_str() {
|
||||
if let Some(ext) = ext {
|
||||
return match ext {
|
||||
"pdf" => "pdf-icon",
|
||||
"doc" | "docx" | "odt" | "rtf" => "doc-icon",
|
||||
"xls" | "xlsx" | "ods" | "csv" => "spreadsheet-icon",
|
||||
@@ -268,6 +493,12 @@ pub fn icon_special_class_for(name: &str, mime: &str) -> &'static str {
|
||||
|
||||
/// Returns a human-readable category label, considering MIME + extension.
|
||||
pub fn category_for(name: &str, mime: &str) -> &'static str {
|
||||
let mut buf = [0u8; MAX_CLASSIFIED_EXT];
|
||||
let ext = ext_of(name).and_then(|e| lower_ext_into(e, &mut buf));
|
||||
category_with_ext(mime, ext)
|
||||
}
|
||||
|
||||
fn category_with_ext(mime: &str, ext: Option<&str>) -> &'static str {
|
||||
// 1. Specific MIME matches
|
||||
match mime {
|
||||
"application/pdf" => return "PDF",
|
||||
@@ -320,8 +551,8 @@ pub fn category_for(name: &str, mime: &str) -> &'static str {
|
||||
}
|
||||
|
||||
// 3. Extension fallback
|
||||
if let Some(ext) = ext_of(name) {
|
||||
return match ext.to_ascii_lowercase().as_str() {
|
||||
if let Some(ext) = ext {
|
||||
return match ext {
|
||||
"pdf" => "PDF",
|
||||
"doc" | "docx" | "odt" | "rtf" | "txt" => "Document",
|
||||
"xls" | "xlsx" | "ods" | "csv" => "Spreadsheet",
|
||||
@@ -388,11 +619,21 @@ pub fn format_file_size(bytes: u64) -> String {
|
||||
|
||||
let value = bytes as f64 / K.powi(i as i32);
|
||||
|
||||
// Two decimal places, then strip trailing zeros (matches JS parseFloat behaviour)
|
||||
let formatted = format!("{:.2}", value);
|
||||
let formatted = formatted.trim_end_matches('0').trim_end_matches('.');
|
||||
|
||||
format!("{} {}", formatted, SIZES[i])
|
||||
// Single buffer: write the 2-decimal value, strip trailing zeros in
|
||||
// place (matches JS parseFloat behaviour), then append the unit.
|
||||
// 16 chars covers the worst case ("16777216 TB" for u64::MAX,
|
||||
// "1023.99 Bytes" for the longest unit), so no realloc occurs.
|
||||
let mut out = String::with_capacity(16);
|
||||
let _ = write!(out, "{:.2}", value);
|
||||
while out.ends_with('0') {
|
||||
out.pop();
|
||||
}
|
||||
if out.ends_with('.') {
|
||||
out.pop();
|
||||
}
|
||||
out.push(' ');
|
||||
out.push_str(SIZES[i]);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -506,6 +747,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Every value the closed-set display functions can return must hit
|
||||
/// the intern table (same bytes, shared allocation) — a miss is only
|
||||
/// a lost optimization, but this test keeps the table in sync.
|
||||
#[test]
|
||||
fn test_intern_display_covers_closed_sets_and_shares_storage() {
|
||||
for s in [
|
||||
"fas fa-file-pdf",
|
||||
"fas fa-file",
|
||||
"fas fa-terminal",
|
||||
"fas fa-folder",
|
||||
"code-icon rust-icon",
|
||||
"folder-icon",
|
||||
"",
|
||||
"PDF",
|
||||
"Folder",
|
||||
"Document",
|
||||
"Markdown",
|
||||
] {
|
||||
let a = intern_display(s);
|
||||
let b = intern_display(s);
|
||||
assert_eq!(&*a, s, "interned bytes must be identical");
|
||||
assert!(
|
||||
Arc::ptr_eq(&a, &b),
|
||||
"closed-set value {s:?} must come from the intern table"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intern_mime_common_hits_table_exotic_falls_back() {
|
||||
let a = intern_mime("image/jpeg");
|
||||
let b = intern_mime("image/jpeg");
|
||||
assert_eq!(&*a, "image/jpeg");
|
||||
assert!(Arc::ptr_eq(&a, &b), "common MIME must be interned");
|
||||
|
||||
let exotic = intern_mime("chemical/x-pdb");
|
||||
assert_eq!(&*exotic, "chemical/x-pdb");
|
||||
let exotic2 = intern_mime("chemical/x-pdb");
|
||||
assert!(
|
||||
!Arc::ptr_eq(&exotic, &exotic2),
|
||||
"exotic MIME falls back to a fresh Arc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_of() {
|
||||
assert_eq!(ext_of("file.txt"), Some("txt"));
|
||||
|
||||
@@ -4,9 +4,7 @@ use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||
use super::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
use super::display_helpers::{classify_display, format_file_size};
|
||||
use super::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
|
||||
@@ -55,11 +53,6 @@ pub struct FavoriteItemDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub item_path: Option<String>,
|
||||
|
||||
/// UUID of the file/folder's actual owner (may differ from `user_id` when
|
||||
/// the item was shared and then favourited by another user).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_id: Option<String>,
|
||||
|
||||
// ── Pre-computed display fields ──
|
||||
/// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder")
|
||||
pub icon_class: String,
|
||||
@@ -89,9 +82,10 @@ impl FavoriteItemDto {
|
||||
.item_mime_type
|
||||
.as_deref()
|
||||
.unwrap_or("application/octet-stream");
|
||||
self.icon_class = icon_class_for(name, mime).to_string();
|
||||
self.icon_special_class = icon_special_class_for(name, mime).to_string();
|
||||
self.category = category_for(name, mime).to_string();
|
||||
let classes = classify_display(name, mime);
|
||||
self.icon_class = classes.icon_class.to_string();
|
||||
self.icon_special_class = classes.icon_special_class.to_string();
|
||||
self.category = classes.category.to_string();
|
||||
self.size_formatted = format_file_size(self.item_size.unwrap_or(0) as u64);
|
||||
}
|
||||
self
|
||||
@@ -124,11 +118,20 @@ pub struct FavoriteResourceRow {
|
||||
pub size: i64,
|
||||
pub resource_created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
pub owner_id: Uuid,
|
||||
/// Drive that owns this row. Surfaced on the favorites listing
|
||||
/// so a UI can tell when a favorited item lives in a different
|
||||
/// drive than the user's home (post-D6 cross-drive moves +
|
||||
/// copies make this reachable).
|
||||
pub drive_id: Uuid,
|
||||
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
|
||||
/// folder rows. Routes into `FileDto::content_hash` and feeds
|
||||
/// `File::compute_etag` to populate `FileDto::etag`.
|
||||
pub blob_hash: Option<String>,
|
||||
/// §14 provenance — who created the row. `None` when the creator
|
||||
/// was deleted (FK `ON DELETE SET NULL`).
|
||||
pub created_by: Option<Uuid>,
|
||||
/// §14 provenance — who last touched the row.
|
||||
pub updated_by: Option<Uuid>,
|
||||
/// `true` when `owner_id == requesting user_id`.
|
||||
pub is_owner: bool,
|
||||
pub favorited_at: DateTime<Utc>,
|
||||
|
||||
@@ -5,9 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
use super::display_helpers::{classify_display, format_file_size, intern_display, intern_mime};
|
||||
|
||||
/// DTO for file responses
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
@@ -54,10 +52,6 @@ pub struct FileDto {
|
||||
/// Human-readable formatted size (e.g. "3.27 MB")
|
||||
pub size_formatted: String,
|
||||
|
||||
/// Owner user ID (omitted from JSON when None)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_id: Option<String>,
|
||||
|
||||
/// Sort date for Photos timeline — COALESCE(EXIF captured_at, created_at).
|
||||
/// Only populated by the /api/photos endpoint.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -99,22 +93,31 @@ impl From<File> for FileDto {
|
||||
// already-extracted parts. `content_hash` is just the raw
|
||||
// blob hash; `etag` is the cache token derived from it.
|
||||
let etag = file.etag();
|
||||
let content_hash = file.content_hash().to_string();
|
||||
|
||||
// Consume the entity by moving all fields — zero heap allocations
|
||||
// for id, name, path, folder_id, owner_id (previously 5× .to_string()).
|
||||
// for id, name, path, folder_id (previously 4× .to_string()), and now
|
||||
// for `content_hash` too: `into_parts()` moves `blob_hash` out, so it is
|
||||
// reused verbatim below instead of cloning it through the
|
||||
// `content_hash()` getter. The moved `parts.blob_hash` was previously
|
||||
// dropped unused while the getter clone paid 1 alloc/row on every file
|
||||
// listing (folder browse, streaming PROPFIND, search/favorites/recent
|
||||
// hydration). `etag` is still computed first from the live entity.
|
||||
let parts = file.into_parts();
|
||||
|
||||
let icon_class = Arc::from(icon_class_for(&parts.name, &parts.mime_type));
|
||||
let icon_special_class = Arc::from(icon_special_class_for(&parts.name, &parts.mime_type));
|
||||
let category = Arc::from(category_for(&parts.name, &parts.mime_type));
|
||||
// Display fields come from closed static tables and MIME values
|
||||
// repeat massively across rows — intern instead of allocating a
|
||||
// fresh Arc<str> per row (`Arc::from(&str)` always allocs+copies).
|
||||
let classes = classify_display(&parts.name, &parts.mime_type);
|
||||
let icon_class = intern_display(classes.icon_class);
|
||||
let icon_special_class = intern_display(classes.icon_special_class);
|
||||
let category = intern_display(classes.category);
|
||||
let size_formatted = format_file_size(parts.size);
|
||||
let mime_type = Arc::from(parts.mime_type.as_str());
|
||||
let mime_type = intern_mime(&parts.mime_type);
|
||||
|
||||
Self {
|
||||
id: parts.id,
|
||||
name: parts.name,
|
||||
path: parts.path_string,
|
||||
path: parts.storage_path.into_joined(),
|
||||
size: parts.size,
|
||||
mime_type,
|
||||
folder_id: parts.folder_id,
|
||||
@@ -124,9 +127,8 @@ impl From<File> for FileDto {
|
||||
icon_special_class,
|
||||
category,
|
||||
size_formatted,
|
||||
owner_id: parts.owner_id.map(|u| u.to_string()),
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
content_hash: parts.blob_hash,
|
||||
etag,
|
||||
created_by: parts.created_by,
|
||||
updated_by: parts.updated_by,
|
||||
@@ -157,9 +159,8 @@ impl FileDto {
|
||||
///
|
||||
/// Used when a file is returned to a share recipient: `path` reveals the
|
||||
/// full folder hierarchy above the file which the recipient may not have
|
||||
/// access to. `folder_id` and `owner_id` are intentionally kept — the
|
||||
/// former is needed for sub-folder navigation (covered by the cascade
|
||||
/// grant), and the latter is harmless metadata.
|
||||
/// access to. `folder_id` is intentionally kept — it's needed for
|
||||
/// sub-folder navigation (covered by the cascade grant).
|
||||
#[must_use]
|
||||
pub fn without_hierarchy_info(self) -> Self {
|
||||
Self {
|
||||
@@ -175,15 +176,14 @@ impl FileDto {
|
||||
name: "stub-file".to_string(),
|
||||
path: "/stub/path".to_string(),
|
||||
size: 0,
|
||||
mime_type: Arc::from("application/octet-stream"),
|
||||
mime_type: intern_mime("application/octet-stream"),
|
||||
folder_id: None,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
icon_class: Arc::from("fas fa-file"),
|
||||
icon_special_class: Arc::from(""),
|
||||
category: Arc::from("Document"),
|
||||
icon_class: intern_display("fas fa-file"),
|
||||
icon_special_class: intern_display(""),
|
||||
category: intern_display("Document"),
|
||||
size_formatted: "0 Bytes".to_string(),
|
||||
owner_id: None,
|
||||
content_hash: String::new(),
|
||||
etag: String::new(),
|
||||
sort_date: None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||
use crate::application::dtos::display_helpers::intern_display;
|
||||
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
@@ -48,10 +49,6 @@ pub struct FolderDto {
|
||||
/// Parent folder ID
|
||||
pub parent_id: Option<String>,
|
||||
|
||||
/// Owner user ID (scopes visibility per user)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_id: Option<String>,
|
||||
|
||||
/// Drive that owns this folder. The scope axis for path-based
|
||||
/// lookups across REST / WebDAV / NextCloud / CalDAV / CardDAV.
|
||||
/// Post-D0 `storage.folders.drive_id` is `NOT NULL`; stub /
|
||||
@@ -103,25 +100,33 @@ pub struct FolderDto {
|
||||
|
||||
impl From<Folder> for FolderDto {
|
||||
fn from(folder: Folder) -> Self {
|
||||
let is_root = folder.parent_id().is_none();
|
||||
let etag = folder.etag().to_string();
|
||||
// Consume the entity by moving all fields — zero heap allocations
|
||||
// for id, name, path, parent_id (previously 3-4× .to_string()).
|
||||
let parts = folder.into_parts();
|
||||
|
||||
let is_root = parts.parent_id.is_none();
|
||||
// Single-allocation ETag straight from the owned parts. The old
|
||||
// shape (`folder.etag().to_string()`) built the String and then
|
||||
// cloned it — a pure double-alloc.
|
||||
let etag = Folder::compute_etag(&parts.id, parts.tree_modified_at);
|
||||
|
||||
Self {
|
||||
id: folder.id().to_string(),
|
||||
name: folder.name().to_string(),
|
||||
path: folder.path_string().to_string(),
|
||||
parent_id: folder.parent_id().map(String::from),
|
||||
owner_id: folder.owner_id().map(|u| u.to_string()),
|
||||
drive_id: folder.drive_id(),
|
||||
created_at: folder.created_at(),
|
||||
modified_at: folder.modified_at(),
|
||||
id: parts.id,
|
||||
name: parts.name,
|
||||
path: parts.storage_path.into_joined(),
|
||||
parent_id: parts.parent_id,
|
||||
drive_id: parts.drive_id,
|
||||
created_at: parts.created_at,
|
||||
modified_at: parts.modified_at,
|
||||
is_root,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
// Constant display fields: refcount bump on interned statics
|
||||
// instead of 3 fresh Arc allocations per row.
|
||||
icon_class: intern_display("fas fa-folder"),
|
||||
icon_special_class: intern_display("folder-icon"),
|
||||
category: intern_display("Folder"),
|
||||
etag,
|
||||
created_by: folder.created_by(),
|
||||
updated_by: folder.updated_by(),
|
||||
created_by: parts.created_by,
|
||||
updated_by: parts.updated_by,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,9 +152,8 @@ impl FolderDto {
|
||||
///
|
||||
/// Used when a folder is returned to a share recipient: `path` reveals the
|
||||
/// full folder hierarchy above the shared folder which the recipient may
|
||||
/// not have access to. `parent_id` and `owner_id` are intentionally kept
|
||||
/// — the former is needed for sub-folder navigation (covered by the
|
||||
/// cascade grant), and the latter is harmless metadata.
|
||||
/// not have access to. `parent_id` is intentionally kept — it's needed
|
||||
/// for sub-folder navigation (covered by the cascade grant).
|
||||
#[must_use]
|
||||
pub fn without_hierarchy_info(self) -> Self {
|
||||
Self {
|
||||
@@ -165,14 +169,13 @@ impl FolderDto {
|
||||
name: "stub-folder".to_string(),
|
||||
path: "/stub/path".to_string(),
|
||||
parent_id: None,
|
||||
owner_id: None,
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
is_root: true,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
icon_class: intern_display("fas fa-folder"),
|
||||
icon_special_class: intern_display("folder-icon"),
|
||||
category: intern_display("Folder"),
|
||||
etag: String::new(),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
@@ -205,12 +208,24 @@ pub struct FolderResourceRow {
|
||||
pub size: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
pub owner_id: Uuid,
|
||||
/// Drive that owns this row. Same column as
|
||||
/// `storage.folders.drive_id` / `storage.files.drive_id`. Surfaced
|
||||
/// on the listing so a UI can tell when a child lives in a
|
||||
/// different drive than its parent (post-D6 cross-drive moves +
|
||||
/// copies make this reachable).
|
||||
pub drive_id: Uuid,
|
||||
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
|
||||
/// folder rows. Populates `FileDto::content_hash` + `FileDto::etag`
|
||||
/// on the REST `/api/folders/{id}/resources` listing so API
|
||||
/// consumers can issue conditional requests against listed files.
|
||||
pub blob_hash: Option<String>,
|
||||
/// §14 provenance — who created the row. `None` when the creator was
|
||||
/// deleted (FK `ON DELETE SET NULL`). Populates
|
||||
/// `FileDto::created_by` / `FolderDto::created_by` on the listing so
|
||||
/// the UI can render the owner column without a follow-up query.
|
||||
pub created_by: Option<Uuid>,
|
||||
/// §14 provenance — who last touched the row.
|
||||
pub updated_by: Option<Uuid>,
|
||||
// Pre-computed sort fields — returned by the SQL for cursor construction.
|
||||
/// `LOWER(name)` used by `name`/`type` sorts.
|
||||
pub sort_str: String,
|
||||
|
||||
@@ -55,11 +55,14 @@ impl From<Subject> for SubjectDto {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResourceTypeDto {
|
||||
Folder,
|
||||
File,
|
||||
Drive,
|
||||
Calendar,
|
||||
AddressBook,
|
||||
Playlist,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
@@ -75,6 +78,9 @@ impl From<ResourceDto> for Resource {
|
||||
ResourceTypeDto::Folder => Resource::Folder(dto.id),
|
||||
ResourceTypeDto::File => Resource::File(dto.id),
|
||||
ResourceTypeDto::Drive => Resource::Drive(dto.id),
|
||||
ResourceTypeDto::Calendar => Resource::Calendar(dto.id),
|
||||
ResourceTypeDto::AddressBook => Resource::AddressBook(dto.id),
|
||||
ResourceTypeDto::Playlist => Resource::Playlist(dto.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +91,9 @@ impl From<Resource> for ResourceDto {
|
||||
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
|
||||
Resource::File(id) => (ResourceTypeDto::File, id),
|
||||
Resource::Drive(id) => (ResourceTypeDto::Drive, id),
|
||||
Resource::Calendar(id) => (ResourceTypeDto::Calendar, id),
|
||||
Resource::AddressBook(id) => (ResourceTypeDto::AddressBook, id),
|
||||
Resource::Playlist(id) => (ResourceTypeDto::Playlist, id),
|
||||
};
|
||||
ResourceDto { kind, id }
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||
use super::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
use super::display_helpers::{classify_display, format_file_size};
|
||||
use super::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
|
||||
@@ -80,9 +78,10 @@ impl RecentItemDto {
|
||||
.item_mime_type
|
||||
.as_deref()
|
||||
.unwrap_or("application/octet-stream");
|
||||
self.icon_class = icon_class_for(name, mime).to_string();
|
||||
self.icon_special_class = icon_special_class_for(name, mime).to_string();
|
||||
self.category = category_for(name, mime).to_string();
|
||||
let classes = classify_display(name, mime);
|
||||
self.icon_class = classes.icon_class.to_string();
|
||||
self.icon_special_class = classes.icon_special_class.to_string();
|
||||
self.category = classes.category.to_string();
|
||||
self.size_formatted = format_file_size(self.item_size.unwrap_or(0) as u64);
|
||||
}
|
||||
self
|
||||
@@ -104,11 +103,25 @@ pub struct RecentResourceRow {
|
||||
pub size: i64,
|
||||
pub resource_created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
pub owner_id: Uuid,
|
||||
/// Drive that owns this row. Surfaced on the recent listing
|
||||
/// so a UI can tell when a recently-accessed item lives in a
|
||||
/// different drive than the user's home (post-D6 cross-drive
|
||||
/// moves + copies make this reachable).
|
||||
pub drive_id: Uuid,
|
||||
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
|
||||
/// folder rows. Feeds `File::compute_etag` so this listing's
|
||||
/// `etag` matches GET/HEAD/PROPFIND for the same file.
|
||||
pub blob_hash: Option<String>,
|
||||
/// §14 provenance — who created the row. `None` when the creator
|
||||
/// was deleted (FK `ON DELETE SET NULL`). Powers the owner column
|
||||
/// on the `/recent` UI (aligned with `/files` and `/favorites`
|
||||
/// for cross-surface consistency, rather than the finer-grained
|
||||
/// but noisier "who touched this last" signal).
|
||||
pub created_by: Option<Uuid>,
|
||||
/// §14 provenance — who last touched the row. Not currently
|
||||
/// consumed by the UI but surfaced for API parity with the other
|
||||
/// listing endpoints.
|
||||
pub updated_by: Option<Uuid>,
|
||||
/// `true` when `owner_id == requesting user_id`.
|
||||
pub is_owner: bool,
|
||||
pub accessed_at: DateTime<Utc>,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/**
|
||||
@@ -109,8 +110,10 @@ pub struct SearchFileResultDto {
|
||||
pub path: String,
|
||||
/// Size in bytes
|
||||
pub size: u64,
|
||||
/// MIME type
|
||||
pub mime_type: String,
|
||||
/// MIME type — `Arc<str>` so enrichment reuses `FileDto`'s interned
|
||||
/// value (an atomic increment) instead of allocating per result row.
|
||||
#[schema(value_type = String)]
|
||||
pub mime_type: Arc<str>,
|
||||
/// Parent folder ID
|
||||
pub folder_id: Option<String>,
|
||||
/// Creation timestamp
|
||||
@@ -122,11 +125,14 @@ pub struct SearchFileResultDto {
|
||||
/// Human-readable file size (e.g., "2.5 MB")
|
||||
pub size_formatted: String,
|
||||
/// CSS icon class for the file type (e.g., "fas fa-file-pdf")
|
||||
pub icon_class: String,
|
||||
#[schema(value_type = String)]
|
||||
pub icon_class: Arc<str>,
|
||||
/// Extra CSS class for icon styling (e.g., "pdf-icon", "code-icon js-icon")
|
||||
pub icon_special_class: String,
|
||||
#[schema(value_type = String)]
|
||||
pub icon_special_class: Arc<str>,
|
||||
/// Content category: "document", "image", "video", "audio", "archive", "code", "other"
|
||||
pub category: String,
|
||||
#[schema(value_type = String)]
|
||||
pub category: Arc<str>,
|
||||
/// Raw BLAKE3 content hash. Feeds `FileDto::content_hash` and
|
||||
/// `File::compute_etag` when search results are converted to
|
||||
/// `FileDto` (NC REPORT/SEARCH response). Defaults to `String::new()`
|
||||
@@ -155,6 +161,10 @@ pub struct SearchFolderResultDto {
|
||||
pub path: String,
|
||||
/// Parent folder ID
|
||||
pub parent_id: Option<String>,
|
||||
/// Drive that owns this folder. Same column as `storage.folders.drive_id`,
|
||||
/// carried through so downstream callers (e.g. the NC search REPORT
|
||||
/// handler) can populate `FolderDto::drive_id` without a fallback sentinel.
|
||||
pub drive_id: uuid::Uuid,
|
||||
/// Creation timestamp
|
||||
pub created_at: u64,
|
||||
/// Last modification timestamp
|
||||
@@ -263,9 +273,11 @@ pub struct SearchSuggestionItem {
|
||||
/// Path for context
|
||||
pub path: String,
|
||||
/// CSS icon class
|
||||
pub icon_class: String,
|
||||
#[schema(value_type = String)]
|
||||
pub icon_class: Arc<str>,
|
||||
/// Extra CSS class for icon styling
|
||||
pub icon_special_class: String,
|
||||
#[schema(value_type = String)]
|
||||
pub icon_special_class: Arc<str>,
|
||||
/// Relevance score
|
||||
pub relevance_score: u32,
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ pub struct TrashResourceRow {
|
||||
pub size: i64,
|
||||
pub resource_created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
pub owner_id: Uuid,
|
||||
/// Drive the trashed item belongs to. Surfaced verbatim on the wire
|
||||
/// (`TrashResourceItemDto.drive_id`) so the `/trash` UI can group by
|
||||
/// drive without an extra lookup per row. D2b: filtering by drive is
|
||||
@@ -72,6 +71,12 @@ pub struct TrashResourceRow {
|
||||
/// same file (restorable trash items are conditional-request
|
||||
/// targets too).
|
||||
pub blob_hash: Option<String>,
|
||||
/// §14 provenance — who created the row. `None` when the creator
|
||||
/// was deleted (FK `ON DELETE SET NULL`).
|
||||
pub created_by: Option<Uuid>,
|
||||
/// §14 provenance — who last touched the row (includes the trash
|
||||
/// action itself, which stamps `updated_by = caller_id`).
|
||||
pub updated_by: Option<Uuid>,
|
||||
pub trashed_at: DateTime<Utc>,
|
||||
pub deletion_date: DateTime<Utc>,
|
||||
/// Original location path (for folders: `path`; for files: `parent.path || '/' || name`).
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::domain::entities::user::User;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smol_str::SmolStr;
|
||||
use std::sync::Arc;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -61,30 +63,49 @@ pub struct UserDto {
|
||||
/// could never claim the share. Round-trips through `/api/auth/me`
|
||||
/// and `PATCH /api/auth/me/profile`.
|
||||
pub notify_on_share: bool,
|
||||
/// Opaque UI preferences bag. Cross-device store for pure UI
|
||||
/// toggles (hide dotfiles, view mode, sidebar collapse, …). The
|
||||
/// server never inspects the contents — this DTO field just echoes
|
||||
/// what was PATCHed via `PATCH /api/auth/me/profile`. Shape is a
|
||||
/// JSON object; the frontend defines the keys it cares about (see
|
||||
/// `frontend/src/lib/stores/preferences.svelte.ts`). Always present
|
||||
/// on the wire; empty bag is `{}`, never `null`.
|
||||
pub ui_preferences: serde_json::Value,
|
||||
}
|
||||
|
||||
impl From<User> for UserDto {
|
||||
fn from(user: User) -> Self {
|
||||
// `user` is owned and dropped here, so every owned field is MOVED out
|
||||
// via `into_parts` rather than cloned through the borrowing accessors —
|
||||
// the accessor form deep-cloned `image` (a data URI up to 512 KiB) and
|
||||
// the whole `ui_preferences` JSON tree on every `/api/auth/me` and admin
|
||||
// user listing (benches/ROUND20.md §A2). The two derived values read the
|
||||
// entity before the move.
|
||||
let role = format!("{}", user.role());
|
||||
let can_edit_image = !user.is_oidc_user();
|
||||
let p = user.into_parts();
|
||||
Self {
|
||||
id: user.id().to_string(),
|
||||
username: user.username().map(str::to_string),
|
||||
email: user.email().to_string(),
|
||||
role: format!("{}", user.role()),
|
||||
storage_quota_bytes: user.storage_quota_bytes(),
|
||||
storage_used_bytes: user.storage_used_bytes(),
|
||||
created_at: user.created_at(),
|
||||
updated_at: user.updated_at(),
|
||||
last_login_at: user.last_login_at(),
|
||||
active: user.is_active(),
|
||||
auth_provider: user.oidc_provider().unwrap_or("local").to_string(),
|
||||
image: user.image().map(|s| s.to_string()),
|
||||
can_edit_image: !user.is_oidc_user(),
|
||||
is_external: user.is_external(),
|
||||
given_name: user.given_name().map(str::to_string),
|
||||
family_name: user.family_name().map(str::to_string),
|
||||
email_verified_at: user.email_verified_at(),
|
||||
preferred_locale: user.preferred_locale().map(str::to_string),
|
||||
notify_on_share: user.notify_on_share(),
|
||||
id: p.id.to_string(),
|
||||
username: p.username,
|
||||
email: p.email,
|
||||
role,
|
||||
storage_quota_bytes: p.storage_quota_bytes,
|
||||
storage_used_bytes: p.storage_used_bytes,
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
last_login_at: p.last_login_at,
|
||||
active: p.active,
|
||||
// Some(provider) moves the String; None still allocates "local".
|
||||
auth_provider: p.oidc_provider.unwrap_or_else(|| "local".to_string()),
|
||||
image: p.image,
|
||||
can_edit_image,
|
||||
is_external: p.is_external,
|
||||
given_name: p.given_name,
|
||||
family_name: p.family_name,
|
||||
email_verified_at: p.email_verified_at,
|
||||
preferred_locale: p.preferred_locale,
|
||||
notify_on_share: p.notify_on_share,
|
||||
ui_preferences: p.ui_preferences,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +206,19 @@ pub struct UpdateProfileDto {
|
||||
/// always send.
|
||||
#[serde(default)]
|
||||
pub notify_on_share: Option<bool>,
|
||||
/// Partial patch into the opaque UI preferences bag. **Must be a
|
||||
/// JSON object.** Applied via a SHALLOW merge on the server:
|
||||
/// keys present here overwrite existing top-level keys; keys not
|
||||
/// present survive. A key value of `null` REMOVES that key from
|
||||
/// the bag (implemented via `jsonb_strip_nulls` after the merge).
|
||||
///
|
||||
/// Example: current bag `{"a":1,"b":2}`, patch `{"b":3,"c":4}`
|
||||
/// → merged `{"a":1,"b":3,"c":4}`. Patch `{"a":null}` → `{"b":2}`.
|
||||
///
|
||||
/// Absent → no change to the bag. This is a UI-only surface;
|
||||
/// server never inspects the keys.
|
||||
#[serde(default)]
|
||||
pub ui_preferences: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
@@ -207,13 +241,39 @@ pub struct RefreshTokenDto {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// Body for `POST /api/auth/upgrade-to-internal`. Converts an
|
||||
/// authenticated external user into an internal user with their own
|
||||
/// personal drive.
|
||||
///
|
||||
/// `password` is optional — semantics decided per deployment:
|
||||
/// * If `magic_link` is in `OXICLOUD_AUTH_METHODS` (and OIDC isn't
|
||||
/// enabled) → password can be omitted; user remains magic-link-only
|
||||
/// for login after upgrade.
|
||||
/// * Otherwise → password is required; refusal returns 400
|
||||
/// `error_type = "PasswordRequired"`. Without it the upgraded user
|
||||
/// would have no login path.
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpgradeToInternalDto {
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
/// Authenticated current user data (for use in application services)
|
||||
///
|
||||
/// Built once per authenticated request in the auth middlewares.
|
||||
/// `username`/`email` are `Arc<str>` (refcount-bump clones from the cached
|
||||
/// `TokenClaims` / Basic-auth cache — JSON shape unchanged) and `role` is an
|
||||
/// inline `SmolStr` ("admin"/"user" fit the 23-byte inline buffer, so the
|
||||
/// per-request live-role render allocates nothing).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CurrentUser {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
#[schema(value_type = String)]
|
||||
pub username: Arc<str>,
|
||||
#[schema(value_type = String)]
|
||||
pub email: Arc<str>,
|
||||
#[schema(value_type = String)]
|
||||
pub role: SmolStr,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -264,13 +324,26 @@ pub struct OidcExchangeDto {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// Information about available OIDC providers
|
||||
/// Information about available OIDC providers + self-service auth
|
||||
/// methods enabled on the deployment. Consumed by the login page to
|
||||
/// decide which forms/buttons to render.
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct OidcProviderInfoDto {
|
||||
pub enabled: bool,
|
||||
pub provider_name: String,
|
||||
pub authorize_endpoint: String,
|
||||
pub password_login_enabled: bool,
|
||||
/// True iff the server accepts magic-link login requests
|
||||
/// (`OXICLOUD_AUTH_METHODS` includes `magic_link` AND SMTP is
|
||||
/// configured). Frontend renders the magic-link form when true.
|
||||
#[serde(default)]
|
||||
pub magic_link_login_enabled: bool,
|
||||
/// True iff `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set. Frontend uses
|
||||
/// this hint to explain the `EmailNotVerified` login response and
|
||||
/// to nudge new users toward the magic-link verification path
|
||||
/// straight after signup.
|
||||
#[serde(default)]
|
||||
pub require_verified_email: bool,
|
||||
}
|
||||
|
||||
/// Claims extracted from the validated OIDC ID token
|
||||
|
||||
@@ -26,10 +26,24 @@ pub trait PasswordHasherPort: Send + Sync + 'static {
|
||||
}
|
||||
|
||||
/// Claims contained in a JWT token
|
||||
///
|
||||
/// `username` / `email` are `Arc<str>` so the per-request `CurrentUser`
|
||||
/// build clones them with a refcount bump instead of copying the strings —
|
||||
/// the validation cache already hands the whole struct out behind an `Arc`,
|
||||
/// but the two display fields still had to be deep-cloned out of it on
|
||||
/// EVERY authenticated request (the "2 allocs/request" item deferred since
|
||||
/// ROUND6).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokenClaims {
|
||||
/// Subject identifier (user ID)
|
||||
pub sub: String,
|
||||
/// `sub` pre-parsed to a `Uuid` at decode time so the auth middleware
|
||||
/// reads it as a `Copy` on every request instead of re-parsing the
|
||||
/// 36-char string per request — even on validation-cache hits, which
|
||||
/// return the same `Arc<TokenClaims>` (benches/ROUND14.md §A3). Nil only
|
||||
/// if a verified token somehow carried a non-UUID `sub` (unreachable for
|
||||
/// tokens we sign); the middleware rejects nil defensively.
|
||||
pub sub_id: Uuid,
|
||||
/// Expiration timestamp (seconds since Unix epoch)
|
||||
pub exp: i64,
|
||||
/// Issued at timestamp (seconds since Unix epoch)
|
||||
@@ -37,9 +51,9 @@ pub struct TokenClaims {
|
||||
/// JWT unique ID
|
||||
pub jti: String,
|
||||
/// Username
|
||||
pub username: String,
|
||||
pub username: Arc<str>,
|
||||
/// User email
|
||||
pub email: String,
|
||||
pub email: Arc<str>,
|
||||
/// User role
|
||||
pub role: String,
|
||||
}
|
||||
@@ -124,9 +138,46 @@ pub trait UserStoragePort: Send + Sync + 'static {
|
||||
include_external: bool,
|
||||
) -> Result<Vec<User>, DomainError>;
|
||||
|
||||
/// Username-only projection of [`search_users`] — same WHERE / ORDER /
|
||||
/// LIMIT semantics, but skips hydrating the 21-column row (incl. the
|
||||
/// up-to-512 KiB avatar `image`) when the caller only needs handles.
|
||||
/// Rows whose username is NULL are returned as `None` so callers can
|
||||
/// keep the wide flow's post-limit filtering semantics.
|
||||
async fn search_usernames(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: i64,
|
||||
include_external: bool,
|
||||
) -> Result<Vec<Option<String>>, DomainError>;
|
||||
|
||||
/// Stamps `email_verified_at = NOW()` iff it is still NULL (idempotent,
|
||||
/// preserves the first timestamp — the SQL twin of
|
||||
/// `User::mark_email_verified`). Narrow single-column write; avoids the
|
||||
/// full-row [`update_user`] (incl. the avatar `image`) on the
|
||||
/// magic-link redemption path.
|
||||
async fn mark_email_verified(&self, user_id: Uuid) -> Result<(), DomainError>;
|
||||
|
||||
/// OIDC repeat-login profile sync: persists the IdP-provided avatar and
|
||||
/// stamps `email_verified_at` (guarded, idempotent) in ONE narrow
|
||||
/// statement. The `IS DISTINCT FROM` guard makes the common case (same
|
||||
/// avatar, already verified) a zero-write no-op — vs the full 17-column
|
||||
/// row rewrite this path used to pay per login. `last_login_at` is NOT
|
||||
/// touched here: session creation stamps it, as on every login path.
|
||||
async fn sync_oidc_login_profile(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
image: Option<&str>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Lists users by role (e.g., "admin" or "user")
|
||||
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError>;
|
||||
|
||||
/// Counts users with a given role WITHOUT hydrating their rows — a scalar
|
||||
/// `COUNT(*)` instead of fetching every full user row (incl. the up-to-512
|
||||
/// KiB avatar `image` and the `ui_preferences` JSONB) only to `.len()` them
|
||||
/// (benches/ROUND29.md §G).
|
||||
async fn count_users_by_role(&self, role: &str) -> Result<i64, DomainError>;
|
||||
|
||||
/// Deletes a user by their ID
|
||||
async fn delete_user(&self, user_id: Uuid) -> Result<(), DomainError>;
|
||||
|
||||
@@ -232,6 +283,16 @@ pub trait SessionStoragePort: Send + Sync + 'static {
|
||||
/// Creates a new session
|
||||
async fn create_session(&self, session: Session) -> Result<Session, DomainError>;
|
||||
|
||||
/// Refresh-token rotation: revokes `old_session_id` and creates
|
||||
/// `new_session` in ONE transaction (the refresh path used to pay two
|
||||
/// full BEGIN/COMMIT round-trip pairs per rotation). Also stamps the
|
||||
/// user's `last_login_at` exactly like [`create_session`] does.
|
||||
async fn rotate_session(
|
||||
&self,
|
||||
old_session_id: Uuid,
|
||||
new_session: Session,
|
||||
) -> Result<Session, DomainError>;
|
||||
|
||||
/// Gets a session by refresh token
|
||||
async fn get_session_by_refresh_token(
|
||||
&self,
|
||||
|
||||
@@ -16,6 +16,28 @@ use crate::domain::services::authorization::{
|
||||
ResourceKind, Role, Subject,
|
||||
};
|
||||
|
||||
/// Discriminates the two denial shapes surfaced by
|
||||
/// [`AuthorizationEngine::require_visible`] in the `authz.denied` audit line.
|
||||
/// Log-aggregation consumers key off the string form via `as_str`; keep the
|
||||
/// values stable — a new denial shape means a new variant, never a renamed
|
||||
/// existing one.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum AuthzDenialVisibility {
|
||||
/// Caller has `Read` on the resource — 403 Forbidden.
|
||||
Visible,
|
||||
/// Caller has no `Read` — 404 anti-enum.
|
||||
Hidden,
|
||||
}
|
||||
|
||||
impl AuthzDenialVisibility {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Visible => "visible",
|
||||
Self::Hidden => "hidden",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
/// Returns true if `subject` has `permission` on `resource`, considering
|
||||
/// owner short-circuit AND cascading from folder ancestors.
|
||||
@@ -29,9 +51,52 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
resource: Resource,
|
||||
) -> Result<bool, DomainError>;
|
||||
|
||||
/// Convenience wrapper around `check`: returns `Ok(())` when allowed and
|
||||
/// `DomainError::not_found` when denied (anti-enumeration — same error as
|
||||
/// "resource doesn't exist" so attackers can't probe IDs by error shape).
|
||||
/// Batched `check(subject, Read, File(id))` over a result page: returns
|
||||
/// the subset of `file_ids` the subject may read. Semantically identical
|
||||
/// to looping [`Self::check`] (the default does exactly that); the
|
||||
/// `PgAclEngine` override resolves every file's drive in ONE query and
|
||||
/// reuses the per-drive role cache, so verifying a 200-hit search page
|
||||
/// costs 1 SQL round-trip instead of up to 200 sequential ones
|
||||
/// (benches/SEARCH-REBAC.md).
|
||||
async fn check_files_read_batch(
|
||||
&self,
|
||||
subject: Subject,
|
||||
file_ids: &[Uuid],
|
||||
) -> Result<std::collections::HashSet<Uuid>, DomainError> {
|
||||
let mut allowed = std::collections::HashSet::with_capacity(file_ids.len());
|
||||
for id in file_ids {
|
||||
if self
|
||||
.check(subject, Permission::Read, Resource::File(*id))
|
||||
.await?
|
||||
{
|
||||
allowed.insert(*id);
|
||||
}
|
||||
}
|
||||
Ok(allowed)
|
||||
}
|
||||
|
||||
/// Graduated-denial wrapper around `check`. Semantics:
|
||||
///
|
||||
/// - `permission` granted → `Ok(())`
|
||||
/// - `permission` denied, `Read` also denied → `DomainError::not_found`
|
||||
/// (404, anti-enumeration — same shape as "doesn't exist" so a probing
|
||||
/// caller can't distinguish "wrong id" from "no access")
|
||||
/// - `permission` denied, `Read` granted → `DomainError::access_denied`
|
||||
/// (403 — the caller can already see the resource, so hiding existence
|
||||
/// leaks nothing new; a clear 403 beats a confusing 404 for UX and for
|
||||
/// API-first clients like rclone)
|
||||
///
|
||||
/// Special case: when `permission == Read`, the visibility gate collapses
|
||||
/// onto itself — a `Read` denial IS a "hidden" outcome by definition, so
|
||||
/// the method short-circuits to the strict anti-enum 404 without a second
|
||||
/// DB round-trip. That's why there's only one method: strict Read-denial
|
||||
/// and graduated write-denial fall out of the same signature.
|
||||
///
|
||||
/// Do NOT use this in search / enumeration paths where existence itself is
|
||||
/// the attack vector — those must filter at the SQL/index layer, never
|
||||
/// touch this method with per-row ids. Cross-tenant probes on ids the
|
||||
/// caller has no prior read handle for degrade to the 404 shape naturally
|
||||
/// (Read denied → `Hidden`).
|
||||
async fn require(
|
||||
&self,
|
||||
subject: Subject,
|
||||
@@ -56,36 +121,68 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
permission,
|
||||
resource
|
||||
);
|
||||
Ok(())
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Visibility probe. Short-circuit: when the target permission IS
|
||||
// `Read` and the check above returned false, we already know Read is
|
||||
// denied — visibility is `Hidden` by definition, no second DB hop.
|
||||
// Otherwise probe Read; a DB-hop failure here degrades to `Hidden` so
|
||||
// the caller sees the strict anti-enum shape (safe default).
|
||||
let visibility = if permission == Permission::Read {
|
||||
AuthzDenialVisibility::Hidden
|
||||
} else if self
|
||||
.check(subject, Permission::Read, resource)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
AuthzDenialVisibility::Visible
|
||||
} else {
|
||||
let (kind, id) = match resource {
|
||||
Resource::Folder(id) => ("Folder", id),
|
||||
Resource::File(id) => ("File", id),
|
||||
Resource::Drive(id) => ("Drive", id),
|
||||
};
|
||||
// Audit-worthy: denials are the interesting signal. Routed
|
||||
// through the `audit` tracing target so log aggregators can
|
||||
// surface them separately from operational debug traffic.
|
||||
// Span context (request_id, client_ip, user_id) is attached
|
||||
// automatically by the request-scope span set in
|
||||
// `interfaces/middleware/trace_span.rs`, so this log line
|
||||
// doesn't need to duplicate those fields — they appear in
|
||||
// the structured output of every log written inside the
|
||||
// request span.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "authz.denied",
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
permission = permission.as_str(),
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
"👮🏻♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}'",
|
||||
subject,
|
||||
permission,
|
||||
resource
|
||||
);
|
||||
Err(DomainError::not_found(kind, id.to_string()))
|
||||
AuthzDenialVisibility::Hidden
|
||||
};
|
||||
|
||||
let (kind, id) = match resource {
|
||||
Resource::Folder(id) => ("Folder", id),
|
||||
Resource::File(id) => ("File", id),
|
||||
Resource::Drive(id) => ("Drive", id),
|
||||
Resource::Calendar(id) => ("Calendar", id),
|
||||
Resource::AddressBook(id) => ("AddressBook", id),
|
||||
Resource::Playlist(id) => ("Playlist", id),
|
||||
};
|
||||
|
||||
// Audit-worthy: denials are the interesting signal. Routed through
|
||||
// the `audit` tracing target so log aggregators can surface them
|
||||
// separately from operational debug traffic. Span context
|
||||
// (request_id, client_ip, user_id) comes from the request-scope
|
||||
// span set in `interfaces/middleware/trace_span.rs`, so this line
|
||||
// doesn't need to duplicate those fields.
|
||||
//
|
||||
// The `visibility` field discriminates the two denial shapes for
|
||||
// operators grepping exists-but-denied vs fully-hidden. `visible`
|
||||
// denials are the ones surfaced to the caller as 403 (and safe to
|
||||
// detail in the UI); `hidden` denials are the 404 anti-enum path.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "authz.denied",
|
||||
visibility = visibility.as_str(),
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
permission = permission.as_str(),
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
"👮🏻♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}' (visibility={})",
|
||||
subject,
|
||||
permission,
|
||||
resource,
|
||||
visibility.as_str()
|
||||
);
|
||||
|
||||
match visibility {
|
||||
AuthzDenialVisibility::Visible => Err(DomainError::access_denied(
|
||||
kind,
|
||||
format!("Missing '{}' permission on {} {}", permission, kind, id),
|
||||
)),
|
||||
AuthzDenialVisibility::Hidden => Err(DomainError::not_found(kind, id.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +244,26 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Delete every row from `storage.role_grants` whose `expires_at` is
|
||||
/// more than `grace_days` in the past. Returns the count of rows
|
||||
/// removed.
|
||||
///
|
||||
/// The engine's `check` / `list_grants_*` paths already ignore
|
||||
/// expired rows (they filter on `expires_at > NOW()` in-query), so
|
||||
/// this is pure garbage collection — no live authorization decision
|
||||
/// changes. The grace window preserves the audit / support answer
|
||||
/// to "what happened to my access?" for a couple of weeks past
|
||||
/// expiration.
|
||||
///
|
||||
/// Grace of `0` means "delete every row whose `expires_at` is in
|
||||
/// the past, right now" — used by the admin `?force=true` trigger
|
||||
/// endpoint to enable Hurl regression testing without waiting the
|
||||
/// configured grace out.
|
||||
///
|
||||
/// Rows with `expires_at IS NULL` (permanent grants) are never
|
||||
/// touched.
|
||||
async fn purge_expired_grants(&self, grace_days: u32) -> Result<u64, DomainError>;
|
||||
|
||||
/// Revoke a single role grant by its UUID. Idempotent — returns `Ok(())`
|
||||
/// whether or not the row existed. The id comes from a prior listing
|
||||
/// or `find_grant_full_by_id` lookup.
|
||||
|
||||
@@ -6,6 +6,19 @@ use crate::common::errors::DomainError;
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Result of a multi-VEVENT PUT (`upsert_ical_events`). See #528.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpsertEventsResult {
|
||||
/// Every event that was persisted for this PUT. Ordered as they
|
||||
/// appeared in the body — the master (if present) is typically
|
||||
/// first, followed by exception overrides.
|
||||
pub events: Vec<CalendarEventDto>,
|
||||
/// True if at least one row was newly created; false if every
|
||||
/// event replaced an existing row. Drives the handler's choice
|
||||
/// between 201 Created and 204 No Content.
|
||||
pub any_inserted: bool,
|
||||
}
|
||||
|
||||
/// Port for external calendar storage mechanisms
|
||||
pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
// Calendar operations
|
||||
@@ -21,42 +34,21 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
) -> Result<CalendarDto, DomainError>;
|
||||
async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_calendar(&self, calendar_id: &str) -> Result<CalendarDto, DomainError>;
|
||||
|
||||
/// Batch sibling of [`Self::get_calendar`]: hydrate a page of
|
||||
/// grant-derived calendar ids in ONE storage round-trip. Missing
|
||||
/// rows (deleted/trashed race) drop out silently; ordering is not
|
||||
/// guaranteed.
|
||||
async fn get_calendars_by_ids(&self, ids: &[Uuid]) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_calendars_by_owner(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_calendars_shared_with_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_public_calendars(
|
||||
&self,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn check_calendar_access(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<bool, DomainError>;
|
||||
|
||||
// Calendar sharing
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
access_level: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> Result<Vec<(String, String)>, DomainError>;
|
||||
|
||||
// Calendar properties
|
||||
async fn set_calendar_property(
|
||||
&self,
|
||||
@@ -80,6 +72,23 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
) -> Result<CalendarEventDto, DomainError>;
|
||||
/// Upsert every VEVENT in an iCalendar body — one master and zero
|
||||
/// or more per-instance exception overrides (RFC 5545 §3.8.4.4).
|
||||
///
|
||||
/// Routing: an event whose `RECURRENCE-ID` is unset targets the
|
||||
/// master row `(calendar_id, ical_uid) WHERE recurrence_id IS NULL`;
|
||||
/// an event whose `RECURRENCE-ID` is set targets its own exception
|
||||
/// row `(calendar_id, ical_uid, recurrence_id)` and never touches
|
||||
/// the master. Existing rows are replaced (delete-then-insert to
|
||||
/// stay compatible with the DB-level partial unique indexes and to
|
||||
/// keep the ETag surface identical to the pre-#528 single-event
|
||||
/// path).
|
||||
///
|
||||
/// See AtalayaLabs/OxiCloud#528.
|
||||
async fn upsert_ical_events(
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
) -> Result<UpsertEventsResult, DomainError>;
|
||||
async fn update_event(
|
||||
&self,
|
||||
event_id: &str,
|
||||
@@ -87,6 +96,10 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
) -> Result<CalendarEventDto, DomainError>;
|
||||
async fn delete_event(&self, event_id: &str) -> Result<(), DomainError>;
|
||||
async fn get_event(&self, event_id: &str) -> Result<CalendarEventDto, DomainError>;
|
||||
/// Narrow projection for authz gates: the owning calendar of an event
|
||||
/// without hydrating the full event row (notably `ical_data`, the raw
|
||||
/// iCalendar body, which can run to tens of KB on recurring events).
|
||||
async fn calendar_id_for_event(&self, event_id: &str) -> Result<String, DomainError>;
|
||||
/// Indexed single-row lookup by iCalendar UID — the CalDAV
|
||||
/// object-resource paths must use this instead of listing the whole
|
||||
/// calendar (every row + its `ical_data`) and filtering client-side.
|
||||
@@ -107,6 +120,12 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
/// Cursor stream over the calendar's events in bundle order (see
|
||||
/// the repository doc) — feeds the streaming CalDAV emitters.
|
||||
fn stream_events_uid_order(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> futures::stream::BoxStream<'static, Result<CalendarEventDto, DomainError>>;
|
||||
async fn list_events_by_calendar_paginated(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
@@ -146,33 +165,12 @@ pub trait CalendarUseCase: Send + Sync + 'static {
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarDto, DomainError>;
|
||||
async fn list_my_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_shared_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
async fn list_public_calendars(
|
||||
&self,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<CalendarDto>, DomainError>;
|
||||
|
||||
// Calendar sharing
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
target_user_id: Uuid,
|
||||
access_level: &str,
|
||||
caller_user_id: Uuid,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
target_user_id: Uuid,
|
||||
caller_user_id: Uuid,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<(String, String)>, DomainError>;
|
||||
|
||||
// Event operations
|
||||
async fn create_event(
|
||||
&self,
|
||||
@@ -184,6 +182,15 @@ pub trait CalendarUseCase: Send + Sync + 'static {
|
||||
event: CreateEventICalDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError>;
|
||||
/// Route a PUT'd iCalendar body containing one or more VEVENTs to
|
||||
/// their per-instance rows. See `CalendarStoragePort::upsert_ical_events`
|
||||
/// for the routing rules; this method just adds the `Permission::Create`
|
||||
/// gate for the caller.
|
||||
async fn upsert_ical_events(
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<UpsertEventsResult, DomainError>;
|
||||
async fn update_event(
|
||||
&self,
|
||||
event_id: &str,
|
||||
@@ -221,6 +228,16 @@ pub trait CalendarUseCase: Send + Sync + 'static {
|
||||
offset: Option<i64>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError>;
|
||||
/// Streaming support: cursor over the calendar's events in bundle
|
||||
/// order, behind the same Read authz gate as [`Self::list_events`].
|
||||
async fn stream_events_uid_order(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<
|
||||
futures::stream::BoxStream<'static, Result<CalendarEventDto, DomainError>>,
|
||||
DomainError,
|
||||
>;
|
||||
async fn get_events_in_range(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
|
||||
@@ -1,16 +1,120 @@
|
||||
use crate::application::dtos::address_book_dto::{
|
||||
AddressBookDto, CreateAddressBookDto, ShareAddressBookDto, UnshareAddressBookDto,
|
||||
UpdateAddressBookDto,
|
||||
AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto,
|
||||
};
|
||||
use crate::application::dtos::contact_dto::{
|
||||
ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, CreateContactVCardDto,
|
||||
GroupMembershipDto, UpdateContactDto, UpdateContactGroupDto,
|
||||
};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::contact::{AddressBook, Contact, ContactGroup};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type CardDavRepositoryError = DomainError;
|
||||
|
||||
/// Low-level storage port for CardDAV resources. Post-Round-3 the
|
||||
/// port covers ONLY raw storage operations — everything that used
|
||||
/// to be routed through it for sharing (`share_address_book`,
|
||||
/// `unshare_address_book`, `get_address_book_shares`) or
|
||||
/// scope-listing (`get_address_books_by_owner`,
|
||||
/// `get_shared_address_books`) is gone. Access decisions live in
|
||||
/// `AuthorizationEngine`; sharing state lives in
|
||||
/// `storage.role_grants`. The service layer (`ContactService`) gates
|
||||
/// each call, then reaches through this port for storage.
|
||||
///
|
||||
/// Symmetric with `CalendarStoragePort`. Implemented by
|
||||
/// `ContactStorageAdapter` against Postgres today; a future backend
|
||||
/// (external CardDAV, LDAP directory, in-memory test mock) would
|
||||
/// implement the same trait and swap in via DI.
|
||||
pub trait ContactStoragePort: Send + Sync + 'static {
|
||||
// ── Address books ────────────────────────────────────────────
|
||||
async fn create_address_book(
|
||||
&self,
|
||||
address_book: AddressBook,
|
||||
) -> Result<AddressBook, DomainError>;
|
||||
async fn update_address_book(
|
||||
&self,
|
||||
address_book: AddressBook,
|
||||
) -> Result<AddressBook, DomainError>;
|
||||
async fn delete_address_book(&self, id: &Uuid) -> Result<(), DomainError>;
|
||||
async fn get_address_book_by_id(&self, id: &Uuid) -> Result<Option<AddressBook>, DomainError>;
|
||||
|
||||
/// Batch sibling of [`Self::get_address_book_by_id`]: hydrate a page
|
||||
/// of grant-derived ids in ONE storage round-trip. Missing rows drop
|
||||
/// out silently; ordering is not guaranteed.
|
||||
async fn get_address_books_by_ids(&self, ids: &[Uuid])
|
||||
-> Result<Vec<AddressBook>, DomainError>;
|
||||
async fn get_public_address_books(&self) -> Result<Vec<AddressBook>, DomainError>;
|
||||
|
||||
// ── Contacts ─────────────────────────────────────────────────
|
||||
async fn create_contact(&self, contact: Contact) -> Result<Contact, DomainError>;
|
||||
async fn update_contact(&self, contact: Contact) -> Result<Contact, DomainError>;
|
||||
async fn delete_contact(&self, id: &Uuid) -> Result<(), DomainError>;
|
||||
async fn get_contact_by_id(&self, id: &Uuid) -> Result<Option<Contact>, DomainError>;
|
||||
/// Indexed single-row lookup by vCard UID within a specific book.
|
||||
async fn get_contact_by_uid(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
uid: &str,
|
||||
) -> Result<Option<Contact>, DomainError>;
|
||||
/// Indexed batch lookup by vCard UID within a specific book.
|
||||
async fn get_contacts_by_uids(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
uids: &[String],
|
||||
) -> Result<Vec<Contact>, DomainError>;
|
||||
async fn get_contacts_by_address_book(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
) -> Result<Vec<Contact>, DomainError>;
|
||||
/// Cursor stream over the book's contacts in listing order — feeds
|
||||
/// the streaming CardDAV emitters.
|
||||
fn stream_contacts_by_book(
|
||||
&self,
|
||||
address_book_id: Uuid,
|
||||
) -> futures::stream::BoxStream<'static, Result<Contact, DomainError>>;
|
||||
async fn get_contacts_by_address_book_paginated(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Contact>, DomainError>;
|
||||
async fn search_contacts(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
query: &str,
|
||||
) -> Result<Vec<Contact>, DomainError>;
|
||||
|
||||
// ── Contact groups ───────────────────────────────────────────
|
||||
async fn create_group(&self, group: ContactGroup) -> Result<ContactGroup, DomainError>;
|
||||
async fn update_group(&self, group: ContactGroup) -> Result<ContactGroup, DomainError>;
|
||||
async fn delete_group(&self, id: &Uuid) -> Result<(), DomainError>;
|
||||
async fn get_group_by_id(&self, id: &Uuid) -> Result<Option<ContactGroup>, DomainError>;
|
||||
async fn get_groups_by_address_book(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
) -> Result<Vec<ContactGroup>, DomainError>;
|
||||
|
||||
// ── Group membership ─────────────────────────────────────────
|
||||
async fn add_contact_to_group(
|
||||
&self,
|
||||
group_id: &Uuid,
|
||||
contact_id: &Uuid,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn remove_contact_from_group(
|
||||
&self,
|
||||
group_id: &Uuid,
|
||||
contact_id: &Uuid,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_contacts_in_group(&self, group_id: &Uuid) -> Result<Vec<Contact>, DomainError>;
|
||||
/// Membership count without hydrating the contacts (vCard TEXT +
|
||||
/// 3 JSONB parses per row) — for group summary DTOs.
|
||||
async fn count_contacts_in_group(&self, group_id: &Uuid) -> Result<i64, DomainError>;
|
||||
async fn get_groups_for_contact(
|
||||
&self,
|
||||
contact_id: &Uuid,
|
||||
) -> Result<Vec<ContactGroup>, DomainError>;
|
||||
}
|
||||
|
||||
pub trait AddressBookUseCase: Send + Sync + 'static {
|
||||
// Address Book operations
|
||||
async fn create_address_book(
|
||||
@@ -37,23 +141,6 @@ pub trait AddressBookUseCase: Send + Sync + 'static {
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<AddressBookDto>, DomainError>;
|
||||
async fn list_public_address_books(&self) -> Result<Vec<AddressBookDto>, DomainError>;
|
||||
|
||||
// Address Book sharing
|
||||
async fn share_address_book(
|
||||
&self,
|
||||
dto: ShareAddressBookDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn unshare_address_book(
|
||||
&self,
|
||||
dto: UnshareAddressBookDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_address_book_shares(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<(String, bool)>, DomainError>;
|
||||
}
|
||||
|
||||
pub trait ContactUseCase: Send + Sync + 'static {
|
||||
@@ -96,6 +183,15 @@ pub trait ContactUseCase: Send + Sync + 'static {
|
||||
/// List contacts in an address book. `limit`/`offset` bound the
|
||||
/// result for paginated callers (REST API); `None` returns the full
|
||||
/// book, which the CardDAV listing/sync paths rely on.
|
||||
/// Streaming support: cursor over the book's contacts (same Read
|
||||
/// gate as [`Self::list_contacts`], checked once before the cursor
|
||||
/// opens).
|
||||
async fn stream_contacts_by_book(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<futures::stream::BoxStream<'static, Result<ContactDto, DomainError>>, DomainError>;
|
||||
|
||||
async fn list_contacts(
|
||||
&self,
|
||||
address_book_id: &str,
|
||||
|
||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::face::{DetectedFace, Face, Person};
|
||||
use crate::domain::entities::face::{DetectedFace, Face, FaceBox, Person};
|
||||
|
||||
/// Detects faces in an image and produces an aligned, L2-normalized embedding
|
||||
/// for each. Takes raw encoded bytes (it decodes internally) so the
|
||||
@@ -29,7 +29,16 @@ pub trait FaceAnalyzerPort: Send + Sync + 'static {
|
||||
pub trait FaceRepository: Send + Sync + 'static {
|
||||
// ── faces ──────────────────────────────────────────────────────
|
||||
async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError>;
|
||||
async fn faces_for_file(&self, file_id: Uuid) -> Result<Vec<Face>, DomainError>;
|
||||
/// Face boxes for a photo, caller-scoped — the lightbox tagging overlay
|
||||
/// needs only `(id, person_id, bbox)`, so this narrow projection drops the
|
||||
/// 2 KiB embedding BYTEA (+ det_score/quality/blob_hash/created_at) a full
|
||||
/// `Face` fetch hydrates, and pushes the caller filter into SQL instead of
|
||||
/// filtering in Rust. See benches/ROUND14.md §Q1.
|
||||
async fn face_boxes_for_file(
|
||||
&self,
|
||||
file_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<FaceBox>, DomainError>;
|
||||
async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError>;
|
||||
async fn faces_for_user(&self, user_id: Uuid) -> Result<Vec<Face>, DomainError>;
|
||||
/// Faces previously computed for any file sharing this content hash —
|
||||
@@ -39,12 +48,38 @@ pub trait FaceRepository: Send + Sync + 'static {
|
||||
user_id: Uuid,
|
||||
blob_hash: &str,
|
||||
) -> Result<Vec<Face>, DomainError>;
|
||||
/// `(person_id, face_count)` per non-empty cluster — a grouped COUNT
|
||||
/// instead of dragging every face row (each with a 2 KiB embedding
|
||||
/// BYTEA) across the wire just to count them. See benches/PEOPLE-LIST.md.
|
||||
async fn person_face_stats(&self, user_id: Uuid) -> Result<Vec<(Uuid, i64)>, DomainError>;
|
||||
/// face id → file id for the given faces (cover-photo resolution).
|
||||
async fn file_ids_for_faces(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
face_ids: &[Uuid],
|
||||
) -> Result<std::collections::HashMap<Uuid, Uuid>, DomainError>;
|
||||
/// Reassign every face of `from` to `into` in one statement (merge).
|
||||
async fn reassign_person_faces(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
from: Uuid,
|
||||
into: Uuid,
|
||||
) -> Result<u64, DomainError>;
|
||||
async fn assign_person(
|
||||
&self,
|
||||
face_id: Uuid,
|
||||
person_id: Option<Uuid>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Batch variant of [`Self::assign_person`]: apply every
|
||||
/// `(face_id, person_id)` pair in one statement. Reclustering an
|
||||
/// F-face library used to issue F sequential UPDATE round-trips
|
||||
/// (benches/ROUND11.md §Q5 — the ROUND10 `save_faces` UNNEST pattern).
|
||||
async fn assign_person_batch(
|
||||
&self,
|
||||
assignments: &[(Uuid, Option<Uuid>)],
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
// ── persons ────────────────────────────────────────────────────
|
||||
async fn create_person(&self, person: &Person) -> Result<(), DomainError>;
|
||||
async fn persons_for_user(&self, user_id: Uuid) -> Result<Vec<Person>, DomainError>;
|
||||
|
||||
@@ -60,6 +60,25 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
|
||||
caller_id: Uuid,
|
||||
) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// `_with_perms` variant of `upload_file_streaming` — enforces
|
||||
/// `Create` on the target folder before registering the row.
|
||||
///
|
||||
/// AuthZ audit #17 (2026-07-12): the chunked-upload `complete`
|
||||
/// path called plain `upload_file_streaming` at finalize; a grant
|
||||
/// revoked between session open and finalize stayed effective
|
||||
/// until the caller landed the final chunk (up to 24h JWT TTL,
|
||||
/// forever with app-passwords). Handlers now call this variant
|
||||
/// so the engine re-checks at finalize regardless of how long
|
||||
/// the session was open.
|
||||
async fn upload_file_streaming_with_perms(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
blob: StoredBlob,
|
||||
caller_id: Uuid,
|
||||
) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// Replace the content of the file at `path` with an already-ingested
|
||||
/// blob, or create the file when it doesn't exist (WebDAV/WOPI PUT).
|
||||
///
|
||||
@@ -75,7 +94,22 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
|
||||
/// `updated_by` column reflects the principal that performed the
|
||||
/// PUT — not the file's existing owner (D2 shared drives let
|
||||
/// non-owners overwrite content).
|
||||
async fn update_file_streaming(
|
||||
/// `_with_perms` suffix (AGENTS.md AuthZ convention): the
|
||||
/// implementation calls `authz.require(caller, Update, File(id))`
|
||||
/// on the overwrite branch and `authz.require(caller, Create,
|
||||
/// Folder|Drive(id))` on the new-file branch. Handlers just plumb
|
||||
/// `caller_id` through — no protocol-layer authz.
|
||||
///
|
||||
/// `expected_hash`: forwarded to
|
||||
/// `FileWritePort::update_file_content_with_blob` on the overwrite
|
||||
/// branch for compare-and-swap; ignored on the new-file branch
|
||||
/// (nothing to compare against). Pass `None` for plain PUT/WOPI/
|
||||
/// chunked-upload last-write-wins semantics; pass the pre-write
|
||||
/// snapshot's content hash for PATCH, where a concurrent write
|
||||
/// during the (potentially slow) splice must be rejected rather
|
||||
/// than silently clobbered.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn update_file_streaming_with_perms(
|
||||
&self,
|
||||
path: &str,
|
||||
drive_id: Uuid,
|
||||
@@ -83,6 +117,7 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
|
||||
content_type: &str,
|
||||
modified_at: Option<i64>,
|
||||
caller_id: Uuid,
|
||||
expected_hash: Option<&str>,
|
||||
) -> Result<FileDto, DomainError>;
|
||||
}
|
||||
|
||||
@@ -106,6 +141,17 @@ pub enum OptimizedFileContent {
|
||||
Stream(Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>),
|
||||
}
|
||||
|
||||
/// Result of a cache-aware HTTP-Range read
|
||||
/// (`FileRetrievalService::get_file_range_preloaded`). Same split as
|
||||
/// [`OptimizedFileContent`]: handlers map each variant onto a response body.
|
||||
pub enum RangeContent {
|
||||
/// Zero-copy slice out of the RAM content cache (a `Bytes::slice` is a
|
||||
/// refcount bump — no allocation, no I/O, no DB).
|
||||
Bytes(Bytes),
|
||||
/// Streaming range read from the blob store (cache miss / large file).
|
||||
Stream(Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>),
|
||||
}
|
||||
|
||||
/// Primary port for file retrieval operations
|
||||
pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
/// Gets a file by its ID (system/internal — no ownership check).
|
||||
@@ -230,34 +276,33 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
async fn list_files_batch(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
offset: i64,
|
||||
after_name: Option<&str>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
let all = self.list_files(folder_id).await?;
|
||||
let mut all = self.list_files(folder_id).await?;
|
||||
all.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.skip(offset as usize)
|
||||
.filter(|f| after_name.is_none_or(|a| f.name.as_str() > a))
|
||||
.take(limit as usize)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Like [`list_files_batch`], but scoped to a specific owner.
|
||||
/// Like [`list_files_batch`], but scoped to a specific caller.
|
||||
///
|
||||
/// Used by streaming WebDAV PROPFIND so that each user only sees their
|
||||
/// own files, even in shared folder_id namespaces.
|
||||
/// Used by streaming WebDAV PROPFIND. Post-D7 the concrete
|
||||
/// implementation in `FileRetrievalService` uses drive-membership
|
||||
/// grants; this default falls back to the unscoped listing (the
|
||||
/// caller passes through `owner_id` for interface parity but the
|
||||
/// stub can't apply a real filter without a repo lookup).
|
||||
async fn list_files_batch_with_perms(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
offset: i64,
|
||||
_owner_id: Uuid,
|
||||
after_name: Option<&str>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
let all = self.list_files_batch(folder_id, offset, limit).await?;
|
||||
let owner_str = owner_id.to_string();
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| f.owner_id.as_deref().is_some_and(|o| o == owner_str))
|
||||
.collect())
|
||||
self.list_files_batch(folder_id, after_name, limit).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,31 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
||||
pagination: &crate::application::dtos::pagination::PaginationRequestDto,
|
||||
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
|
||||
|
||||
/// Keyset-paged sub-folder listing in name order, scoped to a caller —
|
||||
/// `name > after_name LIMIT limit`, `has_next = len() == limit`.
|
||||
///
|
||||
/// Used by streaming WebDAV/NC PROPFIND: O(page) per page off the
|
||||
/// `idx_folders_unique_name` index instead of the quadratic
|
||||
/// `COUNT(*) OVER() … LIMIT/OFFSET` walk (benches/FOLDER-KEYSET.md).
|
||||
///
|
||||
/// The default implementation falls back to `list_folders_with_perms`
|
||||
/// + in-memory slice so stubs and mocks compile without changes.
|
||||
async fn list_folders_batch_with_perms(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
caller_id: Uuid,
|
||||
after_name: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<FolderDto>, DomainError> {
|
||||
let mut all = self.list_folders_with_perms(parent_id, caller_id).await?;
|
||||
all.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| after_name.is_none_or(|a| f.name.as_str() > a))
|
||||
.take(limit)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Renames a folder (ownership verified against caller_id)
|
||||
async fn rename_folder_with_perms(
|
||||
&self,
|
||||
|
||||
@@ -27,11 +27,15 @@ pub trait SearchUseCase: Send + Sync + 'static {
|
||||
) -> Result<Arc<SearchResultsDto>, DomainError>;
|
||||
|
||||
/// Returns quick suggestions for autocomplete (lightweight, fast).
|
||||
/// `caller_id` scopes results to drives the caller can Read — without
|
||||
/// it the endpoint leaks names + paths across every tenant on the
|
||||
/// instance (AuthZ audit finding #1, 2026-07-12).
|
||||
async fn suggest(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
caller_id: Uuid,
|
||||
) -> Result<SearchSuggestionsDto, DomainError>;
|
||||
|
||||
/// Clears the search results cache.
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod music_ports;
|
||||
pub mod outbound;
|
||||
pub mod plugin_ports;
|
||||
pub mod recent_ports;
|
||||
pub mod resource_access_hook;
|
||||
pub mod share_ports;
|
||||
pub mod storage_ports;
|
||||
pub mod thumbnail_ports;
|
||||
|
||||
@@ -104,6 +104,11 @@ pub trait MusicStoragePort: Send + Sync {
|
||||
|
||||
async fn get_playlist(&self, playlist_id: &str) -> Result<Option<PlaylistDto>, DomainError>;
|
||||
|
||||
/// Batch sibling of [`Self::get_playlist`]: hydrate a page of
|
||||
/// grant-derived ids in ONE storage round-trip. Missing rows drop
|
||||
/// out silently; ordering is not guaranteed.
|
||||
async fn get_playlists_by_ids(&self, ids: &[Uuid]) -> Result<Vec<PlaylistDto>, DomainError>;
|
||||
|
||||
async fn list_playlists_by_owner(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
|
||||
@@ -41,7 +41,11 @@ pub trait RecentItemsRepositoryPort: Send + Sync + 'static {
|
||||
async fn get_recent_items(&self, user_id: Uuid, limit: i32) -> Result<Vec<RecentItemDto>>;
|
||||
|
||||
/// Records/updates access to an item (upsert by user+item+type).
|
||||
async fn upsert_access(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result<()>;
|
||||
/// Returns `true` when a NEW row was inserted (the recent set grew) and
|
||||
/// `false` when an existing row's timestamp was merely refreshed — the
|
||||
/// caller prunes only in the former case, since a re-access can never
|
||||
/// push the user over the cap (benches/ROUND13.md §Q3).
|
||||
async fn upsert_access(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result<bool>;
|
||||
|
||||
/// Removes an item from recents. Returns `true` if it existed.
|
||||
async fn remove_item(&self, user_id: Uuid, item_id: &str, item_type: &str) -> Result<bool>;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Observer notified when a caller successfully reads or mutates a file.
|
||||
//!
|
||||
//! Read-event sibling of [`crate::application::ports::file_lifecycle`]. The
|
||||
//! lifecycle hook fires on content changes (created/copied/updated/deleted);
|
||||
//! this one fires on access — every authorised file read, every successful
|
||||
//! upload, every PUT/COPY — and lets cross-cutting observers (Recent list,
|
||||
//! audit trail, future "last seen by" UX) react without each
|
||||
//! protocol-surface handler having to remember to call them.
|
||||
//!
|
||||
//! Folders are deliberately out of scope: a listing fires on every UI
|
||||
//! navigation, every PROPFIND, every NC sync poll, and would dominate
|
||||
//! `auth.user_recent_files` with noise that no user actually opened.
|
||||
//! Only file-level interactions count.
|
||||
//!
|
||||
//! Implementors run **after** the service layer's authZ check has passed and
|
||||
//! the read/write has succeeded; a denied or 404'd request never fires the
|
||||
//! hook. The method is synchronous — implementors that need to do real work
|
||||
//! spawn it themselves so the user-facing request is never blocked on the
|
||||
//! side-effect. The recording impl lives in
|
||||
//! `infrastructure/services/recent_recording_hook.rs`.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Fired by the application services on a successful, authorised access to a
|
||||
/// file owned (or shared with) the caller.
|
||||
///
|
||||
/// `caller_id` is mandatory because the recording side needs to know **who**
|
||||
/// touched the file — the same file accessed by two different users records
|
||||
/// two separate Recent rows. Anonymous surfaces (public share downloads via
|
||||
/// `/api/s/{token}`) deliberately do not call this hook: a "viewer" without
|
||||
/// an authenticated identity has no Recent list to land in.
|
||||
pub trait ResourceAccessHook: Send + Sync {
|
||||
/// Called after a file read or successful write touched `file_id` on
|
||||
/// behalf of `caller_id`. The caller has already been authorised — the
|
||||
/// hook is fire-and-forget; failures are the implementor's problem and
|
||||
/// must never propagate.
|
||||
fn on_file_accessed(&self, caller_id: Uuid, file_id: &str);
|
||||
|
||||
/// Called after `caller_id` has emptied their Recent list (either by
|
||||
/// clearing the whole table or removing a single row). Implementors
|
||||
/// hold in-memory throttle / dedup state keyed by `(caller, item)`;
|
||||
/// without this signal a freshly-cleared list would refuse to record
|
||||
/// the next access until the throttle TTL expires, leaving the user
|
||||
/// staring at an empty Recent and wondering why their open-then-close
|
||||
/// did nothing.
|
||||
///
|
||||
/// Default no-op: implementations without any in-memory state — most
|
||||
/// audit-trail-style observers — needn't react.
|
||||
fn on_recents_cleared(&self, _caller_id: Uuid) {}
|
||||
}
|
||||
@@ -99,6 +99,28 @@ pub trait ShareStoragePort: Send + Sync + 'static {
|
||||
share: &crate::domain::entities::share::Share,
|
||||
) -> Result<crate::domain::entities::share::Share, DomainError>;
|
||||
|
||||
/// Atomically bump a link's access counter (public share landing).
|
||||
/// Returns the number of rows updated — 0 means "no live share for
|
||||
/// this token" (missing OR expired).
|
||||
///
|
||||
/// The default is the legacy read-modify-write (kept for test mocks);
|
||||
/// `SharePgRepository` overrides it with a single `UPDATE … SET
|
||||
/// access_count = access_count + 1`, replacing 2 correlated-subquery
|
||||
/// round-trips per anonymous visit with 1 and removing the lost-update
|
||||
/// race between concurrent visitors (benches/SHARE-ACCESS.md).
|
||||
async fn increment_access_count(&self, token: &str) -> Result<u64, DomainError> {
|
||||
let share = match self.find_share_by_token(token).await {
|
||||
Ok(s) => s,
|
||||
Err(e) if e.kind == crate::common::errors::ErrorKind::NotFound => return Ok(0),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if share.is_expired() {
|
||||
return Ok(0);
|
||||
}
|
||||
self.update_share(&share.increment_access_count()).await?;
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
async fn find_shares_by_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use uuid::Uuid;
|
||||
@@ -31,40 +30,9 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
|
||||
async fn get_file_or_trashed(&self, id: &str) -> Result<File, DomainError>;
|
||||
|
||||
/// Gets a file by its ID, scoped to a specific owner.
|
||||
///
|
||||
/// Returns `NotFound` if the file does not exist **or** belongs to a
|
||||
/// different user. This is the primary IDOR-safe accessor — handlers
|
||||
/// serving end-user requests should always prefer this over `get_file`.
|
||||
async fn get_file_for_owner(&self, id: &str, owner_id: Uuid) -> Result<File, DomainError>;
|
||||
|
||||
/// Verifies that the file identified by `id` belongs to `owner_id`.
|
||||
///
|
||||
/// Returns `Ok(())` on success or `NotFound` when the file does not
|
||||
/// exist or belongs to another user.
|
||||
async fn verify_file_owner(&self, id: &str, owner_id: Uuid) -> Result<(), DomainError> {
|
||||
self.get_file_for_owner(id, owner_id).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Lists files in a folder.
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError>;
|
||||
|
||||
/// Lists files in a folder scoped to a specific owner (SQL-level).
|
||||
///
|
||||
/// Default falls back to `list_files` + in-memory filter.
|
||||
/// Repositories should override with a direct `AND user_id = $N` query.
|
||||
async fn list_files_for_owner(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
) -> Result<Vec<File>, DomainError> {
|
||||
let all = self.list_files(folder_id).await?;
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| f.owner_id() == Some(owner_id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Gets content as a stream (ideal for large files).
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
@@ -139,38 +107,29 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Lists files in a folder with LIMIT/OFFSET pagination.
|
||||
/// Lists files in a folder in name order, keyset-paginated.
|
||||
///
|
||||
/// Used by streaming WebDAV PROPFIND to avoid loading all files at once.
|
||||
/// `after_name` is the last name of the previous page (`None` = first
|
||||
/// page); names are unique within a folder (unique index on
|
||||
/// `(drive_id, folder_id, name)`), so `name > after_name` is a total,
|
||||
/// stable cursor. Unlike LIMIT/OFFSET, every page is O(page) — the old
|
||||
/// offset shape re-scanned and re-sorted the whole folder per page
|
||||
/// (benches/PROPFIND-PAGING.md).
|
||||
///
|
||||
/// Default: falls back to `list_files` (loads all, then slices in memory).
|
||||
async fn list_files_batch(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
offset: i64,
|
||||
after_name: Option<&str>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<File>, DomainError> {
|
||||
let all = self.list_files(folder_id).await?;
|
||||
let start = (offset as usize).min(all.len());
|
||||
let end = (start + limit as usize).min(all.len());
|
||||
Ok(all.into_iter().skip(start).take(end - start).collect())
|
||||
}
|
||||
|
||||
/// Like [`list_files_batch`], but only returns files owned by `owner_id`.
|
||||
///
|
||||
/// Used by streaming WebDAV PROPFIND to list files scoped to the
|
||||
/// authenticated user, preventing cross-user data leakage.
|
||||
async fn list_files_batch_for_owner(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> Result<Vec<File>, DomainError> {
|
||||
// Default: filter in-memory (repos should override with SQL)
|
||||
let all = self.list_files_batch(folder_id, offset, limit).await?;
|
||||
let mut all = self.list_files(folder_id).await?;
|
||||
all.sort_by(|a, b| a.name().cmp(b.name()));
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| f.owner_id() == Some(owner_id))
|
||||
.filter(|f| after_name.is_none_or(|a| f.name() > a))
|
||||
.take(limit as usize)
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -195,7 +154,10 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
/// # Arguments
|
||||
/// * `folder_id` - Optional folder ID to scope the search (for recursive search, pass None)
|
||||
/// * `criteria` - Search criteria including name_contains, file_types, date ranges, size ranges
|
||||
/// * `user_id` - User ID for ownership filtering
|
||||
/// * `caller_id` - Caller user id — scoped by drive-membership grants
|
||||
/// (`role_grants` on `resource_type='drive'`) rather than the legacy
|
||||
/// `files.user_id` column. Group memberships (direct + transitive)
|
||||
/// are expanded inline via `storage.caller_group_ids($caller)`.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (files, total_count) where files are paginated and filtered
|
||||
@@ -203,50 +165,50 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(Vec<File>, usize), DomainError>;
|
||||
|
||||
/// Search files recursively in a folder subtree using ltree.
|
||||
///
|
||||
/// When `root_folder_id` is Some, uses ltree descendant queries to find
|
||||
/// all files within the subtree rooted at that folder. When None, searches
|
||||
/// all files for the user. This replaces the O(N) recursive spawn-per-folder
|
||||
/// approach with O(1) SQL queries.
|
||||
/// all files within the subtree rooted at that folder. When None,
|
||||
/// delegates to `search_files_paginated`.
|
||||
///
|
||||
/// Post-PR-B: scoped by drive-membership grants (same semantics as
|
||||
/// `search_files_paginated`), not by `files.user_id`.
|
||||
///
|
||||
/// Returns a tuple of (matching files, total count for pagination).
|
||||
async fn search_files_in_subtree(
|
||||
&self,
|
||||
root_folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(Vec<File>, usize), DomainError> {
|
||||
// Default: delegate to paginated search (non-recursive fallback)
|
||||
self.search_files_paginated(root_folder_id, criteria, user_id)
|
||||
self.search_files_paginated(root_folder_id, criteria, caller_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Count files matching the search criteria (without loading them).
|
||||
///
|
||||
/// Used for pagination metadata without fetching the actual files.
|
||||
async fn count_files(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<usize, DomainError>;
|
||||
|
||||
/// Return up to `limit` files whose name contains `query` (case-insensitive).
|
||||
///
|
||||
/// Results are ordered by relevance (exact > starts-with > contains) so the
|
||||
/// caller can use them directly for autocomplete suggestions.
|
||||
///
|
||||
/// The default implementation falls back to `list_files` + in-memory filter
|
||||
/// so that stubs and mocks compile without changes.
|
||||
/// `caller_id` scopes results to files whose owning drive the caller can
|
||||
/// Read (direct or group-mediated `role_grants`). Without it the endpoint
|
||||
/// leaks names + paths across every tenant on the instance — closed as
|
||||
/// AuthZ audit finding #1 (2026-07-12).
|
||||
///
|
||||
/// The default implementation falls back to `list_files` + in-memory
|
||||
/// filter so that stubs and mocks compile without changes. Stub-mode
|
||||
/// callers already operate against a single tenant's data, so ignoring
|
||||
/// `caller_id` here is safe; the PG impl enforces the real scope.
|
||||
async fn suggest_files_by_name(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<Vec<File>, DomainError> {
|
||||
let all = self.list_files(folder_id).await?;
|
||||
let q = query.to_lowercase();
|
||||
@@ -337,6 +299,14 @@ pub trait FileWritePort: Send + Sync + 'static {
|
||||
///
|
||||
/// `caller_id` is stamped into `updated_by` alongside the
|
||||
/// `updated_at` bump (§14 provenance).
|
||||
///
|
||||
/// `expected_hash`: when `Some`, makes this a true compare-and-swap —
|
||||
/// the write only takes effect if the row's current `blob_hash`
|
||||
/// still equals it, checked and applied atomically under the same
|
||||
/// row lock (no gap between check and write for a concurrent writer
|
||||
/// to land in). A mismatch returns `ErrorKind::PreconditionFailed`
|
||||
/// and leaves the row untouched. `None` keeps the previous
|
||||
/// blind-overwrite behaviour (PUT/WOPI/chunked-upload finalize).
|
||||
async fn update_file_content_with_blob(
|
||||
&self,
|
||||
file_id: &str,
|
||||
@@ -344,6 +314,7 @@ pub trait FileWritePort: Send + Sync + 'static {
|
||||
size: u64,
|
||||
modified_at: Option<i64>,
|
||||
caller_id: Uuid,
|
||||
expected_hash: Option<&str>,
|
||||
) -> Result<(String, i64), DomainError>;
|
||||
|
||||
/// Registers file metadata WITHOUT writing content to disk (write-behind).
|
||||
@@ -453,10 +424,40 @@ pub trait StorageUsagePort: Send + Sync + 'static {
|
||||
|
||||
/// Returns (used_bytes, quota_bytes) for a user.
|
||||
async fn get_user_storage_info(&self, user_id: Uuid) -> Result<(i64, i64), DomainError>;
|
||||
}
|
||||
|
||||
/// Generic storage service interface for calendar and contact services
|
||||
pub trait StorageUseCase: Send + Sync + 'static {
|
||||
/// Handle a request with the specified action and parameters
|
||||
async fn handle_request(&self, action: &str, params: Value) -> Result<Value, DomainError>;
|
||||
/// Incrementally adjust one drive's cached `storage.drives.used_bytes`
|
||||
/// by `delta` bytes — O(1), the per-upload counterpart to the
|
||||
/// O(N) full recompute below. Mirrors `add_user_storage_usage_delta`
|
||||
/// in shape: single statement, `GREATEST(0, …)` clamp so a late or
|
||||
/// duplicate adjustment can never drive the counter negative.
|
||||
/// Deletes/trash do not decrement here (mirroring user-quota
|
||||
/// design); the periodic reconciliation sweep is the correctness
|
||||
/// backstop.
|
||||
async fn add_drive_storage_usage_delta(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
delta: i64,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Reconcile every drive's cached `used_bytes` against the actual
|
||||
/// sum of its non-trashed files in one set-based UPDATE. Same
|
||||
/// shape as `update_all_users_storage_usage`: `LEFT JOIN` over a
|
||||
/// `GROUP BY drive_id` aggregate, with an `IS DISTINCT FROM`
|
||||
/// guard so idle drives don't churn dead tuples. Runs from the
|
||||
/// same reconciliation ticker.
|
||||
async fn update_all_drives_storage_usage(&self) -> Result<(), DomainError>;
|
||||
|
||||
/// Pre-upload quota check on a single drive.
|
||||
///
|
||||
/// Returns `Ok(())` when `used_bytes + additional_bytes` fits under
|
||||
/// `quota_bytes`, or `Err(QuotaExceeded)` otherwise.
|
||||
/// `quota_bytes IS NULL` short-circuits to `Ok(())` — unlimited
|
||||
/// drive. Single read-only `SELECT` on `storage.drives`; the
|
||||
/// check/write window is a soft cap by design (same semantics as
|
||||
/// the user-quota path), bounded by the sweep interval.
|
||||
async fn check_drive_quota(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,19 @@ pub enum ThumbnailSize {
|
||||
}
|
||||
|
||||
impl ThumbnailSize {
|
||||
/// Stable name, byte-identical to the derived `Debug` output. Used by
|
||||
/// the thumbnail/preview ETags on the hottest revalidation path — a
|
||||
/// `&'static str` push beats routing through the `Debug` machinery
|
||||
/// (benches/ROUND11.md §7) while keeping every already-cached client
|
||||
/// ETag valid.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ThumbnailSize::Icon => "Icon",
|
||||
ThumbnailSize::Preview => "Preview",
|
||||
ThumbnailSize::Large => "Large",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the maximum dimension for this size.
|
||||
pub fn max_dimension(&self) -> u32 {
|
||||
match self {
|
||||
@@ -64,6 +77,15 @@ pub enum ThumbnailFormat {
|
||||
}
|
||||
|
||||
impl ThumbnailFormat {
|
||||
/// Stable name, byte-identical to the derived `Debug` output (see
|
||||
/// [`ThumbnailSize::as_str`] — same ETag-stability contract).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ThumbnailFormat::Webp => "Webp",
|
||||
ThumbnailFormat::Jpeg => "Jpeg",
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk file extension for this format (no dot).
|
||||
pub fn ext(self) -> &'static str {
|
||||
match self {
|
||||
|
||||
@@ -19,4 +19,14 @@ pub trait TrashUseCase: Send + Sync {
|
||||
|
||||
/// Empty the trash for a specific user
|
||||
async fn empty_trash(&self, user_id: Uuid) -> Result<()>;
|
||||
|
||||
/// Empty the trash within a single drive the caller can Delete in.
|
||||
///
|
||||
/// Same destructive shape as `empty_trash`, but scoped to one drive
|
||||
/// — the Drive group-by on `/trash` exposes a per-row "Empty"
|
||||
/// affordance so multi-drive owners can clear one drive without
|
||||
/// touching the others. Refused (`NotFound`) when the caller has no
|
||||
/// Delete-bearing role on the named drive (anti-enum: same shape
|
||||
/// as if the drive didn't exist), or when the drive id is unknown.
|
||||
async fn empty_trash_for_drive(&self, user_id: Uuid, drive_id: Uuid) -> Result<()>;
|
||||
}
|
||||
|
||||
@@ -193,4 +193,32 @@ pub trait UserLifecycleHook: Send + Sync {
|
||||
mode: DeletionMode,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Fires after `AuthApplicationService::upgrade_to_internal`
|
||||
/// successfully persists `is_external = false` on the user row —
|
||||
/// the external → internal conversion path. The `user` argument
|
||||
/// reflects the POST-upgrade state (`is_external() == false`,
|
||||
/// `storage_quota_bytes > 0`, `password_hash` maybe stamped).
|
||||
///
|
||||
/// Load-bearing implementations:
|
||||
/// * `PersonalDriveLifecycleHook` → provisions the home drive
|
||||
/// (would have short-circuited on `on_user_created` because
|
||||
/// the user was external at creation).
|
||||
/// * `AuditLifecycleHook` → emits `event="auth.user_upgraded"`.
|
||||
///
|
||||
/// Default: no-op. Hooks that don't care about upgrade don't need
|
||||
/// to opt in — this keeps the trait extension backwards-compatible
|
||||
/// with existing implementations. Do NOT reuse `on_user_created`
|
||||
/// for this event: hooks that observe `last_login_at().is_none()`
|
||||
/// as "first ever" or that clean up magic-link tokens
|
||||
/// (`ExternalIdentityLifecycleHook`) would mis-fire.
|
||||
///
|
||||
/// Idempotency: fires exactly once per successful upgrade transition
|
||||
/// (guarded by `is_external` toggling). A retried upgrade after a
|
||||
/// crash would hit the `AlreadyInternal` guard in the service and
|
||||
/// this hook wouldn't fire again — so hooks may assume "first
|
||||
/// upgrade" semantics.
|
||||
async fn on_upgraded_to_internal(&self, _user: &User) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
||||
use chrono::{Duration, Utc};
|
||||
use moka::future::Cache;
|
||||
use rand_core::RngCore;
|
||||
use smol_str::SmolStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration as StdDuration;
|
||||
use uuid::Uuid;
|
||||
@@ -56,12 +57,17 @@ const BASIC_AUTH_CACHE_TTL_SECS: u64 = 300;
|
||||
const BASIC_AUTH_CACHE_MAX_ENTRIES: u64 = 10_000;
|
||||
|
||||
/// Cached identity returned after a successful Basic Auth verification.
|
||||
///
|
||||
/// `Arc<str>` / inline `SmolStr` fields: moka's `get` clones the value, so
|
||||
/// with owned `String`s every warm Basic-auth request (all DAV traffic)
|
||||
/// paid 3 string copies just to read the cached identity. Now a hit is
|
||||
/// refcount bumps + a 24-byte memcpy.
|
||||
#[derive(Clone)]
|
||||
struct CachedBasicAuthResult {
|
||||
user_id: Uuid,
|
||||
username: String,
|
||||
email: String,
|
||||
role: String,
|
||||
username: Arc<str>,
|
||||
email: Arc<str>,
|
||||
role: SmolStr,
|
||||
}
|
||||
|
||||
pub struct AppPasswordService {
|
||||
@@ -299,17 +305,62 @@ impl AppPasswordService {
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(Uuid, String, String, String), DomainError> {
|
||||
) -> Result<(Uuid, Arc<str>, Arc<str>, SmolStr), DomainError> {
|
||||
// ── 1. Compute cache key = blake3("username:password") ────────
|
||||
let cache_key: [u8; 32] =
|
||||
blake3::hash(format!("{}:{}", username, password).as_bytes()).into();
|
||||
// Stream the parts into an incremental hasher instead of
|
||||
// `blake3::hash(format!("{username}:{password}").as_bytes())` — the
|
||||
// `format!` heap-allocated one throw-away `String` per request (this
|
||||
// runs before the cache lookup, so even cache hits paid it), and DAV
|
||||
// sync clients hammer Basic auth on every request. Byte-identical key:
|
||||
// blake3 is a stream hash, so `hash(a || ":" || b)` == feeding the same
|
||||
// bytes in order (benches/ROUND19.md §M1).
|
||||
let cache_key: [u8; 32] = {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(username.as_bytes());
|
||||
h.update(b":");
|
||||
h.update(password.as_bytes());
|
||||
h.finalize().into()
|
||||
};
|
||||
|
||||
// ── 2. Cache hit → return immediately ────────────────────────
|
||||
if let Some(cached) = self.auth_cache.get(&cache_key).await {
|
||||
return Ok((cached.user_id, cached.username, cached.email, cached.role));
|
||||
}
|
||||
// ── 2. Single-flight cache lookup ─────────────────────────────
|
||||
// Concurrent misses on the same credential coalesce into ONE
|
||||
// full verification: DAV sync clients hold 4-8 parallel
|
||||
// connections, so an expiring cache entry used to fan out into
|
||||
// K simultaneous Argon2id runs (~100-300 ms CPU + 64 MiB RAM
|
||||
// apiece) every TTL — a recurring p99 spike on every DAV
|
||||
// surface (8 -> 1 verifications, benches/AUTH-HERD.md).
|
||||
// `try_get_with` caches only `Ok` results, so failed
|
||||
// verifications are still never cached, preserving the full
|
||||
// Argon2id cost as a brute-force deterrent.
|
||||
let result = self
|
||||
.auth_cache
|
||||
.try_get_with(
|
||||
cache_key,
|
||||
self.verify_basic_auth_uncached(username, password),
|
||||
)
|
||||
.await
|
||||
.map_err(
|
||||
|e: std::sync::Arc<DomainError>| match std::sync::Arc::try_unwrap(e) {
|
||||
Ok(err) => err,
|
||||
// Another coalesced waiter still holds the Arc — rebuild
|
||||
// an equivalent error (the source chain isn't clonable).
|
||||
Err(shared) => {
|
||||
DomainError::new(shared.kind, shared.entity_type, shared.message.clone())
|
||||
}
|
||||
},
|
||||
)?;
|
||||
Ok((result.user_id, result.username, result.email, result.role))
|
||||
}
|
||||
|
||||
// ── 3. Cache miss → full verification ────────────────────────
|
||||
/// The uncached Basic Auth slow path: user lookup, prefix-scoped
|
||||
/// candidate fetch, Argon2id verification. Runs at most once per
|
||||
/// credential per TTL — `verify_basic_auth` coalesces concurrent
|
||||
/// callers onto a single in-flight instance of this future.
|
||||
async fn verify_basic_auth_uncached(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<CachedBasicAuthResult, DomainError> {
|
||||
let user = self
|
||||
.user_repo
|
||||
.get_user_by_username(username)
|
||||
@@ -363,15 +414,14 @@ impl AppPasswordService {
|
||||
{
|
||||
let _ = self.repo.touch_last_used(ap.id).await;
|
||||
|
||||
let result = CachedBasicAuthResult {
|
||||
// Caching happens in `verify_basic_auth`: `try_get_with`
|
||||
// stores this value under the blake3 key on return.
|
||||
return Ok(CachedBasicAuthResult {
|
||||
user_id: user.id(),
|
||||
username: user.username().unwrap_or("").to_string(),
|
||||
email: user.email().to_string(),
|
||||
role: user.role().to_string(),
|
||||
};
|
||||
|
||||
self.auth_cache.insert(cache_key, result.clone()).await;
|
||||
return Ok((result.user_id, result.username, result.email, result.role));
|
||||
username: Arc::from(user.username().unwrap_or("")),
|
||||
email: Arc::from(user.email()),
|
||||
role: SmolStr::new_static(user.role().as_str()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::application::dtos::user_dto::{
|
||||
AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, UserDto,
|
||||
AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto,
|
||||
UpgradeToInternalDto, UserDto,
|
||||
};
|
||||
use crate::application::ports::auth_ports::{
|
||||
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
|
||||
@@ -7,7 +8,7 @@ use crate::application::ports::auth_ports::{
|
||||
};
|
||||
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason};
|
||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||
use crate::common::config::OidcConfig;
|
||||
use crate::common::config::{AuthMethod, OidcConfig};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkStatus};
|
||||
use crate::domain::entities::session::Session;
|
||||
@@ -146,8 +147,22 @@ pub struct AuthApplicationService {
|
||||
/// request. The short TTL keeps the "role changes apply without token
|
||||
/// rotation" property within seconds while removing one DB round-trip
|
||||
/// per request; the known mutation paths (`change_user_role`,
|
||||
/// `set_user_active`) also invalidate eagerly.
|
||||
user_flags_cache: Cache<Uuid, UserFlags>,
|
||||
/// `set_user_active`) also invalidate eagerly. `moka::future` so
|
||||
/// concurrent misses for one user coalesce into a single DB lookup
|
||||
/// (`try_get_with` single-flight) — every authenticated request
|
||||
/// calls this, so each 30 s TTL expiry used to fan out one SELECT
|
||||
/// per in-flight request of that user.
|
||||
user_flags_cache: moka::future::Cache<Uuid, UserFlags>,
|
||||
/// Self-service auth-method allowlist (mirrors
|
||||
/// `AuthConfig::allowed_auth_methods`). Empty = both methods
|
||||
/// allowed. Consulted by login / register / magic-link handlers via
|
||||
/// `is_password_login_allowed()` / `is_magic_link_login_allowed()`
|
||||
/// so callers don't have to reach for the app config.
|
||||
allowed_auth_methods: Vec<AuthMethod>,
|
||||
/// Whether `POST /api/auth/login` refuses accounts whose
|
||||
/// `email_verified_at IS NULL`. Mirrors
|
||||
/// `AuthConfig::require_verified_email`.
|
||||
require_verified_email: bool,
|
||||
}
|
||||
|
||||
/// TTL for [`AuthApplicationService::user_flags_cache`]. Upper bound on how
|
||||
@@ -187,13 +202,99 @@ impl AuthApplicationService {
|
||||
.time_to_live(Duration::from_secs(120))
|
||||
.build(),
|
||||
magic_link_repo: None,
|
||||
user_flags_cache: Cache::builder()
|
||||
user_flags_cache: moka::future::Cache::builder()
|
||||
.max_capacity(10_000)
|
||||
.time_to_live(USER_FLAGS_CACHE_TTL)
|
||||
.build(),
|
||||
allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink],
|
||||
require_verified_email: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Populates the auth-method allowlist + `require_verified_email`
|
||||
/// snapshot from the loaded config. Called by the DI factory. If
|
||||
/// left uncalled (test builds), defaults are permissive: both
|
||||
/// methods enabled, verified-email not required.
|
||||
pub fn with_auth_policy(
|
||||
mut self,
|
||||
allowed_methods: Vec<AuthMethod>,
|
||||
require_verified_email: bool,
|
||||
) -> Self {
|
||||
self.allowed_auth_methods = allowed_methods;
|
||||
self.require_verified_email = require_verified_email;
|
||||
self
|
||||
}
|
||||
|
||||
/// True iff `POST /api/auth/login` is a supported endpoint on this
|
||||
/// deployment. Composes the OIDC `disable_password_login` legacy
|
||||
/// flag with the newer `OXICLOUD_AUTH_METHODS` allowlist.
|
||||
pub fn is_password_login_allowed(&self) -> bool {
|
||||
!self.password_login_disabled()
|
||||
&& (self.allowed_auth_methods.is_empty()
|
||||
|| self.allowed_auth_methods.contains(&AuthMethod::Password))
|
||||
}
|
||||
|
||||
/// True iff `POST /api/auth/magic-link/send` should mint tokens for
|
||||
/// end-user login on this deployment.
|
||||
///
|
||||
/// Requires ALL of:
|
||||
/// * repo wired (SMTP configured, tokens can actually be minted);
|
||||
/// * allowlist permits `MagicLink` (or is empty = permissive);
|
||||
/// * OIDC is NOT enabled at the deployment level.
|
||||
///
|
||||
/// The OIDC guard is a hard rule: when OIDC is enabled it is the
|
||||
/// master identity provider — magic-link would bypass any 2FA / step-up
|
||||
/// policy that the IdP enforces. An operator running OIDC + local
|
||||
/// accounts hybrid must NOT expose magic-link login for the local
|
||||
/// accounts either, because a user provisioned via OIDC-JIT could
|
||||
/// receive a magic-link on the same mailbox and sidestep MFA. Admin-
|
||||
/// mediated invites use OIDC or password bootstrap instead.
|
||||
pub fn is_magic_link_login_allowed(&self) -> bool {
|
||||
self.magic_link_enabled()
|
||||
&& !self.oidc_enabled()
|
||||
&& (self.allowed_auth_methods.is_empty()
|
||||
|| self.allowed_auth_methods.contains(&AuthMethod::MagicLink))
|
||||
}
|
||||
|
||||
/// True iff login should reject accounts with `email_verified_at IS
|
||||
/// NULL`. Backed by `OXICLOUD_REQUIRE_VERIFIED_EMAIL`.
|
||||
pub fn require_verified_email(&self) -> bool {
|
||||
self.require_verified_email
|
||||
}
|
||||
|
||||
/// Resolve a login-identifier (username OR email) to the account's
|
||||
/// registered email address. Mirrors the `POST /api/auth/login`
|
||||
/// dispatcher (`@` presence → email lookup, else → username
|
||||
/// lookup). Returns `None` when the identifier doesn't match any
|
||||
/// account — callers that need anti-enumeration semantics MUST
|
||||
/// still return their uniform response after logging the reason.
|
||||
///
|
||||
/// The username namespace forbids `@` (PR 16), so the two paths
|
||||
/// are disjoint — no ambiguity.
|
||||
pub async fn resolve_login_identifier_to_email(&self, identifier: &str) -> Option<String> {
|
||||
if identifier.contains('@') {
|
||||
Some(identifier.to_string())
|
||||
} else {
|
||||
self.user_storage
|
||||
.get_user_by_username(identifier)
|
||||
.await
|
||||
.ok()
|
||||
.map(|u| u.email().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct lookup helpers used by handlers that need the full `User`
|
||||
/// entity (not just the email). Mirrors the internal `user_storage`
|
||||
/// calls the service already makes in `login`. Currently used by
|
||||
/// the login handler to auto-mint a verification magic-link after
|
||||
/// a successful password check.
|
||||
pub async fn find_user_by_email(&self, email: &str) -> Result<User, DomainError> {
|
||||
self.user_storage.get_user_by_email(email).await
|
||||
}
|
||||
pub async fn find_user_by_username(&self, username: &str) -> Result<User, DomainError> {
|
||||
self.user_storage.get_user_by_username(username).await
|
||||
}
|
||||
|
||||
/// Wire the magic-link token repository. Called from the DI factory
|
||||
/// when the magic-link feature is configured. Mirrors the
|
||||
/// `with_oidc` / `with_user_lifecycle` builder pattern.
|
||||
@@ -508,6 +609,13 @@ impl AuthApplicationService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// First-run admin is authoritative by definition — they set the
|
||||
// password themselves, at the console, on a fresh install. Mark
|
||||
// verified so `OXICLOUD_REQUIRE_VERIFIED_EMAIL` never locks the
|
||||
// sole account with root-level power out of their own instance.
|
||||
let mut user = user;
|
||||
user.mark_email_verified();
|
||||
|
||||
let created_user = self.user_storage.create_user(user).await?;
|
||||
|
||||
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
|
||||
@@ -527,6 +635,26 @@ impl AuthApplicationService {
|
||||
}
|
||||
|
||||
pub async fn login(&self, dto: LoginDto) -> Result<AuthResponseDto, DomainError> {
|
||||
// Gate: policy may forbid password logins entirely (either the
|
||||
// legacy OIDC-only mode or the newer `OXICLOUD_AUTH_METHODS`
|
||||
// allowlist without `password`). Refuse BEFORE the user lookup
|
||||
// so we don't leak account existence via timing on a disabled
|
||||
// endpoint.
|
||||
if !self.is_password_login_allowed() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.login_rejected",
|
||||
reason = "password_login_disabled",
|
||||
attempted_username = %dto.username,
|
||||
"🔐 login rejected: password login disabled by policy",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
"Password login is disabled",
|
||||
));
|
||||
}
|
||||
|
||||
// Dispatch on `@` in the input: presence of `@` means an email
|
||||
// was typed, absence means a username. The two namespaces are
|
||||
// provably disjoint (PR 16 forbids `@` in usernames), so this
|
||||
@@ -612,6 +740,45 @@ impl AuthApplicationService {
|
||||
));
|
||||
}
|
||||
|
||||
// Gate: `OXICLOUD_REQUIRE_VERIFIED_EMAIL`. Checked AFTER password
|
||||
// validation so an attacker with only a username cannot probe
|
||||
// account verification state (the response shape is
|
||||
// `Invalid credentials` for bad passwords regardless of whether
|
||||
// the email is verified — a wrong-password observer learns
|
||||
// nothing).
|
||||
//
|
||||
// ADMIN EXEMPTION: admins are trusted by fiat and predate this
|
||||
// gate. Fresh admin accounts (admin_create_user /
|
||||
// setup_create_admin) are stamped verified at creation; the
|
||||
// exemption covers pre-existing admin accounts installed before
|
||||
// the flag shipped.
|
||||
//
|
||||
// The auto-send of a verification magic-link when this branch
|
||||
// fires is done at the handler layer (login handler triggers
|
||||
// `send_verification_link_authenticated`) rather than here —
|
||||
// the service returns the distinguished error and the handler
|
||||
// orchestrates the side effect. Keeps this method side-effect-
|
||||
// free on the audit path.
|
||||
if self.require_verified_email
|
||||
&& !matches!(user.role(), UserRole::Admin)
|
||||
&& !user.is_email_verified()
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.login_rejected",
|
||||
reason = "email_not_verified",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
"🔐 login rejected: email not verified for '{}' (password OK)",
|
||||
user.display_for_audit(),
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
"Email not verified",
|
||||
));
|
||||
}
|
||||
|
||||
// Lifecycle: dispatch login BEFORE register_login() so hooks
|
||||
// observing `last_login_at().is_none()` see "first ever login"
|
||||
// correctly. See tip #1 in user_lifecycle.rs.
|
||||
@@ -619,9 +786,14 @@ impl AuthApplicationService {
|
||||
lc.dispatch_login(&user).await;
|
||||
}
|
||||
|
||||
// Update last login
|
||||
// Update last login (in-memory only — the DTO below carries it).
|
||||
// The full-row `update_user` this path used to issue was 100%
|
||||
// redundant: `create_session` stamps `last_login_at`/`updated_at`
|
||||
// in its own transaction right below, and nothing re-reads the row
|
||||
// in between. Dropping it removes one transaction + a 17-column
|
||||
// rewrite (incl. the up-to-512 KiB avatar) per password login
|
||||
// (benches/ROUND12.md §2, 4.45x).
|
||||
user.register_login();
|
||||
self.user_storage.update_user(user.clone()).await?;
|
||||
|
||||
// Generate tokens using the injected token service
|
||||
let access_token = self.token_service.generate_access_token(&user)?;
|
||||
@@ -689,6 +861,17 @@ impl AuthApplicationService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Defense-in-depth: if magic-link login was minted under an older
|
||||
// policy and the operator has since flipped OIDC on (or dropped
|
||||
// `MagicLink` from `OXICLOUD_AUTH_METHODS`), we must not honour
|
||||
// pre-existing login tokens. Invitation tokens (resource_kind =
|
||||
// File / Folder) are checked separately below — they represent
|
||||
// an admin-mediated invite, which is a distinct policy question
|
||||
// from "self-service login via email".
|
||||
//
|
||||
// We do the token lookup FIRST so we can classify by
|
||||
// `resource_kind()` before applying the gate — invitations
|
||||
// survive, plain logins do not.
|
||||
let mlt = repo.find_by_token(token).await?.ok_or_else(|| {
|
||||
// Audit: unknown / forged magic-link redemption. The first
|
||||
// 8 chars of the bogus token are logged so a recurring
|
||||
@@ -710,6 +893,27 @@ impl AuthApplicationService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Enforce the login-magic-link policy on stale tokens.
|
||||
// resource_kind = None means "plain login-via-email"; anything
|
||||
// else is an invite (which follows its own admin-mediated
|
||||
// trust chain). Refuse the login case if the current policy
|
||||
// forbids magic-link login.
|
||||
if mlt.resource_kind().is_none() && !self.is_magic_link_login_allowed() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.redemption_rejected",
|
||||
reason = "login_disabled_by_policy",
|
||||
token_id = %mlt.id(),
|
||||
user_id = %mlt.user_id(),
|
||||
"🔗 magic-link rejected: login-via-email disabled by policy (OIDC-master or allowlist)",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"MagicLink",
|
||||
"magic-link login is disabled",
|
||||
));
|
||||
}
|
||||
|
||||
// Friendly early-rejection messages. The atomic `mark_used`
|
||||
// below is the canonical single-use guard.
|
||||
if mlt.status() == MagicLinkStatus::Used {
|
||||
@@ -818,9 +1022,12 @@ impl AuthApplicationService {
|
||||
// PR 23: clicking the magic-link IS proof of email control —
|
||||
// stamp the verification (idempotent, preserves the first
|
||||
// timestamp). Applies to both invitation and login-via-email
|
||||
// tokens.
|
||||
// tokens. Narrow single-column write: `last_login_at` is stamped
|
||||
// by `create_session` below, so the full-row `update_user` this
|
||||
// path used to issue only ever contributed the verification
|
||||
// timestamp (benches/ROUND12.md §3, 8.9x).
|
||||
user.mark_email_verified();
|
||||
self.user_storage.update_user(user.clone()).await?;
|
||||
self.user_storage.mark_email_verified(user.id()).await?;
|
||||
|
||||
let access_token = self.token_service.generate_access_token(&user)?;
|
||||
let refresh_token = self.token_service.generate_refresh_token();
|
||||
@@ -903,9 +1110,9 @@ impl AuthApplicationService {
|
||||
|
||||
Ok(crate::application::dtos::user_dto::CurrentUser {
|
||||
id: user.id(),
|
||||
username: user.username().unwrap_or("").to_string(),
|
||||
email: user.email().to_string(),
|
||||
role: user.role().to_string(),
|
||||
username: std::sync::Arc::from(user.username().unwrap_or("")),
|
||||
email: std::sync::Arc::from(user.email()),
|
||||
role: smol_str::SmolStr::new_static(user.role().as_str()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -964,15 +1171,15 @@ impl AuthApplicationService {
|
||||
));
|
||||
}
|
||||
|
||||
// Revoke current session before issuing the next token in the family
|
||||
self.session_storage.revoke_session(session.id()).await?;
|
||||
|
||||
// Generate new tokens
|
||||
let access_token = self.token_service.generate_access_token(&user)?;
|
||||
let new_refresh_token = self.token_service.generate_refresh_token();
|
||||
|
||||
// New session inherits the family_id so reuse of any ancestor triggers
|
||||
// full-family revocation
|
||||
// full-family revocation. Revoking the old session and inserting the
|
||||
// new one happen in ONE transaction (`rotate_session`) — this path
|
||||
// used to pay two BEGIN/COMMIT pairs per refresh, and DAV clients
|
||||
// rotate constantly (benches/ROUND12.md §4).
|
||||
let new_session = Session::new(
|
||||
user.id(),
|
||||
new_refresh_token.clone(),
|
||||
@@ -982,7 +1189,9 @@ impl AuthApplicationService {
|
||||
session.family_id(),
|
||||
);
|
||||
|
||||
self.session_storage.create_session(new_session).await?;
|
||||
self.session_storage
|
||||
.rotate_session(session.id(), new_session)
|
||||
.await?;
|
||||
|
||||
Ok(AuthResponseDto {
|
||||
user: UserDto::from(user),
|
||||
@@ -1039,6 +1248,258 @@ impl AuthApplicationService {
|
||||
Ok(revoked_count)
|
||||
}
|
||||
|
||||
/// External → internal account upgrade.
|
||||
///
|
||||
/// Contract:
|
||||
/// * Caller must be authenticated as the user being upgraded.
|
||||
/// Session-elevation is not required — being logged in as
|
||||
/// yourself IS the proof of intent.
|
||||
/// * User must be `is_external = true` — else the entity refuses
|
||||
/// with `UserError::AlreadyInternal`, surfaced as `error_type =
|
||||
/// "AlreadyInternal"` (409).
|
||||
/// * OIDC-linked users are refused (the IdP owns their identity).
|
||||
/// * If `dto.password` is `None`, the deployment MUST have magic-
|
||||
/// link login enabled — otherwise the upgraded user would have
|
||||
/// no login path. Refused with `error_type = "PasswordRequired"`
|
||||
/// (400) in that case.
|
||||
/// * Domain-allowlist check lives at the HANDLER layer, mirroring
|
||||
/// the register handler — the service doesn't hold that config.
|
||||
///
|
||||
/// On success:
|
||||
/// * User's `is_external` flipped to `false`.
|
||||
/// * `password_hash` set from the provided password (Argon2id) or
|
||||
/// left as-is (magic-link-only upgrade).
|
||||
/// * `storage_quota_bytes` set to the default user quota (capped
|
||||
/// by disk).
|
||||
/// * `PersonalDriveLifecycleHook::on_upgraded_to_internal` runs and
|
||||
/// provisions the home drive + root folder + owner grant via the
|
||||
/// atomic CTE. Failure at this step is logged but the row update
|
||||
/// stands — the next login's `on_user_login` safety-net retries
|
||||
/// provisioning.
|
||||
/// * `user_flags_cache` invalidated eagerly so per-request guards
|
||||
/// (WebDAV / CalDAV / CardDAV) observe the new `is_external`
|
||||
/// within cache-round-trip time, not the 30-second TTL.
|
||||
/// * Audit log emits `event="user.upgraded_to_internal"` via the
|
||||
/// `AuditLifecycleHook` on the dispatched event.
|
||||
pub async fn upgrade_to_internal(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
dto: UpgradeToInternalDto,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
let mut user = self.user_storage.get_user_by_id(caller_id).await?;
|
||||
|
||||
// Precondition: caller is currently external. Fast-path 409 so
|
||||
// the audit log carries a clear reason before the entity's own
|
||||
// guard fires.
|
||||
if !user.is_external() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.upgrade_rejected",
|
||||
reason = "already_internal",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
"👮🏻♂️ upgrade refused: user is already internal",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::Conflict,
|
||||
"User",
|
||||
"Account is already internal",
|
||||
));
|
||||
}
|
||||
|
||||
// OIDC-linked: never. The IdP owns identity and role.
|
||||
if user.is_oidc_user() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.upgrade_rejected",
|
||||
reason = "oidc_user",
|
||||
user_id = %user.id(),
|
||||
"👮🏻♂️ upgrade refused: OIDC-linked user is managed by the IdP",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"User",
|
||||
"SSO/OIDC accounts are managed by your identity provider",
|
||||
));
|
||||
}
|
||||
|
||||
// Password policy composite:
|
||||
// * Provided → validate + hash.
|
||||
// * Omitted → only accepted when magic-link login is on
|
||||
// for this deployment (otherwise no login path post-upgrade).
|
||||
let password_hash = match dto.password.as_deref() {
|
||||
Some(pw) if !pw.is_empty() => {
|
||||
if pw.len() < 8 {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"Password must be at least 8 characters long",
|
||||
));
|
||||
}
|
||||
Some(self.password_hasher.hash_password(pw).await?)
|
||||
}
|
||||
_ => {
|
||||
if !self.is_magic_link_login_allowed() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.upgrade_rejected",
|
||||
reason = "password_required",
|
||||
user_id = %user.id(),
|
||||
"👮🏻♂️ upgrade refused: password omitted but magic-link login is not available on this deployment",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"Password is required — magic-link login is not enabled on this deployment",
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Quota policy: same as a fresh regular-user signup.
|
||||
let quota = self.capped_quota(&UserRole::User);
|
||||
|
||||
user.promote_to_internal(password_hash, quota)
|
||||
.map_err(|e| {
|
||||
// The entity refuses `AlreadyInternal` here belt-and-braces
|
||||
// against a race with a concurrent upgrade; the pre-check
|
||||
// above already covers the intended path.
|
||||
DomainError::new(
|
||||
ErrorKind::Conflict,
|
||||
"User",
|
||||
format!("Upgrade refused: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let updated = self.user_storage.update_user(user).await?;
|
||||
|
||||
// Invalidate the flags cache so subsequent per-request guards
|
||||
// observe the new `is_external=false` without waiting for the
|
||||
// 30-second TTL. Same pattern as `change_user_role`.
|
||||
self.user_flags_cache.invalidate(&caller_id).await;
|
||||
|
||||
// Dispatch — home-drive provisioning happens here. Log-and-
|
||||
// continue: a provisioning failure leaves the row updated and
|
||||
// the next login's safety-net (`on_user_login`) retries.
|
||||
if let Some(lc) = &self.user_lifecycle {
|
||||
lc.dispatch_upgraded_to_internal(&updated).await;
|
||||
}
|
||||
|
||||
Ok(UserDto::from(updated))
|
||||
}
|
||||
|
||||
/// Admin-driven external → internal promotion.
|
||||
///
|
||||
/// Same wire outcome as [`Self::upgrade_to_internal`] but the actor
|
||||
/// is an operator, not the target user. The target's password stays
|
||||
/// as it was (usually `None` — magic-link-only accounts) so the
|
||||
/// deployment MUST have magic-link login enabled, otherwise the
|
||||
/// promoted user has no login path at all.
|
||||
///
|
||||
/// Refuses:
|
||||
/// - Target is already internal → 409 `AlreadyInternal`.
|
||||
/// - Target is OIDC-linked → 403 (IdP owns identity).
|
||||
/// - Magic-link login disabled deployment-wide → 400 with a hint.
|
||||
///
|
||||
/// On success:
|
||||
/// - `is_external → false`, `storage_quota_bytes → capped default`.
|
||||
/// - Home-drive provisioning fires via
|
||||
/// `PersonalDriveLifecycleHook::on_upgraded_to_internal` — same
|
||||
/// hook the self-upgrade path uses.
|
||||
/// - `user_flags_cache` invalidated on the target so per-request
|
||||
/// guards observe the new flag within one cache round-trip.
|
||||
/// - Audit line `event = "user.promoted_to_internal_by_admin"`
|
||||
/// with `by = <admin_id>`, `target_id = <user_id>`.
|
||||
pub async fn admin_promote_external_to_internal(
|
||||
&self,
|
||||
admin_id: Uuid,
|
||||
target_id: Uuid,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
let mut user = self.user_storage.get_user_by_id(target_id).await?;
|
||||
|
||||
if !user.is_external() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.promote_rejected",
|
||||
reason = "already_internal",
|
||||
by = %admin_id,
|
||||
target_id = %target_id,
|
||||
"👮🏻♂️ admin-promote refused: target user is already internal",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::Conflict,
|
||||
"User",
|
||||
"Account is already internal",
|
||||
));
|
||||
}
|
||||
|
||||
if user.is_oidc_user() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.promote_rejected",
|
||||
reason = "oidc_user",
|
||||
by = %admin_id,
|
||||
target_id = %target_id,
|
||||
"👮🏻♂️ admin-promote refused: OIDC-linked user is managed by the IdP",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"User",
|
||||
"SSO/OIDC accounts are managed by your identity provider",
|
||||
));
|
||||
}
|
||||
|
||||
// Admin can't set a password on the target's behalf, so the
|
||||
// upgraded account MUST have magic-link login available on the
|
||||
// deployment — otherwise no login path exists post-promotion.
|
||||
if !self.is_magic_link_login_allowed() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.promote_rejected",
|
||||
reason = "no_login_path",
|
||||
by = %admin_id,
|
||||
target_id = %target_id,
|
||||
"👮🏻♂️ admin-promote refused: magic-link login disabled and admin can't set the target's password",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"Cannot promote: magic-link login is disabled on this deployment, so the user would have no login path.",
|
||||
));
|
||||
}
|
||||
|
||||
let quota = self.capped_quota(&UserRole::User);
|
||||
|
||||
user.promote_to_internal(None, quota).map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::Conflict,
|
||||
"User",
|
||||
format!("Promote refused: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let updated = self.user_storage.update_user(user).await?;
|
||||
|
||||
// Invalidate the target's flags cache — same reason as the
|
||||
// self-upgrade path.
|
||||
self.user_flags_cache.invalidate(&target_id).await;
|
||||
|
||||
if let Some(lc) = &self.user_lifecycle {
|
||||
lc.dispatch_upgraded_to_internal(&updated).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.promoted_to_internal_by_admin",
|
||||
by = %admin_id,
|
||||
target_id = %target_id,
|
||||
"👮🏻♂️ external user promoted to internal by admin",
|
||||
);
|
||||
|
||||
Ok(UserDto::from(updated))
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
@@ -1172,12 +1633,20 @@ impl AuthApplicationService {
|
||||
/// Staleness is bounded by [`USER_FLAGS_CACHE_TTL`]; role and active
|
||||
/// changes made through this service invalidate the entry eagerly.
|
||||
pub async fn get_user_flags(&self, user_id: Uuid) -> Result<UserFlags, DomainError> {
|
||||
if let Some(flags) = self.user_flags_cache.get(&user_id) {
|
||||
return Ok(flags);
|
||||
}
|
||||
let flags = self.user_storage.get_user_flags(user_id).await?;
|
||||
self.user_flags_cache.insert(user_id, flags);
|
||||
Ok(flags)
|
||||
// Single-flight: concurrent misses for the same user coalesce
|
||||
// into ONE storage lookup; errors are never cached (same herd
|
||||
// shape ROUND3 fixed for basic-auth, minus the Argon2 cost).
|
||||
self.user_flags_cache
|
||||
.try_get_with(user_id, async {
|
||||
Ok::<_, DomainError>(self.user_storage.get_user_flags(user_id).await?)
|
||||
})
|
||||
.await
|
||||
// try_get_with hands back `Arc<DomainError>` shared by all
|
||||
// waiters; DomainError isn't Clone, so rebuild a fresh one
|
||||
// preserving the kind / entity / message.
|
||||
.map_err(|shared: std::sync::Arc<DomainError>| {
|
||||
DomainError::new(shared.kind, shared.entity_type, shared.message.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a profile update on behalf of the calling user (PR 24).
|
||||
@@ -1348,12 +1817,51 @@ impl AuthApplicationService {
|
||||
changed.push("notify_on_share");
|
||||
}
|
||||
|
||||
if changed.is_empty() {
|
||||
// ── UI preferences shallow-merge ──────────────────────────
|
||||
// The other fields above modify the in-memory `user` and land
|
||||
// via `update_user(user)` at the end. UI preferences take a
|
||||
// different path because the merge has to happen at write
|
||||
// time in SQL — two devices PATCH'ing partial patches
|
||||
// concurrently would otherwise race and clobber each other if
|
||||
// we did merge-then-write in application code. See
|
||||
// `UserPgRepository::update_ui_preferences` for the SQL.
|
||||
//
|
||||
// Boundary validation only: shape must be a JSON object.
|
||||
// Contents are opaque to the server — no key inspection here.
|
||||
// Size cap is enforced by the schema CHECK constraint; a
|
||||
// violating merge surfaces as a repo error.
|
||||
let ui_prefs_patch = if let Some(patch) = dto.ui_preferences.as_ref() {
|
||||
if !patch.is_object() {
|
||||
return Err(DomainError::validation_error(
|
||||
"ui_preferences must be a JSON object".to_string(),
|
||||
));
|
||||
}
|
||||
Some(patch.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if changed.is_empty() && ui_prefs_patch.is_none() {
|
||||
// No-op — return the current user without a DB write.
|
||||
return Ok(UserDto::from(user));
|
||||
}
|
||||
|
||||
let updated = self.user_storage.update_user(user).await?;
|
||||
// Persist the typed-field changes first (if any). Skip the
|
||||
// `update_user` call entirely when only `ui_preferences`
|
||||
// changed — the shallow-merge SQL below is authoritative for
|
||||
// that field, and running `update_user` unnecessarily would
|
||||
// rewrite every column with its current in-memory value.
|
||||
if !changed.is_empty() {
|
||||
self.user_storage.update_user(user).await?;
|
||||
}
|
||||
|
||||
if let Some(patch) = ui_prefs_patch {
|
||||
self.user_storage
|
||||
.update_ui_preferences(caller_id, &patch)
|
||||
.await?;
|
||||
changed.push("ui_preferences");
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.profile_updated",
|
||||
@@ -1362,7 +1870,11 @@ impl AuthApplicationService {
|
||||
"👤 profile updated for {}",
|
||||
caller_id,
|
||||
);
|
||||
Ok(UserDto::from(updated))
|
||||
|
||||
// Refetch so the returned DTO reflects the merged JSONB bag
|
||||
// (the in-memory `user` above holds the pre-merge value).
|
||||
let refreshed = self.user_storage.get_user_by_id(caller_id).await?;
|
||||
Ok(UserDto::from(refreshed))
|
||||
}
|
||||
|
||||
// Alias for consistency with handler method
|
||||
@@ -1420,17 +1932,28 @@ impl AuthApplicationService {
|
||||
expose_system_users: bool,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
let caller = self.user_storage.get_user_by_id(caller_id).await?;
|
||||
|
||||
// (1) Self.
|
||||
// (1) Self — a single fetch suffices (the check compares the input
|
||||
// UUIDs, so the target read is never needed on this path).
|
||||
if caller_id == target_id {
|
||||
let caller = self.user_storage.get_user_by_id(caller_id).await?;
|
||||
return Ok(UserDto::from(caller));
|
||||
}
|
||||
|
||||
// Caller and target are independent point reads (the self-case already
|
||||
// returned; the branch above compares input UUIDs, not fetched data) —
|
||||
// overlap them with `join!` instead of two serial round-trips.
|
||||
// `caller_res?` first preserves the caller-error precedence of the old
|
||||
// sequential form. (benches/ROUND23.md §P1)
|
||||
let (caller_res, target_res) = tokio::join!(
|
||||
self.user_storage.get_user_by_id(caller_id),
|
||||
self.user_storage.get_user_by_id(target_id)
|
||||
);
|
||||
let caller = caller_res?;
|
||||
|
||||
// Anti-enumeration: NotFound for everything that doesn't pass.
|
||||
// Convert a real NotFound on `target` to the same anonymous 404,
|
||||
// so existence isn't leaked through differential responses.
|
||||
let target = match self.user_storage.get_user_by_id(target_id).await {
|
||||
let target = match target_res {
|
||||
Ok(u) => u,
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
tracing::info!(
|
||||
@@ -1533,6 +2056,59 @@ impl AuthApplicationService {
|
||||
))
|
||||
}
|
||||
|
||||
/// Username-keyed sibling of [`Self::get_user_profile`], routing every
|
||||
/// lookup through the same visibility check as the user-profile REST
|
||||
/// endpoint. Preserves the anti-enum shape end-to-end: whether the
|
||||
/// username doesn't exist OR the caller has no visibility path, the
|
||||
/// response is `NotFound`.
|
||||
///
|
||||
/// AuthZ audit #11 (2026-07-12): NextCloud OCS user-provisioning
|
||||
/// (`nextcloud/ocs_handler.rs::user_provisioning_response`) used to
|
||||
/// resolve `userid` via bare `get_user_by_username`, gated only by a
|
||||
/// bespoke `caller.role == "admin"` shortcut. Admins bypassed the
|
||||
/// `expose_system_users` gate; non-admins got a `403 Insufficient
|
||||
/// privileges` for any cross-user probe (leaking existence via the
|
||||
/// differential vs a genuine 404); zero audit lines. This wrapper
|
||||
/// closes all three.
|
||||
///
|
||||
/// The username→id resolution happens here so the target isn't
|
||||
/// leaked through the audit line as a plaintext username on failure:
|
||||
/// the `target_username_not_found` event carries the string
|
||||
/// (unavoidable — we resolved it, we log it), but every other
|
||||
/// downstream event keys off `target_id` after resolution, matching
|
||||
/// the id-based endpoint.
|
||||
pub async fn get_user_profile_by_username_with_perms(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
username: &str,
|
||||
expose_system_users: bool,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
let target = match self.user_storage.get_user_by_username(username).await {
|
||||
Ok(u) => u,
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user_profile.rejected",
|
||||
reason = "target_username_not_found",
|
||||
caller_id = %caller_id,
|
||||
target_username = %username,
|
||||
"👮🏻♂️ user-profile rejected: username '{}' does not exist (caller {})",
|
||||
username,
|
||||
caller_id,
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"User",
|
||||
"User not found",
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
self.get_user_profile(caller_id, target.id(), expose_system_users, pool)
|
||||
.await
|
||||
}
|
||||
|
||||
// New method to get user by username - needed for admin user handling
|
||||
pub async fn get_user_by_username(&self, username: &str) -> Result<UserDto, DomainError> {
|
||||
let user = self.user_storage.get_user_by_username(username).await?;
|
||||
@@ -1542,21 +2118,11 @@ impl AuthApplicationService {
|
||||
// Method to count how many admin users exist in the system
|
||||
// Used to determine if we have multiple admins or just the default one
|
||||
pub async fn count_admin_users(&self) -> Result<i64, DomainError> {
|
||||
// Use the list_users_by_role method or similar from user_storage port
|
||||
// For now, we'll use a basic implementation that counts all users with role = "admin"
|
||||
let admin_users = self
|
||||
.user_storage
|
||||
.list_users_by_role("admin")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"User",
|
||||
format!("Error counting admin users: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(admin_users.len() as i64)
|
||||
// Scalar COUNT(*) — the old form fetched every admin's FULL row (incl.
|
||||
// the up-to-512 KiB avatar `image` + `ui_preferences` JSONB) only to
|
||||
// call `.len()`, on a status/init endpoint that is polled at bootstrap
|
||||
// (benches/ROUND29.md §G).
|
||||
self.user_storage.count_users_by_role("admin").await
|
||||
}
|
||||
|
||||
/// Lists internal users only. External (grant-only) users are filtered
|
||||
@@ -1586,6 +2152,24 @@ impl AuthApplicationService {
|
||||
Ok(users.into_iter().map(UserDto::from).collect())
|
||||
}
|
||||
|
||||
/// Username-only search for the NC sharee autocomplete: identical
|
||||
/// predicate / order / limit to [`search_users`], but the repository
|
||||
/// projects just `username` — no 21-column hydration (incl. the
|
||||
/// up-to-512 KiB avatar `image`) per matched row, per keystroke
|
||||
/// (benches/ROUND12.md §1). NULL usernames (email-only signups) are
|
||||
/// filtered app-side, exactly like the wide flow's post-limit filter.
|
||||
pub async fn search_sharee_usernames(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: i64,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
let names = self
|
||||
.user_storage
|
||||
.search_usernames(query, limit, false)
|
||||
.await?;
|
||||
Ok(names.into_iter().flatten().collect())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Admin User Management Methods
|
||||
// ========================================================================
|
||||
@@ -1717,6 +2301,16 @@ impl AuthApplicationService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Admin fiat counts as verification. When
|
||||
// `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set, admin-created users
|
||||
// still get to log in without a magic-link round-trip — the
|
||||
// operator explicitly vouched for the address at creation. This
|
||||
// mirrors the OIDC-JIT convention (see `redeem_pending_oidc_token`
|
||||
// and `login_oidc_callback` which also stamp
|
||||
// `email_verified_at` on first sight).
|
||||
let mut user = user;
|
||||
user.mark_email_verified();
|
||||
|
||||
// Persist
|
||||
let created = self.user_storage.create_user(user).await?;
|
||||
|
||||
@@ -1837,11 +2431,17 @@ impl AuthApplicationService {
|
||||
self.user_storage
|
||||
.set_user_active_status(user_id, active)
|
||||
.await?;
|
||||
self.user_flags_cache.invalidate(&user_id);
|
||||
self.user_flags_cache.invalidate(&user_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change user role (admin only)
|
||||
/// Change user role (admin only).
|
||||
///
|
||||
/// Refuses `role = "admin"` when the target is external (grant-only).
|
||||
/// The DB CHECK `users_external_not_admin` would also refuse this at
|
||||
/// COMMIT, but surfacing it here yields a clean `InvalidInput` error
|
||||
/// with an audit line naming the reason, instead of a bare
|
||||
/// constraint-violation stringified out of Postgres.
|
||||
pub async fn change_user_role(&self, user_id: Uuid, role: &str) -> Result<(), DomainError> {
|
||||
if role != "admin" && role != "user" {
|
||||
return Err(DomainError::new(
|
||||
@@ -1850,8 +2450,27 @@ impl AuthApplicationService {
|
||||
format!("Invalid role: {}. Must be 'admin' or 'user'", role),
|
||||
));
|
||||
}
|
||||
|
||||
if role == "admin" {
|
||||
let target = self.user_storage.get_user_by_id(user_id).await?;
|
||||
if target.is_external() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.role_change_rejected",
|
||||
reason = "external_cannot_be_admin",
|
||||
target_id = %user_id,
|
||||
"👮🏻♂️ role change refused: external users cannot hold the admin role",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"External accounts cannot hold the admin role. Promote the user to internal first.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
self.user_storage.change_role(user_id, role).await?;
|
||||
self.user_flags_cache.invalidate(&user_id);
|
||||
self.user_flags_cache.invalidate(&user_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2153,6 +2772,15 @@ impl AuthApplicationService {
|
||||
if let Some(lc) = &self.user_lifecycle {
|
||||
lc.dispatch_login(&existing_user).await;
|
||||
}
|
||||
// Decide BEFORE mutating: the row just fetched already
|
||||
// carries the stored avatar + verification stamp, so the
|
||||
// repeat-login common case (same IdP picture, already
|
||||
// verified) skips the DB entirely — the old shape rewrote
|
||||
// all 17 columns per login, and even a guarded UPDATE
|
||||
// would ship the avatar over the wire just to compare it
|
||||
// (benches/ROUND12.md §3b).
|
||||
let needs_profile_sync = existing_user.email_verified_at().is_none()
|
||||
|| existing_user.image() != claims.picture.as_deref();
|
||||
existing_user.register_login();
|
||||
existing_user.set_image(claims.picture.clone());
|
||||
// PR 23: retroactive email verification for OIDC users
|
||||
@@ -2161,7 +2789,16 @@ impl AuthApplicationService {
|
||||
// any user reaching this branch has a verified email
|
||||
// by the IdP's word; stamping is safe and idempotent.
|
||||
existing_user.mark_email_verified();
|
||||
self.user_storage.update_user(existing_user.clone()).await?;
|
||||
// Narrow guarded sync instead of the 17-column row rewrite:
|
||||
// persists the IdP avatar + the verification stamp only
|
||||
// when either actually changed; `last_login_at` is stamped
|
||||
// by `create_session` at the end of this flow
|
||||
// (benches/ROUND12.md §3).
|
||||
if needs_profile_sync {
|
||||
self.user_storage
|
||||
.sync_oidc_login_profile(existing_user.id(), claims.picture.as_deref())
|
||||
.await?;
|
||||
}
|
||||
existing_user
|
||||
}
|
||||
Err(_) => {
|
||||
|
||||
@@ -726,25 +726,46 @@ impl BatchOperationService {
|
||||
let mut items_added: usize = 0;
|
||||
|
||||
// ── Add individual files at the root of the ZIP ──────────────────
|
||||
// Authorize + fetch metadata for the whole multi-select in 2 round-trips
|
||||
// (one batch Read check + one batch get) instead of the per-file
|
||||
// `get_file_with_perms` N+1 (2 round-trips/file). The batch check also
|
||||
// primes the resource→drive cache, so `add_file_entry_streamed`'s
|
||||
// per-file stream-open re-check lands on the cache. A denied / missing /
|
||||
// unparseable id is absent from the map → skipped in the same input
|
||||
// order, exactly as the old per-file loop skipped it. Authorization is
|
||||
// UNCHANGED — still enforced (pre-check here + the stream open's own
|
||||
// Read check + Recents recording) before any ZIP entry is written, so a
|
||||
// denied file never leaks its name into the archive (benches/ROUND24.md).
|
||||
let authorized = self
|
||||
.file_retrieval
|
||||
.get_files_by_ids_with_perms(&file_ids, user_id)
|
||||
.await
|
||||
.map_err(BatchOperationError::Domain)?;
|
||||
let by_id: HashMap<Uuid, FileDto> = authorized
|
||||
.into_iter()
|
||||
.filter_map(|f| Uuid::parse_str(&f.id).ok().map(|u| (u, f)))
|
||||
.collect();
|
||||
for file_id in &file_ids {
|
||||
let file_dto = match Uuid::parse_str(file_id).ok().and_then(|u| by_id.get(&u)) {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
info!("Skipping file {} (not accessible or missing)", file_id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match self
|
||||
.file_retrieval
|
||||
.get_file_with_perms(file_id, user_id)
|
||||
.add_file_entry_streamed(
|
||||
&mut zip,
|
||||
file_id,
|
||||
&file_dto.name,
|
||||
&file_dto.mime_type,
|
||||
Some(user_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(file_dto) => {
|
||||
match self
|
||||
.add_file_entry_streamed(&mut zip, file_id, &file_dto.name, user_id)
|
||||
.await
|
||||
{
|
||||
Ok(_) => items_added += 1,
|
||||
Err(e) => {
|
||||
info!("Could not add file {} to ZIP: {}", file_dto.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => items_added += 1,
|
||||
Err(e) => {
|
||||
info!("Could not get file metadata {}: {}", file_id, e);
|
||||
info!("Could not add file {} to ZIP: {}", file_dto.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -758,7 +779,7 @@ impl BatchOperationService {
|
||||
{
|
||||
Ok(root_folder) => {
|
||||
match self
|
||||
.add_folder_subtree_to_zip(&mut zip, folder_id, &root_folder, user_id)
|
||||
.add_folder_subtree_to_zip(&mut zip, folder_id, &root_folder)
|
||||
.await
|
||||
{
|
||||
Ok(_) => items_added += 1,
|
||||
@@ -803,24 +824,44 @@ impl BatchOperationService {
|
||||
}
|
||||
|
||||
/// Streams a single file into an async ZIP entry (~64 KB peak RAM per file).
|
||||
///
|
||||
/// Already-compressed content (per its MIME type) is `Stored` — deflating
|
||||
/// JPEG/MP4/… burns ~a CPU core per download for ~0 % size gain.
|
||||
///
|
||||
/// `caller_id = Some(uid)` enforces the per-file Read check and records
|
||||
/// the access in Recents (explicitly-selected top-level files).
|
||||
/// `None` = the file was enumerated from a folder subtree whose ROOT the
|
||||
/// caller already passed `get_folder_with_perms` for — per-file
|
||||
/// re-authorization and per-file Recent spam (2 writes/file via the
|
||||
/// recent hook) are skipped, mirroring `ZipService::create_folder_zip`
|
||||
/// on the native folder-download path (benches/ZIP-BATCH-AUTHZ.md).
|
||||
async fn add_file_entry_streamed(
|
||||
&self,
|
||||
zip: &mut ZipFileWriter<tokio_util::compat::Compat<BufWriter<tokio::fs::File>>>,
|
||||
file_id: &str,
|
||||
entry_name: &str,
|
||||
caller_id: Uuid,
|
||||
mime_type: &str,
|
||||
caller_id: Option<Uuid>,
|
||||
) -> Result<(), BatchOperationError> {
|
||||
let entry = ZipEntryBuilder::new(entry_name.to_string().into(), Compression::Deflate);
|
||||
let compression = crate::common::mime_detect::zip_entry_compression(mime_type);
|
||||
let entry = ZipEntryBuilder::new(entry_name.to_string().into(), compression);
|
||||
let mut writer = zip
|
||||
.write_entry_stream(entry)
|
||||
.await
|
||||
.map_err(|e| BatchOperationError::Internal(format!("zip entry start: {}", e)))?;
|
||||
|
||||
let stream = self
|
||||
.file_retrieval
|
||||
.get_file_stream_with_perms(file_id, caller_id)
|
||||
.await
|
||||
.map_err(BatchOperationError::Domain)?;
|
||||
let stream = match caller_id {
|
||||
Some(uid) => self
|
||||
.file_retrieval
|
||||
.get_file_stream_with_perms(file_id, uid)
|
||||
.await
|
||||
.map_err(BatchOperationError::Domain)?,
|
||||
None => self
|
||||
.file_retrieval
|
||||
.get_file_stream(file_id)
|
||||
.await
|
||||
.map_err(BatchOperationError::Domain)?,
|
||||
};
|
||||
let mut stream = std::pin::Pin::from(stream);
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
@@ -849,7 +890,6 @@ impl BatchOperationService {
|
||||
zip: &mut ZipFileWriter<tokio_util::compat::Compat<BufWriter<tokio::fs::File>>>,
|
||||
folder_id: &str,
|
||||
root_folder: &FolderDto,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), BatchOperationError> {
|
||||
// Bulk-fetch folder tree (small — one entry per folder)
|
||||
let all_folders = self
|
||||
@@ -903,8 +943,10 @@ impl BatchOperationService {
|
||||
if let Some(files) = files_by_folder.get(&folder.id) {
|
||||
for file in files {
|
||||
let file_path = format!("{}{}", zip_dir, file.name);
|
||||
// Subtree pre-authorized at the root folder — see
|
||||
// `add_file_entry_streamed` docs for why `None`.
|
||||
if let Err(e) = self
|
||||
.add_file_entry_streamed(zip, &file.id, &file_path, caller_id)
|
||||
.add_file_entry_streamed(zip, &file.id, &file_path, &file.mime_type, None)
|
||||
.await
|
||||
{
|
||||
info!("Could not add file {} to ZIP: {}", file.name, e);
|
||||
|
||||
@@ -10,6 +10,7 @@ mod tests {
|
||||
use crate::application::services::batch_operations::{
|
||||
BatchOperationService, BatchResult, BatchStats,
|
||||
};
|
||||
use crate::application::services::file_lifecycle_service::FileLifecycleService;
|
||||
use crate::application::services::file_management_service::FileManagementService;
|
||||
use crate::application::services::file_retrieval_service::FileRetrievalService;
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
@@ -105,7 +106,12 @@ mod tests {
|
||||
crate::application::services::mount_registry::MountRegistry::empty(),
|
||||
)),
|
||||
);
|
||||
let folder_service = Arc::new(FolderService::new(folder_repo, authz, mount_router));
|
||||
let folder_service = Arc::new(FolderService::new(
|
||||
folder_repo,
|
||||
authz,
|
||||
Arc::new(FileLifecycleService::new()),
|
||||
mount_router,
|
||||
));
|
||||
|
||||
let _batch_service = BatchOperationService::new(
|
||||
file_retrieval,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -6,17 +7,82 @@ use crate::application::dtos::calendar_dto::{
|
||||
CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto,
|
||||
UpdateCalendarDto, UpdateEventDto,
|
||||
};
|
||||
use crate::application::ports::calendar_ports::{CalendarStoragePort, CalendarUseCase};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::calendar_ports::{
|
||||
CalendarStoragePort, CalendarUseCase, UpsertEventsResult,
|
||||
};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
/// Calendar service — the CalDAV / REST entry point for every calendar
|
||||
/// or event operation. Every method routes through `AuthorizationEngine`;
|
||||
/// the pre-Round-3 `check_calendar_access` bespoke helper is gone.
|
||||
///
|
||||
/// Ownership + sharing live entirely in `storage.role_grants`
|
||||
/// (`resource_type='calendar'`). `caldav.calendars.owner_id` stays for
|
||||
/// provenance and legacy queries but is no longer consulted for access
|
||||
/// decisions.
|
||||
pub struct CalendarService {
|
||||
calendar_storage: Arc<CalendarStorageAdapter>,
|
||||
/// ReBAC engine — every user-facing method calls `authz.require`
|
||||
/// with the appropriate `Permission`. `create_calendar` also
|
||||
/// uses it to seed an Owner grant for the caller so the common
|
||||
/// "owning my own calendar" case takes a single indexed
|
||||
/// role_grants lookup.
|
||||
authz: Arc<PgAclEngine>,
|
||||
}
|
||||
|
||||
impl CalendarService {
|
||||
pub fn new(calendar_storage: Arc<CalendarStorageAdapter>) -> Self {
|
||||
Self { calendar_storage }
|
||||
pub fn new(calendar_storage: Arc<CalendarStorageAdapter>, authz: Arc<PgAclEngine>) -> Self {
|
||||
Self {
|
||||
calendar_storage,
|
||||
authz,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `calendar_id` and enforce `permission` on `Resource::Calendar(uuid)`.
|
||||
/// On denial `authz.require` returns `NotFound` (anti-enum — same
|
||||
/// shape as "no such calendar") and emits the `authz.denied` audit
|
||||
/// line. Returns the parsed UUID on success so the caller doesn't
|
||||
/// have to parse it a second time.
|
||||
async fn require_calendar_perm(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<Uuid, DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid ID"))?;
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
permission,
|
||||
Resource::Calendar(uuid),
|
||||
)
|
||||
.await?;
|
||||
Ok(uuid)
|
||||
}
|
||||
|
||||
/// Check `permission` on a calendar without throwing. Used by the
|
||||
/// read paths that also allow a public-calendar bypass — they need
|
||||
/// a bool, not a `Result<(), NotFound>`.
|
||||
async fn has_calendar_perm(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid ID"))?;
|
||||
self.authz
|
||||
.check(
|
||||
Subject::User(caller_id),
|
||||
permission,
|
||||
Resource::Calendar(uuid),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +92,30 @@ impl CalendarUseCase for CalendarService {
|
||||
calendar: CreateCalendarDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
self.calendar_storage
|
||||
// No pre-write gate: creating a calendar is a personal act
|
||||
// (like creating a folder in your own drive). Storage stamps
|
||||
// `owner_id = user_id`; we then seed an Owner role_grant so
|
||||
// the engine's cache warms on first-read.
|
||||
let created = self
|
||||
.calendar_storage
|
||||
.create_calendar(calendar, user_id)
|
||||
.await
|
||||
.await?;
|
||||
let calendar_uuid = Uuid::parse_str(&created.id).map_err(|_| {
|
||||
DomainError::internal_error("Calendar", "storage returned invalid calendar id")
|
||||
})?;
|
||||
// `set_role` is idempotent on the `(subject, resource)` unique
|
||||
// key — a re-run (rare — only if storage retried) is a no-op.
|
||||
// `granted_by = user_id` is the self-seeded creation event.
|
||||
self.authz
|
||||
.set_role(
|
||||
user_id,
|
||||
Subject::User(user_id),
|
||||
Role::Owner,
|
||||
Resource::Calendar(calendar_uuid),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
async fn update_calendar(
|
||||
@@ -37,35 +124,28 @@ impl CalendarUseCase for CalendarService {
|
||||
update: UpdateCalendarDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
self.require_calendar_perm(calendar_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to update this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage
|
||||
.update_calendar(calendar_id, update)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_calendar(&self, calendar_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
let uuid = self
|
||||
.require_calendar_perm(calendar_id, user_id, Permission::Delete)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to delete this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.delete_calendar(calendar_id).await
|
||||
self.calendar_storage.delete_calendar(calendar_id).await?;
|
||||
// Wipe every grant on this calendar so a re-used UUID (impossible
|
||||
// today but cheap to defend against) doesn't inherit stale ACLs.
|
||||
// The storage DELETE won't cascade to `storage.role_grants` — the
|
||||
// legacy `caldav.calendar_shares` had an FK, `role_grants`
|
||||
// doesn't (it's cross-schema).
|
||||
let _ = self
|
||||
.authz
|
||||
.revoke_all_for_resource(Resource::Calendar(uuid))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_calendar(
|
||||
@@ -74,28 +154,48 @@ impl CalendarUseCase for CalendarService {
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view this calendar",
|
||||
));
|
||||
// Public-calendar bypass: anonymous-ish read. `check` returns
|
||||
// bool (no throw); combine with the public flag before
|
||||
// deciding.
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
Ok(calendar)
|
||||
}
|
||||
|
||||
async fn list_my_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
self.calendar_storage.list_calendars_by_owner(user_id).await
|
||||
}
|
||||
// Post-Round-3 semantics: every calendar the caller has any
|
||||
// grant on — owned + shared, one union. The pre-Round-3
|
||||
// `list_calendars_by_owner` returned owner-only; shared
|
||||
// calendars never surfaced through this method. See
|
||||
// `docs/plan/caldav-carddav-migration-to-authz.md`.
|
||||
let grants = self
|
||||
.authz
|
||||
.list_incoming_grants(Subject::User(user_id))
|
||||
.await?;
|
||||
|
||||
async fn list_shared_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
self.calendar_storage
|
||||
.list_calendars_shared_with_user(user_id)
|
||||
.await
|
||||
// Deduplicate — a user can hold multiple grants on the same
|
||||
// calendar (direct + group-inherited). We only need one DTO
|
||||
// per resource.
|
||||
let calendar_ids: HashSet<Uuid> = grants
|
||||
.into_iter()
|
||||
.filter_map(|g| match g.resource {
|
||||
Resource::Calendar(id) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Hydrate DTOs in ONE `= ANY` round-trip (was one point SELECT
|
||||
// per accessible calendar — K serial round-trips on every
|
||||
// CalDAV discovery poll). Missing rows (deleted/trashed race)
|
||||
// drop out of the result set instead of erroring, so a
|
||||
// lifecycle-race still doesn't turn a PROPFIND into a 5xx.
|
||||
let ids: Vec<Uuid> = calendar_ids.into_iter().collect();
|
||||
self.calendar_storage.get_calendars_by_ids(&ids).await
|
||||
}
|
||||
|
||||
async fn list_public_calendars(
|
||||
@@ -103,6 +203,8 @@ impl CalendarUseCase for CalendarService {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
// No caller gate: public listing by definition. Storage
|
||||
// filters on `is_public = true`.
|
||||
let limit = limit.unwrap_or(100);
|
||||
let offset = offset.unwrap_or(0);
|
||||
self.calendar_storage
|
||||
@@ -110,90 +212,13 @@ impl CalendarUseCase for CalendarService {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
target_user_id: Uuid,
|
||||
access_level: &str,
|
||||
caller_user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if calendar.owner_id != caller_user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can change sharing settings",
|
||||
));
|
||||
}
|
||||
match access_level {
|
||||
"read" | "write" | "owner" => {}
|
||||
_ => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
format!(
|
||||
"Invalid access level: {}. Valid values are: read, write, owner",
|
||||
access_level
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
self.calendar_storage
|
||||
.share_calendar(calendar_id, target_user_id, access_level)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
target_user_id: Uuid,
|
||||
caller_user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if calendar.owner_id != caller_user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can change sharing settings",
|
||||
));
|
||||
}
|
||||
self.calendar_storage
|
||||
.remove_calendar_sharing(calendar_id, target_user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<(String, String)>, DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if calendar.owner_id != user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can view sharing settings",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.get_calendar_shares(calendar_id).await
|
||||
}
|
||||
|
||||
async fn create_event(
|
||||
&self,
|
||||
event: CreateEventDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to add events to this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.create_event(event).await
|
||||
}
|
||||
|
||||
@@ -202,54 +227,50 @@ impl CalendarUseCase for CalendarService {
|
||||
event: CreateEventICalDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to add events to this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.create_event_from_ical(event).await
|
||||
}
|
||||
|
||||
async fn upsert_ical_events(
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<UpsertEventsResult, DomainError> {
|
||||
// Same gate as create_event_from_ical — a PUT to the collection
|
||||
// is a write. `Permission::Create` matches the single-event
|
||||
// path; per-instance exception updates ride on the same
|
||||
// permission because from the ACL's perspective it's still
|
||||
// a write to the calendar.
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||
.await?;
|
||||
self.calendar_storage.upsert_ical_events(event).await
|
||||
}
|
||||
|
||||
async fn update_event(
|
||||
&self,
|
||||
event_id: &str,
|
||||
update: UpdateEventDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
let has_access = self
|
||||
// Only the owning calendar id is needed for the gate — skip the
|
||||
// full event hydration (`ical_data` can run to tens of KB).
|
||||
let calendar_id = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.calendar_id_for_event(event_id)
|
||||
.await?;
|
||||
self.require_calendar_perm(&calendar_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to update events in this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.update_event(event_id, update).await
|
||||
}
|
||||
|
||||
async fn delete_event(&self, event_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
let has_access = self
|
||||
let calendar_id = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.calendar_id_for_event(event_id)
|
||||
.await?;
|
||||
self.require_calendar_perm(&calendar_id, user_id, Permission::Delete)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to delete events in this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.delete_event(event_id).await
|
||||
}
|
||||
|
||||
@@ -259,20 +280,17 @@ impl CalendarUseCase for CalendarService {
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self
|
||||
.calendar_storage
|
||||
.get_calendar(&event.calendar_id)
|
||||
.await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
// Same public-calendar bypass as `get_calendar`.
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(&event.calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Event", event_id));
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
@@ -283,17 +301,13 @@ impl CalendarUseCase for CalendarService {
|
||||
ical_uid: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
self.calendar_storage
|
||||
.find_event_by_ical_uid(calendar_id, ical_uid)
|
||||
@@ -306,17 +320,13 @@ impl CalendarUseCase for CalendarService {
|
||||
ical_uids: &[String],
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
if ical_uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -333,17 +343,13 @@ impl CalendarUseCase for CalendarService {
|
||||
offset: Option<i64>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
if limit.is_some() || offset.is_some() {
|
||||
let limit = limit.unwrap_or(100);
|
||||
@@ -358,6 +364,28 @@ impl CalendarUseCase for CalendarService {
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_events_uid_order(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<
|
||||
futures::stream::BoxStream<'static, Result<CalendarEventDto, DomainError>>,
|
||||
DomainError,
|
||||
> {
|
||||
// Same Read gate as `list_events`, checked ONCE before the
|
||||
// cursor opens — the stream itself carries no further authz
|
||||
// (single request, same caller, same resource).
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
Ok(self.calendar_storage.stream_events_uid_order(calendar_id))
|
||||
}
|
||||
|
||||
async fn get_events_in_range(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
@@ -365,20 +393,188 @@ impl CalendarUseCase for CalendarService {
|
||||
end: DateTime<Utc>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
self.calendar_storage
|
||||
.get_events_in_time_range(calendar_id, &start, &end)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DefaultCalendarLifecycleHook
|
||||
//
|
||||
// Ensures every internal user has at least one owned calendar so CalDAV
|
||||
// clients (Thunderbird, Apple Calendar, DAVx⁵, Gnome Calendar) succeed at
|
||||
// their PROPFIND-based calendar discovery on first connect. Without this,
|
||||
// a fresh user's calendar home collection is empty and every mainstream
|
||||
// client returns "no calendars found" rather than offering to create one
|
||||
// (see AtalayaLabs/OxiCloud#545).
|
||||
//
|
||||
// Idempotency: keyed on "user owns at least one calendar" via
|
||||
// `list_calendars_by_owner`. If the user has any owned calendar — whether
|
||||
// auto-provisioned by an earlier run, manually created by the user, or
|
||||
// migrated in from another source — the hook skips. A user who deletes
|
||||
// their only calendar gets a fresh default on next login (Nextcloud-style
|
||||
// safety-net), matching `PersonalDriveLifecycleHook`. If they don't want
|
||||
// a default, they're free to leave one they never open — it's an entry
|
||||
// in a list, not a bill.
|
||||
//
|
||||
// Skips `is_external = true`. External users don't own resources; they
|
||||
// only receive shares. When an external is later upgraded to internal via
|
||||
// `POST /api/auth/upgrade-to-internal`, `on_upgraded_to_internal` fires
|
||||
// and provisions the default at that point.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
|
||||
use crate::domain::entities::user::User;
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct DefaultCalendarLifecycleHook {
|
||||
calendar_storage: Arc<CalendarStorageAdapter>,
|
||||
/// Concrete engine — same reasoning as `PersonalDriveLifecycleHook`:
|
||||
/// `AuthorizationEngine` isn't dyn-compatible (native async-fn-in-
|
||||
/// trait), so we hold the concrete `PgAclEngine`.
|
||||
authorization: Arc<PgAclEngine>,
|
||||
/// Display name for the default calendar. Matches the Nextcloud
|
||||
/// convention so switching users don't notice the difference.
|
||||
/// Not user-visible-only — CalDAV clients render this string.
|
||||
default_name: String,
|
||||
}
|
||||
|
||||
impl DefaultCalendarLifecycleHook {
|
||||
pub fn new(
|
||||
calendar_storage: Arc<CalendarStorageAdapter>,
|
||||
authorization: Arc<PgAclEngine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
calendar_storage,
|
||||
authorization,
|
||||
// "Personal" mirrors the Nextcloud default. Kept as a
|
||||
// struct field so a future `OXICLOUD_DEFAULT_CALENDAR_NAME`
|
||||
// env var can override without touching the hook body.
|
||||
default_name: "Personal".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotent provisioning. Shared by `on_user_created`,
|
||||
/// `on_user_login` (safety-net for pre-existing users), and
|
||||
/// `on_upgraded_to_internal` (external → internal promotion).
|
||||
async fn provision_if_needed(&self, user: &User) -> Result<(), DomainError> {
|
||||
if user.is_external() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ownership-based idempotency check (see hook docstring for
|
||||
// the design rationale). Whether the existing calendar was
|
||||
// auto-provisioned by a prior run, manually created by the
|
||||
// user, or migrated in, we respect it and skip. `EXISTS`
|
||||
// short-circuits at the first owned row instead of hydrating them
|
||||
// all just to test emptiness — this runs on EVERY login
|
||||
// (benches/ROUND13.md §Q2).
|
||||
let has_calendar = self
|
||||
.calendar_storage
|
||||
.has_owned_calendar(user.id())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"DefaultCalendarHook",
|
||||
format!("has_owned_calendar: {e}"),
|
||||
)
|
||||
})?;
|
||||
if has_calendar {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Provision. Two writes: calendar row + Owner role_grant. The
|
||||
// Owner grant makes the CalDAV engine's grant lookup on first
|
||||
// read a cache hit, matching the pattern in
|
||||
// `CalendarService::create_calendar`.
|
||||
let dto = CreateCalendarDto {
|
||||
name: self.default_name.clone(),
|
||||
description: None,
|
||||
color: None,
|
||||
is_public: Some(false),
|
||||
};
|
||||
let created = self
|
||||
.calendar_storage
|
||||
.create_calendar(dto, user.id())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("DefaultCalendarHook", format!("create_calendar: {e}"))
|
||||
})?;
|
||||
let calendar_uuid = Uuid::parse_str(&created.id).map_err(|_| {
|
||||
DomainError::internal_error(
|
||||
"DefaultCalendarHook",
|
||||
"storage returned invalid calendar id",
|
||||
)
|
||||
})?;
|
||||
self.authorization
|
||||
.set_role(
|
||||
user.id(),
|
||||
Subject::User(user.id()),
|
||||
Role::Owner,
|
||||
Resource::Calendar(calendar_uuid),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
target: "user_lifecycle",
|
||||
hook = "default_calendar",
|
||||
user_id = %user.id(),
|
||||
calendar_id = %calendar_uuid,
|
||||
"Default calendar provisioned"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserLifecycleHook for DefaultCalendarLifecycleHook {
|
||||
fn name(&self) -> &'static str {
|
||||
"default_calendar"
|
||||
}
|
||||
|
||||
async fn on_user_created(&self, user: &User) -> Result<(), DomainError> {
|
||||
self.provision_if_needed(user).await
|
||||
}
|
||||
|
||||
/// Safety-net: fires on every login, provisions if the user has no
|
||||
/// owned calendar. This is what fixes pre-existing users after the
|
||||
/// hook ships — no data migration needed, they get their default on
|
||||
/// their next login. Same pattern as `PersonalDriveLifecycleHook`.
|
||||
async fn on_user_login(&self, user: &User) -> Result<(), DomainError> {
|
||||
self.provision_if_needed(user).await
|
||||
}
|
||||
|
||||
/// External → internal upgrade. At creation the user was external
|
||||
/// (guarded off in `provision_if_needed`); now they're internal
|
||||
/// and eligible for a default calendar.
|
||||
async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> {
|
||||
self.provision_if_needed(user).await
|
||||
}
|
||||
|
||||
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_user_deleted(
|
||||
&self,
|
||||
_user: &User,
|
||||
_mode: DeletionMode,
|
||||
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DomainError> {
|
||||
// `caldav.calendars.owner_id` has ON DELETE CASCADE on
|
||||
// `auth.users(id)`, and calendar_events cascade off calendar.
|
||||
// The trigger on `role_grants` reaps the token grants. No
|
||||
// hook-side cleanup needed.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -331,6 +331,21 @@ impl DeltaUploadService {
|
||||
.check_storage_quota(caller_id, total_size)
|
||||
.await?;
|
||||
|
||||
// ── Per-drive quota (D4) ─────────────────────────────────
|
||||
// Mirrors the per-user check above on the same `total_size`.
|
||||
// Only on CREATE — Update replaces an existing row's content;
|
||||
// tight size-delta accounting on update is a follow-up (today
|
||||
// the periodic sweep reconciles drift either way). The
|
||||
// single-statement `check_drive_quota_by_folder` lookup is a
|
||||
// PK probe; cost matches the existing per-user check.
|
||||
if let CommitMode::Create { folder_id, .. } = &mode {
|
||||
let folder_uuid = Uuid::parse_str(folder_id)
|
||||
.map_err(|_| DomainError::not_found("Folder", folder_id.clone()))?;
|
||||
self.quota
|
||||
.check_drive_quota_by_folder(folder_uuid, total_size)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// ── Whole-file fast path: caller already owns this exact content ──
|
||||
// Mirrors the instant-upload endpoint: a reference bump, no chunk
|
||||
// work at all. Ownership is required — an existing-but-foreign
|
||||
@@ -383,7 +398,7 @@ impl DeltaUploadService {
|
||||
let verification = self
|
||||
.dedup
|
||||
.hash_chunk_sequence(
|
||||
&request
|
||||
request
|
||||
.chunks
|
||||
.iter()
|
||||
.map(|c| (c.h.clone(), c.s))
|
||||
@@ -431,8 +446,12 @@ impl DeltaUploadService {
|
||||
ct if ct.is_empty() => "application/octet-stream".to_string(),
|
||||
ct => ct,
|
||||
};
|
||||
let chunk_hashes: Vec<String> = request.chunks.iter().map(|c| c.h.clone()).collect();
|
||||
let chunk_sizes: Vec<u64> = request.chunks.iter().map(|c| c.s).collect();
|
||||
// `request.chunks` is owned and dead after this line (only
|
||||
// `request.file_hash` is read below), so move the hashes out instead of
|
||||
// cloning each 64-char hash a third time — the distinct set and the
|
||||
// verification tuple already materialized it twice (benches/ROUND25.md §M2).
|
||||
let (chunk_hashes, chunk_sizes): (Vec<String>, Vec<u64>) =
|
||||
request.chunks.into_iter().map(|c| (c.h, c.s)).unzip();
|
||||
let attached = self
|
||||
.dedup
|
||||
.attach_manifest(
|
||||
@@ -534,7 +553,10 @@ impl DeltaUploadService {
|
||||
self.max_chunk_count()
|
||||
)));
|
||||
}
|
||||
let mut distinct_seen = HashSet::new();
|
||||
// foldhash::quality::RandomState — a fast, per-instance random-seeded
|
||||
// hasher, DoS-safe for these attacker-controlled client hashes (up to
|
||||
// max_chunk_count() of them per request) — benches/ROUND26.md §G1.
|
||||
let mut distinct_seen: HashSet<&str, foldhash::quality::RandomState> = HashSet::default();
|
||||
for hash in &request.hashes {
|
||||
if !is_valid_hash(hash) {
|
||||
return Err(DomainError::validation_error(
|
||||
@@ -592,6 +614,12 @@ impl DeltaUploadService {
|
||||
Ok(DeltaDownloadOutcome::Ready(ordered))
|
||||
}
|
||||
|
||||
/// Backend-recommended read-ahead depth for multi-chunk drains
|
||||
/// (see `DedupService::read_prefetch`).
|
||||
pub fn read_prefetch(&self) -> usize {
|
||||
self.dedup.read_prefetch()
|
||||
}
|
||||
|
||||
/// Stream one authorized chunk's bytes (entitlement was established by
|
||||
/// [`authorize_chunk_download_with_perms`]).
|
||||
pub async fn chunk_stream(
|
||||
@@ -659,7 +687,9 @@ fn sanitize_file_name(name: &str) -> Result<String, DomainError> {
|
||||
|
||||
/// Distinct hashes in first-occurrence order.
|
||||
fn distinct_hashes(chunks: &[ChunkRef]) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
// foldhash::quality::RandomState — fast, per-instance random-seeded and thus
|
||||
// DoS-safe for these attacker-controlled client hashes (benches/ROUND26.md §G1).
|
||||
let mut seen: HashSet<&str, foldhash::quality::RandomState> = HashSet::default();
|
||||
chunks
|
||||
.iter()
|
||||
.filter(|c| seen.insert(c.h.as_str()))
|
||||
|
||||
@@ -24,11 +24,13 @@ use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::entities::drive::DriveKind;
|
||||
use crate::domain::repositories::drive_repository::{DriveRepository, DriveRepositoryError};
|
||||
use crate::domain::repositories::subject_group_repository::SubjectGroupRepository;
|
||||
use crate::domain::services::authorization::{Grant, Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::repositories::pg::DrivePgRepository;
|
||||
use crate::infrastructure::repositories::pg::SubjectGroupPgRepository;
|
||||
use crate::infrastructure::repositories::pg::UserPgRepository;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
pub struct DriveManagementService {
|
||||
@@ -39,6 +41,11 @@ pub struct DriveManagementService {
|
||||
/// constructing an orphan-owned drive (the "drive must always have
|
||||
/// ≥1 effective Owner-user" invariant from day one).
|
||||
group_repo: Arc<SubjectGroupPgRepository>,
|
||||
/// D5: `set_member_role` reads `users.is_external` to enforce
|
||||
/// `forbid_external_sharing` on the drive — closes the gap that the
|
||||
/// `POST /api/drives/{id}/members` route would otherwise open
|
||||
/// (the grant_handler check only catches `POST /api/grants`).
|
||||
user_repo: Arc<UserPgRepository>,
|
||||
}
|
||||
|
||||
impl DriveManagementService {
|
||||
@@ -46,11 +53,13 @@ impl DriveManagementService {
|
||||
drive_repo: Arc<DrivePgRepository>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
group_repo: Arc<SubjectGroupPgRepository>,
|
||||
user_repo: Arc<UserPgRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
drive_repo,
|
||||
authz,
|
||||
group_repo,
|
||||
user_repo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +210,30 @@ impl DriveManagementService {
|
||||
|
||||
self.refuse_if_personal(drive_id, "set_member_role").await?;
|
||||
|
||||
// D5: `forbid_external_sharing` on a shared drive — refuses
|
||||
// grant writes whose User subject is `is_external = true`.
|
||||
// Closes the `POST /api/drives/{id}/members` gap that
|
||||
// grant_handler's same-shaped check (covering `POST /api/grants`
|
||||
// only) doesn't reach. Group/Token subjects can't be external
|
||||
// by construction, so the lookup runs only for User subjects.
|
||||
// See `docs/plan/drive.md` §8.
|
||||
self.refuse_if_forbid_external_sharing(drive_id, subject, caller_id)
|
||||
.await?;
|
||||
|
||||
// D5: `forbid_owner_role_change` — locks the Owner roster
|
||||
// against non-admin callers. Fires when this write would add a
|
||||
// new Owner (role == Owner) OR demote a current Owner
|
||||
// (subject is currently Owner and role != Owner).
|
||||
self.refuse_if_forbid_owner_role_change(
|
||||
drive_id,
|
||||
subject,
|
||||
Some(role),
|
||||
caller_id,
|
||||
caller_is_admin,
|
||||
"set_member_role",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Demotion of the last owner = last-owner protection trips. A fresh
|
||||
// owner-role write or any non-owner subject is fine; only the case
|
||||
// "this subject is currently the only owner AND the new role is not
|
||||
@@ -217,18 +250,45 @@ impl DriveManagementService {
|
||||
.set_role(caller_id, subject, role, resource, expires_at)
|
||||
.await?;
|
||||
|
||||
if caller_is_admin {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive_membership.set_via_admin",
|
||||
drive_id = %drive_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
role = role.as_str(),
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ admin set drive member role bypassing Manage check",
|
||||
);
|
||||
// Drop the entire drive-role cache for this drive so the new
|
||||
// grant is visible on the very next `check` — without this, a
|
||||
// caller that gets Owner via `POST /api/drives/{id}/members`
|
||||
// then immediately acts on drive content (WebDAV cross-drive
|
||||
// MOVE, admin-driven cleanup, drive management) hits the
|
||||
// stale "no role for this subject on this drive" entry
|
||||
// seeded at some earlier `check`. TTL rescues eventually,
|
||||
// but the storage_cleanup_check.sh drain pattern hits this
|
||||
// race within a single test-second and fails on `authz.denied`
|
||||
// for admin's cascade to files inside.
|
||||
self.authz
|
||||
.invalidate_drive_role_cache_for_drive(drive_id)
|
||||
.await;
|
||||
// Same freshness contract for the repo's readable-drives cache:
|
||||
// the subject's drive list changed with this grant.
|
||||
match subject {
|
||||
Subject::User(uid) => self.drive_repo.invalidate_readable_for_user(uid).await,
|
||||
_ => self.drive_repo.invalidate_readable_all(),
|
||||
}
|
||||
|
||||
// D6 §11: canonical `drive.member_added` audit event covers
|
||||
// every successful membership write (add + role-refresh, since
|
||||
// the underlying `set_role` is UPSERT — distinguishing the two
|
||||
// would require an additional read and bring no extra ops
|
||||
// value). `via_admin` carries the bypass signal that used to
|
||||
// live in a separate `drive_membership.set_via_admin` event;
|
||||
// log aggregators now have one canonical name per operation.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.member_added",
|
||||
drive_id = %drive_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
role = role.as_str(),
|
||||
via_admin = caller_is_admin,
|
||||
by = %caller_id,
|
||||
expires_at = ?expires_at,
|
||||
"🤝 drive member added",
|
||||
);
|
||||
Ok(grant)
|
||||
}
|
||||
|
||||
@@ -255,25 +315,378 @@ impl DriveManagementService {
|
||||
|
||||
self.refuse_if_personal(drive_id, "remove_member").await?;
|
||||
|
||||
// D5: `forbid_owner_role_change` — locks the Owner roster
|
||||
// against non-admin callers. Fires when this would remove a
|
||||
// current Owner.
|
||||
self.refuse_if_forbid_owner_role_change(
|
||||
drive_id,
|
||||
subject,
|
||||
None, // None = removal, not a role write
|
||||
caller_id,
|
||||
caller_is_admin,
|
||||
"remove_member",
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.refuse_if_last_owner_change(drive_id, subject, caller_id)
|
||||
.await?;
|
||||
|
||||
self.authz.clear_role(subject, resource).await?;
|
||||
|
||||
if caller_is_admin {
|
||||
// Mirror of `set_member_role`'s cache invalidation: after
|
||||
// clearing a role we MUST drop the `drive_role_cache` entries
|
||||
// targeting this drive, otherwise the just-removed subject's
|
||||
// former role stays visible until TTL expires. Same anti-drift
|
||||
// reason as the sibling add path above.
|
||||
self.authz
|
||||
.invalidate_drive_role_cache_for_drive(drive_id)
|
||||
.await;
|
||||
// And the repo's readable-drives cache: the drive must vanish
|
||||
// from the removed subject's list immediately.
|
||||
match subject {
|
||||
Subject::User(uid) => self.drive_repo.invalidate_readable_for_user(uid).await,
|
||||
_ => self.drive_repo.invalidate_readable_all(),
|
||||
}
|
||||
|
||||
// D6 §11: canonical `drive.member_removed` audit event covers
|
||||
// every successful removal (owner-driven or admin bypass).
|
||||
// `via_admin` replaces the separate
|
||||
// `drive_membership.removed_via_admin` event — single name,
|
||||
// one boolean field for the bypass signal.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.member_removed",
|
||||
drive_id = %drive_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
via_admin = caller_is_admin,
|
||||
by = %caller_id,
|
||||
"👋 drive member removed",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `DELETE /api/drives/{id}` and `DELETE /api/admin/drives/{id}`.
|
||||
///
|
||||
/// Policy (drive.md §6 + memos):
|
||||
/// - Caller must hold `Permission::Manage` on the drive — typically
|
||||
/// the Owner. `caller_is_admin = true` bypasses this check; the
|
||||
/// route gate is the access control then. Audit emits
|
||||
/// `drive.deleted_via_admin` when the bypass fires.
|
||||
/// - The user's default Personal drive (`drives.default_for_user
|
||||
/// IS NOT NULL`) is refused with `405` — deleting your home is a
|
||||
/// category error. Secondary personal drives + shared drives
|
||||
/// follow the same content-empty rule below.
|
||||
/// - The drive must be empty (no live folders other than the root,
|
||||
/// no live files). Trashed rows are excluded — owners can
|
||||
/// delete a drive whose trash bin still holds rows; the trash GC
|
||||
/// cleans them up after the retention window. Non-empty drives
|
||||
/// return `409 Conflict` so the UI can prompt the owner to
|
||||
/// move/trash content first.
|
||||
///
|
||||
/// On success the drive row, its root folder, and every
|
||||
/// `role_grants` row scoped to the drive are removed in one
|
||||
/// transaction.
|
||||
pub async fn delete_drive(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
caller_is_admin: bool,
|
||||
drive_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let resource = Resource::Drive(drive_id);
|
||||
if !caller_is_admin {
|
||||
self.authz
|
||||
.require(Subject::User(caller_id), Permission::Manage, resource)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}"))
|
||||
})?;
|
||||
|
||||
if drive.drive.default_for_user.is_some() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive_membership.removed_via_admin",
|
||||
event = "drive_delete.rejected",
|
||||
reason = "default_personal_drive",
|
||||
drive_id = %drive_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ admin removed drive member bypassing Manage check",
|
||||
"👮🏻♂️ refused delete on default personal drive {drive_id}",
|
||||
);
|
||||
return Err(DomainError::operation_not_supported(
|
||||
"Drive",
|
||||
"The default Personal drive cannot be deleted.",
|
||||
));
|
||||
}
|
||||
|
||||
let empty = self.drive_repo.is_empty(drive_id).await.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("Failed to check emptiness: {e:?}"))
|
||||
})?;
|
||||
if !empty {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive_delete.rejected",
|
||||
reason = "drive_not_empty",
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ refused delete on non-empty drive {drive_id}",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
crate::common::errors::ErrorKind::Conflict,
|
||||
"Drive",
|
||||
"Drive is not empty — move or trash its contents before deleting.",
|
||||
));
|
||||
}
|
||||
|
||||
self.drive_repo
|
||||
.delete_atomic(drive_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("Drive", format!("delete failed: {e:?}")))?;
|
||||
|
||||
// Drop every cached drive-role entry for this drive so the next
|
||||
// /api/drives listing for any subject doesn't show a row pointing
|
||||
// at a deleted drive_id. Single-key cache invalidations are safe
|
||||
// even when no entry matches.
|
||||
self.authz
|
||||
.invalidate_drive_role_cache_for_drive(drive_id)
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = if caller_is_admin {
|
||||
"drive.deleted_via_admin"
|
||||
} else {
|
||||
"drive.deleted"
|
||||
},
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
"🗑 drive deleted",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `PATCH /api/drives/{id}/policies`. OxiCloud-admin only.
|
||||
///
|
||||
/// The drive's `policies` JSONB bag is a compliance surface — same
|
||||
/// category as `drives.quota_bytes` and `users.storage_quota_bytes`
|
||||
/// (§7). Owner mutation would make the policies self-policing
|
||||
/// (an owner could disable `forbid_external_sharing`, share, and
|
||||
/// re-enable), so mutation is restricted to the tenant operator.
|
||||
/// The handler is the gate (refuses non-admin callers with 404 for
|
||||
/// anti-enumeration); this method trusts that gate and writes
|
||||
/// unconditionally.
|
||||
///
|
||||
/// JSONB-level merge preserves unknown keys; only the partial
|
||||
/// supplied is overwritten. Returns the post-merge typed view.
|
||||
/// Audit emits `drive.policy_changed` with the post-merge bag for
|
||||
/// steady-state observability.
|
||||
///
|
||||
/// Ed's call, 2026-07-17: intentional deviation from the AGENTS.md
|
||||
/// "AuthZ in service layer" rule for this specific endpoint —
|
||||
/// the handler-layer admin check stays, this method stays trusting.
|
||||
/// See memory `feedback_drive_policies_admin_at_handler`.
|
||||
pub async fn update_policies(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
partial: serde_json::Value,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DomainError> {
|
||||
let merged = self
|
||||
.drive_repo
|
||||
.update_policies(drive_id, &partial)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
DriveRepositoryError::NotFound(_) => {
|
||||
DomainError::not_found("Drive", drive_id.to_string())
|
||||
}
|
||||
other => DomainError::internal_error(
|
||||
"Drive",
|
||||
format!("update_policies failed: {other:?}"),
|
||||
),
|
||||
})?;
|
||||
|
||||
// Flush the cached typed policy view so the very next mutating
|
||||
// authz check on any resource in this drive sees the fresh
|
||||
// `read_only` value (and every other policy field). Without this,
|
||||
// a policy change would take up to `DRIVE_POLICIES_CACHE_TTL` (30 s)
|
||||
// to take effect on the hot path — unacceptable for the read_only
|
||||
// freeze, which admins expect to be effective immediately.
|
||||
self.authz
|
||||
.invalidate_drive_policies_cache_for_drive(drive_id)
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.policy_changed",
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
forbid_sharing = merged.forbid_sharing,
|
||||
forbid_external_sharing = merged.forbid_external_sharing,
|
||||
forbid_public_links = merged.forbid_public_links,
|
||||
forbid_cross_drive_move = merged.forbid_cross_drive_move,
|
||||
forbid_owner_role_change = merged.forbid_owner_role_change,
|
||||
include_in_photo_index = merged.include_in_photo_index,
|
||||
include_in_music_index = merged.include_in_music_index,
|
||||
read_only = merged.read_only,
|
||||
"📜 drive policies updated",
|
||||
);
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// `PATCH /api/drives/{id}/quota`. OxiCloud-admin only.
|
||||
///
|
||||
/// `quota_bytes = None` (or ≤ 0 from the wire, normalised to None
|
||||
/// here) means unlimited — matches the DB convention where a NULL
|
||||
/// `drives.quota_bytes` row is treated as no cap by
|
||||
/// `storage_usage_service`.
|
||||
///
|
||||
/// **Refuses personal drives** with `InvalidInput`. Personal
|
||||
/// drives carry `NULL` on the row by design (memory
|
||||
/// `project_user_envelope_quota_model`) — the effective cap comes
|
||||
/// from the owner user's `storage_quota_bytes`, editable via
|
||||
/// `PUT /api/admin/users/{id}/quota`. Allowing a per-personal-drive
|
||||
/// quota here would fork the model into two competing enforcement
|
||||
/// paths; keep the envelope model intact.
|
||||
///
|
||||
/// **Soft-quota semantic on reduction.** A newly-lowered quota
|
||||
/// can land BELOW the drive's current `used_bytes` — this method
|
||||
/// accepts that without failing. `storage_usage_service` gates
|
||||
/// new writes on `used + delta ≤ quota`, so a shared drive
|
||||
/// already over its freshly-reduced cap can only shrink (delete)
|
||||
/// until it comes back under; no existing content is retroactively
|
||||
/// touched. Ed's call: intentional design, matches how filesystems
|
||||
/// treat quota shrink (Linux xfs quota tools do the same).
|
||||
///
|
||||
/// Follows the same handler-gates-admin deviation from AGENTS.md
|
||||
/// as `update_policies` — see memory
|
||||
/// `feedback_drive_policies_admin_at_handler`. The handler
|
||||
/// refuses non-admin callers with 404 anti-enumeration; this
|
||||
/// method trusts that gate and writes unconditionally on
|
||||
/// shared-kind drives.
|
||||
///
|
||||
/// Emits `drive.quota_changed` for steady-state observability.
|
||||
/// Returns the persisted post-mutation quota so the handler can
|
||||
/// echo it in the API response.
|
||||
pub async fn update_quota(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
quota_bytes: Option<i64>,
|
||||
) -> Result<Option<i64>, DomainError> {
|
||||
// Normalise sentinel values: `0` and negative numbers on the
|
||||
// wire all mean "unlimited" — same convention the storage
|
||||
// service uses on the query side (see `check_drive_quota`).
|
||||
// Doing this once here (rather than in every caller) keeps the
|
||||
// audit line + DB row consistent.
|
||||
let quota_bytes = quota_bytes.filter(|&q| q > 0);
|
||||
|
||||
let drive = self
|
||||
.drive_repo
|
||||
.get_by_id(drive_id)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
DriveRepositoryError::NotFound(_) => {
|
||||
DomainError::not_found("Drive", drive_id.to_string())
|
||||
}
|
||||
other => DomainError::internal_error(
|
||||
"Drive",
|
||||
format!("Failed to fetch drive: {other:?}"),
|
||||
),
|
||||
})?;
|
||||
|
||||
// Personal drives are refused with `InvalidInput` — a 400 that
|
||||
// the handler doesn't need to translate specially. Audit line
|
||||
// captures the attempt so an operator can see if someone is
|
||||
// trying to circumvent the envelope model.
|
||||
if drive.drive.kind == crate::domain::entities::drive::DriveKind::Personal {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.quota_change_rejected",
|
||||
reason = "personal_drive_uses_user_envelope",
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ refused quota edit on personal drive {drive_id} — use PUT /api/admin/users/{{id}}/quota",
|
||||
);
|
||||
return Err(DomainError::validation_error(
|
||||
"Personal drive quota is not editable here — set the owner user's storage envelope via PUT /api/admin/users/{id}/quota instead.",
|
||||
));
|
||||
}
|
||||
|
||||
let persisted = self
|
||||
.drive_repo
|
||||
.update_quota(drive_id, quota_bytes)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
DriveRepositoryError::NotFound(_) => {
|
||||
DomainError::not_found("Drive", drive_id.to_string())
|
||||
}
|
||||
other => {
|
||||
DomainError::internal_error("Drive", format!("update_quota failed: {other:?}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
// Under-usage note in the audit line: an admin should be able
|
||||
// to spot from `grep audit drive.quota_changed` whether the
|
||||
// new cap put the drive into the "over quota, delete-only"
|
||||
// state, so the numbers (used, new quota) are both present.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.quota_changed",
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
new_quota_bytes = ?persisted,
|
||||
used_bytes = drive.drive.used_bytes,
|
||||
over_quota = persisted.map(|q| drive.drive.used_bytes > q).unwrap_or(false),
|
||||
"💾 drive quota updated",
|
||||
);
|
||||
|
||||
Ok(persisted)
|
||||
}
|
||||
|
||||
/// D5 `forbid_external_sharing` for `set_member_role`. Fetches the
|
||||
/// data this surface has but grant_handler doesn't (drive policies +
|
||||
/// user flags), then defers the decision + audit + canonical error
|
||||
/// to `DrivePolicies::refuse_external_sharing` — the same gate
|
||||
/// `grant_handler::create_grant` runs for File/Folder resources. One
|
||||
/// rejection shape across both entry points.
|
||||
///
|
||||
/// Group / Token subjects can't be external by construction, so the
|
||||
/// user lookup is skipped (the gate handles those branches too, but
|
||||
/// returning early avoids a wasted SELECT on the drive row).
|
||||
async fn refuse_if_forbid_external_sharing(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
subject: Subject,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let Subject::User(uid) = subject else {
|
||||
return Ok(());
|
||||
};
|
||||
let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}"))
|
||||
})?;
|
||||
let policies = drive.drive.typed_policies();
|
||||
if !policies.forbid_external_sharing {
|
||||
return Ok(());
|
||||
}
|
||||
let flags = self
|
||||
.user_repo
|
||||
.get_user_flags(uid)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("User", format!("flags lookup: {e:?}")))?;
|
||||
policies.refuse_external_sharing(
|
||||
subject,
|
||||
flags.is_external,
|
||||
crate::domain::entities::drive::ExternalSharingGateContext {
|
||||
caller_id,
|
||||
stage: "drive_member",
|
||||
drive_id: Some(drive_id),
|
||||
resource_type: None,
|
||||
resource_id: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ── Business rules ──────────────────────────────────────────────────────
|
||||
|
||||
/// Personal drives are single-user single-owner; any member mutation is
|
||||
@@ -282,7 +695,7 @@ impl DriveManagementService {
|
||||
let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}"))
|
||||
})?;
|
||||
if drive.drive.is_personal() {
|
||||
if matches!(drive.drive.kind, DriveKind::Personal) {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive_membership.rejected",
|
||||
@@ -299,6 +712,73 @@ impl DriveManagementService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// D5 `forbid_owner_role_change`. Fetches drive policies (one PK
|
||||
/// probe), bails out early when the policy is off or the caller is
|
||||
/// admin, then determines whether the requested op actually
|
||||
/// mutates the Owner roster:
|
||||
///
|
||||
/// - `new_role = Some(Role::Owner)` — Owner add or refresh. Owner
|
||||
/// roster mutation.
|
||||
/// - `new_role = Some(Role::X)` and subject is currently Owner —
|
||||
/// demotion. Owner roster mutation.
|
||||
/// - `new_role = None` (remove) and subject is currently Owner —
|
||||
/// removal. Owner roster mutation.
|
||||
///
|
||||
/// In any of those cases, defers to
|
||||
/// `DrivePolicies::refuse_owner_role_change` for the audit + error.
|
||||
async fn refuse_if_forbid_owner_role_change(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
subject: Subject,
|
||||
new_role: Option<Role>,
|
||||
caller_id: Uuid,
|
||||
caller_is_admin: bool,
|
||||
operation: &'static str,
|
||||
) -> Result<(), DomainError> {
|
||||
// Fast bypass for the tenant operator.
|
||||
if caller_is_admin {
|
||||
return Ok(());
|
||||
}
|
||||
let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}"))
|
||||
})?;
|
||||
let policies = drive.drive.typed_policies();
|
||||
if !policies.forbid_owner_role_change {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Determine whether this op touches the Owner roster. An Owner
|
||||
// add (role == Owner) always does; a non-Owner write or a
|
||||
// removal only does when the subject currently holds Owner —
|
||||
// fetched lazily on the second case to skip the round-trip
|
||||
// when we already know the answer.
|
||||
let touches_owner = if matches!(new_role, Some(Role::Owner)) {
|
||||
true
|
||||
} else {
|
||||
let grants = self
|
||||
.authz
|
||||
.list_grants_on_resource(Resource::Drive(drive_id))
|
||||
.await?;
|
||||
grants
|
||||
.iter()
|
||||
.any(|g| g.subject == subject && matches!(g.role, Role::Owner))
|
||||
};
|
||||
if !touches_owner {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
policies.refuse_owner_role_change(
|
||||
crate::domain::entities::drive::OwnerRoleChangeGateContext {
|
||||
caller_id,
|
||||
caller_is_admin,
|
||||
drive_id,
|
||||
operation,
|
||||
subject_type: subject.type_str(),
|
||||
subject_id: subject.id(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Refuse the change if `subject` is currently the sole `Owner` on the
|
||||
/// drive and the operation would remove or demote them. A shared drive
|
||||
/// must always have at least one Owner — otherwise it becomes orphaned
|
||||
|
||||
@@ -9,10 +9,12 @@ use crate::application::dtos::favorites_dto::{
|
||||
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoriteResourceRow,
|
||||
FavoritesCursor,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
use crate::common::errors::Result;
|
||||
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject};
|
||||
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
/// Implementation of the FavoritesUseCase for managing user favorites.
|
||||
///
|
||||
@@ -20,12 +22,22 @@ use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
||||
/// accessing the database directly, following hexagonal architecture.
|
||||
pub struct FavoritesService {
|
||||
repo: Arc<FavoritesPgRepository>,
|
||||
/// ReBAC engine — enforces `Permission::Read` on the referenced
|
||||
/// file/folder before enrolling it into a user's favorites.
|
||||
/// Without this gate the write path is an information oracle:
|
||||
/// listing endpoints JOIN back to `storage.files/folders` and
|
||||
/// return name/mime/size/drive_id for any UUID the caller was
|
||||
/// able to enroll. See `docs/plan/authz_audit/rest_storage.md`.
|
||||
authorization: Arc<PgAclEngine>,
|
||||
}
|
||||
|
||||
impl FavoritesService {
|
||||
/// Create a new FavoritesService with the given repository port
|
||||
pub fn new(repo: Arc<FavoritesPgRepository>) -> Self {
|
||||
Self { repo }
|
||||
pub fn new(repo: Arc<FavoritesPgRepository>, authorization: Arc<PgAclEngine>) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
authorization,
|
||||
}
|
||||
}
|
||||
|
||||
/// Subset of `(item_id, item_type)` pairs the user has favorited — used to
|
||||
@@ -60,13 +72,15 @@ impl FavoritesUseCase for FavoritesService {
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Favorites",
|
||||
"Item type must be 'file' or 'folder'",
|
||||
));
|
||||
}
|
||||
// AuthZ pre-write: caller must have Read on the referenced
|
||||
// resource. Denial routes through `require` → NotFound
|
||||
// (anti-enum, matches the listing shape) + `authz.denied`
|
||||
// audit line. Without this gate the write path was an
|
||||
// information oracle over the whole tenant.
|
||||
let resource = Resource::parse(item_type, item_id)?;
|
||||
self.authorization
|
||||
.require(Subject::User(user_id), Permission::Read, resource)
|
||||
.await?;
|
||||
|
||||
self.repo.add_favorite(user_id, item_id, item_type).await?;
|
||||
info!(
|
||||
@@ -125,18 +139,23 @@ impl FavoritesUseCase for FavoritesService {
|
||||
user_id
|
||||
);
|
||||
|
||||
// Validate all item types
|
||||
// AuthZ pre-write: caller must have Read on every referenced
|
||||
// resource. Fail the whole batch on the first denial so the
|
||||
// response shape doesn't tell an attacker which items were
|
||||
// valid (partial success would leak the same oracle we
|
||||
// closed on the single-item path). See
|
||||
// `docs/plan/authz_audit/rest_storage.md`.
|
||||
//
|
||||
// Deliberately serial: a `try_join_all` fan-out measured WORSE
|
||||
// on both the cold (drive_of point-SELECTs) and warm (all-moka)
|
||||
// paths — future orchestration + pool-acquire contention cost
|
||||
// more than the local round trips they overlap. Rejected by
|
||||
// `bench_favorites_authz`; numbers in benches/ROUND6.md.
|
||||
for (item_id, item_type) in items {
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Favorites",
|
||||
format!(
|
||||
"Item type must be 'file' or 'folder' for item '{}'",
|
||||
item_id
|
||||
),
|
||||
));
|
||||
}
|
||||
let resource = Resource::parse(item_type, item_id)?;
|
||||
self.authorization
|
||||
.require(Subject::User(user_id), Permission::Read, resource)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let requested = items.len();
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||
use crate::application::ports::file_ports::FileManagementUseCase;
|
||||
use crate::application::ports::resource_access_hook::ResourceAccessHook;
|
||||
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::application::services::external_mount_router::{MountRouter, ResolvedId};
|
||||
@@ -38,6 +39,24 @@ pub struct FileManagementService {
|
||||
/// External-mount classifier. `None` in stub/test construction → all ids
|
||||
/// are treated as native.
|
||||
mount_router: Option<Arc<MountRouter>>,
|
||||
/// Read/write access hook — fired so Recent reflects "this is the file
|
||||
/// I just copied / renamed / moved", same way the read paths surface
|
||||
/// downloads. Distinct from the lifecycle hook because lifecycle hooks
|
||||
/// don't carry the `caller_id` the recording side needs.
|
||||
resource_access_hook: Option<Arc<dyn ResourceAccessHook>>,
|
||||
/// Drive repository — used by D5's `forbid_cross_drive_move` gate
|
||||
/// on `move_file_with_perms`. Optional so stubs / test factories
|
||||
/// can build the service without wiring the full drive repo; in
|
||||
/// that case the cross-drive move check is skipped (the policy
|
||||
/// is silently off). Production DI wires it in.
|
||||
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
/// Storage-usage service — used to pre-check the destination
|
||||
/// drive's `used_bytes + delta ≤ quota_bytes` invariant on
|
||||
/// cross-drive MOVE, matching the pre-write check the upload path
|
||||
/// already performs. Without it, the check is silently skipped
|
||||
/// (stub/test builders); production DI wires it in.
|
||||
storage_usage:
|
||||
Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
|
||||
}
|
||||
|
||||
impl FileManagementService {
|
||||
@@ -61,6 +80,9 @@ impl FileManagementService {
|
||||
authz,
|
||||
file_lifecycle_hook: None,
|
||||
mount_router: None,
|
||||
resource_access_hook: None,
|
||||
drive_repo: None,
|
||||
storage_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +145,42 @@ impl FileManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the read/write access hook (Recent list recorder).
|
||||
pub fn with_resource_access_hook(mut self, hook: Arc<dyn ResourceAccessHook>) -> Self {
|
||||
self.resource_access_hook = Some(hook);
|
||||
self
|
||||
}
|
||||
|
||||
/// Internal helper: fire the access hook if registered.
|
||||
fn notify_file_accessed(&self, caller_id: Uuid, file_id: &str) {
|
||||
if let Some(hook) = &self.resource_access_hook {
|
||||
hook.on_file_accessed(caller_id, file_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires the drive repository, enabling D5 `forbid_cross_drive_move`
|
||||
/// enforcement on `move_file_with_perms`. Without it, the gate is
|
||||
/// silently skipped.
|
||||
pub fn with_drive_repo(
|
||||
mut self,
|
||||
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||
) -> Self {
|
||||
self.drive_repo = Some(drive_repo);
|
||||
self
|
||||
}
|
||||
|
||||
/// Wires the storage-usage service so `move_file_with_perms` can
|
||||
/// pre-check the destination drive's quota on cross-drive moves.
|
||||
pub fn with_storage_usage(
|
||||
mut self,
|
||||
storage_usage: Arc<
|
||||
crate::application::services::storage_usage_service::StorageUsageService,
|
||||
>,
|
||||
) -> Self {
|
||||
self.storage_usage = Some(storage_usage);
|
||||
self
|
||||
}
|
||||
|
||||
/// Engine check for a file resource. Parses the id into a `Uuid` and
|
||||
/// requires the specified permission.
|
||||
async fn require_file_perm(
|
||||
@@ -217,6 +275,9 @@ impl FileManagementService {
|
||||
if let Some(hook) = &self.file_lifecycle_hook {
|
||||
hook.on_file_copied(&dto.id, &dto.content_hash, &dto.mime_type, file_id);
|
||||
}
|
||||
// The caller just spawned a fresh file — show it in their Recent
|
||||
// list. The source file isn't recorded; only the visible target.
|
||||
self.notify_file_accessed(caller_id, &dto.id);
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
@@ -344,7 +405,96 @@ impl FileManagementUseCase for FileManagementService {
|
||||
.await?;
|
||||
self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id)
|
||||
.await?;
|
||||
self.move_file(file_id, folder_id, caller_id).await
|
||||
|
||||
// D5 `forbid_cross_drive_move` + D6 `resource.moved_between_drives` audit
|
||||
// share the same src/dst drive_id lookup: the gate refuses
|
||||
// before the move; the audit fires after a successful move
|
||||
// when the two drives differ. Silently skipped if the drive
|
||||
// repo isn't wired (stub builders) or the move target is None
|
||||
// (root namespace — same-drive semantics).
|
||||
let mut cross_drive: Option<(Uuid, Uuid)> = None;
|
||||
if let Some(drive_repo) = &self.drive_repo
|
||||
&& let Some(target_folder_id) = folder_id.as_deref()
|
||||
{
|
||||
let file_uuid =
|
||||
Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?;
|
||||
let dst_folder_uuid = Uuid::parse_str(target_folder_id)
|
||||
.map_err(|_| DomainError::not_found("Folder", target_folder_id))?;
|
||||
// Independent point reads — overlapped so the pre-move drive
|
||||
// resolution pays one round-trip, not two (ROUND10).
|
||||
let (src_res, dst_res) = tokio::join!(
|
||||
drive_repo.get_drive_id_and_policies_for_file(file_uuid),
|
||||
drive_repo.drive_id_for_folder(dst_folder_uuid),
|
||||
);
|
||||
let (src_drive_id, src_policies) = src_res.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("source drive lookup: {e:?}"))
|
||||
})?;
|
||||
let dst_drive_id = dst_res.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("destination drive lookup: {e:?}"))
|
||||
})?;
|
||||
if src_drive_id != dst_drive_id {
|
||||
src_policies.refuse_cross_drive_move(
|
||||
crate::domain::entities::drive::CrossDriveMoveGateContext {
|
||||
caller_id,
|
||||
resource_type: "file",
|
||||
resource_id: file_uuid,
|
||||
src_drive_id,
|
||||
dst_drive_id,
|
||||
},
|
||||
)?;
|
||||
// Destination drive quota: same pre-write check the
|
||||
// upload path already runs (`file_upload_service.rs`
|
||||
// `check_storage_quota`), applied here so a caller
|
||||
// can't sneak content past the drive cap via MOVE.
|
||||
// Denial → `DomainError::QuotaExceeded` → 507
|
||||
// Insufficient Storage. Skipped when `storage_usage`
|
||||
// isn't wired (stub builders) — same shape as the
|
||||
// upload path's skip semantics.
|
||||
if let Some(storage_usage) = &self.storage_usage
|
||||
&& let Some(size_bytes) = storage_usage.file_bytes(file_uuid).await?
|
||||
&& let Ok(size_u64) = u64::try_from(size_bytes)
|
||||
{
|
||||
storage_usage
|
||||
.check_drive_quota(dst_drive_id, size_u64)
|
||||
.await?;
|
||||
}
|
||||
cross_drive = Some((src_drive_id, dst_drive_id));
|
||||
}
|
||||
}
|
||||
|
||||
let dto = self.move_file(file_id, folder_id, caller_id).await?;
|
||||
|
||||
// Cross-drive move invalidates the file's `owner_cache` entry
|
||||
// in the authz engine — the cache assumed drive_id stability
|
||||
// that no longer holds. Without this call the drive-role
|
||||
// precheck at `check_inner` steers to the (stale) source
|
||||
// drive and legitimate Delete/Update by a destination-drive
|
||||
// role-holder returns 404 for up to the cache TTL.
|
||||
if cross_drive.is_some()
|
||||
&& let Ok(file_uuid) = Uuid::parse_str(file_id)
|
||||
{
|
||||
self.authz
|
||||
.invalidate_owner_cache_for_resource(Resource::File(file_uuid))
|
||||
.await;
|
||||
}
|
||||
|
||||
// D6 §11 audit: emit only when the move actually crossed a
|
||||
// drive boundary. Same-drive moves are too noisy to audit at
|
||||
// info — operators care about the cross-drive case for
|
||||
// exfiltration / quota tracking.
|
||||
if let Some((src_drive_id, dst_drive_id)) = cross_drive {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "resource.moved_between_drives",
|
||||
resource_type = "file",
|
||||
resource_id = %dto.id,
|
||||
src_drive_id = %src_drive_id,
|
||||
dst_drive_id = %dst_drive_id,
|
||||
by = %caller_id,
|
||||
"📦 file moved between drives",
|
||||
);
|
||||
}
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
async fn copy_file_with_perms(
|
||||
@@ -359,6 +509,31 @@ impl FileManagementUseCase for FileManagementService {
|
||||
.await?;
|
||||
self.require_target_folder_perm(target_folder_id.as_deref(), Permission::Create, caller_id)
|
||||
.await?;
|
||||
|
||||
// Destination drive quota: COPY creates a new file row that
|
||||
// counts against the destination drive's `used_bytes` even
|
||||
// though blob dedup means no new bytes hit the store. Same
|
||||
// pre-flight shape the delta-upload path already uses.
|
||||
// Skipped when `storage_usage` isn't wired (stub builders) or
|
||||
// `target_folder_id` is None (root namespace — same-drive
|
||||
// semantics inherit the source's cap coverage). Denial →
|
||||
// `QuotaExceeded` → 507.
|
||||
if let (Some(storage_usage), Some(target_folder)) =
|
||||
(&self.storage_usage, target_folder_id.as_deref())
|
||||
{
|
||||
let file_uuid =
|
||||
Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?;
|
||||
let target_folder_uuid = Uuid::parse_str(target_folder)
|
||||
.map_err(|_| DomainError::not_found("Folder", target_folder))?;
|
||||
if let Some(size_bytes) = storage_usage.file_bytes(file_uuid).await?
|
||||
&& let Ok(size_u64) = u64::try_from(size_bytes)
|
||||
{
|
||||
storage_usage
|
||||
.check_drive_quota_by_folder(target_folder_uuid, size_u64)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
self.copy_file(file_id, target_folder_id, new_name.as_deref(), caller_id)
|
||||
.await
|
||||
}
|
||||
@@ -466,6 +641,26 @@ impl FileManagementUseCase for FileManagementService {
|
||||
.await?;
|
||||
self.require_target_folder_perm(target_parent_id.as_deref(), Permission::Create, caller_id)
|
||||
.await?;
|
||||
|
||||
// Destination drive quota: sum the subtree's non-trashed files
|
||||
// and refuse if the destination couldn't hold them. Skipped
|
||||
// when `storage_usage` isn't wired or the target is root
|
||||
// (same rationale as `copy_file_with_perms`).
|
||||
if let (Some(storage_usage), Some(target_parent)) =
|
||||
(&self.storage_usage, target_parent_id.as_deref())
|
||||
{
|
||||
let source_uuid = Uuid::parse_str(source_folder_id)
|
||||
.map_err(|_| DomainError::not_found("Folder", source_folder_id))?;
|
||||
let target_parent_uuid = Uuid::parse_str(target_parent)
|
||||
.map_err(|_| DomainError::not_found("Folder", target_parent))?;
|
||||
let subtree_bytes = storage_usage.folder_subtree_bytes(source_uuid).await?;
|
||||
if let Ok(subtree_u64) = u64::try_from(subtree_bytes) {
|
||||
storage_usage
|
||||
.check_drive_quota_by_folder(target_parent_uuid, subtree_u64)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
self.copy_folder_tree(source_folder_id, target_parent_id, dest_name)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::blob_storage_ports::BlobStream;
|
||||
use crate::application::ports::external_mount_ports::MountStat;
|
||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, OptimizedFileContent};
|
||||
use crate::application::ports::file_ports::{
|
||||
FileRetrievalUseCase, OptimizedFileContent, RangeContent,
|
||||
};
|
||||
use crate::application::ports::resource_access_hook::ResourceAccessHook;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::application::services::mount_registry::MountConfig;
|
||||
use crate::common::errors::DomainError;
|
||||
@@ -40,6 +43,11 @@ pub struct FileRetrievalService {
|
||||
/// External-mount classifier for path-based resolution (WebDAV/NextCloud).
|
||||
/// `None` in the simple/test constructor → no mount support.
|
||||
mount_router: Option<Arc<crate::application::services::external_mount_router::MountRouter>>,
|
||||
/// Optional read-event observer. Currently fans out to the Recent-list
|
||||
/// recorder; future observers (audit trail, "last seen by", …) attach
|
||||
/// to the same hook so service code only knows the trait, not the impl.
|
||||
/// `None` for the test/stub path that constructs via [`Self::new`].
|
||||
resource_access_hook: Option<Arc<dyn ResourceAccessHook>>,
|
||||
}
|
||||
|
||||
impl FileRetrievalService {
|
||||
@@ -53,6 +61,7 @@ impl FileRetrievalService {
|
||||
transcode: None,
|
||||
authz: None,
|
||||
mount_router: None,
|
||||
resource_access_hook: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +79,7 @@ impl FileRetrievalService {
|
||||
transcode: Some(transcode),
|
||||
authz: Some(authz),
|
||||
mount_router: None,
|
||||
resource_access_hook: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +93,14 @@ impl FileRetrievalService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: attach a [`ResourceAccessHook`] that fires after every
|
||||
/// authorised `_with_perms` read. Without it the service is silent —
|
||||
/// existing behaviour for stub / test paths.
|
||||
pub fn with_resource_access_hook(mut self, hook: Arc<dyn ResourceAccessHook>) -> Self {
|
||||
self.resource_access_hook = Some(hook);
|
||||
self
|
||||
}
|
||||
|
||||
/// Test-only constructor: authorization engine without the cache/transcode
|
||||
/// tiers. The external-mount read methods only consult `authz` + the
|
||||
/// provider, so this is sufficient to exercise their authorization.
|
||||
@@ -97,6 +115,24 @@ impl FileRetrievalService {
|
||||
transcode: None,
|
||||
authz: Some(authz),
|
||||
mount_router: None,
|
||||
resource_access_hook: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the access hook if registered. Called from every `_with_perms`
|
||||
/// read after the authZ + lookup has succeeded (never on failure
|
||||
/// paths — denied reads must not surface in Recent).
|
||||
///
|
||||
/// `pub` because the WebDAV / NextCloud DAV handlers resolve files
|
||||
/// by path and authorise via that resolver, not via the
|
||||
/// `*_with_perms` service methods — they then serve content through
|
||||
/// the no-perms `get_file_stream` / `get_file_range_stream`. Those
|
||||
/// handlers must call this directly after their own authZ has
|
||||
/// passed so cross-protocol downloads (NC desktop, davx5, native
|
||||
/// `/webdav/`) also surface in Recent.
|
||||
pub fn notify_file_accessed(&self, caller_id: Uuid, file_id: &str) {
|
||||
if let Some(hook) = &self.resource_access_hook {
|
||||
hook.on_file_accessed(caller_id, file_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +148,26 @@ impl FileRetrievalService {
|
||||
) -> Result<Bytes, DomainError> {
|
||||
let stream = file_read.get_file_stream(id).await?;
|
||||
let mut stream = Pin::from(stream);
|
||||
let mut buf = BytesMut::with_capacity(capacity);
|
||||
// Most sub-threshold reads arrive as ONE owned contiguous frame from the
|
||||
// backend (the local ReaderStream emits ≤256 KiB frames, and a
|
||||
// sub-threshold blob fits in one). Return that frame directly instead of
|
||||
// copying the whole payload a second time into a fresh BytesMut; only a
|
||||
// multi-frame read pays the pre-sized concat — byte-identical output
|
||||
// (benches/ROUND29.md §C).
|
||||
let Some(first) = stream.next().await else {
|
||||
return Ok(Bytes::new());
|
||||
};
|
||||
let first = first.map_err(|e| {
|
||||
DomainError::internal_error("File", format!("Stream read error: {}", e))
|
||||
})?;
|
||||
let Some(second) = stream.next().await else {
|
||||
return Ok(first);
|
||||
};
|
||||
let mut buf = BytesMut::with_capacity(capacity.max(first.len()));
|
||||
buf.extend_from_slice(&first);
|
||||
buf.extend_from_slice(&second.map_err(|e| {
|
||||
DomainError::internal_error("File", format!("Stream read error: {}", e))
|
||||
})?);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
buf.extend_from_slice(&chunk.map_err(|e| {
|
||||
DomainError::internal_error("File", format!("Stream read error: {}", e))
|
||||
@@ -263,7 +318,6 @@ impl FileRetrievalService {
|
||||
) -> Result<(FileDto, OptimizedFileContent), DomainError> {
|
||||
let mime_type = dto.mime_type.clone();
|
||||
let file_size = dto.size;
|
||||
let file_name = dto.name.clone();
|
||||
// The content cache is content-addressed: keyed by the blob hash, not
|
||||
// the file id. Identical content deduplicated to one blob on disk is
|
||||
// then cached ONCE in RAM and shared by every file/user that references
|
||||
@@ -271,34 +325,39 @@ impl FileRetrievalService {
|
||||
// construction, so entries never go stale (no invalidation needed). A
|
||||
// stub DTO without a hash disables caching for that request rather than
|
||||
// colliding every hash-less file on the key "".
|
||||
let cache_key = dto.content_hash.clone();
|
||||
let cacheable = !cache_key.is_empty();
|
||||
let cacheable = !dto.content_hash.is_empty();
|
||||
let do_transcode = accept_webp && !prefer_original;
|
||||
|
||||
// ── Tier 1: Hot cache + transcode (<10 MB) ──────────
|
||||
if file_size < CACHE_THRESHOLD {
|
||||
// Fetch the raw blob bytes. When cacheable, `get_or_load` serves
|
||||
// from the content cache on a hit and, on a miss, coalesces every
|
||||
// concurrent request for the same blob hash into a SINGLE disk read
|
||||
// (single-flight) — no thundering herd under load. Hash-less stub
|
||||
// DTOs are uncacheable and stream straight from disk.
|
||||
// Probe the content cache with a BORROW first: a hit serves the blob
|
||||
// straight from RAM, and only a miss builds the owned load arguments
|
||||
// (the quoted-etag / key / id Strings) that a hit would otherwise
|
||||
// allocate and immediately discard (benches/ROUND29.md §B). On a miss
|
||||
// `load_and_cache` still coalesces concurrent requests for the same
|
||||
// blob hash into a SINGLE disk read (single-flight) — no thundering
|
||||
// herd. Hash-less stub DTOs are uncacheable and stream from disk.
|
||||
let content_bytes = if cacheable && let Some(cache) = &self.content_cache {
|
||||
let etag: Arc<str> = format!("\"{}\"", cache_key).into();
|
||||
let ct: Arc<str> = mime_type.clone();
|
||||
let file_read = Arc::clone(&self.file_read);
|
||||
let id_owned = id.to_string();
|
||||
let cap = file_size as usize;
|
||||
let (bytes, _etag, _ct) = cache
|
||||
.get_or_load(cache_key.clone(), etag, ct, async move {
|
||||
debug!("💾 TIER 1 Cache MISS: {} – loading from disk", id_owned);
|
||||
Self::read_full(&file_read, &id_owned, cap).await
|
||||
})
|
||||
.await?;
|
||||
bytes
|
||||
if let Some((bytes, ..)) = cache.get(&dto.content_hash).await {
|
||||
bytes
|
||||
} else {
|
||||
let etag: Arc<str> = format!("\"{}\"", dto.content_hash).into();
|
||||
let ct: Arc<str> = mime_type.clone();
|
||||
let file_read = Arc::clone(&self.file_read);
|
||||
let id_owned = id.to_string();
|
||||
let cap = file_size as usize;
|
||||
let (bytes, ..) = cache
|
||||
.load_and_cache(dto.content_hash.to_string(), etag, ct, async move {
|
||||
debug!("💾 TIER 1 Cache MISS: {} – loading from disk", id_owned);
|
||||
Self::read_full(&file_read, &id_owned, cap).await
|
||||
})
|
||||
.await?;
|
||||
bytes
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
"💾 TIER 1 (uncacheable): {} – streaming from disk",
|
||||
file_name
|
||||
dto.name
|
||||
);
|
||||
Self::read_full(&self.file_read, id, file_size as usize).await?
|
||||
};
|
||||
@@ -330,7 +389,7 @@ impl FileRetrievalService {
|
||||
// ── Tier 2 + 3: Streaming (≥10 MB) ──────────────────
|
||||
info!(
|
||||
"📡 TIER 2 STREAMING: {} ({} MB)",
|
||||
file_name,
|
||||
dto.name,
|
||||
file_size / (1024 * 1024)
|
||||
);
|
||||
let stream = self.file_read.get_file_stream(id).await?;
|
||||
@@ -347,6 +406,109 @@ impl FileRetrievalService {
|
||||
let files = self.file_read.get_files_by_ids(ids).await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
|
||||
/// Batched, authorized multi-get for the ZIP-download multi-select — the
|
||||
/// batch form of [`FileRetrievalUseCase::get_file_with_perms`] over an
|
||||
/// explicit id list.
|
||||
///
|
||||
/// Authorizes `Read` on every id in ONE `check_files_read_batch`
|
||||
/// round-trip (which resolves all drives in a single query AND primes the
|
||||
/// resource→drive cache, so the per-file re-check the subsequent stream
|
||||
/// open performs becomes a cache hit), then fetches only the authorized ids
|
||||
/// in ONE `get_files_by_ids` query. Replaces `download_zip`'s per-file
|
||||
/// `require_file` + `get_file` loop — 2 round-trips/file → 2 total.
|
||||
///
|
||||
/// Returns the authorized, existing files; a denied / missing / unparseable
|
||||
/// id is simply **absent** from the result (the caller re-associates by id
|
||||
/// and skips the rest, exactly as the per-file loop skipped a denied /
|
||||
/// missing `get_file_with_perms`). Read-authorization is identical to the
|
||||
/// per-file path (`check_files_read_batch` is documented and gated as
|
||||
/// semantically identical to looping `require`). Recents recording is left
|
||||
/// to the subsequent per-file stream open (`get_file_stream_with_perms`),
|
||||
/// which records it (throttle-coalesced) — same net effect as the old
|
||||
/// loop's `notify_file_accessed` + stream double-notify. Fail-closed if no
|
||||
/// engine was injected, mirroring [`Self::require_file`].
|
||||
pub async fn get_files_by_ids_with_perms(
|
||||
&self,
|
||||
ids: &[String],
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
let authz = self.authz.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("FileRetrieval", "Authorization engine unavailable")
|
||||
})?;
|
||||
// Unparseable ids can't be authorized (the per-file path 404s on them),
|
||||
// so drop them here — they stay absent from the authorized set.
|
||||
let uuids: Vec<Uuid> = ids.iter().filter_map(|s| Uuid::parse_str(s).ok()).collect();
|
||||
if uuids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let allowed = authz
|
||||
.check_files_read_batch(Subject::User(caller_id), &uuids)
|
||||
.await?;
|
||||
if allowed.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let allowed_ids: Vec<String> = allowed.iter().map(Uuid::to_string).collect();
|
||||
let files = self.file_read.get_files_by_ids(&allowed_ids).await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
|
||||
/// Range read for HTTP Range Requests, cache-aware.
|
||||
///
|
||||
/// Media players and PDF viewers fetch these files *exclusively* through
|
||||
/// Range requests (a `bytes=0-` probe, then seeks) — the plain streaming
|
||||
/// path paid 1 PG round-trip (blob-hash resolve) + a chunk open/seek for
|
||||
/// EVERY seek, even when the whole blob was already sitting in the moka
|
||||
/// content cache as one contiguous `Bytes`. For sub-`CACHE_THRESHOLD`
|
||||
/// files this now answers from the cache: `Bytes::slice` is a refcount
|
||||
/// bump — zero copy, zero I/O, zero PG (benches/RANGE-CACHE.md). A miss
|
||||
/// populates the cache via the same single-flight `get_or_load` Tier 1
|
||||
/// uses, so one probe warms every subsequent seek. `end` is exclusive
|
||||
/// (callers pass `Some(last_byte + 1)`), matching the streaming variant.
|
||||
pub async fn get_file_range_preloaded(
|
||||
&self,
|
||||
dto: &FileDto,
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
) -> Result<RangeContent, DomainError> {
|
||||
let cacheable = dto.size < CACHE_THRESHOLD && !dto.content_hash.is_empty();
|
||||
if cacheable && let Some(cache) = &self.content_cache {
|
||||
// Probe with a BORROW first: the video-scrub steady state is a cache
|
||||
// hit, and a hit must not allocate the owned load args (quoted-etag /
|
||||
// key / id Strings) it would immediately discard — those are built
|
||||
// only on the miss branch (benches/ROUND29.md §B). A miss still
|
||||
// populates via the same single-flight coalescing.
|
||||
let bytes = if let Some((bytes, ..)) = cache.get(&dto.content_hash).await {
|
||||
bytes
|
||||
} else {
|
||||
let etag: Arc<str> = format!("\"{}\"", dto.content_hash).into();
|
||||
let ct: Arc<str> = dto.mime_type.clone();
|
||||
let file_read = Arc::clone(&self.file_read);
|
||||
let id_owned = dto.id.clone();
|
||||
let cap = dto.size as usize;
|
||||
let (bytes, ..) = cache
|
||||
.load_and_cache(dto.content_hash.to_string(), etag, ct, async move {
|
||||
debug!("💾 Range cache MISS: {} – loading from disk", id_owned);
|
||||
Self::read_full(&file_read, &id_owned, cap).await
|
||||
})
|
||||
.await?;
|
||||
bytes
|
||||
};
|
||||
let len = bytes.len() as u64;
|
||||
let s = start.min(len) as usize;
|
||||
let e = end.unwrap_or(len).min(len) as usize;
|
||||
if s <= e {
|
||||
return Ok(RangeContent::Bytes(bytes.slice(s..e)));
|
||||
}
|
||||
// Degenerate range the validator should have rejected — fall
|
||||
// through to the streaming path rather than panic on slice.
|
||||
}
|
||||
let stream = self
|
||||
.file_read
|
||||
.get_file_range_stream(&dto.id, start, end)
|
||||
.await?;
|
||||
Ok(RangeContent::Stream(stream))
|
||||
}
|
||||
}
|
||||
|
||||
impl FileRetrievalUseCase for FileRetrievalService {
|
||||
@@ -358,6 +520,11 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError> {
|
||||
self.require_file(id, Permission::Read, caller_id).await?;
|
||||
let file = self.file_read.get_file(id).await?;
|
||||
// After authZ + lookup succeed: this caller has just inspected the
|
||||
// file. Recent listing observes via the hook. The throttle in the
|
||||
// recording impl coalesces repeat metadata fetches against the same
|
||||
// file (file viewer poll, browse-then-download pattern).
|
||||
self.notify_file_accessed(caller_id, id);
|
||||
Ok(FileDto::from(file))
|
||||
}
|
||||
|
||||
@@ -421,19 +588,17 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
if folder_id.is_some() {
|
||||
// folder id is defined, check permissions
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
self.list_files(folder_id).await
|
||||
} else {
|
||||
// no folder id, get owners's files' root
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_for_owner(folder_id, owner_id)
|
||||
.await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
// Files always have a `folder_id` in the D0+ model — there is no
|
||||
// longer any concept of "root-level files". A `None` from the
|
||||
// caller means the query string was missing `folder_id`; reject
|
||||
// with a clear error rather than returning an empty set from a
|
||||
// meaningless root-level query.
|
||||
if folder_id.is_none() {
|
||||
return Err(DomainError::validation_error("folder_id is required"));
|
||||
}
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
self.list_files(folder_id).await
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
@@ -454,6 +619,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
caller_id: Uuid,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
self.require_file(id, Permission::Read, caller_id).await?;
|
||||
self.notify_file_accessed(caller_id, id);
|
||||
self.file_read.get_file_stream(id).await
|
||||
}
|
||||
|
||||
@@ -480,6 +646,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
self.require_file(id, Permission::Read, caller_id).await?;
|
||||
let file = self.file_read.get_file(id).await?;
|
||||
let dto = FileDto::from(file);
|
||||
self.notify_file_accessed(caller_id, id);
|
||||
self.optimized_inner(id, dto, accept_webp, prefer_original)
|
||||
.await
|
||||
}
|
||||
@@ -521,6 +688,10 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
end: Option<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
self.require_file(id, Permission::Read, caller_id).await?;
|
||||
// Range requests are bursty (video seeks, NC chunked downloads) —
|
||||
// the recording hook's per-(caller, file) throttle absorbs the
|
||||
// storm so one watched video lands as one Recent row, not 1000.
|
||||
self.notify_file_accessed(caller_id, id);
|
||||
self.file_read.get_file_range_stream(id, start, end).await
|
||||
}
|
||||
|
||||
@@ -537,12 +708,12 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
async fn list_files_batch(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
offset: i64,
|
||||
after_name: Option<&str>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_batch(folder_id, offset, limit)
|
||||
.list_files_batch(folder_id, after_name, limit)
|
||||
.await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
@@ -551,21 +722,21 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
offset: i64,
|
||||
after_name: Option<&str>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
// External mount: list files from the provider (WebDAV/NextCloud
|
||||
// PROPFIND Depth:1 file loop). Authz collapses on the mount root.
|
||||
// Keyset pagination by name mirrors `paginate_mount_entries` —
|
||||
// provider order isn't guaranteed, so sort before slicing on
|
||||
// `after_name`.
|
||||
if let Some(fid) = folder_id
|
||||
&& let Some(router) = &self.mount_router
|
||||
{
|
||||
use crate::application::services::external_mount_router::ResolvedId;
|
||||
let resolved = match router.classify(fid) {
|
||||
ResolvedId::Regular => None,
|
||||
ResolvedId::MountRoot { cfg } => Some((
|
||||
cfg,
|
||||
crate::domain::services::external_mount_id::NodeId::default(),
|
||||
)),
|
||||
ResolvedId::MountRoot { cfg } => Some((cfg, NodeId::default())),
|
||||
ResolvedId::MountChild { cfg, node_id } => Some((cfg, node_id)),
|
||||
};
|
||||
if let Some((cfg, node)) = resolved {
|
||||
@@ -578,34 +749,49 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let entries = cfg.provider.list_dir(&node).await?;
|
||||
let files: Vec<FileDto> = entries
|
||||
.iter()
|
||||
let mut entries: Vec<_> = cfg
|
||||
.provider
|
||||
.list_dir(&node)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|e| !e.is_dir)
|
||||
.skip(offset.max(0) as usize)
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.name.to_lowercase());
|
||||
let start = match after_name {
|
||||
Some(name) => entries
|
||||
.iter()
|
||||
.position(|e| name.eq_ignore_ascii_case(&e.name))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0),
|
||||
None => 0,
|
||||
};
|
||||
let files: Vec<FileDto> = entries
|
||||
.into_iter()
|
||||
.skip(start)
|
||||
.take(limit.max(0) as usize)
|
||||
.map(|e| {
|
||||
crate::application::services::mount_dto::mount_entry_file_dto(&cfg, fid, e)
|
||||
crate::application::services::mount_dto::mount_entry_file_dto(&cfg, fid, &e)
|
||||
})
|
||||
.collect();
|
||||
return Ok(files);
|
||||
}
|
||||
}
|
||||
|
||||
if folder_id.is_some() {
|
||||
// folder id is defined, check permissions
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_batch(folder_id, offset, limit)
|
||||
.await?;
|
||||
return Ok(files.into_iter().map(FileDto::from).collect());
|
||||
}
|
||||
|
||||
// Post-D0: every file lives in a folder — `storage.files.folder_id`
|
||||
// is NOT NULL. `folder_id = None` means the caller is asking for
|
||||
// "root-level files", which by design return an empty set: the
|
||||
// WebDAV synthetic root only lists drive-root folders as
|
||||
// children. Skip the DB round-trip and the pre-D7 owner-fallback
|
||||
// query (which used to hit `_for_owner` and would have driven
|
||||
// the `files.user_id` filter this refactor is retiring).
|
||||
let Some(_) = folder_id else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_batch_for_owner(folder_id, owner_id, offset, limit)
|
||||
.list_files_batch(folder_id, after_name, limit)
|
||||
.await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||
use crate::application::ports::file_ports::{FileUploadUseCase, StoredBlob};
|
||||
use crate::application::ports::resource_access_hook::ResourceAccessHook;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort, StorageUsagePort};
|
||||
use crate::application::services::storage_usage_service::StorageUsageService;
|
||||
use crate::common::errors::DomainError;
|
||||
@@ -14,7 +15,7 @@ use crate::infrastructure::repositories::pg::FileBlobWriteRepository;
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
use crate::infrastructure::services::file_content_cache::FileContentCache;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{Instrument, info, warn};
|
||||
|
||||
/// Service for file upload operations.
|
||||
///
|
||||
@@ -35,6 +36,22 @@ pub struct FileUploadService {
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
/// Single lifecycle dispatcher — fires on_file_created / on_file_updated.
|
||||
file_lifecycle_hook: Option<Arc<dyn FileLifecycleHook>>,
|
||||
/// Read-event hook — fires "caller just touched this file" so Recent
|
||||
/// records uploads / overwrites alongside reads. Distinct from
|
||||
/// `file_lifecycle_hook` because the lifecycle dispatcher only knows
|
||||
/// `(file_id, blob_hash, content_type)`; the recording side needs the
|
||||
/// `caller_id` the service already has in hand.
|
||||
resource_access_hook: Option<Arc<dyn ResourceAccessHook>>,
|
||||
/// ReBAC engine — enforces `Permission::Update` on
|
||||
/// overwrite-existing and `Permission::Create` on new-file paths
|
||||
/// inside `update_file_streaming_with_perms`. Optional at the
|
||||
/// struct level for the minimal test constructors (`new`,
|
||||
/// `new_with_read`) but the WebDAV/NC/WOPI put paths refuse
|
||||
/// (fail-closed internal error) if this isn't wired. Set by
|
||||
/// either `with_instant_upload` or `with_authorization` — both
|
||||
/// stash the same Arc so DI callers wiring instant upload get
|
||||
/// the streaming gate for free.
|
||||
authorization: Option<Arc<PgAclEngine>>,
|
||||
/// Dependencies of the instant-upload path
|
||||
/// (`create_file_from_owned_blob_with_perms`); `None` in minimal test
|
||||
/// wiring.
|
||||
@@ -58,6 +75,8 @@ impl FileUploadService {
|
||||
storage_usage_service: None,
|
||||
content_cache: None,
|
||||
file_lifecycle_hook: None,
|
||||
resource_access_hook: None,
|
||||
authorization: None,
|
||||
instant_upload: None,
|
||||
}
|
||||
}
|
||||
@@ -73,18 +92,35 @@ impl FileUploadService {
|
||||
storage_usage_service: None,
|
||||
content_cache: None,
|
||||
file_lifecycle_hook: None,
|
||||
resource_access_hook: None,
|
||||
authorization: None,
|
||||
instant_upload: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires the authorization engine used by
|
||||
/// `update_file_streaming_with_perms` on the WebDAV / NC / WOPI
|
||||
/// PUT path. Independent of `with_instant_upload` so callers can
|
||||
/// enable the streaming gate without also opting into the
|
||||
/// dedup-instant-upload check (test wiring, minimal deployments).
|
||||
pub fn with_authorization(mut self, authz: Arc<PgAclEngine>) -> Self {
|
||||
self.authorization = Some(authz);
|
||||
self
|
||||
}
|
||||
|
||||
/// Wires the authorization engine, dedup index and quota service that
|
||||
/// power the instant-upload path.
|
||||
///
|
||||
/// Also stashes the `authz` handle in `self.authorization` so
|
||||
/// DI callers wiring instant upload get the streaming-put gate
|
||||
/// for free — a single `Arc` clone, no behavioural coupling.
|
||||
pub fn with_instant_upload(
|
||||
mut self,
|
||||
authz: Arc<PgAclEngine>,
|
||||
dedup: Arc<DedupService>,
|
||||
quota: Arc<StorageUsageService>,
|
||||
) -> Self {
|
||||
self.authorization = Some(authz.clone());
|
||||
self.instant_upload = Some(InstantUploadDeps {
|
||||
authz,
|
||||
dedup,
|
||||
@@ -105,6 +141,19 @@ impl FileUploadService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Registers the read/write access hook (Recent list recorder).
|
||||
pub fn with_resource_access_hook(mut self, hook: Arc<dyn ResourceAccessHook>) -> Self {
|
||||
self.resource_access_hook = Some(hook);
|
||||
self
|
||||
}
|
||||
|
||||
/// Internal helper: fire the access hook if registered.
|
||||
fn notify_file_accessed(&self, caller_id: Uuid, file_id: &str) {
|
||||
if let Some(hook) = &self.resource_access_hook {
|
||||
hook.on_file_accessed(caller_id, file_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the storage usage service
|
||||
pub fn with_storage_usage_service(
|
||||
mut self,
|
||||
@@ -256,7 +305,7 @@ impl FileUploadService {
|
||||
let file = file_read.get_file(file_id).await?;
|
||||
let (new_hash, updated_at) = self
|
||||
.file_write
|
||||
.update_file_content_with_blob(file_id, &blob.hash, blob.size, None, caller_id)
|
||||
.update_file_content_with_blob(file_id, &blob.hash, blob.size, None, caller_id, None)
|
||||
.await?;
|
||||
// The file maps to a different blob now — stale cached content must
|
||||
// never be served for the rest of its TTI window.
|
||||
@@ -274,7 +323,6 @@ impl FileUploadService {
|
||||
parts.folder_id,
|
||||
parts.created_at,
|
||||
updated_at as u64,
|
||||
parts.owner_id,
|
||||
new_hash,
|
||||
)
|
||||
.map_err(|e| DomainError::internal_error("FileUpload", format!("rebuild entity: {e}")))?;
|
||||
@@ -282,6 +330,9 @@ impl FileUploadService {
|
||||
if let Some(hook) = &self.file_lifecycle_hook {
|
||||
hook.on_file_updated(file_id, &dto.content_hash, &dto.mime_type);
|
||||
}
|
||||
// Delta-upload commit path — record the swap so Recent reflects
|
||||
// "this is the file I just delta-updated".
|
||||
self.notify_file_accessed(caller_id, file_id);
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
@@ -292,30 +343,81 @@ impl FileUploadService {
|
||||
/// Incremental (`+size`, O(1)) and fire-and-forget on a background task, so
|
||||
/// it adds neither latency nor a `SUM(size)` over the user's whole library
|
||||
/// to the upload path (the previous full recompute was O(N) per upload,
|
||||
/// O(N²) for a bulk upload). Keyed by the file's `owner_id`; drift — e.g.
|
||||
/// deletes, which don't decrement — is reconciled by the periodic sweep. A
|
||||
/// DTO without a resolvable owner is simply left to that sweep.
|
||||
fn maybe_update_storage_usage(&self, file: &FileDto) {
|
||||
/// O(N²) for a bulk upload). Drift — e.g. deletes, which don't decrement —
|
||||
/// is reconciled by the periodic sweep.
|
||||
///
|
||||
/// Post-D7: `file.owner_id` is now nullable and unpopulated on new
|
||||
/// rows, so the envelope owner comes from `caller_id` (the user who
|
||||
/// just did the upload). The user-side delta is guarded by
|
||||
/// `add_user_storage_usage_delta_if_personal` — it only fires when
|
||||
/// the target drive is `kind='personal'`, so a shared-drive upload
|
||||
/// still doesn't touch any user envelope.
|
||||
fn maybe_update_storage_usage(&self, file: &FileDto, caller_id: Uuid) {
|
||||
self.apply_storage_usage_delta(file.size as i64, &file.folder_id, caller_id);
|
||||
}
|
||||
|
||||
/// Same as [`Self::maybe_update_storage_usage`] but takes an explicit
|
||||
/// `delta` instead of assuming "whole file size" — the overwrite path
|
||||
/// (`update_file_streaming_with_perms`) needs `new_size - old_size`,
|
||||
/// not the new size added a second time on top of what the old
|
||||
/// content already contributed.
|
||||
fn apply_storage_usage_delta(&self, delta: i64, folder_id: &Option<String>, caller_id: Uuid) {
|
||||
let Some(storage_service) = &self.storage_usage_service else {
|
||||
return;
|
||||
};
|
||||
let Some(owner) = file
|
||||
.owner_id
|
||||
.as_deref()
|
||||
.and_then(|s| Uuid::parse_str(s).ok())
|
||||
else {
|
||||
if delta == 0 {
|
||||
return;
|
||||
};
|
||||
let delta = file.size as i64;
|
||||
let service_clone = Arc::clone(storage_service);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = service_clone
|
||||
.add_user_storage_usage_delta(owner, delta)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to bump storage usage for {owner}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let owner = Some(caller_id);
|
||||
let folder = folder_id.as_deref().and_then(|s| Uuid::parse_str(s).ok());
|
||||
|
||||
// Per-user delta — only when the target drive is `kind='personal'`.
|
||||
// The user envelope (`auth.users.storage_quota_bytes`) caps the SUM
|
||||
// of `used_bytes` across the user's personal drives; shared-drive
|
||||
// uploads do NOT count against any user. See
|
||||
// `docs/plan/drive.md` §7.
|
||||
//
|
||||
// The discrimination happens in one SQL statement via an EXISTS
|
||||
// subquery on the folder's drive kind — no extra round-trip vs
|
||||
// the unconditional delta. Without a folder id (root-level
|
||||
// upload — folder service refuses these) the user-side delta is
|
||||
// simply skipped; the sweep reconciles regardless.
|
||||
if let (Some(owner), Some(folder)) = (owner, folder) {
|
||||
let service_clone = Arc::clone(storage_service);
|
||||
tokio::spawn(
|
||||
async move {
|
||||
if let Err(e) = service_clone
|
||||
.add_user_storage_usage_delta_if_personal(owner, folder, delta)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to bump user storage for {owner} (folder {folder}): {e}");
|
||||
}
|
||||
}
|
||||
.in_current_span(),
|
||||
);
|
||||
}
|
||||
|
||||
// Per-drive delta (D4) — same fire-and-forget shape, resolves
|
||||
// the drive id from the file's parent folder in one SQL
|
||||
// statement. `storage.drives.used_bytes` is what the per-drive
|
||||
// quota check and the picker quota bar read; drift from
|
||||
// deletes / trash is reconciled by the same sweep that handles
|
||||
// user-side drift.
|
||||
if let Some(folder) = folder {
|
||||
let service_clone = Arc::clone(storage_service);
|
||||
tokio::spawn(
|
||||
async move {
|
||||
if let Err(e) = service_clone
|
||||
.add_drive_storage_usage_delta_by_folder(folder, delta)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to bump drive usage for folder {folder}: {e}");
|
||||
}
|
||||
}
|
||||
.in_current_span(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,16 +447,67 @@ impl FileUploadUseCase for FileUploadService {
|
||||
"📡 STREAMING UPLOAD: {} ({} bytes, ID: {})",
|
||||
name, blob.size, dto.id
|
||||
);
|
||||
self.maybe_update_storage_usage(&dto);
|
||||
self.maybe_update_storage_usage(&dto, caller_id);
|
||||
if let Some(hook) = &self.file_lifecycle_hook {
|
||||
hook.on_file_created(&dto.id, &dto.content_hash, &dto.mime_type, blob.is_new_blob);
|
||||
}
|
||||
// The caller just created this file — surface it in Recent so the
|
||||
// "I just uploaded X" UX matches the pre-SvelteKit behaviour.
|
||||
self.notify_file_accessed(caller_id, &dto.id);
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
/// AuthZ audit #17 — `Create` on target folder is re-verified here
|
||||
/// so mid-session grant revocations take effect at finalize. When
|
||||
/// `folder_id` is `None` the write lands at drive-root; the drive
|
||||
/// resolution for that case isn't plumbed through the chunked-
|
||||
/// upload session (`UploadSession.folder_id` alone), so we fall
|
||||
/// back to the pre-audit behaviour there. That drive-root path is
|
||||
/// tracked separately as part of the D0 folder-id-walking work;
|
||||
/// closing it here would require session-scoped drive_id.
|
||||
async fn upload_file_streaming_with_perms(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
blob: StoredBlob,
|
||||
caller_id: Uuid,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
if let Some(fid) = folder_id.as_deref() {
|
||||
let Some(authz) = &self.authorization else {
|
||||
return Err(DomainError::internal_error(
|
||||
"FileUpload",
|
||||
"upload_file_streaming_with_perms called without authorization engine wired",
|
||||
));
|
||||
};
|
||||
let folder_uuid = Uuid::parse_str(fid)
|
||||
.map_err(|_| DomainError::not_found("Folder", fid.to_string()))?;
|
||||
authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Create,
|
||||
Resource::Folder(folder_uuid),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
self.upload_file_streaming(name, folder_id, content_type, blob, caller_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Swap the content of the file at `path` to an already-ingested blob,
|
||||
/// creating the file when it doesn't exist (WebDAV/NextCloud/WOPI PUT).
|
||||
async fn update_file_streaming(
|
||||
///
|
||||
/// AuthZ (post-Drive audit Round 2 fix): overwrite path requires
|
||||
/// `Update` on the target file; new-file path requires `Create`
|
||||
/// on the parent folder (or on the drive when writing at drive
|
||||
/// root). Fail-closed if the engine wasn't wired — this method
|
||||
/// is the last line of defence between a Viewer/Commenter drive
|
||||
/// member and cross-tenant PUT. See
|
||||
/// `docs/plan/authz_audit/nextcloud.md` and the sibling native
|
||||
/// `/webdav/*` handler.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn update_file_streaming_with_perms(
|
||||
&self,
|
||||
path: &str,
|
||||
drive_id: Uuid,
|
||||
@@ -362,11 +515,36 @@ impl FileUploadUseCase for FileUploadService {
|
||||
content_type: &str,
|
||||
modified_at: Option<i64>,
|
||||
caller_id: Uuid,
|
||||
expected_hash: Option<&str>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
let Some(authz) = &self.authorization else {
|
||||
return Err(DomainError::internal_error(
|
||||
"FileUpload",
|
||||
"update_file_streaming_with_perms called without authorization engine wired",
|
||||
));
|
||||
};
|
||||
|
||||
// Try to find the existing file first
|
||||
if let Some(file_read) = &self.file_read
|
||||
&& let Some(file) = file_read.find_file_by_path(path, drive_id).await?
|
||||
{
|
||||
// Overwrite branch — caller must have `Update` on the
|
||||
// target file. Denial routes through `require` → 404
|
||||
// (anti-enum, matches read-side shape). Before the D7
|
||||
// audit this whole branch ran unchecked; Viewer members
|
||||
// of shared drives could PUT freely.
|
||||
let file_uuid = Uuid::parse_str(file.id()).map_err(|_| {
|
||||
DomainError::internal_error("FileUpload", "invalid file id from repository")
|
||||
})?;
|
||||
authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Update,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let old_size = file.size();
|
||||
let file_id = file.id().to_string();
|
||||
let (new_hash, updated_at) = self
|
||||
.file_write
|
||||
@@ -376,6 +554,7 @@ impl FileUploadUseCase for FileUploadService {
|
||||
blob.size,
|
||||
modified_at,
|
||||
caller_id,
|
||||
expected_hash,
|
||||
)
|
||||
.await?;
|
||||
// Invalidate content cache — file content has changed.
|
||||
@@ -396,16 +575,21 @@ impl FileUploadUseCase for FileUploadService {
|
||||
parts.folder_id,
|
||||
parts.created_at,
|
||||
updated_at as u64,
|
||||
parts.owner_id,
|
||||
new_hash,
|
||||
)
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileUpload", format!("rebuild entity: {e}"))
|
||||
})?;
|
||||
let dto = FileDto::from(updated);
|
||||
self.apply_storage_usage_delta(
|
||||
blob.size as i64 - old_size as i64,
|
||||
&dto.folder_id,
|
||||
caller_id,
|
||||
);
|
||||
if let Some(hook) = &self.file_lifecycle_hook {
|
||||
hook.on_file_updated(&file_id, &dto.content_hash, content_type);
|
||||
}
|
||||
self.notify_file_accessed(caller_id, &file_id);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
@@ -435,6 +619,32 @@ impl FileUploadUseCase for FileUploadService {
|
||||
None
|
||||
};
|
||||
|
||||
// Create branch — caller must have `Create` on the parent
|
||||
// scope. Two cases:
|
||||
// * `parent_id.is_some()` → caller needs Create on the
|
||||
// parent Folder resource.
|
||||
// * `parent_id.is_none()` → the write lands at the drive
|
||||
// root (either the path was single-segment, or the
|
||||
// parent-folder lookup failed). We require Create on
|
||||
// the Drive itself — bundled with owner/editor/contributor
|
||||
// role_grants, refused for viewer/commenter.
|
||||
let create_resource = match &parent_id {
|
||||
Some(pid) => {
|
||||
let uuid = Uuid::parse_str(pid).map_err(|_| {
|
||||
DomainError::internal_error("FileUpload", "invalid parent folder id")
|
||||
})?;
|
||||
Resource::Folder(uuid)
|
||||
}
|
||||
None => Resource::Drive(drive_id),
|
||||
};
|
||||
authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Create,
|
||||
create_resource,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let is_new_blob = blob.is_new_blob;
|
||||
let created = self
|
||||
.file_write
|
||||
@@ -448,9 +658,11 @@ impl FileUploadUseCase for FileUploadService {
|
||||
)
|
||||
.await?;
|
||||
let dto = FileDto::from(created);
|
||||
self.maybe_update_storage_usage(&dto, caller_id);
|
||||
if let Some(hook) = &self.file_lifecycle_hook {
|
||||
hook.on_file_created(&dto.id, &dto.content_hash, content_type, is_new_blob);
|
||||
}
|
||||
self.notify_file_accessed(caller_id, &dto.id);
|
||||
Ok(dto)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ use crate::application::dtos::folder_dto::{
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::external_mount_ports::MountEntry;
|
||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::application::services::external_mount_router::{MountRouter, ResolvedId};
|
||||
use crate::application::services::file_lifecycle_service::FileLifecycleService;
|
||||
use crate::application::services::mount_dto::{
|
||||
audit_mount_write, mount_entry_folder_dto, mount_folder_dto, mount_parent_id,
|
||||
};
|
||||
@@ -28,6 +30,22 @@ pub struct FolderService {
|
||||
/// External-mount classifier. Lets folder operations branch a mount-root or
|
||||
/// `ext:` id onto the provider instead of the PostgreSQL repositories.
|
||||
mount_router: Arc<MountRouter>,
|
||||
/// File lifecycle dispatcher. Carried so `delete_folder_with_perms`
|
||||
/// can fire `on_file_deleted` for every file the PG cascade is about
|
||||
/// to reap. Always present — the dispatcher itself is a no-op when
|
||||
/// no hooks are registered, so callers don't need an Option branch.
|
||||
file_lifecycle: Arc<FileLifecycleService>,
|
||||
/// Drive repository — used by D5's `forbid_cross_drive_move` gate
|
||||
/// on `move_folder_with_perms`. Optional so stubs / test factories
|
||||
/// can build the service without wiring the full drive repo; in
|
||||
/// that case the cross-drive move check is skipped (the policy is
|
||||
/// silently off). Production DI wires it via `with_drive_repo`.
|
||||
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
/// Storage-usage service — used to pre-check the destination
|
||||
/// drive's `used_bytes + subtree_bytes ≤ quota_bytes` invariant
|
||||
/// on cross-drive MOVE. Silently skipped when unwired (stubs).
|
||||
storage_usage:
|
||||
Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
|
||||
}
|
||||
|
||||
impl FolderService {
|
||||
@@ -35,12 +53,16 @@ impl FolderService {
|
||||
pub fn new(
|
||||
folder_storage: Arc<FolderDbRepository>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
file_lifecycle: Arc<FileLifecycleService>,
|
||||
mount_router: Arc<MountRouter>,
|
||||
) -> Self {
|
||||
Self {
|
||||
folder_storage,
|
||||
authz,
|
||||
mount_router,
|
||||
file_lifecycle,
|
||||
drive_repo: None,
|
||||
storage_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +121,31 @@ impl FolderService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires the drive repository, enabling D5
|
||||
/// `forbid_cross_drive_move` enforcement on
|
||||
/// `move_folder_with_perms`. Without it, the gate is silently
|
||||
/// skipped.
|
||||
pub fn with_drive_repo(
|
||||
mut self,
|
||||
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||
) -> Self {
|
||||
self.drive_repo = Some(drive_repo);
|
||||
self
|
||||
}
|
||||
|
||||
/// Wires the storage-usage service so `move_folder_with_perms`
|
||||
/// can pre-check the destination drive's quota on cross-drive
|
||||
/// folder moves.
|
||||
pub fn with_storage_usage(
|
||||
mut self,
|
||||
storage_usage: Arc<
|
||||
crate::application::services::storage_usage_service::StorageUsageService,
|
||||
>,
|
||||
) -> Self {
|
||||
self.storage_usage = Some(storage_usage);
|
||||
self
|
||||
}
|
||||
|
||||
/// Batch counterpart of `get_folder`: resolve many folder ids in ONE
|
||||
/// query instead of one per id. Like `get_folder` it performs no
|
||||
/// per-folder authorization — both current callers (ACL grant listing,
|
||||
@@ -435,18 +482,18 @@ impl FolderUseCase for FolderService {
|
||||
.await?;
|
||||
return self.list_folders(parent_id).await;
|
||||
}
|
||||
// No parent → list the user's root folders.
|
||||
// No parent → list the caller's readable root folders. The
|
||||
// predicate scopes by drive-membership grants (post-PR-B),
|
||||
// closing the pre-D7 gap where the legacy `user_id` filter
|
||||
// surfaced admin-created folders that admin had no role on.
|
||||
let folders = self
|
||||
.folder_storage
|
||||
.list_folders_by_owner(parent_id, caller_id)
|
||||
.list_root_folders_for_caller(caller_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!(
|
||||
"Failed to list folders for owner '{}' in parent {:?}: {}",
|
||||
caller_id, parent_id, e
|
||||
),
|
||||
format!("Failed to list root folders for caller '{caller_id}': {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(folders.into_iter().map(FolderDto::from).collect())
|
||||
@@ -487,6 +534,62 @@ impl FolderUseCase for FolderService {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Keyset-paged sub-folder listing (name order), caller-scoped.
|
||||
///
|
||||
/// AuthZ mirrors `list_folders_paginated_with_perms`: one
|
||||
/// `authz.require(Read)` on the parent per batch; root scope goes
|
||||
/// through the caller's drive-membership listing.
|
||||
async fn list_folders_batch_with_perms(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
caller_id: Uuid,
|
||||
after_name: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<FolderDto>, DomainError> {
|
||||
match parent_id {
|
||||
Some(pid) => {
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Read,
|
||||
Self::folder_resource(pid)?,
|
||||
)
|
||||
.await?;
|
||||
let folders = self
|
||||
.folder_storage
|
||||
.list_folders_batch(parent_id, after_name, limit)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to batch-list folders in parent {pid}: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(folders.into_iter().map(FolderDto::from).collect())
|
||||
}
|
||||
None => {
|
||||
// Root scope: one row per readable drive — a handful.
|
||||
let mut all = self
|
||||
.folder_storage
|
||||
.list_root_folders_for_caller(caller_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to batch-list root folders for '{caller_id}': {e}"),
|
||||
)
|
||||
})?;
|
||||
all.sort_by(|a, b| a.name().cmp(b.name()));
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| after_name.is_none_or(|a| f.name() > a))
|
||||
.take(limit)
|
||||
.map(FolderDto::from)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists folders with pagination, scoped to a specific owner.
|
||||
async fn list_folders_paginated_with_perms(
|
||||
&self,
|
||||
@@ -534,24 +637,23 @@ impl FolderUseCase for FolderService {
|
||||
return self.list_folders_paginated(parent_id, &pagination).await;
|
||||
} else {
|
||||
let (folders, total_items) = self
|
||||
.folder_storage
|
||||
.list_folders_by_owner_paginated(
|
||||
parent_id,
|
||||
owner_id,
|
||||
pagination.offset(),
|
||||
pagination.limit(),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!(
|
||||
"Failed to list folders for owner '{}' with pagination in parent {:?}: {}",
|
||||
owner_id, parent_id, e
|
||||
),
|
||||
.folder_storage
|
||||
.list_root_folders_for_caller_paginated(
|
||||
owner_id,
|
||||
pagination.offset(),
|
||||
pagination.limit(),
|
||||
true,
|
||||
)
|
||||
})?;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!(
|
||||
"Failed to list root folders for caller '{}' with pagination: {}",
|
||||
owner_id, e
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let total = total_items.unwrap_or(folders.len());
|
||||
|
||||
@@ -630,7 +732,7 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let folder = self
|
||||
let renamed = self
|
||||
.folder_storage
|
||||
.rename_folder(id, dto.name, caller_id)
|
||||
.await
|
||||
@@ -641,7 +743,26 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(FolderDto::from(folder))
|
||||
// Root folders double as the drive's display name (see the
|
||||
// `required_perm` branch above and `drive_pg_repository.rs`
|
||||
// `readable_cache` + `default_drive_cache` docs).
|
||||
// `drives.name` is sourced from `folders.name` of the root
|
||||
// folder, so a rename affects BOTH caches — every user's
|
||||
// readable-drive list AND the per-user default-drive lookup.
|
||||
// Both are 30 s TTL; without the invalidation, `GET /api/drives`
|
||||
// returns the stale name for up to that window after a root
|
||||
// rename. Surfaced by `tests/api/drives_membership.hurl`
|
||||
// Step 23. Regression from commit `12dc648c` ("perf: round 4 —
|
||||
// drive-selector cache") which added the caches without
|
||||
// wiring the root-rename invalidation.
|
||||
if folder.parent_id().is_none()
|
||||
&& let Some(drive_repo) = &self.drive_repo
|
||||
{
|
||||
drive_repo.invalidate_readable_all();
|
||||
drive_repo.invalidate_default_drive_all();
|
||||
}
|
||||
|
||||
Ok(FolderDto::from(renamed))
|
||||
}
|
||||
|
||||
/// Moves a folder to a new parent. Requires `Update` on the source and
|
||||
@@ -712,6 +833,60 @@ impl FolderUseCase for FolderService {
|
||||
// TODO: full descendant-cycle check (moving a folder into one of its own descendants)
|
||||
}
|
||||
|
||||
// D5 `forbid_cross_drive_move` + D6 `resource.moved_between_drives`
|
||||
// audit share the same src/dst lookup. Gate before the move,
|
||||
// audit after a successful move when the two drives differ.
|
||||
// Skipped for parent_id=None (root namespace, same-drive
|
||||
// semantics) and when drive_repo isn't wired (stubs/tests) —
|
||||
// same shape as `move_file_with_perms`.
|
||||
let mut cross_drive: Option<(Uuid, Uuid)> = None;
|
||||
if let Some(drive_repo) = &self.drive_repo
|
||||
&& let Some(parent_id) = &dto.parent_id
|
||||
{
|
||||
let src_folder_uuid =
|
||||
Uuid::parse_str(id).map_err(|_| DomainError::not_found("Folder", id))?;
|
||||
let dst_folder_uuid = Uuid::parse_str(parent_id)
|
||||
.map_err(|_| DomainError::not_found("Folder", parent_id.as_str()))?;
|
||||
// Independent point reads — overlapped so the pre-move drive
|
||||
// resolution pays one round-trip, not two (ROUND10, same shape
|
||||
// as `move_file_with_perms`).
|
||||
let (src_res, dst_res) = tokio::join!(
|
||||
drive_repo.get_drive_id_and_policies_for_folder(src_folder_uuid),
|
||||
drive_repo.drive_id_for_folder(dst_folder_uuid),
|
||||
);
|
||||
let (src_drive_id, src_policies) = src_res.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("source drive lookup: {e:?}"))
|
||||
})?;
|
||||
let dst_drive_id = dst_res.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("destination drive lookup: {e:?}"))
|
||||
})?;
|
||||
if src_drive_id != dst_drive_id {
|
||||
src_policies.refuse_cross_drive_move(
|
||||
crate::domain::entities::drive::CrossDriveMoveGateContext {
|
||||
caller_id,
|
||||
resource_type: "folder",
|
||||
resource_id: src_folder_uuid,
|
||||
src_drive_id,
|
||||
dst_drive_id,
|
||||
},
|
||||
)?;
|
||||
// Destination drive quota: sum the moved subtree's
|
||||
// non-trashed files and refuse if the destination
|
||||
// couldn't hold them. Same 507 shape as the file
|
||||
// path + upload path — DomainError::QuotaExceeded
|
||||
// maps at the AppError boundary.
|
||||
if let Some(storage_usage) = &self.storage_usage {
|
||||
let subtree_bytes = storage_usage.folder_subtree_bytes(src_folder_uuid).await?;
|
||||
if let Ok(subtree_u64) = u64::try_from(subtree_bytes) {
|
||||
storage_usage
|
||||
.check_drive_quota(dst_drive_id, subtree_u64)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
cross_drive = Some((src_drive_id, dst_drive_id));
|
||||
}
|
||||
}
|
||||
|
||||
let parent_ref = dto.parent_id.as_deref();
|
||||
let folder = self
|
||||
.folder_storage
|
||||
@@ -724,12 +899,46 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Cross-drive move flushes the authz engine's `owner_cache`
|
||||
// — every descendant's cached `Resource → drive_id` mapping
|
||||
// just got stale via the cascade trigger, and we don't (yet)
|
||||
// walk the subtree to invalidate individually. Small perf
|
||||
// cost (single JOIN per resource touched over the next
|
||||
// minute) versus a stale-authz bug where destination-drive
|
||||
// Owner cascades don't apply to moved content.
|
||||
if cross_drive.is_some() {
|
||||
self.authz.invalidate_owner_cache_all().await;
|
||||
}
|
||||
|
||||
// D6 audit: only emit when the move crossed a drive boundary.
|
||||
// The cascade trigger has already propagated drive_id to the
|
||||
// subtree at this point (see migration
|
||||
// `20260807000000_cascade_drive_id_on_folder_move.sql`).
|
||||
if let Some((src_drive_id, dst_drive_id)) = cross_drive {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "resource.moved_between_drives",
|
||||
resource_type = "folder",
|
||||
resource_id = %folder.id(),
|
||||
src_drive_id = %src_drive_id,
|
||||
dst_drive_id = %dst_drive_id,
|
||||
by = %caller_id,
|
||||
"📦 folder moved between drives",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(FolderDto::from(folder))
|
||||
}
|
||||
|
||||
/// Deletes a folder after verifying the caller has `Delete` permission.
|
||||
/// The DB trigger `trg_cleanup_grants_folder` cleans up `access_grants`
|
||||
/// rows targeting the deleted folder automatically.
|
||||
///
|
||||
/// Enumerates the subtree's file ids BEFORE the bulk DELETE so
|
||||
/// `on_file_deleted` fires per file the PG cascade is about to reap —
|
||||
/// without this, file-id-keyed lifecycle data (e.g. `ext-{file_id}.jpg`
|
||||
/// video thumbnails, moka cache entries) leaks past the cascade.
|
||||
/// Same shape `clear_trash_in` uses (`trash_service.rs:804-846`).
|
||||
async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
|
||||
// External mount: delete on the provider (permanent — mounts have no
|
||||
// trash). The mount root is a real folder row and is not deletable here.
|
||||
@@ -758,12 +967,28 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Snapshot the file ids BEFORE the bulk DELETE — the rows are gone
|
||||
// afterward. Failure to enumerate is non-fatal (logged in the repo
|
||||
// method); the delete proceeds and only file-id-keyed cleanup is
|
||||
// skipped (blob-keyed thumbnails still get reaped by GC).
|
||||
let cascaded_file_ids = self
|
||||
.folder_storage
|
||||
.list_file_ids_in_subtree(id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
self.folder_storage.delete_folder(id).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to delete folder with ID: {}: {}", id, e),
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
for file_id in &cascaded_file_ids {
|
||||
self.file_lifecycle.on_file_deleted(file_id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,9 +1327,22 @@ impl PersonalDriveLifecycleHook {
|
||||
// parent_id=NULL, drive_id pinned) + drives.root_folder_id
|
||||
// wire-up + Owner role_grant. Single SQL statement, atomic
|
||||
// against server crash mid-sequence (docs/plan/drive.md §3).
|
||||
//
|
||||
// `quota_bytes = None` (NULL in the DB) is the invariant for
|
||||
// every personal drive per plan §7: the cap for a user's
|
||||
// personal storage lives on `auth.users.storage_quota_bytes`
|
||||
// (the user envelope), not on the drive row. Passing
|
||||
// `Some(user.storage_quota_bytes())` here previously baked
|
||||
// the user quota into `drives.quota_bytes` and — combined
|
||||
// with the "0 = unlimited" convention on the user check but
|
||||
// "0 = literal zero" convention on the drive check — turned
|
||||
// "unlimited user" into "0-byte drive" (see #595). The
|
||||
// migration `20260916000000_null_personal_drive_quota.sql`
|
||||
// heals existing rows and adds a CHECK constraint pinning
|
||||
// this invariant at the schema layer.
|
||||
let drive_with_name = self
|
||||
.drive_repo
|
||||
.create_personal_drive_atomic(user.id(), Some(user.storage_quota_bytes()))
|
||||
.create_personal_drive_atomic(user.id(), None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
@@ -1142,6 +1380,17 @@ impl UserLifecycleHook for PersonalDriveLifecycleHook {
|
||||
self.provision_if_needed(user).await
|
||||
}
|
||||
|
||||
/// External → internal upgrade. `on_user_created` fired at signup
|
||||
/// with `is_external=true` and short-circuited in
|
||||
/// `provision_if_needed`. The user is now internal — same helper
|
||||
/// runs, but this time the `is_external` guard passes through and
|
||||
/// the atomic CTE creates their default drive + root folder +
|
||||
/// owner grant. Idempotent by construction: a rerun after a partial
|
||||
/// failure hits the `find_default_for_user` short-circuit.
|
||||
async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> {
|
||||
self.provision_if_needed(user).await
|
||||
}
|
||||
|
||||
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
||||
// Drives don't react to logout. Explicit no-op per the
|
||||
// "no defaults" convention.
|
||||
@@ -1415,6 +1664,7 @@ mod mount_authz_integration {
|
||||
let fs = FolderService::new(
|
||||
Arc::new(FolderDbRepository::new(pool.clone())),
|
||||
acl(pool),
|
||||
Arc::new(crate::application::services::file_lifecycle_service::FileLifecycleService::new()),
|
||||
router,
|
||||
);
|
||||
(fs, p.mount_folder_id.to_string(), p.owner_id)
|
||||
@@ -1610,6 +1860,7 @@ mod mount_authz_integration {
|
||||
let folder_service = FolderService::new(
|
||||
Arc::new(FolderDbRepository::new(pool.clone())),
|
||||
acl(&pool),
|
||||
Arc::new(crate::application::services::file_lifecycle_service::FileLifecycleService::new()),
|
||||
router.clone(),
|
||||
);
|
||||
let retrieval = FileRetrievalService::new_with_authz_for_test(
|
||||
@@ -1659,7 +1910,7 @@ mod mount_authz_integration {
|
||||
|
||||
// PROPFIND Depth:1 file loop: list files of the mount root.
|
||||
let files = retrieval
|
||||
.list_files_batch_with_perms(Some(&p.mount_folder_id.to_string()), p.owner_id, 0, 100)
|
||||
.list_files_batch_with_perms(Some(&p.mount_folder_id.to_string()), p.owner_id, None, 100)
|
||||
.await
|
||||
.expect("list mount files");
|
||||
assert_eq!(
|
||||
@@ -1673,7 +1924,6 @@ mod mount_authz_integration {
|
||||
.get_file_by_path(&format!("{}/sub/b.txt", root.path), p.drive_id)
|
||||
.await
|
||||
.expect("nested file");
|
||||
use futures::TryStreamExt as _;
|
||||
let content: Vec<u8> = Box::into_pin(retrieval.get_file_stream(&nested.id).await.unwrap())
|
||||
.map_ok(|b| b.to_vec())
|
||||
.try_concat()
|
||||
@@ -1745,6 +1995,7 @@ mod mount_authz_integration {
|
||||
let folder_service = FolderService::new(
|
||||
Arc::new(FolderDbRepository::new(pool.clone())),
|
||||
acl(&pool),
|
||||
Arc::new(crate::application::services::file_lifecycle_service::FileLifecycleService::new()),
|
||||
router.clone(),
|
||||
);
|
||||
|
||||
@@ -1834,3 +2085,227 @@ mod mount_authz_integration {
|
||||
assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Integration test — verifies the folder-cascade hook fix lands `on_file_deleted`
|
||||
// for every file the PG cascade reaps when a folder is permanently deleted.
|
||||
//
|
||||
// Background: `delete_folder_with_perms` issues a bulk SQL DELETE that the PG
|
||||
// `ON DELETE CASCADE` fans out to descendant folders + files. Without
|
||||
// service-layer enumeration, file-id-keyed lifecycle data (thumbnails keyed
|
||||
// on `ext-{file_id}.jpg`, moka cache entries, future per-file metadata)
|
||||
// silently leaks. See [[bug-folder-cascade-hooks-missing]] in agent memory.
|
||||
//
|
||||
// How to run:
|
||||
// bash tests/common/spawn-db.sh
|
||||
// RUSTFLAGS='--cfg integration_tests' cargo test \
|
||||
// -p oxicloud --lib folder_service::cascade_hook_integration_tests
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
#[cfg(integration_tests)]
|
||||
#[allow(dead_code)]
|
||||
mod cascade_hook_integration_tests {
|
||||
use super::*;
|
||||
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
|
||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||
use crate::infrastructure::repositories::pg::SubjectGroupPgRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
use crate::infrastructure::services::local_blob_backend::LocalBlobBackend;
|
||||
use crate::integration_test_support::{ensure_clean_test_db, test_db_url};
|
||||
use sqlx::Row;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Records every `on_file_deleted` call so the test can assert the
|
||||
/// exact set of file ids the cascade fired hooks for. Other lifecycle
|
||||
/// methods are no-ops — this fix only touches the deletion path.
|
||||
#[derive(Default)]
|
||||
struct RecordingHook {
|
||||
deleted: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FileLifecycleHook for RecordingHook {
|
||||
fn on_file_created(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_blob_hash: &str,
|
||||
_content_type: &str,
|
||||
_is_new_blob: bool,
|
||||
) {
|
||||
}
|
||||
fn on_file_copied(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_blob_hash: &str,
|
||||
_content_type: &str,
|
||||
_source_file_id: &str,
|
||||
) {
|
||||
}
|
||||
fn on_file_updated(&self, _file_id: &str, _blob_hash: &str, _content_type: &str) {}
|
||||
fn on_file_deleted(&self, file_id: &str) {
|
||||
self.deleted.lock().unwrap().push(file_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_pool() -> Arc<sqlx::PgPool> {
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(4)
|
||||
.connect(&test_db_url())
|
||||
.await
|
||||
.expect("connect to test DB — run tests/common/spawn-db.sh first");
|
||||
ensure_clean_test_db(&pool).await;
|
||||
Arc::new(pool)
|
||||
}
|
||||
|
||||
/// Returns `(user_id, drive_id, drive_root_folder_id)` — same default
|
||||
/// Personal drive every internal user gets post-D0 (provisioned by
|
||||
/// `PersonalDriveLifecycleHook`).
|
||||
async fn seed_user(pool: &sqlx::PgPool) -> (Uuid, Uuid, Uuid) {
|
||||
sqlx::query(
|
||||
"SELECT u.id AS user_id, d.id AS drive_id, d.root_folder_id
|
||||
FROM auth.users u
|
||||
JOIN storage.drives d ON d.default_for_user = u.id
|
||||
LIMIT 1",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map(|r| {
|
||||
(
|
||||
r.get::<Uuid, _>("user_id"),
|
||||
r.get::<Uuid, _>("drive_id"),
|
||||
r.get::<Uuid, _>("root_folder_id"),
|
||||
)
|
||||
})
|
||||
.expect("auth.users + storage.drives must be seeded (init-test-schema.sh)")
|
||||
}
|
||||
|
||||
/// Build a real `PgAclEngine` against the test pool so
|
||||
/// `delete_folder_with_perms` can actually evaluate Owner — the user
|
||||
/// from `seed_user` owns the default drive, so `Permission::Delete`
|
||||
/// on its descendants resolves through the Owner short-circuit.
|
||||
async fn build_authz(
|
||||
pool: Arc<sqlx::PgPool>,
|
||||
dir: &TempDir,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
) -> Arc<PgAclEngine> {
|
||||
let backend = Arc::new(LocalBlobBackend::new(&dir.path().join("blobs")));
|
||||
backend.initialize().await.expect("init backend");
|
||||
let dedup = Arc::new(DedupService::new(backend, pool.clone(), pool.clone()));
|
||||
let file_repo = Arc::new(FileBlobReadRepository::new(
|
||||
pool.clone(),
|
||||
dedup,
|
||||
folder_repo.clone(),
|
||||
));
|
||||
let group_repo = Arc::new(SubjectGroupPgRepository::new(pool.clone()));
|
||||
Arc::new(PgAclEngine::new(pool, folder_repo, file_repo, group_repo))
|
||||
}
|
||||
|
||||
/// Seed a file row under `folder_id`. `blob_hash` is just a string —
|
||||
/// `storage.files.blob_hash` is VARCHAR(64) without a FK, so no blob
|
||||
/// row is required. The cascade decrement trigger no-ops when the
|
||||
/// hash is unknown.
|
||||
async fn seed_file_under(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
label: &str,
|
||||
) -> Uuid {
|
||||
let blob_hash = blake3::hash(format!("cascade-{label}-{}", Uuid::new_v4()).as_bytes())
|
||||
.to_hex()
|
||||
.to_string();
|
||||
// Post-D7: `user_id` omitted — the column is nullable and
|
||||
// provenance flows through `created_by` / `updated_by`.
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO storage.files
|
||||
(name, drive_id, folder_id, blob_hash, size, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(format!(
|
||||
"rust-test-cascade-{label}-{}",
|
||||
&Uuid::new_v4().to_string()[..8]
|
||||
))
|
||||
.bind(drive_id)
|
||||
.bind(folder_id)
|
||||
.bind(&blob_hash)
|
||||
.bind(42i64)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed file row")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_folder_with_perms_fires_hook_for_cascaded_files() {
|
||||
let pool = test_pool().await;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (user_id, drive_id, drive_root) = seed_user(&pool).await;
|
||||
|
||||
let folder_repo = Arc::new(FolderDbRepository::new(pool.clone()));
|
||||
let authz = build_authz(pool.clone(), &dir, folder_repo.clone()).await;
|
||||
let recorder: Arc<RecordingHook> = Arc::new(RecordingHook::default());
|
||||
let fls = Arc::new(
|
||||
crate::application::services::file_lifecycle_service::FileLifecycleService::new()
|
||||
.with_hook(recorder.clone() as Arc<dyn FileLifecycleHook>),
|
||||
);
|
||||
let service = FolderService::new(
|
||||
folder_repo.clone(),
|
||||
authz,
|
||||
fls,
|
||||
Arc::new(MountRouter::new(Arc::new(
|
||||
crate::application::services::mount_registry::MountRegistry::empty(),
|
||||
))),
|
||||
);
|
||||
|
||||
// Build parent/child via the production create path — it stamps
|
||||
// provenance and computes paths the same way as live uploads.
|
||||
let parent = folder_repo
|
||||
.create_folder(
|
||||
format!(
|
||||
"rust-test-cascade-parent-{}",
|
||||
&Uuid::new_v4().to_string()[..8]
|
||||
),
|
||||
Some(drive_root.to_string()),
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
.expect("create parent");
|
||||
let child = folder_repo
|
||||
.create_folder(
|
||||
format!(
|
||||
"rust-test-cascade-child-{}",
|
||||
&Uuid::new_v4().to_string()[..8]
|
||||
),
|
||||
Some(parent.id().to_string()),
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
.expect("create child");
|
||||
let child_uuid = Uuid::parse_str(child.id()).expect("child uuid");
|
||||
|
||||
// Two files: one directly under the parent, one nested under
|
||||
// child. The cascade should reap both; the hook must fire for both.
|
||||
let parent_uuid = Uuid::parse_str(parent.id()).expect("parent uuid");
|
||||
let direct_file = seed_file_under(&pool, user_id, drive_id, parent_uuid, "direct").await;
|
||||
let nested_file = seed_file_under(&pool, user_id, drive_id, child_uuid, "nested").await;
|
||||
|
||||
// Act — the production code path under test.
|
||||
service
|
||||
.delete_folder_with_perms(parent.id(), user_id)
|
||||
.await
|
||||
.expect("delete_folder_with_perms");
|
||||
|
||||
// Assert — every cascaded file id appears in the hook record.
|
||||
let captured = recorder.deleted.lock().unwrap().clone();
|
||||
assert!(
|
||||
captured.contains(&direct_file.to_string()),
|
||||
"expected on_file_deleted for direct-child file {direct_file}, got {captured:?}"
|
||||
);
|
||||
assert!(
|
||||
captured.contains(&nested_file.to_string()),
|
||||
"expected on_file_deleted for nested file {nested_file}, got {captured:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
//! Tests for IDOR (Insecure Direct Object Reference) protection.
|
||||
//!
|
||||
//! Verifies that ownership checks at the repository and service layers
|
||||
//! correctly reject access when the caller is not the file owner.
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Mock repositories
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// A simple in-memory mock that maps (file_id → (File, owner_id)).
|
||||
struct MockFileReadPort {
|
||||
/// file_id → (File, owner_id)
|
||||
files: Mutex<HashMap<String, (File, Uuid)>>,
|
||||
}
|
||||
|
||||
impl MockFileReadPort {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
files: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a test file owned by `owner_id`.
|
||||
fn insert(&self, id: &str, name: &str, owner_id: Uuid) {
|
||||
let file = File::new(
|
||||
id.to_string(),
|
||||
name.to_string(),
|
||||
StoragePath::from_string(&format!("/{}", name)),
|
||||
42,
|
||||
"text/plain".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(id.to_string(), (file, owner_id));
|
||||
}
|
||||
}
|
||||
|
||||
impl FileReadPort for MockFileReadPort {
|
||||
async fn get_file(&self, id: &str) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(id)
|
||||
.map(|(f, _)| f.clone())
|
||||
.ok_or_else(|| DomainError::not_found("File", id.to_string()))
|
||||
}
|
||||
|
||||
async fn get_file_or_trashed(&self, id: &str) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(id)
|
||||
.map(|(f, _)| f.clone())
|
||||
.ok_or_else(|| DomainError::not_found("File", id.to_string()))
|
||||
}
|
||||
|
||||
async fn get_file_for_owner(&self, id: &str, owner_id: Uuid) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
match files.get(id) {
|
||||
Some((file, actual_owner)) if *actual_owner == owner_id => Ok(file.clone()),
|
||||
// Return NotFound regardless — do not leak existence
|
||||
_ => Err(DomainError::not_found("File", id.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
_start: u64,
|
||||
_end: Option<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_path(&self, _id: &str) -> Result<StoragePath, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(
|
||||
&self,
|
||||
_path: &str,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> {
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
async fn search_files_paginated(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> Result<(Vec<File>, usize), DomainError> {
|
||||
Ok((Vec::new(), 0))
|
||||
}
|
||||
|
||||
async fn count_files(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn get_folder_id_by_path(
|
||||
&self,
|
||||
_folder_path: &str,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn stream_files_in_subtree(
|
||||
&self,
|
||||
_folder_id: &str,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<File, DomainError>> + Send>>, DomainError> {
|
||||
Ok(Box::pin(futures::stream::empty()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal mock write port — only `move_file` and `rename_file` need real logic.
|
||||
#[allow(dead_code)]
|
||||
struct MockFileWritePort {
|
||||
files: Mutex<HashMap<String, File>>,
|
||||
}
|
||||
|
||||
impl MockFileWritePort {
|
||||
#[allow(dead_code)]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
files: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn insert(&self, id: &str, name: &str) {
|
||||
let file = File::new(
|
||||
id.to_string(),
|
||||
name.to_string(),
|
||||
StoragePath::from_string(&format!("/{}", name)),
|
||||
42,
|
||||
"text/plain".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
self.files.lock().unwrap().insert(id.to_string(), file);
|
||||
}
|
||||
}
|
||||
|
||||
impl FileWritePort for MockFileWritePort {
|
||||
async fn save_file_with_blob(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_blob_hash: &str,
|
||||
_size: u64,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
_target_folder_id: Option<String>,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(file_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id.to_string()))
|
||||
}
|
||||
|
||||
async fn rename_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
_new_name: &str,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(file_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id.to_string()))
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_file_content_with_blob(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_blob_hash: &str,
|
||||
_size: u64,
|
||||
_modified_at: Option<i64>,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<(String, i64), DomainError> {
|
||||
Ok((String::new(), 0))
|
||||
}
|
||||
|
||||
async fn register_file_deferred(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_size: u64,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<(File, PathBuf), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn copy_file(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_target_folder_id: Option<String>,
|
||||
_new_name: Option<&str>,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_to_trash(&self, _file_id: &str, _caller_id: Uuid) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_from_trash(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_original_path: &str,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_file_permanently(&self, _file_id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests — FileReadPort::get_file_for_owner (Repository layer, Solution C)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_for_owner_returns_file_for_correct_owner() {
|
||||
let alice_id = Uuid::new_v4();
|
||||
let repo = MockFileReadPort::new();
|
||||
repo.insert("file-1", "secret.txt", alice_id);
|
||||
|
||||
let result = repo.get_file_for_owner("file-1", alice_id).await;
|
||||
assert!(result.is_ok(), "owner should be able to read own file");
|
||||
assert_eq!(result.unwrap().id(), "file-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_for_owner_rejects_wrong_owner() {
|
||||
let alice_id = Uuid::new_v4();
|
||||
let bob_id = Uuid::new_v4();
|
||||
let repo = MockFileReadPort::new();
|
||||
repo.insert("file-1", "secret.txt", alice_id);
|
||||
|
||||
let result = repo.get_file_for_owner("file-1", bob_id).await;
|
||||
assert!(result.is_err(), "non-owner should be rejected");
|
||||
|
||||
// Must be NotFound, NOT Forbidden — avoids leaking existence
|
||||
let err = result.unwrap_err();
|
||||
let msg = format!("{}", err);
|
||||
assert!(
|
||||
msg.contains("not found") || msg.contains("NotFound"),
|
||||
"error must be NotFound, got: {}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_for_owner_returns_not_found_for_missing_file() {
|
||||
let alice_id = Uuid::new_v4();
|
||||
let repo = MockFileReadPort::new();
|
||||
|
||||
let result = repo.get_file_for_owner("nonexistent", alice_id).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_file_owner_uses_default_impl() {
|
||||
let alice_id = Uuid::new_v4();
|
||||
let bob_id = Uuid::new_v4();
|
||||
let repo = MockFileReadPort::new();
|
||||
repo.insert("file-1", "secret.txt", alice_id);
|
||||
|
||||
// Default impl delegates to get_file_for_owner and maps to ()
|
||||
assert!(repo.verify_file_owner("file-1", alice_id).await.is_ok());
|
||||
assert!(repo.verify_file_owner("file-1", bob_id).await.is_err());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests — FileManagementService _owned methods (Service layer, Solution B)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Note: FileManagementService::with_trash takes concrete types for the write
|
||||
// repository (Arc<FileBlobWriteRepository>). We cannot construct real PG repos
|
||||
// without a database. Instead, we test the verify_owner logic indirectly by
|
||||
// testing the mock-based trait interactions at the port level, and document
|
||||
// that integration tests hitting the real DB are the ultimate verification.
|
||||
//
|
||||
// The tests below verify the *contract*: _owned methods must call
|
||||
// verify_owner before delegating, and verify_owner must fail-closed when
|
||||
// no read repo is available.
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_file_owner_delegates_to_read_port() {
|
||||
// This test verifies the FileReadPort contract that verify_file_owner
|
||||
// returns Ok for the correct owner and Err for others.
|
||||
let user_id = Uuid::new_v4();
|
||||
let attacker_id = Uuid::new_v4();
|
||||
let read = MockFileReadPort::new();
|
||||
read.insert("abc-123", "report.pdf", user_id);
|
||||
|
||||
// Same user → Ok
|
||||
let ok = read.verify_file_owner("abc-123", user_id).await;
|
||||
assert!(ok.is_ok(), "correct owner should pass verify_file_owner");
|
||||
|
||||
// Different user → Err
|
||||
let err = read.verify_file_owner("abc-123", attacker_id).await;
|
||||
assert!(err.is_err(), "wrong owner should fail verify_file_owner");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owned_methods_require_ownership_check_first() {
|
||||
// Simulate what the _owned methods do: verify_owner then delegate.
|
||||
// We test with the mock read port to prove the sequence.
|
||||
let owner_id = Uuid::new_v4();
|
||||
let attacker_id = Uuid::new_v4();
|
||||
let read = MockFileReadPort::new();
|
||||
read.insert("file-1", "data.csv", owner_id);
|
||||
|
||||
// Step 1: verify_owner for correct owner → Ok
|
||||
let step1 = read.verify_file_owner("file-1", owner_id).await;
|
||||
assert!(step1.is_ok());
|
||||
|
||||
// Step 2: verify_owner for attacker → Err, so the move/rename never executes
|
||||
let step2 = read.verify_file_owner("file-1", attacker_id).await;
|
||||
assert!(step2.is_err());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests — Trait-level _owned method stubs (StubFileManagementUseCase)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
use crate::application::ports::file_ports::FileManagementUseCase;
|
||||
use crate::common::stubs::StubFileManagementUseCase;
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_move_file_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileManagementUseCase;
|
||||
let result = stub
|
||||
.move_file_with_perms("file-1", user_id, Some("folder-2".to_string()))
|
||||
.await;
|
||||
assert!(result.is_ok(), "stub should return Ok for move_file_owned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_rename_file_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileManagementUseCase;
|
||||
let result = stub
|
||||
.rename_file_with_perms("file-1", user_id, "new-name.txt")
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"stub should return Ok for rename_file_owned"
|
||||
);
|
||||
}
|
||||
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
use crate::common::stubs::StubFileRetrievalUseCase;
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_get_file_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileRetrievalUseCase;
|
||||
let result = stub.get_file_with_perms("file-1", user_id).await;
|
||||
assert!(result.is_ok(), "stub should return Ok for get_file_owned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_get_file_optimized_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileRetrievalUseCase;
|
||||
let result = stub
|
||||
.get_file_optimized_with_perms("file-1", user_id, true, false)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"stub should return Ok for get_file_optimized_owned"
|
||||
);
|
||||
}
|
||||
@@ -307,20 +307,30 @@ impl MagicLinkInviteService {
|
||||
let (kind, resource_id) = match resource {
|
||||
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
|
||||
Resource::File(id) => (MagicLinkResourceKind::File, id),
|
||||
// Drive sharing — and therefore drive magic-link invitations —
|
||||
// land in D2. The grant DTOs accept `Resource::Drive` from the
|
||||
// wire today (see ResourceTypeDto) but no public API path
|
||||
// actually grants on a drive in D0, so this arm is
|
||||
// defensively unreachable. Treating it as an audit-logged
|
||||
// no-op (grant is in place, mail suppressed) matches the
|
||||
// ineligible-recipient branch above.
|
||||
Resource::Drive(_) => {
|
||||
// Drive / Calendar / AddressBook / Playlist sharing is
|
||||
// out-of-band for the magic-link flow. Drive shares land
|
||||
// through `/api/drives/{id}/members`; Calendar /
|
||||
// AddressBook shares through the Round-3
|
||||
// `/api/(calendars|address-books)/{id}/shares` endpoints;
|
||||
// Playlist shares through `/api/playlists/{id}/share`.
|
||||
// The DTOs accept every `Resource` variant on the wire
|
||||
// (see `ResourceTypeDto`) but only file/folder grants
|
||||
// trigger an invitation email. Treating the other arms
|
||||
// as audit-logged suppressed no-ops keeps the grant in
|
||||
// place while matching the ineligible-recipient branch
|
||||
// above.
|
||||
Resource::Drive(_)
|
||||
| Resource::Calendar(_)
|
||||
| Resource::AddressBook(_)
|
||||
| Resource::Playlist(_) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.invitation_suppressed",
|
||||
reason = "drive_resource_unsupported",
|
||||
reason = "resource_kind_unsupported",
|
||||
user_id = %recipient.id(),
|
||||
"📭 magic-link invitation suppressed: drive resources aren't invitable until D2",
|
||||
resource_kind = %resource.type_str(),
|
||||
"📭 magic-link invitation suppressed: {} resources aren't invitable via email",
|
||||
resource.type_str(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -347,10 +357,13 @@ impl MagicLinkInviteService {
|
||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||
// Unreachable — the early-return above exits before we get
|
||||
// here for a Drive resource. The arm exists only to satisfy
|
||||
// exhaustiveness; if you find this firing, the early-return
|
||||
// was bypassed.
|
||||
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
||||
// here for Drive / Calendar / AddressBook / Playlist
|
||||
// resources. The arms exist only to satisfy exhaustiveness;
|
||||
// if you find any firing, the early-return was bypassed.
|
||||
Resource::Drive(_)
|
||||
| Resource::Calendar(_)
|
||||
| Resource::AddressBook(_)
|
||||
| Resource::Playlist(_) => "server.magic_link.email.kind_folder",
|
||||
};
|
||||
// PR C: render in the recipient's preferred locale (set by UI
|
||||
// switcher, OIDC JIT claim, or inviter inheritance at row
|
||||
@@ -436,7 +449,8 @@ impl MagicLinkInviteService {
|
||||
/// is reserved for `resolve_or_create_recipient` — and if the
|
||||
/// matched user has no other login credential, mint a NULL-resource
|
||||
/// magic-link token and email a sign-in link. The redemption
|
||||
/// endpoint lands a NULL-resource token on `/#/sharedwithme`.
|
||||
/// endpoint lands a NULL-resource token on `/shared-with-me`
|
||||
/// (external users) or `/files` (internal users).
|
||||
///
|
||||
/// Always returns `Ok(())` so the caller can emit a uniform
|
||||
/// response shape (`"If an account exists, a link will be sent."`)
|
||||
@@ -602,6 +616,123 @@ impl MagicLinkInviteService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mint + email a magic-link for **email verification**, called
|
||||
/// only after another authentication factor has already proven the
|
||||
/// caller's identity (currently: the login handler after a
|
||||
/// successful password check).
|
||||
///
|
||||
/// Contract: the caller MUST have validated the user's identity via
|
||||
/// an independent factor before invoking this. The method does NOT
|
||||
/// re-verify credentials — it exists specifically to bypass the
|
||||
/// `has_password` eligibility gate, which would otherwise deadlock
|
||||
/// the `OXICLOUD_REQUIRE_VERIFIED_EMAIL` flow (login rejected as
|
||||
/// unverified → user asks for a verification link → refused
|
||||
/// because they have a password).
|
||||
///
|
||||
/// Rejected: OIDC-linked users, deactivated users. Everything else
|
||||
/// gets a token — including the "has password" case that
|
||||
/// `send_login_link` refuses.
|
||||
pub async fn send_verification_link_authenticated(
|
||||
&self,
|
||||
user: &User,
|
||||
request_challenge: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
// OIDC boundary is unconditional even here — the IdP owns the
|
||||
// identity contract and we must not mint a session-primitive
|
||||
// for a user it manages.
|
||||
if user.is_oidc_user() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "oidc_user",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
"🔗 verify-link suppressed: OIDC user",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if !user.is_active() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "account_deactivated",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
"🔗 verify-link suppressed: account deactivated",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = MagicLinkToken::new(
|
||||
user.id(),
|
||||
chrono::Duration::minutes(self.magic_link_cfg.login_ttl_minutes as i64),
|
||||
None,
|
||||
Some(request_challenge.to_string()),
|
||||
);
|
||||
self.magic_link_repo.create(&token).await?;
|
||||
|
||||
let link = format!(
|
||||
"{}/magic/v1/{}",
|
||||
self.public_base_url.trim_end_matches('/'),
|
||||
token.token(),
|
||||
);
|
||||
// Reuses the login email template for now — same call to
|
||||
// action (click the link), same TTL, same challenge binding.
|
||||
// A dedicated "verify your email" template can land later
|
||||
// without wire changes.
|
||||
let locale = self.locale_for(user);
|
||||
let ttl_minutes = self.magic_link_cfg.login_ttl_minutes.to_string();
|
||||
let login_args: Vec<(&str, &str)> = vec![("link", &link), ("ttl_minutes", &ttl_minutes)];
|
||||
|
||||
let subject = self
|
||||
.i18n_or(
|
||||
"server.magic_link.email.login.subject",
|
||||
&locale,
|
||||
&login_args,
|
||||
)
|
||||
.await;
|
||||
let text_body = self
|
||||
.render_bilingual("server.magic_link.email.login.body", &locale, &login_args)
|
||||
.await;
|
||||
|
||||
let message = EmailMessage {
|
||||
to: user.email().to_string(),
|
||||
subject,
|
||||
text_body,
|
||||
html_body: None,
|
||||
};
|
||||
|
||||
match self.email_sender.send(message).await {
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "sent_verification",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
email = %user.email(),
|
||||
smtp_code = outcome.code,
|
||||
smtp_message = %outcome.message,
|
||||
"🔗 verify-link sent to '{}'",
|
||||
user.email(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send_failed",
|
||||
user_id = %user.id(),
|
||||
email = %user.email(),
|
||||
error = %e.message,
|
||||
"🔗 verify-link SMTP send failed for '{}'",
|
||||
user.email(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a translation, falling back to the literal key on any
|
||||
/// lookup error. Identical to the handler-side helper — kept inline
|
||||
/// here because the service layer can't pull in a UI util module
|
||||
|
||||
@@ -43,8 +43,6 @@ pub mod wopi_token_service;
|
||||
#[cfg(test)]
|
||||
mod batch_operations_test;
|
||||
#[cfg(test)]
|
||||
mod idor_protection_test;
|
||||
#[cfg(test)]
|
||||
mod trash_service_test;
|
||||
|
||||
// Re-exportar para facilitar acceso
|
||||
|
||||
@@ -58,7 +58,6 @@ pub fn mount_folder_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) ->
|
||||
name: node_name(stat.node_id.as_str()).to_owned(),
|
||||
path: String::new(),
|
||||
parent_id: Some(parent_id.to_owned()),
|
||||
owner_id: Some(cfg.owner_id.to_string()),
|
||||
drive_id: cfg.drive_id,
|
||||
created_at: stat.created_at,
|
||||
modified_at: stat.modified_at,
|
||||
@@ -66,8 +65,8 @@ pub fn mount_folder_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) ->
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
created_by: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +79,6 @@ pub fn mount_entry_folder_dto(cfg: &MountConfig, parent_id: &str, entry: &MountE
|
||||
name: entry.name.clone(),
|
||||
path: String::new(),
|
||||
parent_id: Some(parent_id.to_owned()),
|
||||
owner_id: Some(cfg.owner_id.to_string()),
|
||||
drive_id: cfg.drive_id,
|
||||
created_at: entry.created_at,
|
||||
modified_at: entry.modified_at,
|
||||
@@ -88,8 +86,8 @@ pub fn mount_entry_folder_dto(cfg: &MountConfig, parent_id: &str, entry: &MountE
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
created_by: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,12 +110,11 @@ pub fn mount_entry_file_dto(cfg: &MountConfig, parent_id: &str, entry: &MountEnt
|
||||
icon_special_class: Arc::from(icon_special_class_for(name, &mime)),
|
||||
category: Arc::from(category_for(name, &mime)),
|
||||
size_formatted: format_file_size(entry.size),
|
||||
owner_id: Some(cfg.owner_id.to_string()),
|
||||
sort_date: None,
|
||||
content_hash: String::new(),
|
||||
etag: virtual_file_etag(entry.size, entry.modified_at),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
created_by: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,11 +136,10 @@ pub fn mount_file_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) -> F
|
||||
icon_special_class: Arc::from(icon_special_class_for(name, mime)),
|
||||
category: Arc::from(category_for(name, mime)),
|
||||
size_formatted: format_file_size(stat.size),
|
||||
owner_id: Some(cfg.owner_id.to_string()),
|
||||
sort_date: None,
|
||||
content_hash: String::new(),
|
||||
etag: virtual_file_etag(stat.size, stat.modified_at),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
created_by: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -5,17 +6,80 @@ use crate::application::dtos::playlist_dto::{
|
||||
AddTracksDto, AudioMetadataDto, CreatePlaylistDto, PlaylistDto, PlaylistItemDto,
|
||||
PlaylistQueryDto, PlaylistShareInfoDto, ReorderTracksDto, SharePlaylistDto, UpdatePlaylistDto,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::music_ports::{MusicStoragePort, MusicUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::adapters::music_storage_adapter::MusicStorageAdapter;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
/// Music service — the REST entry point for every playlist or audio
|
||||
/// metadata operation. Every method routes through
|
||||
/// `AuthorizationEngine`; the pre-Round-3 `user_has_access` /
|
||||
/// `user_can_write` bespoke helpers on `MusicStorageAdapter` are no
|
||||
/// longer consulted for access decisions.
|
||||
///
|
||||
/// Ownership + sharing live entirely in `storage.role_grants`
|
||||
/// (`resource_type='playlist'`). `audio.playlists.owner_id` stays for
|
||||
/// provenance and legacy queries; `audio.playlist_shares` is
|
||||
/// backfilled and slated for removal in a follow-up migration.
|
||||
pub struct MusicService {
|
||||
storage: Arc<MusicStorageAdapter>,
|
||||
/// ReBAC engine — every user-facing method calls `authz.require`
|
||||
/// with the appropriate `Permission`. `create_playlist` also uses
|
||||
/// it to seed an Owner grant for the caller, so the common
|
||||
/// "owning my own playlist" case takes a single indexed
|
||||
/// role_grants lookup on subsequent reads.
|
||||
authz: Arc<PgAclEngine>,
|
||||
}
|
||||
|
||||
impl MusicService {
|
||||
pub fn new(storage: Arc<MusicStorageAdapter>) -> Self {
|
||||
Self { storage }
|
||||
pub fn new(storage: Arc<MusicStorageAdapter>, authz: Arc<PgAclEngine>) -> Self {
|
||||
Self { storage, authz }
|
||||
}
|
||||
|
||||
/// Parse `playlist_id` and enforce `permission` on
|
||||
/// `Resource::Playlist(uuid)`. On denial `authz.require` returns
|
||||
/// `NotFound` (anti-enum — same shape as "no such playlist") and
|
||||
/// emits the `authz.denied` audit line. Returns the parsed UUID
|
||||
/// on success so the caller doesn't have to parse it a second
|
||||
/// time.
|
||||
async fn require_playlist_perm(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<Uuid, DomainError> {
|
||||
let uuid = Uuid::parse_str(playlist_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid ID"))?;
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
permission,
|
||||
Resource::Playlist(uuid),
|
||||
)
|
||||
.await?;
|
||||
Ok(uuid)
|
||||
}
|
||||
|
||||
/// Check `permission` on a playlist without throwing. Used by the
|
||||
/// read paths that also allow a public-playlist bypass — they
|
||||
/// need a bool, not a `Result<(), NotFound>`.
|
||||
async fn has_playlist_perm(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uuid = Uuid::parse_str(playlist_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid ID"))?;
|
||||
self.authz
|
||||
.check(
|
||||
Subject::User(caller_id),
|
||||
permission,
|
||||
Resource::Playlist(uuid),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +89,26 @@ impl MusicUseCase for MusicService {
|
||||
dto: CreatePlaylistDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<PlaylistDto, DomainError> {
|
||||
self.storage.create_playlist(dto, user_id).await
|
||||
// No pre-write gate: creating a playlist is a personal act.
|
||||
// Storage stamps `owner_id = user_id`; we then seed an Owner
|
||||
// role_grant so subsequent reads hit the same
|
||||
// `storage.role_grants` fast path used everywhere else.
|
||||
let created = self.storage.create_playlist(dto, user_id).await?;
|
||||
let playlist_uuid = Uuid::parse_str(&created.id).map_err(|_| {
|
||||
DomainError::internal_error("Playlist", "storage returned invalid playlist id")
|
||||
})?;
|
||||
// `set_role` is idempotent on the `(subject, resource)` unique
|
||||
// key. `granted_by = user_id` is the self-seeded creation event.
|
||||
self.authz
|
||||
.set_role(
|
||||
user_id,
|
||||
Subject::User(user_id),
|
||||
Role::Owner,
|
||||
Resource::Playlist(playlist_uuid),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
async fn update_playlist(
|
||||
@@ -34,45 +117,25 @@ impl MusicUseCase for MusicService {
|
||||
dto: UpdatePlaylistDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<PlaylistDto, DomainError> {
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to update this playlist",
|
||||
));
|
||||
}
|
||||
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
||||
if !can_write {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You need write access to update this playlist",
|
||||
));
|
||||
}
|
||||
self.require_playlist_perm(playlist_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
self.storage.update_playlist(playlist_id, dto).await
|
||||
}
|
||||
|
||||
async fn delete_playlist(&self, playlist_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
));
|
||||
}
|
||||
};
|
||||
if playlist.owner_id != user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"Only the owner can delete this playlist",
|
||||
));
|
||||
}
|
||||
self.storage.delete_playlist(playlist_id).await
|
||||
let uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Delete)
|
||||
.await?;
|
||||
self.storage.delete_playlist(playlist_id).await?;
|
||||
// Wipe every grant on this playlist so a re-used UUID
|
||||
// (impossible today but cheap to defend against) doesn't
|
||||
// inherit stale ACLs. The storage DELETE won't cascade to
|
||||
// `storage.role_grants` — it's cross-schema.
|
||||
let _ = self
|
||||
.authz
|
||||
.revoke_all_for_resource(Resource::Playlist(uuid))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_playlist(
|
||||
@@ -80,23 +143,22 @@ impl MusicUseCase for MusicService {
|
||||
playlist_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<PlaylistDto, DomainError> {
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to view this playlist",
|
||||
));
|
||||
}
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
match playlist {
|
||||
Some(p) => Ok(p),
|
||||
None => Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
)),
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => return Err(DomainError::not_found("Playlist", playlist_id)),
|
||||
};
|
||||
// Public-playlist bypass: anonymous-ish read. `check` returns
|
||||
// bool (no throw); combine with the public flag before
|
||||
// deciding.
|
||||
let allowed = playlist.is_public
|
||||
|| self
|
||||
.has_playlist_perm(playlist_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Playlist", playlist_id));
|
||||
}
|
||||
Ok(playlist)
|
||||
}
|
||||
|
||||
async fn list_playlists(
|
||||
@@ -109,19 +171,43 @@ impl MusicUseCase for MusicService {
|
||||
let limit = query.limit.unwrap_or(100);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let mut playlists = Vec::new();
|
||||
// Post-Round-3 semantics: playlists the caller has any grant
|
||||
// on come from `list_incoming_grants` — one union of owned +
|
||||
// shared. The pre-Round-3 code fetched them via two separate
|
||||
// queries (`list_playlists_by_owner` + `list_shared_with_user`)
|
||||
// that each read a different table.
|
||||
let grants = self
|
||||
.authz
|
||||
.list_incoming_grants(Subject::User(user_id))
|
||||
.await?;
|
||||
|
||||
let owned = self.storage.list_playlists_by_owner(user_id).await?;
|
||||
playlists.extend(owned);
|
||||
// Deduplicate — a user can hold multiple grants on the same
|
||||
// playlist (direct + group-inherited). We only need one DTO
|
||||
// per resource.
|
||||
let mut playlist_ids: HashSet<Uuid> = grants
|
||||
.into_iter()
|
||||
.filter_map(|g| match g.resource {
|
||||
Resource::Playlist(id) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if include_shared {
|
||||
let shared = self.storage.list_shared_with_user(user_id).await?;
|
||||
for s in shared {
|
||||
if !playlists.iter().any(|p: &PlaylistDto| p.id == s.id) {
|
||||
playlists.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
// `include_shared=false` narrows the listing to owned playlists
|
||||
// only. Owner is a grant like any other in `role_grants`, so we
|
||||
// filter the aggregated set against the owner_id stamped on
|
||||
// each row after hydration — cheaper than a second SQL round-trip.
|
||||
// Hydrate in ONE `= ANY` round-trip (was one point SELECT per
|
||||
// accessible playlist). Missing rows (deleted race) drop out of
|
||||
// the result set silently, as before.
|
||||
let user_str = user_id.to_string();
|
||||
let ids: Vec<Uuid> = playlist_ids.drain().collect();
|
||||
let mut playlists: Vec<PlaylistDto> = self
|
||||
.storage
|
||||
.get_playlists_by_ids(&ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|p| include_shared || p.owner_id == user_str)
|
||||
.collect();
|
||||
|
||||
if include_public {
|
||||
let public = self.storage.list_public_playlists(limit, offset).await?;
|
||||
@@ -141,26 +227,9 @@ impl MusicUseCase for MusicService {
|
||||
dto: AddTracksDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<PlaylistItemDto>, DomainError> {
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to modify this playlist",
|
||||
));
|
||||
}
|
||||
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
||||
if !can_write {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You need write access to add tracks",
|
||||
));
|
||||
}
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
|
||||
let file_ids: Result<Vec<Uuid>, _> =
|
||||
dto.file_ids.iter().map(|id| Uuid::parse_str(id)).collect();
|
||||
@@ -177,30 +246,12 @@ impl MusicUseCase for MusicService {
|
||||
file_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
let file_uuid = Uuid::parse_str(file_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid file ID")
|
||||
})?;
|
||||
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to modify this playlist",
|
||||
));
|
||||
}
|
||||
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
||||
if !can_write {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You need write access to remove tracks",
|
||||
));
|
||||
}
|
||||
|
||||
self.storage.remove_track(&playlist_uuid, &file_uuid).await
|
||||
}
|
||||
|
||||
@@ -210,26 +261,9 @@ impl MusicUseCase for MusicService {
|
||||
dto: ReorderTracksDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to modify this playlist",
|
||||
));
|
||||
}
|
||||
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
||||
if !can_write {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You need write access to reorder tracks",
|
||||
));
|
||||
}
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
|
||||
let item_ids: Result<Vec<Uuid>, _> =
|
||||
dto.item_ids.iter().map(|id| Uuid::parse_str(id)).collect();
|
||||
@@ -248,16 +282,21 @@ impl MusicUseCase for MusicService {
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to view this playlist",
|
||||
));
|
||||
// Public-playlist bypass mirrors `get_playlist`: readers of a
|
||||
// public playlist can see its tracks. Fetch the playlist row
|
||||
// to inspect `is_public` before deciding.
|
||||
let playlist = self
|
||||
.storage
|
||||
.get_playlist(playlist_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::not_found("Playlist", playlist_id))?;
|
||||
let allowed = playlist.is_public
|
||||
|| self
|
||||
.has_playlist_perm(playlist_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Playlist", playlist_id));
|
||||
}
|
||||
|
||||
self.storage.list_playlist_tracks(&playlist_uuid).await
|
||||
}
|
||||
|
||||
@@ -267,36 +306,33 @@ impl MusicUseCase for MusicService {
|
||||
dto: SharePlaylistDto,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
));
|
||||
}
|
||||
};
|
||||
if playlist.owner_id != caller_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"Only the owner can share this playlist",
|
||||
));
|
||||
}
|
||||
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, caller_id, Permission::Share)
|
||||
.await?;
|
||||
let target_user_id = Uuid::parse_str(&dto.user_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid user ID")
|
||||
})?;
|
||||
let can_write = dto.can_write.unwrap_or(false);
|
||||
|
||||
self.storage
|
||||
.share_playlist(&playlist_uuid, target_user_id, can_write)
|
||||
.await
|
||||
// Legacy `can_write` boolean maps into the role bundle system:
|
||||
// - false → Viewer (Read only)
|
||||
// - true → Editor (Read + Update)
|
||||
// The endpoint stays boolean-shaped for API back-compat; new
|
||||
// integrations should switch to the unified `/api/grants` API
|
||||
// which exposes the full role set.
|
||||
let role = if dto.can_write.unwrap_or(false) {
|
||||
Role::Editor
|
||||
} else {
|
||||
Role::Viewer
|
||||
};
|
||||
self.authz
|
||||
.set_role(
|
||||
caller_id,
|
||||
Subject::User(target_user_id),
|
||||
role,
|
||||
Resource::Playlist(playlist_uuid),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_share(
|
||||
@@ -305,33 +341,18 @@ impl MusicUseCase for MusicService {
|
||||
target_user_id: &str,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
));
|
||||
}
|
||||
};
|
||||
if playlist.owner_id != caller_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"Only the owner can manage sharing",
|
||||
));
|
||||
}
|
||||
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, caller_id, Permission::Share)
|
||||
.await?;
|
||||
let target_uuid = Uuid::parse_str(target_user_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid user ID")
|
||||
})?;
|
||||
|
||||
self.storage.remove_share(&playlist_uuid, target_uuid).await
|
||||
self.authz
|
||||
.clear_role(
|
||||
Subject::User(target_uuid),
|
||||
Resource::Playlist(playlist_uuid),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_playlist_shares(
|
||||
@@ -339,35 +360,26 @@ impl MusicUseCase for MusicService {
|
||||
playlist_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<PlaylistShareInfoDto>, DomainError> {
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
));
|
||||
}
|
||||
};
|
||||
if playlist.owner_id != user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"Only the owner can view sharing info",
|
||||
));
|
||||
}
|
||||
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
|
||||
let shares = self.storage.get_shares(&playlist_uuid).await?;
|
||||
Ok(shares
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Share)
|
||||
.await?;
|
||||
// `list_grants_on_resource` returns every role_grant row for
|
||||
// the playlist. Drop the Owner self-grant seeded at creation
|
||||
// (the caller already knows they own it) and collapse the
|
||||
// role bundle back to a boolean `can_write` for the legacy
|
||||
// DTO shape.
|
||||
let grants = self
|
||||
.authz
|
||||
.list_grants_on_resource(Resource::Playlist(playlist_uuid))
|
||||
.await?;
|
||||
Ok(grants
|
||||
.into_iter()
|
||||
.map(|(uid, can_write)| PlaylistShareInfoDto {
|
||||
user_id: uid.to_string(),
|
||||
can_write,
|
||||
.filter_map(|g| match g.subject {
|
||||
Subject::User(uid) if g.role != Role::Owner => Some(PlaylistShareInfoDto {
|
||||
user_id: uid.to_string(),
|
||||
can_write: g.role.expand().contains(&Permission::Update),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -375,10 +387,23 @@ impl MusicUseCase for MusicService {
|
||||
async fn get_audio_metadata(
|
||||
&self,
|
||||
file_id: &str,
|
||||
_user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Option<AudioMetadataDto>, DomainError> {
|
||||
let file_uuid = Uuid::parse_str(file_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Music", "Invalid file ID"))?;
|
||||
// AuthZ pre-read: caller must have `Read` on the underlying
|
||||
// audio file. Before this check the endpoint returned
|
||||
// metadata for any known file id (cross-tenant IDOR — the
|
||||
// `_user_id` parameter was deliberately unused). `require`
|
||||
// returns 404 on denial to match the anti-enum shape used
|
||||
// everywhere else.
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await?;
|
||||
self.storage.get_audio_metadata(&file_uuid).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,55 +41,50 @@ impl NextcloudFileIdService {
|
||||
|
||||
/// Resolve — creating when absent — stable numeric file IDs for many
|
||||
/// UUIDs at once. Cache hits cost nothing; the misses are resolved with a
|
||||
/// single backing query. The returned map is keyed by the caller's
|
||||
/// original id strings; unresolvable inputs are simply absent (mirroring
|
||||
/// the `.ok()` behaviour the callers relied on).
|
||||
pub async fn get_or_create_file_ids(
|
||||
&self,
|
||||
file_ids: &[String],
|
||||
) -> Result<HashMap<String, i64>> {
|
||||
/// single backing query. The returned map is keyed by parsed UUID;
|
||||
/// unparseable/unresolvable inputs are simply absent (mirroring the
|
||||
/// `.ok()` behaviour the callers relied on).
|
||||
pub async fn get_or_create_file_ids(&self, file_ids: &[&str]) -> Result<HashMap<Uuid, i64>> {
|
||||
self.get_or_create_many("file", file_ids).await
|
||||
}
|
||||
|
||||
/// Folder counterpart of [`Self::get_or_create_file_ids`].
|
||||
pub async fn get_or_create_folder_ids(
|
||||
&self,
|
||||
folder_ids: &[String],
|
||||
) -> Result<HashMap<String, i64>> {
|
||||
folder_ids: &[&str],
|
||||
) -> Result<HashMap<Uuid, i64>> {
|
||||
self.get_or_create_many("folder", folder_ids).await
|
||||
}
|
||||
|
||||
async fn get_or_create_many(
|
||||
&self,
|
||||
object_type: &str,
|
||||
raw_ids: &[String],
|
||||
) -> Result<HashMap<String, i64>> {
|
||||
raw_ids: &[&str],
|
||||
) -> Result<HashMap<Uuid, i64>> {
|
||||
let mut result = HashMap::with_capacity(raw_ids.len());
|
||||
// Parsed-UUID → caller's original string; also dedupes the miss list.
|
||||
let mut pending: HashMap<Uuid, String> = HashMap::new();
|
||||
let mut misses: Vec<Uuid> = Vec::new();
|
||||
|
||||
for raw in raw_ids {
|
||||
let Ok(uuid) = Uuid::parse_str(raw) else {
|
||||
continue; // Unparseable ids never had a mapping — skip silently.
|
||||
};
|
||||
if let Some(id) = self.cache.get(&uuid).await {
|
||||
result.insert(raw.clone(), id);
|
||||
result.insert(uuid, id);
|
||||
} else {
|
||||
pending.entry(uuid).or_insert_with(|| raw.clone());
|
||||
misses.push(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
if !pending.is_empty() {
|
||||
let misses: Vec<Uuid> = pending.keys().copied().collect();
|
||||
if !misses.is_empty() {
|
||||
misses.sort_unstable();
|
||||
misses.dedup();
|
||||
let resolved = self
|
||||
.repo()?
|
||||
.get_or_create_many(object_type, &misses)
|
||||
.await?;
|
||||
for (uuid, id) in resolved {
|
||||
self.cache.insert(uuid, id).await;
|
||||
if let Some(original) = pending.get(&uuid) {
|
||||
result.insert(original.clone(), id);
|
||||
}
|
||||
result.insert(uuid, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,10 +179,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_get_or_create_file_ids_skips_unparseable() {
|
||||
let svc = NextcloudFileIdService::new_stub();
|
||||
let map = svc
|
||||
.get_or_create_file_ids(&["not-a-uuid".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
let map = svc.get_or_create_file_ids(&["not-a-uuid"]).await.unwrap();
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ struct PendingFlow {
|
||||
/// if the flow token leaks. `None` for single-drive accounts (legacy
|
||||
/// path goes straight to `completed`).
|
||||
pending_user_id: Option<Uuid>,
|
||||
/// App-password label to persist when this multi-drive flow finally
|
||||
/// completes. Stashed by `resolve_drive_or_complete` (login_v2_handler)
|
||||
/// alongside `pending_user_id` so `handle_drive_pick` can preserve
|
||||
/// provenance (`"Nextcloud"` vs `"Nextcloud (OIDC)"`) across the
|
||||
/// picker round-trip. Consumed by `take_pending_app_password_label`.
|
||||
pending_app_password_label: Option<String>,
|
||||
completed: Option<LoginResult>,
|
||||
}
|
||||
|
||||
@@ -89,6 +95,7 @@ impl NextcloudLoginFlowService {
|
||||
created_at: Instant::now(),
|
||||
poll_token: poll_token.clone(),
|
||||
pending_user_id: None,
|
||||
pending_app_password_label: None,
|
||||
completed: None,
|
||||
},
|
||||
);
|
||||
@@ -143,6 +150,35 @@ impl NextcloudLoginFlowService {
|
||||
.and_then(|pending| pending.pending_user_id.take())
|
||||
}
|
||||
|
||||
/// Stash the app-password label to use when the flow eventually
|
||||
/// completes via `handle_drive_pick`. Called alongside
|
||||
/// `mark_awaiting_drive` so the multi-drive round-trip preserves
|
||||
/// the provenance string passed in at the auth step
|
||||
/// (`"Nextcloud"` for password login, `"Nextcloud (OIDC)"` for OIDC).
|
||||
/// Silently no-ops when the flow token is unknown or expired —
|
||||
/// the earlier `mark_awaiting_drive` on the same token is the
|
||||
/// authoritative "exists?" signal so we don't need to log again.
|
||||
pub fn set_pending_app_password_label(&self, flow_token: &str, label: &str) {
|
||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
prune_expired(&mut state, self.ttl);
|
||||
if let Some(pending) = state.flows.get_mut(flow_token) {
|
||||
pending.pending_app_password_label = Some(label.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the stashed app-password label (single-use). Returns
|
||||
/// `None` when the flow was never marked, was password-shortcut
|
||||
/// (single-drive), or the token is unknown / expired — the caller
|
||||
/// falls back to a sensible default in that case.
|
||||
pub fn take_pending_app_password_label(&self, flow_token: &str) -> Option<String> {
|
||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
prune_expired(&mut state, self.ttl);
|
||||
state
|
||||
.flows
|
||||
.get_mut(flow_token)
|
||||
.and_then(|pending| pending.pending_app_password_label.take())
|
||||
}
|
||||
|
||||
pub fn complete(
|
||||
&self,
|
||||
flow_token: &str,
|
||||
|
||||
@@ -23,17 +23,30 @@ use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::face::Person;
|
||||
use crate::infrastructure::repositories::pg::FacePgRepository;
|
||||
|
||||
/// Cosine similarity of two equal-length vectors. Embeddings are produced
|
||||
/// L2-normalized, so this is ~a dot product; we normalize anyway for safety.
|
||||
fn cosine(a: &[f32], b: &[f32]) -> f32 {
|
||||
/// Squared L2 norm, accumulated in the same order `cosine` used to, so
|
||||
/// the precomputed-norm path is bit-identical to the old per-pair one.
|
||||
fn norm_sq(v: &[f32]) -> f32 {
|
||||
let mut n = 0.0f32;
|
||||
for &x in v {
|
||||
n += x * x;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Cosine similarity of two equal-length vectors given their precomputed
|
||||
/// squared norms. Embeddings are produced L2-normalized, so this is ~a dot
|
||||
/// product; we normalize anyway for safety. The O(N²) recluster pair loop
|
||||
/// used to re-accumulate BOTH norms on every pair — precomputing them once
|
||||
/// per face keeps only the dot product in the hot loop while the final
|
||||
/// `dot / (√na · √nb)` expression (and the zero guards) stay exactly as
|
||||
/// before, so results are bit-identical (benches/ROUND11.md §17).
|
||||
fn cosine_with_norms(a: &[f32], b: &[f32], na: f32, nb: f32) -> f32 {
|
||||
if a.len() != b.len() || a.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let (mut dot, mut na, mut nb) = (0.0f32, 0.0f32, 0.0f32);
|
||||
let mut dot = 0.0f32;
|
||||
for (&x, &y) in a.iter().zip(b.iter()) {
|
||||
dot += x * y;
|
||||
na += x * x;
|
||||
nb += y * y;
|
||||
}
|
||||
if na == 0.0 || nb == 0.0 {
|
||||
return 0.0;
|
||||
@@ -102,10 +115,13 @@ impl PeopleService {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let norms: Vec<f32> = faces.iter().map(|f| norm_sq(&f.embedding)).collect();
|
||||
let mut uf = UnionFind::new(n);
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
if cosine(&faces[i].embedding, &faces[j].embedding) >= self.cluster_threshold {
|
||||
if cosine_with_norms(&faces[i].embedding, &faces[j].embedding, norms[i], norms[j])
|
||||
>= self.cluster_threshold
|
||||
{
|
||||
uf.union(i, j);
|
||||
}
|
||||
}
|
||||
@@ -117,13 +133,19 @@ impl PeopleService {
|
||||
groups.entry(root).or_default().push(i);
|
||||
}
|
||||
|
||||
// Accumulate every (face, person) change and apply them in ONE
|
||||
// UNNEST batch at the end — the old per-face `assign_person` loop
|
||||
// issued up to F sequential UPDATE round-trips per recluster
|
||||
// (benches/ROUND11.md §Q5; the ROUND10 `save_faces` pattern). The
|
||||
// final column state is identical.
|
||||
let mut assignments: Vec<(Uuid, Option<Uuid>)> = Vec::new();
|
||||
let mut created = 0usize;
|
||||
for idxs in groups.into_values() {
|
||||
if idxs.len() < self.min_faces {
|
||||
// Too small to be a person — leave/reset these faces unassigned.
|
||||
for &i in &idxs {
|
||||
if faces[i].person_id.is_some() {
|
||||
self.repo.assign_person(faces[i].id, None).await?;
|
||||
assignments.push((faces[i].id, None));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -151,9 +173,7 @@ impl PeopleService {
|
||||
};
|
||||
for &i in &idxs {
|
||||
if faces[i].person_id != Some(person_id) {
|
||||
self.repo
|
||||
.assign_person(faces[i].id, Some(person_id))
|
||||
.await?;
|
||||
assignments.push((faces[i].id, Some(person_id)));
|
||||
}
|
||||
}
|
||||
let _ = self
|
||||
@@ -161,23 +181,29 @@ impl PeopleService {
|
||||
.set_person_cover(person_id, faces[idxs[0]].id)
|
||||
.await;
|
||||
}
|
||||
self.repo.assign_person_batch(&assignments).await?;
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
/// People (non-empty clusters), most-photographed first.
|
||||
///
|
||||
/// Counts come from a grouped-COUNT query and cover photos from one
|
||||
/// batched lookup of just the cover face ids — the previous
|
||||
/// `faces_for_user` shipped every face row (2 KiB embedding included)
|
||||
/// only to count them: ~20 MB of BYTEA per request on a 10k-face
|
||||
/// library (benches/PEOPLE-LIST.md).
|
||||
pub async fn list_people(&self, caller_id: Uuid) -> Result<Vec<PersonDto>, DomainError> {
|
||||
let persons = self.repo.persons_for_user(caller_id).await?;
|
||||
let faces = self.repo.faces_for_user(caller_id).await?;
|
||||
|
||||
let mut count: HashMap<Uuid, i64> = HashMap::new();
|
||||
let mut face_file: HashMap<Uuid, Uuid> = HashMap::new();
|
||||
for f in &faces {
|
||||
if let Some(pid) = f.person_id {
|
||||
*count.entry(pid).or_default() += 1;
|
||||
}
|
||||
face_file.insert(f.id, f.file_id);
|
||||
}
|
||||
let count: HashMap<Uuid, i64> = self
|
||||
.repo
|
||||
.person_face_stats(caller_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
let cover_ids: Vec<Uuid> = persons.iter().filter_map(|p| p.cover_face_id).collect();
|
||||
let face_file: HashMap<Uuid, Uuid> =
|
||||
self.repo.file_ids_for_faces(caller_id, &cover_ids).await?;
|
||||
|
||||
let mut out: Vec<PersonDto> = persons
|
||||
.into_iter()
|
||||
@@ -219,10 +245,11 @@ impl PeopleService {
|
||||
caller_id: Uuid,
|
||||
file_id: Uuid,
|
||||
) -> Result<Vec<FaceBoxDto>, DomainError> {
|
||||
let faces = self.repo.faces_for_file(file_id).await?;
|
||||
Ok(faces
|
||||
// The narrow projection scopes to the caller in SQL (WHERE user_id),
|
||||
// so no post-filter is needed here. See benches/ROUND14.md §Q1.
|
||||
let boxes = self.repo.face_boxes_for_file(file_id, caller_id).await?;
|
||||
Ok(boxes
|
||||
.into_iter()
|
||||
.filter(|f| f.user_id == caller_id)
|
||||
.map(|f| FaceBoxDto {
|
||||
id: f.id.to_string(),
|
||||
person_id: f.person_id.map(|u| u.to_string()),
|
||||
@@ -245,11 +272,13 @@ impl PeopleService {
|
||||
|
||||
/// Merge `from` into `into` by reassigning all of `from`'s faces. The
|
||||
/// now-empty `from` person is hidden by `list_people`.
|
||||
///
|
||||
/// One set-based UPDATE — the previous shape loaded every face row
|
||||
/// (embeddings included) and issued one UPDATE per matching face.
|
||||
pub async fn merge(&self, caller_id: Uuid, into: Uuid, from: Uuid) -> Result<(), DomainError> {
|
||||
let faces = self.repo.faces_for_user(caller_id).await?;
|
||||
for f in faces.into_iter().filter(|f| f.person_id == Some(from)) {
|
||||
self.repo.assign_person(f.id, Some(into)).await?;
|
||||
}
|
||||
self.repo
|
||||
.reassign_person_faces(caller_id, from, into)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ use crate::infrastructure::repositories::pg::FileBlobReadRepository;
|
||||
/// "Places" use case: the caller's geotagged photos aggregated into map
|
||||
/// clusters.
|
||||
///
|
||||
/// Strictly user-scoped — the repository filters `WHERE fi.user_id = $1`, so,
|
||||
/// like [`RecentService`](super::recent_service::RecentService) and the photos
|
||||
/// timeline, it needs no `AuthorizationEngine` check: the `caller_id`
|
||||
/// parameter *is* the access scope.
|
||||
/// Post-§15 the surface follows the Photos scope: drives where the
|
||||
/// caller has Read AND `policies.include_in_photo_index = true`
|
||||
/// (default personal drives materialise the flag at creation).
|
||||
/// Group-membership expansion is handled inline by
|
||||
/// `storage.caller_group_ids(caller)` inside the repo's SQL, so this
|
||||
/// service is a thin coordinate-math wrapper — no engine dependency.
|
||||
pub struct PlacesService {
|
||||
file_read: Arc<FileBlobReadRepository>,
|
||||
}
|
||||
@@ -30,7 +32,8 @@ impl PlacesService {
|
||||
360.0 / (2_f64.powi(z) * 4.0)
|
||||
}
|
||||
|
||||
/// Clustered geotagged photos for `caller_id` within `bounds`.
|
||||
/// Clustered geotagged photos in the caller's Photos-scope drive set,
|
||||
/// within `bounds`.
|
||||
pub async fn clusters(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::recent_dto::{RecentCursor, RecentItemDto, RecentResourceRow};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
use crate::application::ports::resource_access_hook::ResourceAccessHook;
|
||||
use crate::common::errors::{DomainError, Result};
|
||||
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject};
|
||||
use crate::infrastructure::repositories::pg::RecentItemsPgRepository;
|
||||
use std::sync::Arc;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -15,16 +18,97 @@ use uuid::Uuid;
|
||||
pub struct RecentService {
|
||||
repo: Arc<RecentItemsPgRepository>,
|
||||
max_recent_items: i32,
|
||||
/// ReBAC engine — enforces `Permission::Read` on the referenced
|
||||
/// file/folder before enrolling it into a user's Recent list.
|
||||
/// The listing side JOINs back to `storage.files/folders` and
|
||||
/// returns name/mime/size/drive_id for any enrolled UUID, so
|
||||
/// the write path is an information oracle without this gate.
|
||||
/// See `docs/plan/authz_audit/rest_storage.md`.
|
||||
authorization: Arc<PgAclEngine>,
|
||||
/// Set after construction via [`Self::set_resource_access_hook`].
|
||||
/// The hook is built FROM this service (it wraps an `Arc<Self>`), so
|
||||
/// we can't take it as a constructor arg without circular ownership;
|
||||
/// the OnceLock holds the back-edge so this service can notify the
|
||||
/// hook when the user clears or removes Recent rows. The notification
|
||||
/// lets the hook drop its in-memory throttle entries — otherwise a
|
||||
/// freshly-cleared Recent refuses to re-record until the TTL expires.
|
||||
resource_access_hook: OnceLock<Arc<dyn ResourceAccessHook>>,
|
||||
}
|
||||
|
||||
impl RecentService {
|
||||
/// Create a new recent items service
|
||||
pub fn new(repo: Arc<RecentItemsPgRepository>, max_recent_items: i32) -> Self {
|
||||
pub fn new(
|
||||
repo: Arc<RecentItemsPgRepository>,
|
||||
authorization: Arc<PgAclEngine>,
|
||||
max_recent_items: i32,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
max_recent_items: max_recent_items.clamp(1, 100),
|
||||
authorization,
|
||||
resource_access_hook: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire the access hook in after construction. Idempotent: a second
|
||||
/// `set` is a no-op (returns the existing value as `Err`). Called
|
||||
/// from DI once `RecentRecordingHook::new(Arc<Self>)` has produced
|
||||
/// the back-edge that closes the loop.
|
||||
pub fn set_resource_access_hook(&self, hook: Arc<dyn ResourceAccessHook>) {
|
||||
let _ = self.resource_access_hook.set(hook);
|
||||
}
|
||||
|
||||
/// Internal helper: notify the hook (if registered) that `user_id`
|
||||
/// has emptied their Recent list — wholly or by removing a single
|
||||
/// row. The hook drops its in-memory throttle entries so the very
|
||||
/// next access re-records into the freshly-empty table.
|
||||
fn notify_recents_cleared(&self, user_id: Uuid) {
|
||||
if let Some(hook) = self.resource_access_hook.get() {
|
||||
hook.on_recents_cleared(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record access to an item WITHOUT the pre-write `authz.require`
|
||||
/// gate. Callers must have gated the caller's Read upstream — this
|
||||
/// method exists for the `RecentRecordingHook` fast path: writes
|
||||
/// that reach the hook have already passed a `_with_perms` service
|
||||
/// method (uploads, streams, GETs, etc.), so re-checking here
|
||||
/// would be pure duplicate work AND widen the race window between
|
||||
/// the POST response and the `tokio::spawn`ed upsert (
|
||||
/// `tests/api/recent.hurl` step 7 hits this — the extra SQL
|
||||
/// round-trip pushes the upsert past the client's immediate
|
||||
/// `GET /api/recent/resources`).
|
||||
///
|
||||
/// **Do NOT call this from an externally-reachable handler.** The
|
||||
/// REST endpoint goes through the trait method `record_item_access`
|
||||
/// below, which enforces the Read gate per AGENTS.md convention.
|
||||
pub async fn record_item_access_internal(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
item_id: &str,
|
||||
item_type: &str,
|
||||
) -> Result<()> {
|
||||
// Type validation only — no authz, no resource parse for the
|
||||
// engine (the hook path is already resource-typed by construction).
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
crate::common::errors::ErrorKind::InvalidInput,
|
||||
"RecentItems",
|
||||
"Item type must be 'file' or 'folder'",
|
||||
));
|
||||
}
|
||||
|
||||
// Prune only when the upsert actually inserted a NEW row — a
|
||||
// re-access refreshes an existing row's timestamp and can never
|
||||
// grow the set past the cap, so the prune (a DELETE over an
|
||||
// OFFSET self-subquery) is a wasted round-trip on that common path
|
||||
// (benches/ROUND13.md §Q3).
|
||||
let inserted = self.repo.upsert_access(user_id, item_id, item_type).await?;
|
||||
if inserted {
|
||||
self.repo.prune(user_id, self.max_recent_items).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl RecentItemsUseCase for RecentService {
|
||||
@@ -59,16 +143,24 @@ impl RecentItemsUseCase for RecentService {
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"RecentItems",
|
||||
"Item type must be 'file' or 'folder'",
|
||||
));
|
||||
}
|
||||
// AuthZ pre-write: caller must have Read on the referenced
|
||||
// resource. Denial routes through `require` → NotFound
|
||||
// (anti-enum) + `authz.denied` audit line. Without this
|
||||
// gate the write path was an information oracle over the
|
||||
// whole tenant via the listing endpoint's JOIN back to
|
||||
// storage.files/folders.
|
||||
//
|
||||
// Internal hook callers (RecentRecordingHook) bypass the
|
||||
// trait entry point and call `record_item_access_internal`
|
||||
// directly — Read has already been enforced upstream on
|
||||
// whatever `_with_perms` service produced the access event.
|
||||
let resource = Resource::parse(item_type, item_id)?;
|
||||
self.authorization
|
||||
.require(Subject::User(user_id), Permission::Read, resource)
|
||||
.await?;
|
||||
|
||||
self.repo.upsert_access(user_id, item_id, item_type).await?;
|
||||
self.repo.prune(user_id, self.max_recent_items).await?;
|
||||
self.record_item_access_internal(user_id, item_id, item_type)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"Successfully recorded access to {} '{}' for user {}",
|
||||
@@ -100,6 +192,12 @@ impl RecentItemsUseCase for RecentService {
|
||||
item_id,
|
||||
user_id
|
||||
);
|
||||
// Drop the throttle entries so the next access re-records. We
|
||||
// notify on every call (even when `removed == false`) so the
|
||||
// semantics are "the user expressed intent to forget this" —
|
||||
// the hook owns the per-(user, item) cache anyway, dropping a
|
||||
// miss is a no-op.
|
||||
self.notify_recents_cleared(user_id);
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
@@ -108,6 +206,7 @@ impl RecentItemsUseCase for RecentService {
|
||||
info!("Clearing all recent items for user {}", user_id);
|
||||
self.repo.clear_all(user_id).await?;
|
||||
info!("Cleared all recent items for user {}", user_id);
|
||||
self.notify_recents_cleared(user_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,18 +472,21 @@ impl RecipientNotificationService {
|
||||
let kind_key = match resource {
|
||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||
// Drives don't generate share notifications in D0 — drive
|
||||
// sharing lands in D2 and gets its own template key. Fall
|
||||
// back to the folder label so any path that does reach
|
||||
// here produces a readable, if generic, mail body.
|
||||
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
||||
// Drive / Calendar / AddressBook / Playlist shares don't
|
||||
// produce email notifications through this path. Fall
|
||||
// back to the folder label so any code that does reach
|
||||
// here still produces a readable (if generic) mail body.
|
||||
Resource::Drive(_)
|
||||
| Resource::Calendar(_)
|
||||
| Resource::AddressBook(_)
|
||||
| Resource::Playlist(_) => "server.magic_link.email.kind_folder",
|
||||
};
|
||||
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
|
||||
// Short form for the subject, long form (with email) for the
|
||||
// body — same pattern as `MagicLinkInviteService::issue_invitation`.
|
||||
let inviter_short = granter.display_full(false);
|
||||
let inviter_full = granter.display_full(true);
|
||||
let login_link = format!("{}/#/login", self.public_base_url.trim_end_matches('/'),);
|
||||
let login_link = format!("{}/login", self.public_base_url.trim_end_matches('/'),);
|
||||
|
||||
let args: Vec<(&str, &str)> = vec![
|
||||
("inviter", inviter_short.as_str()),
|
||||
|
||||
@@ -2,9 +2,7 @@ use std::cmp::Reverse;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::application::dtos::display_helpers::{
|
||||
category_for, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
use crate::application::dtos::display_helpers::intern_display;
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::dtos::search_dto::{
|
||||
@@ -15,6 +13,7 @@ use crate::application::ports::content_index_ports::{ContentHitDto, ContentIndex
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::common::errors::Result;
|
||||
use crate::common::text::ascii_ci_contains;
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
@@ -67,9 +66,80 @@ pub struct SearchService {
|
||||
/// Lock-free concurrent cache with automatic TTL and LRU eviction (moka).
|
||||
/// Values are `Arc<SearchResultsDto>` so cache insert/hit is a single
|
||||
/// atomic ref-count increment (~1 ns) instead of cloning thousands of Strings.
|
||||
///
|
||||
/// **Byte-bounded**, not entry-bounded: entries are weighed by
|
||||
/// [`search_results_entry_weight`] and `max_capacity` is a byte budget.
|
||||
/// Keys span user × query × offset × limit, and each page holds up to 500
|
||||
/// enriched rows (~500–900 B of owned Strings each) — an entry-count bound
|
||||
/// let hundreds of MB of result pages accumulate invisibly.
|
||||
search_cache: moka::future::Cache<u64, Arc<SearchResultsDto>>,
|
||||
}
|
||||
|
||||
// ─── Search-results cache (byte-bounded) ─────────────────────────────────
|
||||
|
||||
/// Approximate heap bytes retained by one cached search page.
|
||||
///
|
||||
/// With a `weigher` installed, moka's `max_capacity` is the sum of entry
|
||||
/// *weights*, so this converts the cache bound from "number of entries" to
|
||||
/// real bytes: the length of every owned `String` in each file/folder row,
|
||||
/// plus a fixed per-row and per-entry overhead for struct fields, the 24-B
|
||||
/// `String` headers, `Vec` slots and allocator slop. Same pattern as the
|
||||
/// file-content cache and the dedup manifest cache.
|
||||
///
|
||||
/// `pub` so `examples/bench_search_cache_mem.rs` can recompute retained
|
||||
/// bytes with the exact production formula.
|
||||
pub fn search_results_entry_weight(_key: &u64, value: &Arc<SearchResultsDto>) -> u32 {
|
||||
/// Fixed per-row overhead: struct scalars + one 24-B header per `String`
|
||||
/// field (12 on a file row, 4 on a folder row) + `Vec` slot + allocator
|
||||
/// slop. Deliberately a round upper-ish estimate — under-weighing is the
|
||||
/// failure mode that re-opens the memory hole.
|
||||
const ROW_OVERHEAD: usize = 200;
|
||||
/// Fixed per-entry overhead: `Arc` + `SearchResultsDto` scalars + `Vec`
|
||||
/// headers + moka's own bookkeeping per entry.
|
||||
const ENTRY_OVERHEAD: usize = 256;
|
||||
|
||||
fn opt_len(s: &Option<String>) -> usize {
|
||||
s.as_deref().map_or(0, str::len)
|
||||
}
|
||||
|
||||
let mut bytes = ENTRY_OVERHEAD + value.sort_by.len();
|
||||
for f in &value.files {
|
||||
bytes += ROW_OVERHEAD
|
||||
+ f.id.len()
|
||||
+ f.name.len()
|
||||
+ f.path.len()
|
||||
+ f.mime_type.len()
|
||||
+ opt_len(&f.folder_id)
|
||||
+ f.size_formatted.len()
|
||||
+ f.icon_class.len()
|
||||
+ f.icon_special_class.len()
|
||||
+ f.category.len()
|
||||
+ f.blob_hash.len()
|
||||
+ opt_len(&f.snippet)
|
||||
+ opt_len(&f.match_source);
|
||||
}
|
||||
for d in &value.folders {
|
||||
bytes += ROW_OVERHEAD + d.id.len() + d.name.len() + d.path.len() + opt_len(&d.parent_id);
|
||||
}
|
||||
bytes.min(u32::MAX as usize) as u32
|
||||
}
|
||||
|
||||
/// Build the search-results cache exactly as production wires it: a byte
|
||||
/// budget enforced through [`search_results_entry_weight`], plus TTL.
|
||||
///
|
||||
/// Shared with `examples/bench_search_cache_mem.rs` so the benchmark
|
||||
/// measures the identical cache configuration that serves requests.
|
||||
pub fn build_search_results_cache(
|
||||
cache_ttl_secs: u64,
|
||||
max_bytes: u64,
|
||||
) -> moka::future::Cache<u64, Arc<SearchResultsDto>> {
|
||||
moka::future::Cache::builder()
|
||||
.max_capacity(max_bytes)
|
||||
.weigher(search_results_entry_weight)
|
||||
.time_to_live(Duration::from_secs(cache_ttl_secs))
|
||||
.build()
|
||||
}
|
||||
|
||||
// ─── Utility functions (pure, no self — computed on the server) ─────────
|
||||
|
||||
/// Compute relevance score (0–100) for a name against a query.
|
||||
@@ -77,19 +147,40 @@ pub struct SearchService {
|
||||
///
|
||||
/// `query_lower` **must** already be lowercased by the caller so that the
|
||||
/// allocation happens once per search, not once per result.
|
||||
///
|
||||
/// The overwhelmingly common all-ASCII filename takes an allocation-free
|
||||
/// ASCII case-fold fast path — `name.to_lowercase()` (full Unicode) is pure
|
||||
/// waste there, and it ran once *per result row* (and per keystroke on the
|
||||
/// suggest path). Non-ASCII names fall back to the exact Unicode-lowercase
|
||||
/// comparison, so behavior is unchanged (for ASCII, lowercasing preserves
|
||||
/// length, so the `contains` length ratio is identical). See benches/ROUND14.md §A2.
|
||||
fn compute_relevance(name: &str, query_lower: &str) -> u32 {
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
if name_lower == query_lower {
|
||||
100
|
||||
} else if name_lower.starts_with(query_lower) {
|
||||
80
|
||||
} else if name_lower.contains(query_lower) {
|
||||
// Bonus for shorter names (more specific match)
|
||||
let ratio = query_lower.len() as f64 / name_lower.len() as f64;
|
||||
50 + (ratio * 20.0) as u32
|
||||
if name.is_ascii() {
|
||||
let (nb, qb) = (name.as_bytes(), query_lower.as_bytes());
|
||||
if nb.eq_ignore_ascii_case(qb) {
|
||||
100
|
||||
} else if nb.len() >= qb.len() && nb[..qb.len()].eq_ignore_ascii_case(qb) {
|
||||
80
|
||||
} else if ascii_ci_contains(nb, qb) {
|
||||
// Bonus for shorter names (more specific match). ASCII lowercase
|
||||
// preserves length, so `name.len()` == the old `name_lower.len()`.
|
||||
let ratio = query_lower.len() as f64 / name.len() as f64;
|
||||
50 + (ratio * 20.0) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
let name_lower = name.to_lowercase();
|
||||
if name_lower == query_lower {
|
||||
100
|
||||
} else if name_lower.starts_with(query_lower) {
|
||||
80
|
||||
} else if name_lower.contains(query_lower) {
|
||||
let ratio = query_lower.len() as f64 / name_lower.len() as f64;
|
||||
50 + (ratio * 20.0) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,28 +229,15 @@ fn format_bytes(bytes: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Font Awesome icon class for a file based on extension and MIME type.
|
||||
/// Delegates to the centralised `display_helpers` so every API surface is
|
||||
/// consistent.
|
||||
fn get_icon_class(name: &str, mime: &str) -> String {
|
||||
icon_class_for(name, mime).to_string()
|
||||
}
|
||||
|
||||
/// Get CSS special class for icon styling.
|
||||
fn get_icon_special_class(name: &str, mime: &str) -> String {
|
||||
icon_special_class_for(name, mime).to_string()
|
||||
}
|
||||
|
||||
/// Get category label from centralised helpers.
|
||||
fn get_category(name: &str, mime: &str) -> String {
|
||||
category_for(name, mime).to_string()
|
||||
}
|
||||
|
||||
// ─── SearchService implementation ───────────────────────────────────────
|
||||
|
||||
impl SearchService {
|
||||
/**
|
||||
* Creates a new instance of the search service.
|
||||
*
|
||||
* `max_cache_bytes` is the byte budget for the results cache (weigher-
|
||||
* bounded, see [`search_results_entry_weight`]) — it replaced the old
|
||||
* entry-count capacity, which was blind to how big each cached page is.
|
||||
*/
|
||||
pub fn new(
|
||||
file_repository: Arc<FileBlobReadRepository>,
|
||||
@@ -168,12 +246,9 @@ impl SearchService {
|
||||
authorization: Option<Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>>,
|
||||
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
cache_ttl: u64,
|
||||
max_cache_size: usize,
|
||||
max_cache_bytes: u64,
|
||||
) -> Self {
|
||||
let search_cache = moka::future::Cache::builder()
|
||||
.max_capacity(max_cache_size as u64)
|
||||
.time_to_live(Duration::from_secs(cache_ttl))
|
||||
.build();
|
||||
let search_cache = build_search_results_cache(cache_ttl, max_cache_bytes);
|
||||
|
||||
Self {
|
||||
file_repository,
|
||||
@@ -195,8 +270,14 @@ impl SearchService {
|
||||
|
||||
/// Enrich a FileDto → SearchFileResultDto with server-computed metadata.
|
||||
///
|
||||
/// Consumes the DTO: every `String` moves and the interned display
|
||||
/// fields (`mime_type`/`icon_class`/`icon_special_class`/`category`,
|
||||
/// already computed once in `FileDto::from`) transfer as refcount
|
||||
/// bumps — the old borrow-based version cloned all of them AND re-ran
|
||||
/// the three display classifiers per result row.
|
||||
///
|
||||
/// `query_lower` must already be lowercased (empty string when no query).
|
||||
fn enrich_file(file: &FileDto, query_lower: &str) -> SearchFileResultDto {
|
||||
fn enrich_file(file: FileDto, query_lower: &str) -> SearchFileResultDto {
|
||||
let relevance = if query_lower.is_empty() {
|
||||
50
|
||||
} else {
|
||||
@@ -204,23 +285,23 @@ impl SearchService {
|
||||
};
|
||||
|
||||
SearchFileResultDto {
|
||||
id: file.id.clone(),
|
||||
name: file.name.clone(),
|
||||
path: file.path.clone(),
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
mime_type: file.mime_type.to_string(),
|
||||
folder_id: file.folder_id.clone(),
|
||||
mime_type: file.mime_type,
|
||||
folder_id: file.folder_id,
|
||||
created_at: file.created_at,
|
||||
modified_at: file.modified_at,
|
||||
relevance_score: relevance,
|
||||
size_formatted: format_bytes(file.size),
|
||||
icon_class: get_icon_class(&file.name, &file.mime_type),
|
||||
icon_special_class: get_icon_special_class(&file.name, &file.mime_type),
|
||||
category: get_category(&file.name, &file.mime_type),
|
||||
icon_class: file.icon_class,
|
||||
icon_special_class: file.icon_special_class,
|
||||
category: file.category,
|
||||
// Carry the content hash through so REPORT/SEARCH
|
||||
// responses on the NC surface can emit the same ETag
|
||||
// (`File::compute_etag`) as PROPFIND/GET would.
|
||||
blob_hash: file.content_hash.clone(),
|
||||
blob_hash: file.content_hash,
|
||||
snippet: None,
|
||||
match_source: (!query_lower.is_empty() && relevance > 0).then(|| "name".to_string()),
|
||||
}
|
||||
@@ -228,8 +309,10 @@ impl SearchService {
|
||||
|
||||
/// Enrich a FolderDto → SearchFolderResultDto with server-computed metadata.
|
||||
///
|
||||
/// Consumes the DTO so the owned strings move instead of cloning.
|
||||
///
|
||||
/// `query_lower` must already be lowercased (empty string when no query).
|
||||
fn enrich_folder(folder: &FolderDto, query_lower: &str) -> SearchFolderResultDto {
|
||||
fn enrich_folder(folder: FolderDto, query_lower: &str) -> SearchFolderResultDto {
|
||||
let relevance = if query_lower.is_empty() {
|
||||
50
|
||||
} else {
|
||||
@@ -237,10 +320,11 @@ impl SearchService {
|
||||
};
|
||||
|
||||
SearchFolderResultDto {
|
||||
id: folder.id.clone(),
|
||||
name: folder.name.clone(),
|
||||
path: folder.path.clone(),
|
||||
parent_id: folder.parent_id.clone(),
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
parent_id: folder.parent_id,
|
||||
drive_id: folder.drive_id,
|
||||
created_at: folder.created_at,
|
||||
modified_at: folder.modified_at,
|
||||
is_root: folder.is_root,
|
||||
@@ -259,7 +343,7 @@ impl SearchService {
|
||||
user_id: Uuid,
|
||||
) -> Vec<ContentHitDto> {
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::domain::services::authorization::Subject;
|
||||
|
||||
let Some(index) = &self.content_index else {
|
||||
return Vec::new();
|
||||
@@ -282,21 +366,11 @@ impl SearchService {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
// Resolve the caller's accessible drive set via the engine
|
||||
// (handles group-mediated drive grants) + the repo lookup.
|
||||
let caller = Subject::User(user_id);
|
||||
let (subject_types, subject_ids) = match authz.expand_subject_for_listing(caller).await {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
tracing::warn!("Content-index: subject expansion failed — degrading to empty: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let accessible_drives: Vec<Uuid> = match drive_repo
|
||||
.list_for_subjects(&subject_types, &subject_ids)
|
||||
.await
|
||||
{
|
||||
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
|
||||
// Resolve the caller's accessible drive set. Group-mediated
|
||||
// grants are honoured inline by `storage.caller_group_ids` on
|
||||
// the SQL side, so no Rust-side subject expansion here.
|
||||
let accessible_drives: Vec<Uuid> = match drive_repo.list_readable_by(user_id).await {
|
||||
Ok(drives) => drives.iter().map(|d| d.drive.id).collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!("Content-index: drive lookup failed — degrading to empty: {e}");
|
||||
return Vec::new();
|
||||
@@ -325,34 +399,45 @@ impl SearchService {
|
||||
// drive the caller doesn't otherwise have. The Tantivy
|
||||
// filter is drive-only; this re-check restores per-file
|
||||
// resolution.
|
||||
// Failures degrade conservatively (drop the hit, log it) —
|
||||
// never leak.
|
||||
let mut verified = Vec::with_capacity(hits.len());
|
||||
// Failures degrade conservatively (drop the hit / the page,
|
||||
// log it) — never leak. Batched: one drive-resolution query for
|
||||
// the whole page instead of up to CONTENT_HITS_LIMIT sequential
|
||||
// point SELECTs (benches/SEARCH-REBAC.md).
|
||||
// Parse each hit id ONCE and carry the pair through the verify loop
|
||||
// — the old shape re-parsed every `file_id` a second time below
|
||||
// (benches/ROUND11.md §12: 1.6x on a 100-hit page).
|
||||
let mut pairs = Vec::with_capacity(hits.len());
|
||||
for hit in hits {
|
||||
let file_uuid = match Uuid::parse_str(&hit.file_id) {
|
||||
Ok(u) => u,
|
||||
match Uuid::parse_str(&hit.file_id) {
|
||||
Ok(u) => pairs.push((hit, u)),
|
||||
Err(_) => {
|
||||
tracing::warn!("Content-index hit had non-UUID file_id: {}", hit.file_id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match authz
|
||||
.check(caller, Permission::Read, Resource::File(file_uuid))
|
||||
.await
|
||||
{
|
||||
Ok(true) => verified.push(hit),
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::search",
|
||||
file_id = %file_uuid,
|
||||
"dropping content-index hit: ReBAC denies Read after Tantivy filter",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("ReBAC re-check failed for {file_uuid}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let hit_ids: Vec<Uuid> = pairs.iter().map(|(_, u)| *u).collect();
|
||||
let allowed = match authz
|
||||
.check_files_read_batch(Subject::User(user_id), &hit_ids)
|
||||
.await
|
||||
{
|
||||
Ok(set) => set,
|
||||
Err(e) => {
|
||||
tracing::warn!("ReBAC re-check failed for content hits: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let mut verified = Vec::with_capacity(pairs.len());
|
||||
for (hit, file_uuid) in pairs {
|
||||
if allowed.contains(&file_uuid) {
|
||||
verified.push(hit);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::search",
|
||||
file_id = %file_uuid,
|
||||
"dropping content-index hit: ReBAC denies Read after Tantivy filter",
|
||||
);
|
||||
}
|
||||
}
|
||||
verified
|
||||
}
|
||||
|
||||
@@ -408,9 +493,10 @@ impl SearchService {
|
||||
let Some(hit) = by_id.get(dto.id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let mut enriched = Self::enrich_file(&dto, "");
|
||||
enriched.relevance_score = content_relevance(hit.score, max_score);
|
||||
enriched.snippet = hit.snippet.clone();
|
||||
let (score, snippet) = (hit.score, hit.snippet.clone());
|
||||
let mut enriched = Self::enrich_file(dto, "");
|
||||
enriched.relevance_score = content_relevance(score, max_score);
|
||||
enriched.snippet = snippet;
|
||||
enriched.match_source = Some("content".to_string());
|
||||
enriched_files.push(enriched);
|
||||
added += 1;
|
||||
@@ -424,20 +510,28 @@ impl SearchService {
|
||||
/// Quick suggestions search — returns up to `limit` name suggestions
|
||||
/// matching the query. Pushes filtering, relevance sort and LIMIT to SQL
|
||||
/// so only a handful of rows cross the DB→app boundary.
|
||||
pub async fn suggest(
|
||||
///
|
||||
/// `caller_id` scopes the underlying repo queries to drives the caller
|
||||
/// can Read. Without it (the pre-fix shape) any authenticated user —
|
||||
/// including external magic-link recipients — could autocomplete both
|
||||
/// names and full paths across every tenant on the instance (AuthZ
|
||||
/// audit finding #1, 2026-07-12). Named `_with_perms` per the
|
||||
/// AGENTS.md AuthZ convention.
|
||||
pub async fn suggest_with_perms(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
caller_id: Uuid,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
let start = Instant::now();
|
||||
|
||||
// Ask SQL for at most `limit` best-matching files and folders
|
||||
let (files, folders) = tokio::join!(
|
||||
self.file_repository
|
||||
.suggest_files_by_name(folder_id, query, limit),
|
||||
.suggest_files_by_name(folder_id, query, limit, caller_id),
|
||||
self.folder_repository
|
||||
.suggest_folders_by_name(folder_id, query, limit),
|
||||
.suggest_folders_by_name(folder_id, query, limit, caller_id),
|
||||
);
|
||||
let files = files?;
|
||||
let folders = folders?;
|
||||
@@ -448,30 +542,36 @@ impl SearchService {
|
||||
// Pre-compute once — avoids N heap allocations inside the loops.
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
for file in &files {
|
||||
let file_dto = FileDto::from(file.clone());
|
||||
// Consume the entities: the old loop deep-cloned every File into
|
||||
// the DTO conversion and then cloned name/id/path AGAIN into the
|
||||
// suggestion — 3 field clones + a full entity clone per row on
|
||||
// an every-keystroke path.
|
||||
for file in files {
|
||||
let file_dto = FileDto::from(file);
|
||||
let score = compute_relevance(&file_dto.name, &query_lower);
|
||||
suggestions.push(SearchSuggestionItem {
|
||||
name: file_dto.name.clone(),
|
||||
name: file_dto.name,
|
||||
item_type: "file".to_string(),
|
||||
id: file_dto.id.clone(),
|
||||
path: file_dto.path.clone(),
|
||||
icon_class: get_icon_class(&file_dto.name, &file_dto.mime_type),
|
||||
icon_special_class: get_icon_special_class(&file_dto.name, &file_dto.mime_type),
|
||||
id: file_dto.id,
|
||||
path: file_dto.path,
|
||||
// Interned in `FileDto::from` — reuse instead of re-running
|
||||
// the display classifiers per keystroke suggestion.
|
||||
icon_class: file_dto.icon_class,
|
||||
icon_special_class: file_dto.icon_special_class,
|
||||
relevance_score: score,
|
||||
});
|
||||
}
|
||||
|
||||
for folder in &folders {
|
||||
let folder_dto = FolderDto::from(folder.clone());
|
||||
for folder in folders {
|
||||
let folder_dto = FolderDto::from(folder);
|
||||
let score = compute_relevance(&folder_dto.name, &query_lower);
|
||||
suggestions.push(SearchSuggestionItem {
|
||||
name: folder_dto.name.clone(),
|
||||
name: folder_dto.name,
|
||||
item_type: "folder".to_string(),
|
||||
id: folder_dto.id.clone(),
|
||||
path: folder_dto.path.clone(),
|
||||
icon_class: "fas fa-folder".to_string(),
|
||||
icon_special_class: "folder-icon".to_string(),
|
||||
id: folder_dto.id,
|
||||
path: folder_dto.path,
|
||||
icon_class: intern_display("fas fa-folder"),
|
||||
icon_special_class: intern_display("folder-icon"),
|
||||
relevance_score: score,
|
||||
});
|
||||
}
|
||||
@@ -488,6 +588,22 @@ impl SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bench-only public wrappers (feature = "bench") ──────────────────────
|
||||
|
||||
#[cfg(feature = "bench")]
|
||||
impl SearchService {
|
||||
/// Public wrapper over the private `enrich_file` so
|
||||
/// `examples/bench_search_enrich.rs` can measure it.
|
||||
pub fn enrich_file_for_bench(file: FileDto, query_lower: &str) -> SearchFileResultDto {
|
||||
Self::enrich_file(file, query_lower)
|
||||
}
|
||||
|
||||
/// Public wrapper over the private `enrich_folder` for the same bench.
|
||||
pub fn enrich_folder_for_bench(folder: FolderDto, query_lower: &str) -> SearchFolderResultDto {
|
||||
Self::enrich_folder(folder, query_lower)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SearchUseCase trait implementation ──────────────────────────────────
|
||||
|
||||
impl SearchUseCase for SearchService {
|
||||
@@ -512,8 +628,13 @@ impl SearchUseCase for SearchService {
|
||||
criteria: SearchCriteriaDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<Arc<SearchResultsDto>> {
|
||||
let user_id_str = user_id.to_string();
|
||||
let cache_key = Self::create_cache_key(&criteria, &user_id_str);
|
||||
// Stack-encode the UUID (36 ASCII bytes) instead of `to_string()` — the
|
||||
// hasher sees the identical byte sequence, so the u64 key is unchanged,
|
||||
// but the per-request heap `String` is gone (the fn doc even claims
|
||||
// "zero-allocation hashing"). See benches/ROUND19.md §M5.
|
||||
let mut user_id_buf = [0u8; uuid::fmt::Hyphenated::LENGTH];
|
||||
let user_id_str = user_id.hyphenated().encode_lower(&mut user_id_buf);
|
||||
let cache_key = Self::create_cache_key(&criteria, user_id_str);
|
||||
|
||||
// Single-flight: collapse N identical concurrent searches into ONE
|
||||
// execution. `try_get_with` serves the cached result on a hit and, on a
|
||||
@@ -527,44 +648,42 @@ impl SearchUseCase for SearchService {
|
||||
// Pre-compute once — avoids N heap allocations inside enrich_file/enrich_folder.
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
// Content-index candidates (first page only). Feature-off or an
|
||||
// index failure yields an empty set — the search stays name-only.
|
||||
let content_hits = self.lookup_content_hits(&criteria, user_id).await;
|
||||
|
||||
// For non-recursive searches, use efficient database-level pagination
|
||||
// This avoids loading all files into memory
|
||||
if !criteria.recursive {
|
||||
// Use database-level pagination
|
||||
let (files, total_file_count) = self
|
||||
.file_repository
|
||||
.search_files_paginated(criteria.folder_id.as_deref(), &criteria, user_id)
|
||||
.await?;
|
||||
|
||||
// Convert to DTOs and enrich with metadata
|
||||
let file_dtos: Vec<FileDto> = files.into_iter().map(FileDto::from).collect();
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = file_dtos
|
||||
.iter()
|
||||
.map(|f| Self::enrich_file(f, &query_lower))
|
||||
.collect();
|
||||
|
||||
// Get folders for this folder (non-recursive, filtered in SQL)
|
||||
let folders = self
|
||||
.folder_repository
|
||||
.search_folders(
|
||||
// The content-index lookup (drive resolve + Tantivy +
|
||||
// ReBAC batch), the file page and the folder query are
|
||||
// mutually independent — overlap them so the search pays
|
||||
// ~max() instead of the serial sum (`suggest_with_perms`
|
||||
// already used this shape; ROUND10 brought it here).
|
||||
let (content_hits, files_page, folders_res) = tokio::join!(
|
||||
self.lookup_content_hits(&criteria, user_id),
|
||||
self.file_repository.search_files_paginated(
|
||||
criteria.folder_id.as_deref(),
|
||||
&criteria,
|
||||
user_id,
|
||||
),
|
||||
self.folder_repository.search_folders(
|
||||
criteria.folder_id.as_deref(),
|
||||
criteria.name_contains.as_deref(),
|
||||
user_id,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
),
|
||||
);
|
||||
let (files, total_file_count) = files_page?;
|
||||
let folders = folders_res?;
|
||||
|
||||
let filtered_folders: Vec<FolderDto> =
|
||||
folders.into_iter().map(FolderDto::from).collect();
|
||||
// Convert to DTOs and enrich with metadata — one fused
|
||||
// pass, no intermediate Vec<FileDto> materialization.
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = files
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_file(FileDto::from(f), &query_lower))
|
||||
.collect();
|
||||
|
||||
// For folders, apply sorting and pagination in memory (usually fewer folders)
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = filtered_folders
|
||||
.iter()
|
||||
.map(|f| Self::enrich_folder(f, &query_lower))
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = folders
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_folder(FolderDto::from(f), &query_lower))
|
||||
.collect();
|
||||
|
||||
// Sort folders (cached_key avoids O(N log N) temporary String allocations)
|
||||
@@ -601,13 +720,24 @@ impl SearchUseCase for SearchService {
|
||||
|
||||
let folder_start = start_idx.min(folder_count);
|
||||
let folder_end = end_idx.min(folder_count);
|
||||
let paginated_folders = enriched_folders[folder_start..folder_end].to_vec();
|
||||
// Move the page out of the owned vecs instead of
|
||||
// deep-cloning the slice — the source is dropped right
|
||||
// after (benches/ROUND11.md §11: −300 allocs per page).
|
||||
let paginated_folders: Vec<_> = enriched_folders
|
||||
.into_iter()
|
||||
.skip(folder_start)
|
||||
.take(folder_end - folder_start)
|
||||
.collect();
|
||||
|
||||
let file_start = start_idx.saturating_sub(folder_count);
|
||||
let file_end = end_idx
|
||||
.saturating_sub(folder_count)
|
||||
.min(enriched_files.len());
|
||||
let paginated_files = enriched_files[file_start..file_end].to_vec();
|
||||
let paginated_files: Vec<_> = enriched_files
|
||||
.into_iter()
|
||||
.skip(file_start)
|
||||
.take(file_end - file_start)
|
||||
.collect();
|
||||
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
@@ -627,35 +757,36 @@ impl SearchUseCase for SearchService {
|
||||
// ── Recursive search via ltree (single SQL query per entity type) ──
|
||||
// Uses PostgreSQL ltree GiST index to find all files and folders
|
||||
// in the subtree in O(1) queries, replacing the O(N) spawn-per-folder
|
||||
// approach that could saturate the connection pool.
|
||||
let (found_files, total_file_count) = self
|
||||
.file_repository
|
||||
.search_files_in_subtree(criteria.folder_id.as_deref(), &criteria, user_id)
|
||||
.await?;
|
||||
|
||||
// Get folders (SQL-filtered, user-scoped, recursive when applicable)
|
||||
let found_folders: Vec<Folder> = self
|
||||
.folder_repository
|
||||
.search_folders(
|
||||
// approach that could saturate the connection pool. The content
|
||||
// lookup, subtree file query and folder query overlap (`join!`),
|
||||
// same as the non-recursive branch.
|
||||
let (content_hits, files_page, folders_res) = tokio::join!(
|
||||
self.lookup_content_hits(&criteria, user_id),
|
||||
self.file_repository.search_files_in_subtree(
|
||||
criteria.folder_id.as_deref(),
|
||||
&criteria,
|
||||
user_id,
|
||||
),
|
||||
self.folder_repository.search_folders(
|
||||
criteria.folder_id.as_deref(),
|
||||
criteria.name_contains.as_deref(),
|
||||
user_id,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
),
|
||||
);
|
||||
let (found_files, total_file_count) = files_page?;
|
||||
let found_folders: Vec<Folder> = folders_res?;
|
||||
|
||||
// ── Convert to DTOs and enrich with server-computed metadata ──
|
||||
let file_dtos: Vec<FileDto> = found_files.into_iter().map(FileDto::from).collect();
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = file_dtos
|
||||
.iter()
|
||||
.map(|f| Self::enrich_file(f, &query_lower))
|
||||
// Fused single pass: no intermediate DTO Vec materialization.
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = found_files
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_file(FileDto::from(f), &query_lower))
|
||||
.collect();
|
||||
|
||||
let folder_dtos: Vec<FolderDto> =
|
||||
found_folders.into_iter().map(FolderDto::from).collect();
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = folder_dtos
|
||||
.iter()
|
||||
.map(|f| Self::enrich_folder(f, &query_lower))
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = found_folders
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_folder(FolderDto::from(f), &query_lower))
|
||||
.collect();
|
||||
|
||||
// ── Sort folders (cached_key avoids O(N log N) temporary String allocations) ──
|
||||
@@ -691,13 +822,24 @@ impl SearchUseCase for SearchService {
|
||||
|
||||
let folder_start = start_idx.min(folder_count);
|
||||
let folder_end = end_idx.min(folder_count);
|
||||
let paginated_folders = enriched_folders[folder_start..folder_end].to_vec();
|
||||
// Move the page out instead of deep-cloning the slice — the
|
||||
// recursive branch's vecs can hold the whole subtree match
|
||||
// set, all dropped right after (benches/ROUND11.md §11).
|
||||
let paginated_folders: Vec<_> = enriched_folders
|
||||
.into_iter()
|
||||
.skip(folder_start)
|
||||
.take(folder_end - folder_start)
|
||||
.collect();
|
||||
|
||||
let file_start = start_idx.saturating_sub(folder_count);
|
||||
let file_end = end_idx
|
||||
.saturating_sub(folder_count)
|
||||
.min(enriched_files.len());
|
||||
let paginated_files = enriched_files[file_start..file_end].to_vec();
|
||||
let paginated_files: Vec<_> = enriched_files
|
||||
.into_iter()
|
||||
.skip(file_start)
|
||||
.take(file_end - file_start)
|
||||
.collect();
|
||||
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
@@ -723,14 +865,20 @@ impl SearchUseCase for SearchService {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns quick suggestions for autocomplete.
|
||||
/// Returns quick suggestions for autocomplete. Delegates to the
|
||||
/// inherent `suggest_with_perms` — the trait method is preserved as
|
||||
/// the polymorphic entry point (e.g. for `StubSearchUseCase` in
|
||||
/// tests); production callers can equivalently call the inherent
|
||||
/// method directly.
|
||||
async fn suggest(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
caller_id: Uuid,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
self.suggest(query, folder_id, limit).await
|
||||
self.suggest_with_perms(query, folder_id, limit, caller_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Clears the search results cache.
|
||||
@@ -762,6 +910,7 @@ impl SearchService {
|
||||
_query: &str,
|
||||
_folder_id: Option<&str>,
|
||||
_limit: usize,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
Ok(SearchSuggestionsDto {
|
||||
suggestions: Vec::new(),
|
||||
@@ -799,21 +948,103 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
path: format!("/{name}"),
|
||||
size,
|
||||
mime_type: "text/plain".to_string(),
|
||||
mime_type: "text/plain".into(),
|
||||
folder_id: None,
|
||||
created_at: 0,
|
||||
modified_at,
|
||||
relevance_score: relevance,
|
||||
size_formatted: String::new(),
|
||||
icon_class: String::new(),
|
||||
icon_special_class: String::new(),
|
||||
category: String::new(),
|
||||
icon_class: "".into(),
|
||||
icon_special_class: "".into(),
|
||||
category: "".into(),
|
||||
blob_hash: String::new(),
|
||||
snippet: None,
|
||||
match_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_weight_counts_every_owned_string_plus_overheads() {
|
||||
// Empty page: entry overhead + sort_by ("relevance" = 9 bytes).
|
||||
let empty = Arc::new(SearchResultsDto::empty());
|
||||
let base = search_results_entry_weight(&0, &empty) as usize;
|
||||
assert_eq!(base, 256 + 9);
|
||||
|
||||
// One file row: base + row overhead + its owned string bytes
|
||||
// (id 7 + name 7 + path 8 + mime 10; the rest are empty/None).
|
||||
let one_file = Arc::new(SearchResultsDto::new(
|
||||
vec![dto("abc.txt", 50, 10, 1)],
|
||||
Vec::new(),
|
||||
100,
|
||||
0,
|
||||
Some(1),
|
||||
0,
|
||||
"relevance".to_string(),
|
||||
));
|
||||
let w = search_results_entry_weight(&0, &one_file) as usize;
|
||||
assert_eq!(w, base + 200 + 7 + 7 + 8 + 10);
|
||||
|
||||
// Folder rows weigh too (id 2 + name 4 + path 5 + parent 6 = 17).
|
||||
let one_folder = Arc::new(SearchResultsDto::new(
|
||||
Vec::new(),
|
||||
vec![SearchFolderResultDto {
|
||||
id: "f1".to_string(),
|
||||
name: "docs".to_string(),
|
||||
path: "/docs".to_string(),
|
||||
parent_id: Some("parent".to_string()),
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
is_root: false,
|
||||
relevance_score: 50,
|
||||
}],
|
||||
100,
|
||||
0,
|
||||
Some(1),
|
||||
0,
|
||||
"relevance".to_string(),
|
||||
));
|
||||
let w = search_results_entry_weight(&0, &one_folder) as usize;
|
||||
assert_eq!(w, base + 200 + 2 + 4 + 5 + 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_evicts_down_to_the_byte_budget() {
|
||||
// Budget fits ~2 of these entries; inserting 20 must never let the
|
||||
// weighted size settle above the budget.
|
||||
let entry = |i: usize| {
|
||||
Arc::new(SearchResultsDto::new(
|
||||
(0..50)
|
||||
.map(|r| dto(&format!("file_{i}_{r}_{}", "x".repeat(100)), 50, 1, 1))
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
50,
|
||||
0,
|
||||
Some(50),
|
||||
0,
|
||||
"relevance".to_string(),
|
||||
))
|
||||
};
|
||||
let per_entry = search_results_entry_weight(&0, &entry(0)) as u64;
|
||||
let budget = per_entry * 2 + per_entry / 2;
|
||||
|
||||
let cache = build_search_results_cache(300, budget);
|
||||
for i in 0..20u64 {
|
||||
cache.insert(i, entry(i as usize)).await;
|
||||
}
|
||||
cache.run_pending_tasks().await;
|
||||
|
||||
let retained: u64 = cache
|
||||
.iter()
|
||||
.map(|(k, v)| search_results_entry_weight(&k, &v) as u64)
|
||||
.sum();
|
||||
assert!(
|
||||
retained <= budget,
|
||||
"retained {retained} B exceeds budget {budget} B"
|
||||
);
|
||||
assert!(cache.entry_count() <= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_files_resort_by_relevance_and_by_column() {
|
||||
let mut files = vec![
|
||||
|
||||
@@ -4,8 +4,10 @@ use thiserror::Error;
|
||||
use tokio::sync::Semaphore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::authorization::{Resource, Role, Subject};
|
||||
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::repositories::pg::DrivePgRepository;
|
||||
use crate::infrastructure::repositories::pg::SharePgRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
@@ -77,9 +79,18 @@ const MAX_CONCURRENT_HASHES: usize = 2;
|
||||
|
||||
pub struct ShareService {
|
||||
config: Arc<AppConfig>,
|
||||
/// `AppConfig::base_url()` snapshot, taken once at construction —
|
||||
/// the method re-reads `OXICLOUD_BASE_URL` from the environment (a
|
||||
/// global env-lock + String build) and was being called per DTO row
|
||||
/// in the share listings. Process-invariant, so snapshot it.
|
||||
base_url: String,
|
||||
share_repository: Arc<SharePgRepository>,
|
||||
file_repository: Arc<FileBlobReadRepository>,
|
||||
folder_repository: Arc<FolderDbRepository>,
|
||||
/// Drive repository — D5 enforcement reads the drive's `policies`
|
||||
/// JSONB before any per-resource action that a policy can gate
|
||||
/// (e.g. `forbid_public_links` for token-share creation).
|
||||
drive_repository: Arc<DrivePgRepository>,
|
||||
password_hasher: Arc<Argon2PasswordHasher>,
|
||||
/// ReBAC engine — used to create/revoke token grants that mirror public
|
||||
/// share links so that `GET /api/grants/outgoing` reflects them.
|
||||
@@ -90,19 +101,23 @@ pub struct ShareService {
|
||||
}
|
||||
|
||||
impl ShareService {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
config: Arc<AppConfig>,
|
||||
share_repository: Arc<SharePgRepository>,
|
||||
file_repository: Arc<FileBlobReadRepository>,
|
||||
folder_repository: Arc<FolderDbRepository>,
|
||||
drive_repository: Arc<DrivePgRepository>,
|
||||
password_hasher: Arc<Argon2PasswordHasher>,
|
||||
authorization: Arc<PgAclEngine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_url: config.base_url(),
|
||||
config,
|
||||
share_repository,
|
||||
file_repository,
|
||||
folder_repository,
|
||||
drive_repository,
|
||||
password_hasher,
|
||||
authorization,
|
||||
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
|
||||
@@ -195,7 +210,7 @@ impl ShareService {
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&share, &self.base_url))
|
||||
}
|
||||
|
||||
pub fn issue_unlock_jwt(&self, share_token: &str) -> Result<String, DomainError> {
|
||||
@@ -234,6 +249,56 @@ impl ShareUseCase for ShareService {
|
||||
|
||||
self.verify_item_exists(&dto.item_id, &item_type).await?;
|
||||
|
||||
// AuthZ: only callers with `Share` on the resource may mint a
|
||||
// public link. Without this gate, an ex-Viewer who kept a
|
||||
// guessed UUID could launder a temporary read into a
|
||||
// permanent anonymous URL that survives their own grant
|
||||
// revocation. `Permission::Share` is bundled with the
|
||||
// `owner` and `editor` role_grants only. `require` returns
|
||||
// `not_found` on denial (anti-enum, matches the shape used
|
||||
// by every other share route). See `docs/plan/authz_audit/`.
|
||||
let item_uuid_for_authz = Uuid::parse_str(&dto.item_id)
|
||||
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
|
||||
let resource_for_authz = match item_type {
|
||||
ShareItemType::File => Resource::File(item_uuid_for_authz),
|
||||
ShareItemType::Folder => Resource::Folder(item_uuid_for_authz),
|
||||
};
|
||||
self.authorization
|
||||
.require(
|
||||
Subject::User(user_id),
|
||||
Permission::Share,
|
||||
resource_for_authz,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// D5: `forbid_public_links` policy gate. The drive owner can
|
||||
// disable anonymous-link creation on every resource in their
|
||||
// drive without per-resource intervention. Lookup is one JOIN
|
||||
// (`get_policies_for_file` / `_for_folder` — single round-trip);
|
||||
// the decision + audit + canonical error live on
|
||||
// `DrivePolicies::refuse_public_links` so every public-link entry
|
||||
// point (future NC OCS share, etc.) refuses with the same shape.
|
||||
let item_uuid = Uuid::parse_str(&dto.item_id)
|
||||
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
|
||||
let policies = match item_type {
|
||||
ShareItemType::File => self.drive_repository.get_policies_for_file(item_uuid).await,
|
||||
ShareItemType::Folder => {
|
||||
self.drive_repository
|
||||
.get_policies_for_folder(item_uuid)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
let item_type_str: &'static str = match item_type {
|
||||
ShareItemType::File => "file",
|
||||
ShareItemType::Folder => "folder",
|
||||
};
|
||||
policies.refuse_public_links(crate::domain::entities::drive::PublicLinkGateContext {
|
||||
caller_id: user_id,
|
||||
item_type: item_type_str,
|
||||
item_id: item_uuid,
|
||||
})?;
|
||||
|
||||
let password_hash = match dto.password {
|
||||
Some(p) => Some(self.hash_password_async(&p).await?),
|
||||
None => None,
|
||||
@@ -279,7 +344,7 @@ impl ShareUseCase for ShareService {
|
||||
|
||||
// Return DTO with the requested expires_at (grant subquery on the share
|
||||
// row would return NULL at this point since INSERT ran before the grant).
|
||||
let mut response = ShareDto::from_entity(&saved_share, &self.config.base_url());
|
||||
let mut response = ShareDto::from_entity(&saved_share, &self.base_url);
|
||||
response.expires_at = dto.expires_at;
|
||||
Ok(response)
|
||||
}
|
||||
@@ -295,7 +360,7 @@ impl ShareUseCase for ShareService {
|
||||
}
|
||||
|
||||
// Convert the entity to DTO for the response
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&share, &self.base_url))
|
||||
}
|
||||
|
||||
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError> {
|
||||
@@ -321,7 +386,7 @@ impl ShareUseCase for ShareService {
|
||||
// Convert the entities to DTOs for the response
|
||||
let share_dtos = active_shares
|
||||
.iter()
|
||||
.map(|s| ShareDto::from_entity(s, &self.config.base_url()))
|
||||
.map(|s| ShareDto::from_entity(s, &self.base_url))
|
||||
.collect();
|
||||
|
||||
Ok(share_dtos)
|
||||
@@ -368,7 +433,7 @@ impl ShareUseCase for ShareService {
|
||||
|
||||
// Use the requested expires_at for the response (subquery in update_share
|
||||
// runs before set_expiry_for_subject committed, so entity may lag).
|
||||
let mut response = ShareDto::from_entity(&updated_share, &self.config.base_url());
|
||||
let mut response = ShareDto::from_entity(&updated_share, &self.base_url);
|
||||
if dto.expires_at.is_some() {
|
||||
response.expires_at = dto.expires_at;
|
||||
}
|
||||
@@ -403,7 +468,7 @@ impl ShareUseCase for ShareService {
|
||||
// Convert the entities to DTOs
|
||||
let share_dtos: Vec<ShareDto> = shares
|
||||
.iter()
|
||||
.map(|s| ShareDto::from_entity(s, &self.config.base_url()))
|
||||
.map(|s| ShareDto::from_entity(s, &self.base_url))
|
||||
.collect();
|
||||
|
||||
// Create the paginated result
|
||||
@@ -447,33 +512,22 @@ impl ShareUseCase for ShareService {
|
||||
}
|
||||
|
||||
// Password verified (or not required) — return full share metadata
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&share, &self.base_url))
|
||||
}
|
||||
|
||||
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
|
||||
// Find the shared link by its token
|
||||
let share = self
|
||||
.share_repository
|
||||
.find_share_by_token(token)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e))
|
||||
})?;
|
||||
|
||||
// Check if it has expired
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
// One atomic UPDATE (see `ShareStoragePort::increment_access_count`).
|
||||
// 0 rows = missing or expired — collapsed into NotFound, same
|
||||
// response shape either way (anti-enumeration; the landing handler
|
||||
// discards this result regardless).
|
||||
let updated = self.share_repository.increment_access_count(token).await?;
|
||||
if updated == 0 {
|
||||
return Err(ShareServiceError::NotFound(format!(
|
||||
"Share with token {} not found or expired",
|
||||
token
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
// Increment the access counter
|
||||
let updated_share = share.increment_access_count();
|
||||
|
||||
// Save the changes
|
||||
self.share_repository
|
||||
.update_share(&updated_share)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -492,7 +546,9 @@ mod tests {
|
||||
|
||||
/// Test-only service that mirrors `ShareService` logic but accepts generic repos.
|
||||
struct ShareServiceForTest<SR, FR, FoR, PH> {
|
||||
#[allow(dead_code)]
|
||||
config: Arc<AppConfig>,
|
||||
base_url: String,
|
||||
share_repository: Arc<SR>,
|
||||
file_repository: Arc<FR>,
|
||||
folder_repository: Arc<FoR>,
|
||||
@@ -515,6 +571,7 @@ mod tests {
|
||||
password_hasher: Arc<PH>,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_url: config.base_url(),
|
||||
config,
|
||||
share_repository,
|
||||
file_repository,
|
||||
@@ -593,7 +650,7 @@ mod tests {
|
||||
.save_share(&share)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&saved_share, &self.base_url))
|
||||
}
|
||||
|
||||
async fn get_shared_link(
|
||||
@@ -611,7 +668,7 @@ mod tests {
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
}
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&share, &self.base_url))
|
||||
}
|
||||
|
||||
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError> {
|
||||
@@ -625,7 +682,7 @@ mod tests {
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
}
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&share, &self.base_url))
|
||||
}
|
||||
|
||||
async fn get_shared_links_for_item(
|
||||
@@ -642,7 +699,7 @@ mod tests {
|
||||
Ok(shares
|
||||
.into_iter()
|
||||
.filter(|s| !s.is_expired())
|
||||
.map(|s| ShareDto::from_entity(&s, &self.config.base_url()))
|
||||
.map(|s| ShareDto::from_entity(&s, &self.base_url))
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -672,7 +729,7 @@ mod tests {
|
||||
.update_share(&share)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
Ok(ShareDto::from_entity(&updated, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&updated, &self.base_url))
|
||||
}
|
||||
|
||||
async fn delete_shared_link(
|
||||
@@ -701,7 +758,7 @@ mod tests {
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
let dtos = shares
|
||||
.iter()
|
||||
.map(|s| ShareDto::from_entity(s, &self.config.base_url()))
|
||||
.map(|s| ShareDto::from_entity(s, &self.base_url))
|
||||
.collect();
|
||||
Ok(PaginatedResponseDto::new(dtos, page, per_page, total))
|
||||
}
|
||||
@@ -731,9 +788,9 @@ mod tests {
|
||||
"Invalid share password",
|
||||
));
|
||||
}
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&share, &self.base_url))
|
||||
}
|
||||
None => Ok(ShareDto::from_entity(&share, &self.config.base_url())),
|
||||
None => Ok(ShareDto::from_entity(&share, &self.base_url)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,15 +924,6 @@ mod tests {
|
||||
Ok((Vec::new(), 0))
|
||||
}
|
||||
|
||||
async fn count_files(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn stream_files_in_subtree(
|
||||
&self,
|
||||
_folder_id: &str,
|
||||
@@ -891,14 +939,6 @@ mod tests {
|
||||
> {
|
||||
Ok(Box::pin(futures::stream::empty()))
|
||||
}
|
||||
|
||||
async fn get_file_for_owner(
|
||||
&self,
|
||||
id: &str,
|
||||
_owner_id: Uuid,
|
||||
) -> Result<crate::domain::entities::file::File, DomainError> {
|
||||
self.get_file(id).await
|
||||
}
|
||||
}
|
||||
|
||||
impl FolderRepository for MockFolderRepository {
|
||||
@@ -946,10 +986,9 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
async fn list_root_folders_for_caller(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -965,10 +1004,9 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner_paginated(
|
||||
async fn list_root_folders_for_caller_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
_offset: usize,
|
||||
_limit: usize,
|
||||
_include_total: bool,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::application::ports::auth_ports::UserStoragePort;
|
||||
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::infrastructure::repositories::pg::UserPgRepository;
|
||||
@@ -17,9 +16,20 @@ use uuid::Uuid;
|
||||
* Storage usage is calculated directly from the `storage.files` table
|
||||
* by summing file sizes for each user (using the `user_id` column).
|
||||
*/
|
||||
/// Fused quota-gate row: `(user_used, user_quota, drive_used, drive_quota,
|
||||
/// drive_found)` — see [`StorageUsageService::check_upload_quotas`].
|
||||
type QuotaPairRow = (i64, i64, Option<i64>, Option<i64>, bool);
|
||||
|
||||
pub struct StorageUsageService {
|
||||
pool: Arc<PgPool>,
|
||||
user_repository: Arc<UserPgRepository>,
|
||||
/// Optional so DI can wire it lazily and older test constructors
|
||||
/// keep compiling. When `Some`, every write path that mutates
|
||||
/// `drives.used_bytes` or `users.storage_used_bytes` invalidates
|
||||
/// the drive lookup caches so `GET /api/drives` reflects the new
|
||||
/// usage on the next call (see the invalidation calls in the
|
||||
/// delta / sweep methods below).
|
||||
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
}
|
||||
|
||||
impl StorageUsageService {
|
||||
@@ -28,6 +38,44 @@ impl StorageUsageService {
|
||||
Self {
|
||||
pool,
|
||||
user_repository,
|
||||
drive_repo: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires the drive repository used for cache-invalidation-on-write.
|
||||
/// Production DI calls this in `common::di`; tests without a real
|
||||
/// drive repo leave it `None` and the invalidation calls no-op.
|
||||
pub fn with_drive_repo(
|
||||
mut self,
|
||||
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||
) -> Self {
|
||||
self.drive_repo = Some(drive_repo);
|
||||
self
|
||||
}
|
||||
|
||||
/// Drop the per-caller readable-drive listing cache and the
|
||||
/// per-user default-drive cache so `GET /api/drives` and the
|
||||
/// WebDAV / NextCloud / WOPI drive-lookup paths re-read fresh
|
||||
/// values.
|
||||
///
|
||||
/// **Called only from the reconciliation sweep**, not from the
|
||||
/// hot-path `add_drive_storage_usage_delta*` methods. The design
|
||||
/// (Ed's call, 2026-07-17): keep the cache useful under active
|
||||
/// upload load — per-mutation invalidation would nuke the cache
|
||||
/// on every file upload, defeating the point. `used_bytes` on
|
||||
/// `GET /api/drives` therefore lags by up to the cache TTL (30 s),
|
||||
/// which matches the sibling caches' accepted UX phantom for
|
||||
/// drive-name staleness. Tests / operators that need immediate
|
||||
/// freshness call `POST /api/admin/internal/trigger-sweep`, which
|
||||
/// runs `update_all_drives_storage_usage` → this method.
|
||||
///
|
||||
/// Security posture unaffected: `check_drive_quota` reads
|
||||
/// directly from SQL, bypassing the cache entirely, so quota
|
||||
/// enforcement is honest regardless of listing staleness.
|
||||
fn invalidate_drive_lookup_caches(&self) {
|
||||
if let Some(repo) = &self.drive_repo {
|
||||
repo.invalidate_readable_all();
|
||||
repo.invalidate_default_drive_all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,13 +87,22 @@ impl StorageUsageService {
|
||||
/// (was three: user lookup + SUM + UPDATE). NOT called on the request
|
||||
/// path — only by the per-upload background update and the sweep.
|
||||
pub async fn update_user_storage_usage(&self, user_id: Uuid) -> Result<i64, DomainError> {
|
||||
// User envelope = SUM of `drives.used_bytes` across personal
|
||||
// drives owned by the user (see `docs/plan/drive.md` §7). Shared
|
||||
// drives don't count. Ownership is canonical via `role_grants`.
|
||||
let total_usage: Option<i64> = sqlx::query_scalar(
|
||||
r#"
|
||||
UPDATE auth.users u
|
||||
SET storage_used_bytes = COALESCE((
|
||||
SELECT SUM(f.size)::bigint
|
||||
FROM storage.files f
|
||||
WHERE f.user_id = u.id AND NOT f.is_trashed), 0)
|
||||
SELECT SUM(d.used_bytes)::bigint
|
||||
FROM storage.drives d
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
AND g.role = 'owner'
|
||||
AND g.subject_type = 'user'
|
||||
AND g.subject_id = u.id
|
||||
WHERE d.kind = 'personal'), 0)
|
||||
WHERE u.id = $1
|
||||
RETURNING u.storage_used_bytes
|
||||
"#,
|
||||
@@ -77,9 +134,15 @@ impl StorageUsageService {
|
||||
r#"
|
||||
UPDATE auth.users u
|
||||
SET storage_used_bytes = COALESCE((
|
||||
SELECT SUM(f.size)::bigint
|
||||
FROM storage.files f
|
||||
WHERE f.user_id = u.id AND NOT f.is_trashed), 0)
|
||||
SELECT SUM(d.used_bytes)::bigint
|
||||
FROM storage.drives d
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
AND g.role = 'owner'
|
||||
AND g.subject_type = 'user'
|
||||
AND g.subject_id = u.id
|
||||
WHERE d.kind = 'personal'), 0)
|
||||
WHERE u.username = $1
|
||||
RETURNING u.storage_used_bytes
|
||||
"#,
|
||||
@@ -128,6 +191,384 @@ impl StorageUsageService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Conditional user-side delta: only fires when the target folder's
|
||||
/// drive is `kind='personal'`. See `docs/plan/drive.md` §7.
|
||||
///
|
||||
/// The new quota model: `auth.users.storage_quota_bytes` is the cap on
|
||||
/// the SUM of `used_bytes` across the user's personal drives. Shared
|
||||
/// drives never count against any user envelope. The upload hot path
|
||||
/// reads `drives.kind` from the same JOIN that already runs for the
|
||||
/// drive cap check; firing this conditional delta instead of the
|
||||
/// unconditional [`Self::add_user_storage_usage_delta`] keeps the
|
||||
/// counter aligned with that envelope semantics. Idempotent + clamped
|
||||
/// at zero, same as the unconditional sibling.
|
||||
///
|
||||
/// Implementation note: the EXISTS subquery is two indexed PK probes
|
||||
/// (folder by id, drive by id) so the personal/shared discrimination
|
||||
/// adds no real cost vs. the unconditional update.
|
||||
pub async fn add_user_storage_usage_delta_if_personal(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
delta: i64,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE auth.users u
|
||||
SET storage_used_bytes = GREATEST(0, u.storage_used_bytes + $2)
|
||||
WHERE u.id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM storage.folders f
|
||||
JOIN storage.drives d ON d.id = f.drive_id
|
||||
WHERE f.id = $3
|
||||
AND d.kind = 'personal'
|
||||
)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(delta)
|
||||
.bind(folder_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("usage delta if personal: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Incrementally adjust one drive's cached `storage.drives.used_bytes`
|
||||
/// by `delta` bytes — same shape as
|
||||
/// [`Self::add_user_storage_usage_delta`]: single statement, no
|
||||
/// read-then-write window, `GREATEST(0, …)` clamp so a late or
|
||||
/// duplicate adjustment can never drive the counter negative.
|
||||
/// Deletes / trash do not decrement here; the periodic reconciliation
|
||||
/// sweep ([`Self::update_all_drives_storage_usage`]) remains the
|
||||
/// correctness backstop.
|
||||
pub async fn add_drive_storage_usage_delta(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
delta: i64,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE storage.drives
|
||||
SET used_bytes = GREATEST(0, used_bytes + $2)
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.bind(delta)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("StorageUsage", format!("drive delta: {e}")))?;
|
||||
// Deliberate no-invalidate here — see the class doc on
|
||||
// `invalidate_drive_lookup_caches`. Delta writes lag the
|
||||
// cache by up to the TTL; the sweep is the escape hatch.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the size in bytes of a single non-trashed file. `None`
|
||||
/// if the file is trashed or absent. Used by cross-drive MOVE to
|
||||
/// know how many bytes will land on the destination drive so the
|
||||
/// pre-move `check_drive_quota` call can fire.
|
||||
pub async fn file_bytes(&self, file_id: Uuid) -> Result<Option<i64>, DomainError> {
|
||||
let row: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT size::bigint FROM storage.files WHERE id = $1 AND NOT is_trashed",
|
||||
)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("StorageUsage", format!("file_bytes: {e}")))?;
|
||||
Ok(row.map(|(s,)| s))
|
||||
}
|
||||
|
||||
/// Sum the sizes of every non-trashed file whose parent folder is
|
||||
/// `folder_id` itself or a descendant of it via the `lpath` ltree.
|
||||
/// Used by cross-drive MOVE to know how many bytes would land on
|
||||
/// the destination drive — necessary for the pre-move
|
||||
/// `check_drive_quota` call.
|
||||
///
|
||||
/// Returns 0 for an empty subtree AND for a non-existent
|
||||
/// `folder_id` (the JOIN silently drops); callers that need to
|
||||
/// distinguish those two cases must probe the folder separately.
|
||||
pub async fn folder_subtree_bytes(&self, folder_id: Uuid) -> Result<i64, DomainError> {
|
||||
let (bytes,): (Option<i64>,) = sqlx::query_as(
|
||||
"SELECT COALESCE(SUM(f.size), 0)::bigint
|
||||
FROM storage.files f
|
||||
JOIN storage.folders fo ON fo.id = f.folder_id
|
||||
WHERE fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1)
|
||||
AND NOT f.is_trashed",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("folder_subtree_bytes: {e}"))
|
||||
})?;
|
||||
Ok(bytes.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Same as [`Self::add_drive_storage_usage_delta`] but resolves
|
||||
/// the drive id from a parent folder id in a single statement.
|
||||
/// Avoids a separate `SELECT drive_id FROM storage.folders` round
|
||||
/// trip at the upload hook site (where the folder id is what's
|
||||
/// naturally on the FileDto). The nested SELECT is point-lookup
|
||||
/// on the folder PK; clamp + idempotency properties are
|
||||
/// unchanged.
|
||||
pub async fn add_drive_storage_usage_delta_by_folder(
|
||||
&self,
|
||||
folder_id: Uuid,
|
||||
delta: i64,
|
||||
) -> Result<(), DomainError> {
|
||||
// FROM-form UPDATE keeps the same join shape as
|
||||
// `check_drive_quota_by_folder` so both methods agree on
|
||||
// how a folder maps to its drive. A subquery form would
|
||||
// silently `UPDATE … WHERE id = NULL` (matching zero rows)
|
||||
// if the lookup misses; the FROM-form simply doesn't match
|
||||
// — same outcome, more conventional SQL.
|
||||
sqlx::query(
|
||||
"UPDATE storage.drives d
|
||||
SET used_bytes = GREATEST(0, d.used_bytes + $2)
|
||||
FROM storage.folders f
|
||||
WHERE f.drive_id = d.id
|
||||
AND f.id = $1",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.bind(delta)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("drive delta by folder: {e}"))
|
||||
})?;
|
||||
// See `add_drive_storage_usage_delta` — deliberate no-invalidate.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pre-upload quota check on a single drive.
|
||||
///
|
||||
/// Read-only `SELECT (used_bytes, quota_bytes) FROM storage.drives`;
|
||||
/// returns `QuotaExceeded` when the projected `used_bytes +
|
||||
/// additional_bytes` would breach `quota_bytes`. A `NULL`
|
||||
/// `quota_bytes` short-circuits to `Ok(())` (unlimited drive —
|
||||
/// admin override / future system drives).
|
||||
///
|
||||
/// Soft cap by design: the check/write window matches the
|
||||
/// user-quota path, bounded by the sweep interval. The clamp on
|
||||
/// `add_drive_storage_usage_delta` and the set-based reconciliation
|
||||
/// keep the counter honest; small over-quota slippage during the
|
||||
/// window is acceptable.
|
||||
pub async fn check_drive_quota(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let row: Option<(i64, Option<i64>)> =
|
||||
sqlx::query_as("SELECT used_bytes, quota_bytes FROM storage.drives WHERE id = $1")
|
||||
.bind(drive_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("drive quota lookup: {e}"))
|
||||
})?;
|
||||
|
||||
let Some((used, quota)) = row else {
|
||||
// Anti-enum at the upload edge would normally map to 404,
|
||||
// but at this layer we surface the typed not-found and let
|
||||
// the caller decide how to react. In practice the upload
|
||||
// path resolves the drive id from a folder/file lookup
|
||||
// first, so this branch fires only on a deleted-drive race.
|
||||
return Err(DomainError::not_found("Drive", drive_id.to_string()));
|
||||
};
|
||||
Self::eval_drive_cap(used, quota, additional_bytes)
|
||||
}
|
||||
|
||||
/// Drive-cap verdict over already-fetched counters. Shared by
|
||||
/// [`Self::check_drive_quota`] and the fused
|
||||
/// [`Self::check_upload_quotas`] pair so both produce byte-identical
|
||||
/// errors.
|
||||
fn eval_drive_cap(
|
||||
used: i64,
|
||||
quota: Option<i64>,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let Some(quota) = quota else {
|
||||
return Ok(()); // unlimited
|
||||
};
|
||||
// Saturate on the i64 + u64 sum so a hostile / corrupt counter
|
||||
// can't silently overflow into a negative comparison.
|
||||
let projected = (used as i128) + (additional_bytes as i128);
|
||||
if projected > quota as i128 {
|
||||
return Err(DomainError::new(
|
||||
crate::common::errors::ErrorKind::QuotaExceeded,
|
||||
"Drive",
|
||||
format!(
|
||||
"Drive quota exceeded: {} + {} > {} bytes",
|
||||
used, additional_bytes, quota
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// User-envelope verdict over already-fetched counters. Shared by
|
||||
/// `check_storage_quota` and the fused [`Self::check_upload_quotas`]
|
||||
/// pair so both produce byte-identical errors.
|
||||
fn eval_user_envelope(used: i64, quota: i64, additional_bytes: u64) -> Result<(), DomainError> {
|
||||
// Quota of 0 means unlimited
|
||||
if quota <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let additional = additional_bytes as i64;
|
||||
|
||||
// Case 1: the single file alone exceeds the entire quota
|
||||
if additional > quota {
|
||||
let quota_fmt = format_bytes(quota);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"File size ({}) exceeds your total storage quota ({})",
|
||||
file_fmt, quota_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
// Case 2: the upload would push usage over the quota
|
||||
if used + additional > quota {
|
||||
let available = (quota - used).max(0);
|
||||
let avail_fmt = format_bytes(available);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"Not enough storage space. File size: {}, available: {}",
|
||||
file_fmt, avail_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fused pre-upload gate: user envelope + drive cap in ONE round-trip.
|
||||
///
|
||||
/// Upload entry points used to run `check_storage_quota` then
|
||||
/// `check_drive_quota` as two serial point reads — and the NC chunked
|
||||
/// PUT pays that pair on EVERY chunk. One `LEFT JOIN` row carries both
|
||||
/// counter pairs; verdict precedence (user envelope first, then drive
|
||||
/// existence, then drive cap) and every error shape are identical to
|
||||
/// the two-call sequence (benches/ROUND12.md §6, 1.81x).
|
||||
///
|
||||
/// Row shape shared with [`Self::check_upload_quotas_by_folder`]:
|
||||
/// `(user_used, user_quota, drive_used, drive_quota, drive_found)`.
|
||||
pub async fn check_upload_quotas(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let row: Option<QuotaPairRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT u.storage_used_bytes, u.storage_quota_bytes,
|
||||
d.used_bytes, d.quota_bytes, (d.id IS NOT NULL)
|
||||
FROM auth.users u
|
||||
LEFT JOIN storage.drives d ON d.id = $2
|
||||
WHERE u.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(drive_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("upload quota lookup: {e}"))
|
||||
})?;
|
||||
|
||||
let Some((uused, uquota, dused, dquota, drive_found)) = row else {
|
||||
return Err(DomainError::not_found("User", user_id.to_string()));
|
||||
};
|
||||
Self::eval_user_envelope(uused, uquota, additional_bytes)?;
|
||||
if !drive_found {
|
||||
return Err(DomainError::not_found("Drive", drive_id.to_string()));
|
||||
}
|
||||
Self::eval_drive_cap(dused.unwrap_or(0), dquota, additional_bytes)
|
||||
}
|
||||
|
||||
/// [`Self::check_upload_quotas`] with the drive resolved from a parent
|
||||
/// folder id — for the REST upload paths, which hold `folder_id`.
|
||||
/// A missing folder (or a folder whose drive vanished mid-race) maps to
|
||||
/// `not_found("Folder")`, exactly like `check_drive_quota_by_folder`.
|
||||
pub async fn check_upload_quotas_by_folder(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let row: Option<QuotaPairRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT u.storage_used_bytes, u.storage_quota_bytes,
|
||||
d.used_bytes, d.quota_bytes, (d.id IS NOT NULL)
|
||||
FROM auth.users u
|
||||
LEFT JOIN storage.folders f ON f.id = $2
|
||||
LEFT JOIN storage.drives d ON d.id = f.drive_id
|
||||
WHERE u.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(folder_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("upload quota lookup: {e}"))
|
||||
})?;
|
||||
|
||||
let Some((uused, uquota, dused, dquota, drive_found)) = row else {
|
||||
return Err(DomainError::not_found("User", user_id.to_string()));
|
||||
};
|
||||
Self::eval_user_envelope(uused, uquota, additional_bytes)?;
|
||||
if !drive_found {
|
||||
return Err(DomainError::not_found("Folder", folder_id.to_string()));
|
||||
}
|
||||
Self::eval_drive_cap(dused.unwrap_or(0), dquota, additional_bytes)
|
||||
}
|
||||
|
||||
/// Same as [`Self::check_drive_quota`] but resolves the drive id
|
||||
/// from a parent folder id. Mirrors
|
||||
/// [`Self::add_drive_storage_usage_delta_by_folder`] so the upload
|
||||
/// handler (which holds `folder_id` from the multipart form) can
|
||||
/// gate the write in one round trip. Returns
|
||||
/// `DomainError::not_found("Folder", …)` if the folder id doesn't
|
||||
/// resolve — the upload pipeline would 404 on that anyway.
|
||||
pub async fn check_drive_quota_by_folder(
|
||||
&self,
|
||||
folder_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let row: Option<(i64, Option<i64>)> = sqlx::query_as(
|
||||
"SELECT d.used_bytes, d.quota_bytes
|
||||
FROM storage.drives d
|
||||
JOIN storage.folders f ON f.drive_id = d.id
|
||||
WHERE f.id = $1",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("drive quota by folder: {e}"))
|
||||
})?;
|
||||
|
||||
let Some((used, quota)) = row else {
|
||||
return Err(DomainError::not_found("Folder", folder_id.to_string()));
|
||||
};
|
||||
let Some(quota) = quota else {
|
||||
return Ok(()); // unlimited
|
||||
};
|
||||
let projected = (used as i128) + (additional_bytes as i128);
|
||||
if projected > quota as i128 {
|
||||
return Err(DomainError::new(
|
||||
crate::common::errors::ErrorKind::QuotaExceeded,
|
||||
"Drive",
|
||||
format!(
|
||||
"Drive quota exceeded: {} + {} > {} bytes",
|
||||
used, additional_bytes, quota
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawn a background task that periodically reconciles every user's cached
|
||||
/// `storage_used_bytes` against the actual sum of their files.
|
||||
///
|
||||
@@ -152,8 +593,16 @@ impl StorageUsageService {
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
debug!("Running scheduled storage-usage reconciliation");
|
||||
// Drive sweep runs FIRST: the user-side sweep below
|
||||
// reads `drives.used_bytes` (the per-drive sum) to
|
||||
// compute its own counter, so the drive counter must
|
||||
// be honest first. Failure of one is logged but
|
||||
// doesn't skip the other or the next tick.
|
||||
if let Err(e) = service.update_all_drives_storage_usage().await {
|
||||
error!("Scheduled drive storage-usage reconciliation failed: {}", e);
|
||||
}
|
||||
if let Err(e) = service.update_all_users_storage_usage().await {
|
||||
error!("Scheduled storage-usage reconciliation failed: {}", e);
|
||||
error!("Scheduled user storage-usage reconciliation failed: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -192,16 +641,33 @@ impl StorageUsagePort for StorageUsageService {
|
||||
async fn update_all_users_storage_usage(&self) -> Result<(), DomainError> {
|
||||
debug!("Starting storage-usage reconciliation sweep");
|
||||
|
||||
// User envelope = SUM of `drives.used_bytes` across the user's
|
||||
// personal drives. Shared drives don't count against any user
|
||||
// (`docs/plan/drive.md` §7). The drive-side sweep runs FIRST
|
||||
// (`start_reconciliation_job`) so `drives.used_bytes` is
|
||||
// already honest by the time we read it here.
|
||||
//
|
||||
// Ownership lookup uses `role_grants` (canonical per §1) so
|
||||
// both the user's default personal AND any secondary
|
||||
// personals owned via Owner grants are summed. Secondaries
|
||||
// aren't user-creatable today, but a backfill or admin path
|
||||
// can produce them — covering that surface from day one.
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users u
|
||||
SET storage_used_bytes = COALESCE(t.total, 0)
|
||||
FROM auth.users u2
|
||||
LEFT JOIN (
|
||||
SELECT user_id, SUM(size)::bigint AS total
|
||||
FROM storage.files
|
||||
WHERE NOT is_trashed
|
||||
GROUP BY user_id
|
||||
SELECT g.subject_id AS user_id,
|
||||
SUM(d.used_bytes)::bigint AS total
|
||||
FROM storage.drives d
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
AND g.role = 'owner'
|
||||
AND g.subject_type = 'user'
|
||||
WHERE d.kind = 'personal'
|
||||
GROUP BY g.subject_id
|
||||
) t ON t.user_id = u2.id
|
||||
WHERE u.id = u2.id
|
||||
AND NOT u2.is_external
|
||||
@@ -227,44 +693,84 @@ impl StorageUsagePort for StorageUsageService {
|
||||
user_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let user = self.user_repository.get_user_by_id(user_id).await?;
|
||||
let quota = user.storage_quota_bytes();
|
||||
let used = user.storage_used_bytes();
|
||||
|
||||
// Quota of 0 means unlimited
|
||||
if quota <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let additional = additional_bytes as i64;
|
||||
|
||||
// Case 1: the single file alone exceeds the entire quota
|
||||
if additional > quota {
|
||||
let quota_fmt = format_bytes(quota);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"File size ({}) exceeds your total storage quota ({})",
|
||||
file_fmt, quota_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
// Case 2: the upload would push usage over the quota
|
||||
if used + additional > quota {
|
||||
let available = (quota - used).max(0);
|
||||
let avail_fmt = format_bytes(available);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"Not enough storage space. File size: {}, available: {}",
|
||||
file_fmt, avail_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
// Narrow 2-column read — the full user row carries the up-to-512 KiB
|
||||
// avatar `image` column, paid on every upload quota check otherwise.
|
||||
let (used, quota) = self.user_repository.get_storage_usage(user_id).await?;
|
||||
Self::eval_user_envelope(used, quota, additional_bytes)
|
||||
}
|
||||
|
||||
async fn get_user_storage_info(&self, user_id: Uuid) -> Result<(i64, i64), DomainError> {
|
||||
let user = self.user_repository.get_user_by_id(user_id).await?;
|
||||
Ok((user.storage_used_bytes(), user.storage_quota_bytes()))
|
||||
// Narrow 2-column read (avatar-free) — runs on every folder PROPFIND
|
||||
// that reports quota. See benches/QUOTA-PATH.md.
|
||||
Ok(self.user_repository.get_storage_usage(user_id).await?)
|
||||
}
|
||||
|
||||
async fn add_drive_storage_usage_delta(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
delta: i64,
|
||||
) -> Result<(), DomainError> {
|
||||
StorageUsageService::add_drive_storage_usage_delta(self, drive_id, delta).await
|
||||
}
|
||||
|
||||
/// Reconcile every drive's cached `used_bytes` in ONE set-based UPDATE.
|
||||
///
|
||||
/// Same shape as the per-user sweep above: `LEFT JOIN` over the
|
||||
/// `storage.files` aggregate keyed on `drive_id`, `IS DISTINCT
|
||||
/// FROM` guard to skip no-op rewrites so idle drives don't churn
|
||||
/// dead tuples. Runs from the same reconciliation ticker as the
|
||||
/// user sweep; failure is logged but doesn't stop the next tick.
|
||||
async fn update_all_drives_storage_usage(&self) -> Result<(), DomainError> {
|
||||
debug!("Starting drive storage-usage reconciliation sweep");
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE storage.drives d
|
||||
SET used_bytes = COALESCE(t.total, 0)
|
||||
FROM storage.drives d2
|
||||
LEFT JOIN (
|
||||
SELECT drive_id, SUM(size)::bigint AS total
|
||||
FROM storage.files
|
||||
WHERE NOT is_trashed
|
||||
GROUP BY drive_id
|
||||
) t ON t.drive_id = d2.id
|
||||
WHERE d.id = d2.id
|
||||
AND d.used_bytes IS DISTINCT FROM COALESCE(t.total, 0)
|
||||
"#,
|
||||
)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Drive storage-usage reconciliation sweep failed: {}", e);
|
||||
DomainError::internal_error("StorageUsage", format!("drive reconciliation sweep: {e}"))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"Drive storage-usage reconciliation corrected {} drive(s)",
|
||||
result.rows_affected()
|
||||
);
|
||||
// Unconditional invalidation — do NOT gate on
|
||||
// `rows_affected() > 0`. When a fire-and-forget delta has
|
||||
// already made SQL correct BEFORE the sweep runs, the sweep
|
||||
// touches zero rows but the cache may still hold the
|
||||
// pre-delta value from an earlier `GET /api/drives`. Gating
|
||||
// means the cache stays stale in exactly the case
|
||||
// `trigger-sweep` is called to fix. The invalidation cost is
|
||||
// small (moka `invalidate_all` on both caches); the
|
||||
// correctness guarantee matters. Regression avoidance:
|
||||
// drive_quota.hurl Step 6 exercises this race — 2nd upload's
|
||||
// delta lands during the 200 ms delay, sweep sees SQL is
|
||||
// already right → zero rows → without unconditional
|
||||
// invalidation, cache stays at the previous step's value.
|
||||
self.invalidate_drive_lookup_caches();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_drive_quota(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
StorageUsageService::check_drive_quota(self, drive_id, additional_bytes).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +780,7 @@ impl Clone for StorageUsageService {
|
||||
Self {
|
||||
pool: Arc::clone(&self.pool),
|
||||
user_repository: Arc::clone(&self.user_repository),
|
||||
drive_repo: self.drive_repo.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ pub struct SubjectGroupService {
|
||||
/// 30 s TTL. Without this, fresh group-mediated drive grants
|
||||
/// don't appear in `/api/drives` for up to 30 s after `add_member`.
|
||||
engine: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||
/// Same freshness contract for the drive repository's per-user
|
||||
/// readable-drives cache: a membership change on a group that holds
|
||||
/// drive grants changes every affected user's visible drive list,
|
||||
/// so the cached lists drop alongside `user_groups_cache`.
|
||||
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||
}
|
||||
|
||||
impl SubjectGroupService {
|
||||
@@ -52,12 +57,14 @@ impl SubjectGroupService {
|
||||
pool: Arc<PgPool>,
|
||||
user_storage: Arc<UserPgRepository>,
|
||||
engine: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
pool,
|
||||
user_storage,
|
||||
engine,
|
||||
drive_repo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +217,69 @@ impl SubjectGroupService {
|
||||
));
|
||||
}
|
||||
|
||||
// Refuse if this group is the **sole Owner** of any drive — the
|
||||
// cascade-delete below would otherwise wipe the only `owner`
|
||||
// grant on that drive and leave it orphaned (no one can ever
|
||||
// manage it again). The check is "for every drive where this
|
||||
// group holds Owner, does another Owner exist?". A single drive
|
||||
// failing the check is enough to refuse.
|
||||
//
|
||||
// Matching D3a's last-owner-protection rule on `set_member_role`
|
||||
// / `remove_member` — they catch the case where the drive's
|
||||
// last Owner is *directly* a user or group being demoted /
|
||||
// removed via the membership API. This guard catches the same
|
||||
// invariant from the group-lifecycle side.
|
||||
let orphaning: Option<(Uuid,)> = sqlx::query_as(
|
||||
r#"
|
||||
WITH group_owned AS (
|
||||
SELECT resource_id
|
||||
FROM storage.role_grants
|
||||
WHERE subject_type = 'group'
|
||||
AND subject_id = $1
|
||||
AND resource_type = 'drive'
|
||||
AND role = 'owner'
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
)
|
||||
SELECT resource_id
|
||||
FROM storage.role_grants
|
||||
WHERE resource_type = 'drive'
|
||||
AND role = 'owner'
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
AND resource_id IN (SELECT resource_id FROM group_owned)
|
||||
GROUP BY resource_id
|
||||
HAVING COUNT(*) = 1
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"SubjectGroup",
|
||||
format!("sole-owner check: {e}"),
|
||||
)
|
||||
})?;
|
||||
if let Some((drive_id,)) = orphaning {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "group_delete.rejected",
|
||||
reason = "sole_drive_owner",
|
||||
group_id = %id,
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ refused group delete — sole Owner of drive {drive_id}",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::Conflict,
|
||||
"SubjectGroup",
|
||||
"Group is the sole Owner of at least one shared drive — \
|
||||
promote another Owner first or delete the drive."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Atomically delete grants pointing at this group, then the group
|
||||
// itself. If either fails, both roll back.
|
||||
let mut tx = self.pool.begin().await.map_err(|e| {
|
||||
@@ -363,6 +433,7 @@ impl SubjectGroupService {
|
||||
// call for up to 30 s.
|
||||
for uid in self.invalidation_targets(member).await? {
|
||||
self.engine.invalidate_user_groups_cache(uid).await;
|
||||
self.drive_repo.invalidate_readable_for_user(uid).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -406,6 +477,23 @@ impl SubjectGroupService {
|
||||
// user is still reachable via another path after this remove,
|
||||
// they stay in the set on the post-state, so the check would
|
||||
// pass on the next remove instead.
|
||||
// For a nested child-group removal the child's transitive user set is
|
||||
// needed twice: by the would-empty pre-check below AND, after the
|
||||
// remove, as the cache-invalidation set. The edge delete is ABOVE the
|
||||
// child, so it cannot change the child's descendants — compute the
|
||||
// recursive CTE ONCE here and reuse it, instead of the identical query
|
||||
// running twice (the second was hidden inside `invalidation_targets`).
|
||||
// (benches/ROUND23.md §G1)
|
||||
let child_users: Option<Vec<uuid::Uuid>> = match member {
|
||||
GroupMember::Group(child_id) => Some(
|
||||
self.repo
|
||||
.list_transitive_users(child_id)
|
||||
.await
|
||||
.map_err(map_repo_err)?,
|
||||
),
|
||||
GroupMember::User(_) => None,
|
||||
};
|
||||
|
||||
let users_before = self
|
||||
.repo
|
||||
.list_transitive_users(group_id)
|
||||
@@ -414,18 +502,17 @@ impl SubjectGroupService {
|
||||
if !users_before.is_empty() {
|
||||
let would_be_empty = match member {
|
||||
GroupMember::User(uid) => users_before.len() == 1 && users_before.contains(&uid),
|
||||
GroupMember::Group(child_id) => {
|
||||
// For child-group removal: would this drop the
|
||||
// parent's transitive user set to 0? Look up the
|
||||
// child's transitive users — if every user in the
|
||||
// parent's set comes through the child, removing the
|
||||
// child empties the parent.
|
||||
let child_users = self
|
||||
.repo
|
||||
.list_transitive_users(child_id)
|
||||
.await
|
||||
.map_err(map_repo_err)?;
|
||||
!child_users.is_empty() && users_before.iter().all(|u| child_users.contains(u))
|
||||
GroupMember::Group(_) => {
|
||||
// Would removing this child drop the parent's transitive
|
||||
// user set to 0? Reuse the child's transitive users
|
||||
// computed above — if every user in the parent's set comes
|
||||
// through the child, removing the child empties the parent.
|
||||
let child_users = child_users.as_deref().unwrap_or(&[]);
|
||||
// Set probe instead of an O(|before|·|child|) slice scan
|
||||
// (benches/ROUND11.md §13: 5.7x at 500×500).
|
||||
let child_set: std::collections::HashSet<&uuid::Uuid> =
|
||||
child_users.iter().collect();
|
||||
!child_users.is_empty() && users_before.iter().all(|u| child_set.contains(u))
|
||||
}
|
||||
};
|
||||
if would_be_empty {
|
||||
@@ -460,8 +547,17 @@ impl SubjectGroupService {
|
||||
// ancestor. Without this, a removed-from-group user keeps
|
||||
// appearing as a transitive member in `expand_subject_for_listing`
|
||||
// for up to 30 s, surfacing grants they no longer have.
|
||||
for uid in self.invalidation_targets(member).await? {
|
||||
//
|
||||
// Reuse the child's transitive users computed above (unchanged by the
|
||||
// edge delete) as the invalidation set — no second recursive CTE. For a
|
||||
// `User` member it's just that user. (benches/ROUND23.md §G1)
|
||||
let invalidation: Vec<uuid::Uuid> = match member {
|
||||
GroupMember::User(uid) => vec![uid],
|
||||
GroupMember::Group(_) => child_users.unwrap_or_default(),
|
||||
};
|
||||
for uid in invalidation {
|
||||
self.engine.invalidate_user_groups_cache(uid).await;
|
||||
self.drive_repo.invalidate_readable_for_user(uid).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -571,7 +667,9 @@ mod integration_tests {
|
||||
// future test starts exercising real authz lookups.
|
||||
let engine =
|
||||
Arc::new(crate::infrastructure::services::pg_acl_engine::PgAclEngine::new_stub());
|
||||
SubjectGroupService::new(repo, pool, user_storage, engine)
|
||||
let drive_repo =
|
||||
Arc::new(crate::infrastructure::repositories::pg::DrivePgRepository::new(pool.clone()));
|
||||
SubjectGroupService::new(repo, pool, user_storage, engine, drive_repo)
|
||||
}
|
||||
|
||||
async fn first_admin(pool: &sqlx::PgPool) -> Uuid {
|
||||
|
||||
@@ -4,7 +4,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
classify_display, format_file_size, intern_display, intern_mime,
|
||||
};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
@@ -14,7 +14,7 @@ use crate::application::dtos::trash_dto::{
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::application::ports::storage_ports::FileWritePort;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::entities::file::File;
|
||||
@@ -24,7 +24,6 @@ use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository;
|
||||
@@ -49,9 +48,6 @@ pub struct TrashService {
|
||||
/// Repository for trash-specific operations like listing and retrieving trashed items
|
||||
trash_repository: Arc<TrashDbRepository>,
|
||||
|
||||
/// Port for file read operations (get file metadata)
|
||||
file_read_port: Arc<FileBlobReadRepository>,
|
||||
|
||||
/// Port for file write operations (trash, restore, delete)
|
||||
file_write_port: Arc<FileBlobWriteRepository>,
|
||||
|
||||
@@ -75,19 +71,14 @@ pub struct TrashService {
|
||||
/// so trash listings filter by drive membership instead of the legacy
|
||||
/// per-user scope.
|
||||
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||
|
||||
/// Number of days items should be kept in trash before automatic cleanup
|
||||
retention_days: u32,
|
||||
}
|
||||
|
||||
impl TrashService {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
trash_repository: Arc<TrashDbRepository>,
|
||||
file_read_port: Arc<FileBlobReadRepository>,
|
||||
file_write_port: Arc<FileBlobWriteRepository>,
|
||||
folder_storage_port: Arc<FolderDbRepository>,
|
||||
retention_days: u32,
|
||||
dedup_service: Arc<DedupService>,
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
@@ -95,7 +86,6 @@ impl TrashService {
|
||||
) -> Self {
|
||||
Self {
|
||||
trash_repository,
|
||||
file_read_port,
|
||||
file_write_port,
|
||||
folder_storage_port,
|
||||
dedup_service,
|
||||
@@ -103,7 +93,6 @@ impl TrashService {
|
||||
content_cache,
|
||||
authz,
|
||||
drive_repo,
|
||||
retention_days,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,25 +115,31 @@ impl TrashService {
|
||||
"folder-icon".to_string(),
|
||||
),
|
||||
TrashedItemType::File => {
|
||||
let name = item.name();
|
||||
// Use empty MIME type to leverage extension fallback
|
||||
let category = category_for(name, "").to_string();
|
||||
let icon_class = icon_class_for(name, "").to_string();
|
||||
let icon_special_class = icon_special_class_for(name, "").to_string();
|
||||
(category, icon_class, icon_special_class)
|
||||
// Use empty MIME type to leverage extension fallback; one
|
||||
// fused pass lowers the extension once instead of three
|
||||
// times (benches/ROUND11.md §21).
|
||||
let classes = classify_display(item.name(), "");
|
||||
(
|
||||
classes.category.to_string(),
|
||||
classes.icon_class.to_string(),
|
||||
classes.icon_special_class.to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Move the owned Strings out of the consumed item — the getter
|
||||
// `.to_string()` clones paid 2 extra allocations per trash row.
|
||||
let parts = item.into_parts();
|
||||
TrashedItemDto {
|
||||
id: item.id().to_string(),
|
||||
original_id: item.original_id().to_string(),
|
||||
item_type: match item.item_type() {
|
||||
id: parts.id.to_string(),
|
||||
original_id: parts.original_id.to_string(),
|
||||
item_type: match parts.item_type {
|
||||
TrashedItemType::File => "file".to_string(),
|
||||
TrashedItemType::Folder => "folder".to_string(),
|
||||
},
|
||||
name: item.name().to_string(),
|
||||
original_path: item.original_path().to_string(),
|
||||
trashed_at: item.trashed_at(),
|
||||
name: parts.name,
|
||||
original_path: parts.original_path,
|
||||
trashed_at: parts.trashed_at,
|
||||
days_until_deletion,
|
||||
category,
|
||||
icon_class,
|
||||
@@ -177,23 +172,17 @@ impl TrashUseCase for TrashService {
|
||||
// Note: We now verify file/folder ownership BEFORE moving to trash.
|
||||
// This prevents users from trashing items they do not own (IDOR).
|
||||
|
||||
// Parse UUIDs with detailed error handling
|
||||
// Parse UUIDs with detailed error handling. The parsed value is
|
||||
// re-derived per branch below; this early check preserves the 400
|
||||
// (validation) error shape for malformed ids.
|
||||
debug!("Validating item UUID: {}", item_id);
|
||||
let item_uuid = match Uuid::parse_str(item_id) {
|
||||
Ok(uuid) => {
|
||||
debug!("Valid item UUID: {}", uuid);
|
||||
uuid
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Invalid item UUID: {} - Error: {}", item_id, e);
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid item ID: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let user_uuid = user_id;
|
||||
if let Err(e) = Uuid::parse_str(item_id) {
|
||||
error!("Invalid item UUID: {} - Error: {}", item_id, e);
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid item ID: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
|
||||
match item_type {
|
||||
"file" => {
|
||||
@@ -209,59 +198,13 @@ impl TrashUseCase for TrashService {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Authz already passed — use the non-owner-scoped read so that
|
||||
// grantees with Delete permission can trash files they don't own.
|
||||
// The file's user_id in storage.files is unchanged, so the item
|
||||
// will appear in the original owner's trash view.
|
||||
let file = match self.file_read_port.get_file(item_id).await {
|
||||
Ok(file) => {
|
||||
debug!("File found: {} ({})", file.name(), item_id);
|
||||
file
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error getting file: {} - {}", item_id, e);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"File",
|
||||
format!("Error retrieving file {}: {}", item_id, e),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let original_path = file.storage_path().to_string();
|
||||
debug!("Original file path: {}", original_path);
|
||||
|
||||
debug!("Creating TrashedItem object for the file");
|
||||
let trashed_item = TrashedItem::new(
|
||||
item_uuid,
|
||||
user_uuid,
|
||||
TrashedItemType::File,
|
||||
file.name().to_string(),
|
||||
original_path,
|
||||
self.retention_days,
|
||||
);
|
||||
debug!(
|
||||
"TrashedItem created successfully: {} -> {}",
|
||||
file.name(),
|
||||
trashed_item.id()
|
||||
);
|
||||
|
||||
// First add to trash index to register the item
|
||||
info!("Adding file {} to trash index", item_id);
|
||||
match self.trash_repository.add_to_trash(&trashed_item).await {
|
||||
Ok(_) => {
|
||||
debug!("File added to trash index successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error adding file to trash index: {}", e);
|
||||
return Err(DomainError::internal_error(
|
||||
"TrashRepository",
|
||||
format!("Failed to add file to trash: {}", e),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Then physically move the file to trash.
|
||||
// Soft-delete model: the is_trashed flag on the row IS the
|
||||
// trash membership — there is no separate trash index to
|
||||
// register into (`TrashRepository::add_to_trash` is a
|
||||
// documented no-op). The previous shape still fetched the
|
||||
// full file entity and built a `TrashedItem` only to feed
|
||||
// that no-op: one wasted SELECT per trash operation.
|
||||
//
|
||||
// §14: caller_id stamps `updated_by` on the trashed row.
|
||||
info!("Physically moving file to trash: {}", item_id);
|
||||
match self.file_write_port.move_to_trash(item_id, user_id).await {
|
||||
@@ -293,43 +236,10 @@ impl TrashUseCase for TrashService {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let folder = self
|
||||
.folder_storage_port
|
||||
.get_folder(item_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Folder",
|
||||
format!("Error retrieving folder {}: {}", item_id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let original_path = folder.storage_path().to_string();
|
||||
|
||||
let trashed_item = TrashedItem::new(
|
||||
item_uuid,
|
||||
user_uuid,
|
||||
TrashedItemType::Folder,
|
||||
folder.name().to_string(),
|
||||
original_path,
|
||||
self.retention_days,
|
||||
);
|
||||
|
||||
// First add to trash index to register the item
|
||||
debug!("Adding folder {} to trash repository", item_id);
|
||||
match self.trash_repository.add_to_trash(&trashed_item).await {
|
||||
Ok(_) => debug!("Successfully added folder to trash repository"),
|
||||
Err(e) => {
|
||||
error!("Failed to add folder to trash repository: {}", e);
|
||||
return Err(DomainError::internal_error(
|
||||
"TrashRepository",
|
||||
format!("Failed to add folder to trash: {}", e),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Then physically move the folder to trash.
|
||||
// Soft-delete model — same as the file branch above: the
|
||||
// cascade UPDATE below is the whole operation; no folder
|
||||
// fetch or trash-index write needed.
|
||||
//
|
||||
// §14: caller_id stamps `updated_by` on every cascade-trashed row.
|
||||
self.folder_storage_port
|
||||
.move_to_trash(item_id, user_id)
|
||||
@@ -632,6 +542,21 @@ impl TrashUseCase for TrashService {
|
||||
// Permanently delete the folder
|
||||
let folder_id = item.original_id().to_string();
|
||||
|
||||
// Snapshot the cascade's file ids BEFORE the bulk
|
||||
// DELETE so `on_file_deleted` fires per cascaded
|
||||
// file (same shape as the bulk `clear_trash_in`
|
||||
// path at line ~804). Skipped when no hook is
|
||||
// registered — the enumeration is a SQL round-trip
|
||||
// we don't want to pay for nothing.
|
||||
let cascaded_file_ids: Vec<String> = if self.file_deleted_hook.is_some() {
|
||||
self.folder_storage_port
|
||||
.list_file_ids_in_subtree(&folder_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
info!("Permanently deleting folder: {}", folder_id);
|
||||
match self
|
||||
.folder_storage_port
|
||||
@@ -666,6 +591,12 @@ impl TrashUseCase for TrashService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hook) = &self.file_deleted_hook {
|
||||
for file_id in &cascaded_file_ids {
|
||||
hook.on_file_deleted(file_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,13 +656,52 @@ impl TrashUseCase for TrashService {
|
||||
// the drives where the caller is effectively Owner (direct or via a
|
||||
// group). Single-drive users: this resolves to just their personal
|
||||
// drive, identical to the legacy `WHERE user_id = $1` scope.
|
||||
let (subject_types, subject_ids) = self
|
||||
.authz
|
||||
.expand_subject_for_listing(Subject::User(user_id))
|
||||
let drive_ids = self.drives_with_delete_for(user_id).await?;
|
||||
if drive_ids.is_empty() {
|
||||
info!("empty_trash: caller has Delete on no drive — nothing to do");
|
||||
return Ok(());
|
||||
}
|
||||
self.clear_trash_in(&drive_ids, user_id).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn empty_trash_for_drive(&self, user_id: Uuid, drive_id: Uuid) -> Result<()> {
|
||||
// Per-drive trash empty — the Drive group-by on `/trash` exposes
|
||||
// this as a per-row affordance so multi-drive owners can clear
|
||||
// one drive without touching the others.
|
||||
//
|
||||
// Route through `authz.require(Delete, Drive)` so the denial
|
||||
// shape stays consistent with every other write verb: 403 when
|
||||
// the caller has Read on the drive (viewer/editor holding no
|
||||
// Delete), 404 when they don't (anti-enum). Before 2026-07-16
|
||||
// this method rolled its own `drives_with_delete_for` check +
|
||||
// hardcoded `NotFound` — that predated the graduated-denial
|
||||
// engine change and returned 404 unconditionally even for a
|
||||
// Viewer who could see the drive in `/api/drives`. The engine
|
||||
// now emits `authz.denied` with `visibility="visible"|"hidden"`
|
||||
// and the standard mapping renders it as 403 or 404.
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(user_id),
|
||||
Permission::Delete,
|
||||
Resource::Drive(drive_id),
|
||||
)
|
||||
.await?;
|
||||
info!("Emptying trash for drive {} (user {})", drive_id, user_id);
|
||||
self.clear_trash_in(&[drive_id], user_id).await
|
||||
}
|
||||
}
|
||||
|
||||
impl TrashService {
|
||||
/// Drives where the caller has `Permission::Delete` (via any role
|
||||
/// bundle, direct or group-mediated). Shared by `empty_trash` and
|
||||
/// `empty_trash_for_drive`; lifting the lookup out of both methods
|
||||
/// keeps the two HTTP surfaces semantically consistent and avoids
|
||||
/// duplicating the subject-expansion plumbing.
|
||||
async fn drives_with_delete_for(&self, user_id: Uuid) -> Result<Vec<Uuid>> {
|
||||
let drives = self
|
||||
.drive_repo
|
||||
.list_for_subjects(&subject_types, &subject_ids)
|
||||
.list_readable_by(user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
@@ -739,29 +709,32 @@ impl TrashUseCase for TrashService {
|
||||
format!("Failed to resolve accessible drives: {e:?}"),
|
||||
)
|
||||
})?;
|
||||
let drive_ids: Vec<Uuid> = drives
|
||||
Ok(drives
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
d.caller_role
|
||||
.is_some_and(|r| r.expand().contains(&Permission::Delete))
|
||||
})
|
||||
.map(|d| d.drive.id)
|
||||
.collect();
|
||||
.collect())
|
||||
}
|
||||
|
||||
if drive_ids.is_empty() {
|
||||
info!("empty_trash: caller has Delete on no drive — nothing to do");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Collect ALL trashed file IDs BEFORE bulk-deleting so hooks (thumbnail
|
||||
// cleanup, etc.) can run afterward. We use get_all_trashed_file_ids (not
|
||||
// get_trash_items) because the trash_items view excludes files inside a
|
||||
// trashed folder — those files will still be deleted by clear_trash via
|
||||
// the folder CASCADE, but their hooks would otherwise be missed.
|
||||
/// Bulk-clear trash within the given drives, running every side
|
||||
/// effect once: trashed-file id list (for hooks), `clear_trash`
|
||||
/// SQL, dedup GC, content-cache invalidation, file-deleted hook.
|
||||
/// The two `TrashUseCase` entry points compose this with their
|
||||
/// respective drive-id scopes — call-once, no duplication.
|
||||
async fn clear_trash_in(&self, drive_ids: &[Uuid], user_id: Uuid) -> Result<()> {
|
||||
// Collect ALL trashed file IDs BEFORE bulk-deleting so hooks
|
||||
// (thumbnail cleanup, etc.) can run afterward. We use
|
||||
// `get_all_trashed_file_ids` (not `get_trash_items`) because the
|
||||
// trash_items view excludes files inside a trashed folder —
|
||||
// those files will still be deleted by `clear_trash` via the
|
||||
// folder CASCADE, but their hooks would otherwise be missed.
|
||||
let trashed_file_ids: Vec<String> = if self.file_deleted_hook.is_some() {
|
||||
match self
|
||||
.trash_repository
|
||||
.get_all_trashed_file_ids(&drive_ids)
|
||||
.get_all_trashed_file_ids(drive_ids)
|
||||
.await
|
||||
{
|
||||
Ok(ids) => ids,
|
||||
@@ -781,29 +754,33 @@ impl TrashUseCase for TrashService {
|
||||
// Folder deletion cascades (FK ON DELETE CASCADE) to child folders and
|
||||
// their files. The PG trigger `trg_files_decrement_blob_ref` automatically
|
||||
// decrements blob ref_counts for every deleted file row.
|
||||
self.trash_repository.clear_trash(&drive_ids).await?;
|
||||
self.trash_repository.clear_trash(drive_ids).await?;
|
||||
|
||||
// The PG trigger decremented ref_counts but cannot delete disk files or
|
||||
// thumbnails. Run garbage_collect() to remove any blobs whose ref_count
|
||||
// reached 0, along with their blob-keyed thumbnail files.
|
||||
// The PG trigger decremented ref_counts but cannot delete disk
|
||||
// files or thumbnails. `garbage_collect()` removes any blobs
|
||||
// whose ref_count reached 0, along with their blob-keyed
|
||||
// thumbnail files. Failure here is non-fatal — the rows are
|
||||
// gone in any case; the next GC pass mops up.
|
||||
if let Err(e) = self.dedup_service.garbage_collect().await {
|
||||
warn!("empty_trash: garbage_collect failed: {:?}", e);
|
||||
warn!("clear_trash_in: garbage_collect failed: {:?}", e);
|
||||
}
|
||||
|
||||
// Invalidate content cache for all permanently deleted files.
|
||||
if let Some(cc) = &self.content_cache {
|
||||
for file_id in &trashed_file_ids {
|
||||
cc.invalidate(file_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hook) = &self.file_deleted_hook {
|
||||
for file_id in &trashed_file_ids {
|
||||
hook.on_file_deleted(file_id);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Trash emptied for user {}", user_id);
|
||||
info!(
|
||||
"Trash cleared across {} drive(s) for user {}",
|
||||
drive_ids.len(),
|
||||
user_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -832,16 +809,8 @@ impl TrashService {
|
||||
// D2b: scope by drives the caller can read (resolved through
|
||||
// role_grants on resource_type='drive', including group-mediated
|
||||
// grants). Empty set → empty page without a SQL round-trip.
|
||||
let (subject_types, subject_ids) = self
|
||||
.authz
|
||||
.expand_subject_for_listing(Subject::User(user_id))
|
||||
.await?;
|
||||
let drive_ids: Vec<Uuid> = match self
|
||||
.drive_repo
|
||||
.list_for_subjects(&subject_types, &subject_ids)
|
||||
.await
|
||||
{
|
||||
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
|
||||
let drive_ids: Vec<Uuid> = match self.drive_repo.list_readable_by(user_id).await {
|
||||
Ok(drives) => drives.iter().map(|d| d.drive.id).collect(),
|
||||
Err(e) => {
|
||||
return Err(DomainError::internal_error(
|
||||
"Trash",
|
||||
@@ -897,16 +866,18 @@ fn build_trash_cursor(row: &TrashResourceRow, order_by: &str, reverse: bool) ->
|
||||
|
||||
/// Convert a raw repository row into the API DTO.
|
||||
fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
||||
let path = row.path.clone().unwrap_or_default();
|
||||
// `row` is owned and dropped at fn end, so move its String fields into the
|
||||
// DTO instead of cloning (the favorites / recent / folder row mappers
|
||||
// already move these same fields — trash was missed). benches/ROUND19.md §M4.
|
||||
let path = row.path.unwrap_or_default();
|
||||
if row.resource_type == "folder" {
|
||||
let resource_id = row.resource_id.to_string();
|
||||
let dto = FolderDto {
|
||||
etag: resource_id.clone(),
|
||||
id: resource_id,
|
||||
name: row.name.clone(),
|
||||
name: row.name,
|
||||
path,
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
// D2b: the trash listing query now SELECTs `drive_id` (the
|
||||
// unified view exposes it). Surfaced so per-drive grouping
|
||||
// in the `/trash` UI doesn't need an extra lookup per row.
|
||||
@@ -914,12 +885,11 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
is_root: false,
|
||||
icon_class: std::sync::Arc::from("fas fa-folder"),
|
||||
icon_special_class: std::sync::Arc::from("folder-icon"),
|
||||
category: std::sync::Arc::from("Folder"),
|
||||
// §14 provenance not selected by the trash listing query.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
icon_class: intern_display("fas fa-folder"),
|
||||
icon_special_class: intern_display("folder-icon"),
|
||||
category: intern_display("Folder"),
|
||||
created_by: row.created_by,
|
||||
updated_by: row.updated_by,
|
||||
};
|
||||
TrashResourceItemDto {
|
||||
resource_type: ResourceTypeDto::Folder,
|
||||
@@ -938,32 +908,31 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
||||
// match GET/HEAD/PROPFIND ETags — a client restoring a
|
||||
// file may conditional-request it immediately after.
|
||||
let modified_at_u = row.modified_at.timestamp() as u64;
|
||||
let content_hash = row.blob_hash.clone().unwrap_or_default();
|
||||
let content_hash = row.blob_hash.unwrap_or_default();
|
||||
let etag = if content_hash.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
File::compute_etag(&content_hash, modified_at_u)
|
||||
};
|
||||
let classes = classify_display(&row.name, mime);
|
||||
let dto = FileDto {
|
||||
id: row.resource_id.to_string(),
|
||||
name: row.name.clone(),
|
||||
name: row.name,
|
||||
path,
|
||||
size: size_bytes,
|
||||
mime_type: std::sync::Arc::from(mime),
|
||||
mime_type: intern_mime(mime),
|
||||
folder_id: row.parent_id.map(|u| u.to_string()),
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: modified_at_u,
|
||||
icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
|
||||
icon_special_class: std::sync::Arc::from(icon_special_class_for(&row.name, mime)),
|
||||
category: std::sync::Arc::from(category_for(&row.name, mime)),
|
||||
icon_class: intern_display(classes.icon_class),
|
||||
icon_special_class: intern_display(classes.icon_special_class),
|
||||
category: intern_display(classes.category),
|
||||
size_formatted: format_file_size(size_bytes),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
etag,
|
||||
// §14 provenance not selected by the trash listing query.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
created_by: row.created_by,
|
||||
updated_by: row.updated_by,
|
||||
};
|
||||
TrashResourceItemDto {
|
||||
resource_type: ResourceTypeDto::File,
|
||||
|
||||
@@ -319,6 +319,14 @@ where
|
||||
// via `drive_repo.list_for_subjects` + role-bundle filter.
|
||||
self.trash_repository.clear_trash(&[user_id]).await
|
||||
}
|
||||
|
||||
async fn empty_trash_for_drive(&self, _user_id: Uuid, drive_id: Uuid) -> Result<()> {
|
||||
// Test mock — uses the passed-in drive id verbatim. Production
|
||||
// checks the caller's Delete-bearing drives first and refuses
|
||||
// with NotFound on a mismatch; the mock skips that and just
|
||||
// clears the given drive directly.
|
||||
self.trash_repository.clear_trash(&[drive_id]).await
|
||||
}
|
||||
}
|
||||
|
||||
// Mock repositories for testing
|
||||
@@ -528,15 +536,6 @@ impl FileReadPort for MockFileRepository {
|
||||
Ok((Vec::new(), 0))
|
||||
}
|
||||
|
||||
async fn count_files(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> std::result::Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn stream_files_in_subtree(
|
||||
&self,
|
||||
_folder_id: &str,
|
||||
@@ -546,15 +545,6 @@ impl FileReadPort for MockFileRepository {
|
||||
> {
|
||||
Ok(Box::pin(futures::stream::empty()))
|
||||
}
|
||||
|
||||
async fn get_file_for_owner(
|
||||
&self,
|
||||
id: &str,
|
||||
_owner_id: Uuid,
|
||||
) -> std::result::Result<File, DomainError> {
|
||||
// In this mock, ignore ownership — trash tests don't focus on ownership
|
||||
self.get_file(id).await
|
||||
}
|
||||
}
|
||||
|
||||
impl FileWritePort for MockFileRepository {
|
||||
@@ -599,6 +589,7 @@ impl FileWritePort for MockFileRepository {
|
||||
_size: u64,
|
||||
_modified_at: Option<i64>,
|
||||
_caller_id: Uuid,
|
||||
_expected_hash: Option<&str>,
|
||||
) -> std::result::Result<(String, i64), DomainError> {
|
||||
Ok((String::new(), 0))
|
||||
}
|
||||
@@ -737,10 +728,9 @@ impl FolderRepository for MockFolderRepository {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
async fn list_root_folders_for_caller(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
) -> std::result::Result<Vec<Folder>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
@@ -755,10 +745,9 @@ impl FolderRepository for MockFolderRepository {
|
||||
Ok((vec![], Some(0)))
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner_paginated(
|
||||
async fn list_root_folders_for_caller_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
_offset: usize,
|
||||
_limit: usize,
|
||||
_include_total: bool,
|
||||
|
||||
@@ -62,6 +62,28 @@ impl UserLifecycleService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upgraded: log-and-continue. Called by
|
||||
/// `AuthApplicationService::upgrade_to_internal` after the
|
||||
/// `is_external = false` UPDATE persists. Same log-and-continue
|
||||
/// semantics as `dispatch_created` — the row is already updated,
|
||||
/// hook failure at (e.g.) home-drive provisioning is recoverable
|
||||
/// on the next login via `PersonalDriveLifecycleHook::on_user_login`
|
||||
/// (its safety-net path already handles the "user is internal but
|
||||
/// no drive yet" case idempotently).
|
||||
pub async fn dispatch_upgraded_to_internal(&self, user: &User) {
|
||||
for h in &self.hooks {
|
||||
if let Err(e) = h.on_upgraded_to_internal(user).await {
|
||||
tracing::error!(
|
||||
target: "user_lifecycle",
|
||||
hook = h.name(),
|
||||
user_id = %user.id(),
|
||||
error = %e,
|
||||
"on_upgraded_to_internal failed; drive provisioning will retry on next login"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Login: log-and-continue. Same reasoning as `dispatch_created`.
|
||||
/// Must fire BEFORE `user.register_login()` so that hooks observing
|
||||
/// `last_login_at().is_none()` correctly detect the first-ever login.
|
||||
@@ -199,6 +221,19 @@ impl UserLifecycleHook for AuditLifecycleHook {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> {
|
||||
// Post-upgrade state — `is_external` is already `false` here
|
||||
// (the service persisted before dispatching), so we don't log
|
||||
// it as a field; the event name carries the transition.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.upgraded_to_internal",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,14 +31,24 @@ pub struct WopiTokenClaims {
|
||||
|
||||
/// Service for generating and validating WOPI access tokens.
|
||||
pub struct WopiTokenService {
|
||||
secret: String,
|
||||
/// Pre-built signing key — `EncodingKey::from_secret` copies the secret into
|
||||
/// a fresh `Vec` on each call, so build it once (mirrors `JwtTokenService`).
|
||||
encoding_key: EncodingKey,
|
||||
/// Pre-built verification key — same copy-per-call cost as `encoding_key`.
|
||||
decoding_key: DecodingKey,
|
||||
/// Pre-built HS256 validation config — `Validation::new` allocates a
|
||||
/// `required_spec_claims` HashSet + an `algorithms` Vec; Office/Collabora
|
||||
/// hosts poll `validate_token` continuously (benches/ROUND19.md §M2).
|
||||
validation: Validation,
|
||||
token_ttl_secs: i64,
|
||||
}
|
||||
|
||||
impl WopiTokenService {
|
||||
pub fn new(secret: String, token_ttl_secs: i64) -> Self {
|
||||
Self {
|
||||
secret,
|
||||
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
|
||||
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
|
||||
validation: Validation::new(Algorithm::HS256),
|
||||
token_ttl_secs,
|
||||
}
|
||||
}
|
||||
@@ -64,12 +74,7 @@ impl WopiTokenService {
|
||||
iat: now,
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(self.secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| {
|
||||
let token = encode(&Header::default(), &claims, &self.encoding_key).map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"WopiTokenService",
|
||||
@@ -83,25 +88,19 @@ impl WopiTokenService {
|
||||
|
||||
/// Validate a WOPI access token and extract its claims.
|
||||
pub fn validate_token(&self, token: &str) -> Result<WopiTokenClaims, DomainError> {
|
||||
let validation = Validation::new(Algorithm::HS256);
|
||||
|
||||
let token_data = decode::<WopiTokenClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(self.secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.map_err(|e| match e.kind() {
|
||||
jsonwebtoken::errors::ErrorKind::ExpiredSignature => DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"WopiTokenService",
|
||||
"WOPI token expired",
|
||||
),
|
||||
_ => DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"WopiTokenService",
|
||||
format!("Invalid WOPI token: {}", e),
|
||||
),
|
||||
})?;
|
||||
let token_data = decode::<WopiTokenClaims>(token, &self.decoding_key, &self.validation)
|
||||
.map_err(|e| match e.kind() {
|
||||
jsonwebtoken::errors::ErrorKind::ExpiredSignature => DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"WopiTokenService",
|
||||
"WOPI token expired",
|
||||
),
|
||||
_ => DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"WopiTokenService",
|
||||
format!("Invalid WOPI token: {}", e),
|
||||
),
|
||||
})?;
|
||||
|
||||
let claims = token_data.claims;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user