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:
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user