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:
Bradley Nelson
2026-07-21 17:09:36 -06:00
600 changed files with 105575 additions and 14440 deletions
+63 -2
View File
@@ -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,
+149 -32
View File
@@ -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.
+65 -48
View File
@@ -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,
+115 -19
View File
@@ -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,
+37 -2
View File
@@ -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 -15
View File
@@ -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
}
}
+25
View File
@@ -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,
+4
View File
@@ -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.
+1
View File
@@ -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;
+5
View File
@@ -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,
+5 -1
View File
@@ -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) {}
}
+22
View File
@@ -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,
+79 -78
View File
@@ -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>;
}
+22
View File
@@ -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 {
+10
View File
@@ -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<()>;
}
+28
View File
@@ -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(())
}
}