Merge origin/main into webdav-litmus-compliance
This commit is contained in:
@@ -96,7 +96,9 @@ impl CalDavAdapter {
|
||||
for attr in e.attributes().flatten() {
|
||||
let attr_name =
|
||||
std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
|
||||
let attr_value = attr.unescape_value().unwrap_or_default();
|
||||
let attr_value = attr
|
||||
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
|
||||
.unwrap_or_default();
|
||||
|
||||
if attr_name == "start" {
|
||||
// Parse ISO date format with Z for UTC
|
||||
@@ -161,7 +163,9 @@ impl CalDavAdapter {
|
||||
// Parse time-range attributes
|
||||
for attr in e.attributes().flatten() {
|
||||
let attr_name = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
|
||||
let attr_value = attr.unescape_value().unwrap_or_default();
|
||||
let attr_value = attr
|
||||
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
|
||||
.unwrap_or_default();
|
||||
|
||||
if attr_name == "start" {
|
||||
// Parse ISO date format with Z for UTC
|
||||
|
||||
@@ -61,7 +61,10 @@ impl PluginLifecycleHook {
|
||||
|
||||
dispatch.dispatch(PluginEvent {
|
||||
name: EVENT_FILE_UPLOADED,
|
||||
user_id: dto.owner_id,
|
||||
// Post-D7 the wire DTO no longer carries `owner_id`;
|
||||
// §14 `created_by` provenance is the equivalent signal
|
||||
// (who put the file in the system).
|
||||
user_id: dto.created_by.map(|u| u.to_string()),
|
||||
invocation_id: Uuid::new_v4().to_string(),
|
||||
payload: serde_json::json!({
|
||||
"path": dto.path,
|
||||
|
||||
@@ -223,10 +223,42 @@ impl NextcloudPropContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Defense-in-depth cap on attributes per XML element in WebDAV request
|
||||
/// bodies. Legitimate PROPFIND / PROPPATCH elements carry a handful of
|
||||
/// `xmlns:*` declarations and, occasionally, per-property namespace
|
||||
/// bindings — a dozen is already a lot. 100 is generous headroom and
|
||||
/// three orders of magnitude below what an attacker would need to
|
||||
/// exploit a quadratic parser bug (see quick-xml #969, fixed in 0.41;
|
||||
/// this cap fences the same threat model for any future analogous bug
|
||||
/// in whatever parser we swap to).
|
||||
///
|
||||
/// A rejected element yields 400 Bad Request via the ParseError path.
|
||||
pub const MAX_ATTRIBUTES_PER_ELEMENT: usize = 100;
|
||||
|
||||
/// WebDAV adapter for converting between XML and domain objects
|
||||
pub struct WebDavAdapter;
|
||||
|
||||
impl WebDavAdapter {
|
||||
/// Refuse elements carrying an unreasonable attribute count.
|
||||
/// See [`MAX_ATTRIBUTES_PER_ELEMENT`] for the reasoning.
|
||||
///
|
||||
/// `Attributes::count()` is O(N) in the number of attributes (each
|
||||
/// attribute is parsed once), so this check itself is safe even
|
||||
/// against very large elements. The parser may still have paid a
|
||||
/// quadratic cost by the time we get here on a vulnerable version
|
||||
/// of the underlying library — the bump to quick-xml 0.41 closes
|
||||
/// that specific bug; this cap is defense-in-depth against future
|
||||
/// analogous bugs and against adversarially large XML that would
|
||||
/// otherwise reach our downstream code.
|
||||
fn check_attribute_cap(e: &BytesStart) -> Result<()> {
|
||||
if e.attributes().count() > MAX_ATTRIBUTES_PER_ELEMENT {
|
||||
return Err(WebDavError::ParseError(format!(
|
||||
"Element carries more than {MAX_ATTRIBUTES_PER_ELEMENT} attributes"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collect namespace prefix → URI mappings from element attributes.
|
||||
/// E.g. `xmlns:D="DAV:"` maps prefix `"D"` to `"DAV:"`.
|
||||
pub fn collect_ns_decls(
|
||||
@@ -236,11 +268,17 @@ impl WebDavAdapter {
|
||||
for attr in e.attributes().flatten() {
|
||||
let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
|
||||
if let Some(prefix) = key.strip_prefix("xmlns:") {
|
||||
let uri = attr.unescape_value().unwrap_or_default().to_string();
|
||||
let uri = attr
|
||||
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
ns_map.insert(prefix.to_string(), uri);
|
||||
} else if key == "xmlns" {
|
||||
// Default namespace declaration: xmlns="uri"
|
||||
let uri = attr.unescape_value().unwrap_or_default().to_string();
|
||||
let uri = attr
|
||||
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
ns_map.insert(String::new(), uri);
|
||||
}
|
||||
}
|
||||
@@ -252,7 +290,9 @@ impl WebDavAdapter {
|
||||
for attr in e.attributes().flatten() {
|
||||
let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
|
||||
if key.starts_with("xmlns:") {
|
||||
let uri = attr.unescape_value().unwrap_or_default();
|
||||
let uri = attr
|
||||
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
|
||||
.unwrap_or_default();
|
||||
if uri.is_empty() {
|
||||
return Err(WebDavError::ParseError(
|
||||
"Invalid namespace declaration: prefix bound to empty URI".to_string(),
|
||||
@@ -305,6 +345,7 @@ impl WebDavAdapter {
|
||||
loop {
|
||||
match xml_reader.read_event_into(&mut buffer) {
|
||||
Ok(Event::Start(ref e)) => {
|
||||
Self::check_attribute_cap(e)?;
|
||||
Self::collect_ns_decls(e, &mut ns_map);
|
||||
Self::check_ns_decls_valid(e)?;
|
||||
let name = e.name();
|
||||
@@ -343,6 +384,7 @@ impl WebDavAdapter {
|
||||
}
|
||||
}
|
||||
Ok(Event::Empty(ref e)) => {
|
||||
Self::check_attribute_cap(e)?;
|
||||
Self::collect_ns_decls(e, &mut ns_map);
|
||||
Self::check_ns_decls_valid(e)?;
|
||||
let name = e.name();
|
||||
@@ -981,6 +1023,7 @@ impl WebDavAdapter {
|
||||
loop {
|
||||
match xml_reader.read_event_into(&mut buffer) {
|
||||
Ok(Event::Start(ref e)) => {
|
||||
Self::check_attribute_cap(e)?;
|
||||
Self::collect_ns_decls(e, &mut ns_map);
|
||||
let name = e.name();
|
||||
let name_str = std::str::from_utf8(name.as_ref()).unwrap_or("");
|
||||
@@ -1063,6 +1106,7 @@ impl WebDavAdapter {
|
||||
}
|
||||
}
|
||||
Ok(Event::Empty(ref e)) => {
|
||||
Self::check_attribute_cap(e)?;
|
||||
Self::collect_ns_decls(e, &mut ns_map);
|
||||
let name = e.name();
|
||||
let name_str = std::str::from_utf8(name.as_ref()).unwrap_or("");
|
||||
|
||||
@@ -61,16 +61,3 @@ pub struct UpdateAddressBookDto {
|
||||
pub is_public: Option<bool>,
|
||||
pub user_id: String, // Current user making the update
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShareAddressBookDto {
|
||||
pub address_book_id: String,
|
||||
pub user_id: String,
|
||||
pub can_write: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UnshareAddressBookDto {
|
||||
pub address_book_id: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
@@ -55,11 +55,6 @@ pub struct FavoriteItemDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub item_path: Option<String>,
|
||||
|
||||
/// UUID of the file/folder's actual owner (may differ from `user_id` when
|
||||
/// the item was shared and then favourited by another user).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_id: Option<String>,
|
||||
|
||||
// ── Pre-computed display fields ──
|
||||
/// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder")
|
||||
pub icon_class: String,
|
||||
@@ -124,7 +119,6 @@ pub struct FavoriteResourceRow {
|
||||
pub size: i64,
|
||||
pub resource_created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
pub owner_id: Uuid,
|
||||
/// Drive that owns this row. Surfaced on the favorites listing
|
||||
/// so a UI can tell when a favorited item lives in a different
|
||||
/// drive than the user's home (post-D6 cross-drive moves +
|
||||
|
||||
@@ -54,10 +54,6 @@ pub struct FileDto {
|
||||
/// Human-readable formatted size (e.g. "3.27 MB")
|
||||
pub size_formatted: String,
|
||||
|
||||
/// Owner user ID (omitted from JSON when None)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_id: Option<String>,
|
||||
|
||||
/// Sort date for Photos timeline — COALESCE(EXIF captured_at, created_at).
|
||||
/// Only populated by the /api/photos endpoint.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -102,7 +98,7 @@ impl From<File> for FileDto {
|
||||
let content_hash = file.content_hash().to_string();
|
||||
|
||||
// Consume the entity by moving all fields — zero heap allocations
|
||||
// for id, name, path, folder_id, owner_id (previously 5× .to_string()).
|
||||
// for id, name, path, folder_id (previously 4× .to_string()).
|
||||
let parts = file.into_parts();
|
||||
|
||||
let icon_class = Arc::from(icon_class_for(&parts.name, &parts.mime_type));
|
||||
@@ -124,7 +120,6 @@ impl From<File> for FileDto {
|
||||
icon_special_class,
|
||||
category,
|
||||
size_formatted,
|
||||
owner_id: parts.owner_id.map(|u| u.to_string()),
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
etag,
|
||||
@@ -157,9 +152,8 @@ impl FileDto {
|
||||
///
|
||||
/// Used when a file is returned to a share recipient: `path` reveals the
|
||||
/// full folder hierarchy above the file which the recipient may not have
|
||||
/// access to. `folder_id` and `owner_id` are intentionally kept — the
|
||||
/// former is needed for sub-folder navigation (covered by the cascade
|
||||
/// grant), and the latter is harmless metadata.
|
||||
/// access to. `folder_id` is intentionally kept — it's needed for
|
||||
/// sub-folder navigation (covered by the cascade grant).
|
||||
#[must_use]
|
||||
pub fn without_hierarchy_info(self) -> Self {
|
||||
Self {
|
||||
@@ -183,7 +177,6 @@ impl FileDto {
|
||||
icon_special_class: Arc::from(""),
|
||||
category: Arc::from("Document"),
|
||||
size_formatted: "0 Bytes".to_string(),
|
||||
owner_id: None,
|
||||
content_hash: String::new(),
|
||||
etag: String::new(),
|
||||
sort_date: None,
|
||||
|
||||
@@ -48,10 +48,6 @@ pub struct FolderDto {
|
||||
/// Parent folder ID
|
||||
pub parent_id: Option<String>,
|
||||
|
||||
/// Owner user ID (scopes visibility per user)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_id: Option<String>,
|
||||
|
||||
/// Drive that owns this folder. The scope axis for path-based
|
||||
/// lookups across REST / WebDAV / NextCloud / CalDAV / CardDAV.
|
||||
/// Post-D0 `storage.folders.drive_id` is `NOT NULL`; stub /
|
||||
@@ -111,7 +107,6 @@ impl From<Folder> for FolderDto {
|
||||
name: folder.name().to_string(),
|
||||
path: folder.path_string().to_string(),
|
||||
parent_id: folder.parent_id().map(String::from),
|
||||
owner_id: folder.owner_id().map(|u| u.to_string()),
|
||||
drive_id: folder.drive_id(),
|
||||
created_at: folder.created_at(),
|
||||
modified_at: folder.modified_at(),
|
||||
@@ -147,9 +142,8 @@ impl FolderDto {
|
||||
///
|
||||
/// Used when a folder is returned to a share recipient: `path` reveals the
|
||||
/// full folder hierarchy above the shared folder which the recipient may
|
||||
/// not have access to. `parent_id` and `owner_id` are intentionally kept
|
||||
/// — the former is needed for sub-folder navigation (covered by the
|
||||
/// cascade grant), and the latter is harmless metadata.
|
||||
/// not have access to. `parent_id` is intentionally kept — it's needed
|
||||
/// for sub-folder navigation (covered by the cascade grant).
|
||||
#[must_use]
|
||||
pub fn without_hierarchy_info(self) -> Self {
|
||||
Self {
|
||||
@@ -165,7 +159,6 @@ impl FolderDto {
|
||||
name: "stub-folder".to_string(),
|
||||
path: "/stub/path".to_string(),
|
||||
parent_id: None,
|
||||
owner_id: None,
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
@@ -205,7 +198,6 @@ pub struct FolderResourceRow {
|
||||
pub size: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
pub owner_id: Uuid,
|
||||
/// Drive that owns this row. Same column as
|
||||
/// `storage.folders.drive_id` / `storage.files.drive_id`. Surfaced
|
||||
/// on the listing so a UI can tell when a child lives in a
|
||||
|
||||
@@ -55,11 +55,14 @@ impl From<Subject> for SubjectDto {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResourceTypeDto {
|
||||
Folder,
|
||||
File,
|
||||
Drive,
|
||||
Calendar,
|
||||
AddressBook,
|
||||
Playlist,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
@@ -75,6 +78,9 @@ impl From<ResourceDto> for Resource {
|
||||
ResourceTypeDto::Folder => Resource::Folder(dto.id),
|
||||
ResourceTypeDto::File => Resource::File(dto.id),
|
||||
ResourceTypeDto::Drive => Resource::Drive(dto.id),
|
||||
ResourceTypeDto::Calendar => Resource::Calendar(dto.id),
|
||||
ResourceTypeDto::AddressBook => Resource::AddressBook(dto.id),
|
||||
ResourceTypeDto::Playlist => Resource::Playlist(dto.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +91,9 @@ impl From<Resource> for ResourceDto {
|
||||
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
|
||||
Resource::File(id) => (ResourceTypeDto::File, id),
|
||||
Resource::Drive(id) => (ResourceTypeDto::Drive, id),
|
||||
Resource::Calendar(id) => (ResourceTypeDto::Calendar, id),
|
||||
Resource::AddressBook(id) => (ResourceTypeDto::AddressBook, id),
|
||||
Resource::Playlist(id) => (ResourceTypeDto::Playlist, id),
|
||||
};
|
||||
ResourceDto { kind, id }
|
||||
}
|
||||
|
||||
@@ -104,7 +104,6 @@ pub struct RecentResourceRow {
|
||||
pub size: i64,
|
||||
pub resource_created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
pub owner_id: Uuid,
|
||||
/// Drive that owns this row. Surfaced on the recent listing
|
||||
/// so a UI can tell when a recently-accessed item lives in a
|
||||
/// different drive than the user's home (post-D6 cross-drive
|
||||
|
||||
@@ -60,7 +60,6 @@ pub struct TrashResourceRow {
|
||||
pub size: i64,
|
||||
pub resource_created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
pub owner_id: Uuid,
|
||||
/// Drive the trashed item belongs to. Surfaced verbatim on the wire
|
||||
/// (`TrashResourceItemDto.drive_id`) so the `/trash` UI can group by
|
||||
/// drive without an extra lookup per row. D2b: filtering by drive is
|
||||
|
||||
@@ -62,6 +62,9 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
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
|
||||
|
||||
@@ -25,38 +25,11 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
|
||||
&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,
|
||||
@@ -146,33 +119,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,
|
||||
|
||||
@@ -1,16 +1,105 @@
|
||||
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>;
|
||||
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>;
|
||||
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>;
|
||||
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 +126,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 {
|
||||
|
||||
@@ -75,7 +75,12 @@ 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.
|
||||
async fn update_file_streaming_with_perms(
|
||||
&self,
|
||||
path: &str,
|
||||
drive_id: Uuid,
|
||||
@@ -241,23 +246,21 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
.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,
|
||||
_owner_id: Uuid,
|
||||
offset: i64,
|
||||
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, offset, limit).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -155,25 +123,6 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
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?;
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| f.owner_id() == Some(owner_id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Streams every file in the subtree rooted at `folder_id`.
|
||||
///
|
||||
/// Uses an ltree `<@` join against `storage.folders` so the entire
|
||||
@@ -195,7 +144,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,36 +155,39 @@ 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.
|
||||
/// Same drive-membership scoping as `search_files_paginated`.
|
||||
async fn count_files(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<usize, DomainError>;
|
||||
|
||||
/// Return up to `limit` files whose name contains `query` (case-insensitive).
|
||||
@@ -490,9 +445,3 @@ pub trait StorageUsagePort: Send + Sync + 'static {
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), 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>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -6,17 +7,80 @@ use crate::application::dtos::calendar_dto::{
|
||||
CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto,
|
||||
UpdateCalendarDto, UpdateEventDto,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::calendar_ports::{CalendarStoragePort, CalendarUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
/// Calendar service — the CalDAV / REST entry point for every calendar
|
||||
/// or event operation. Every method routes through `AuthorizationEngine`;
|
||||
/// the pre-Round-3 `check_calendar_access` bespoke helper is gone.
|
||||
///
|
||||
/// Ownership + sharing live entirely in `storage.role_grants`
|
||||
/// (`resource_type='calendar'`). `caldav.calendars.owner_id` stays for
|
||||
/// provenance and legacy queries but is no longer consulted for access
|
||||
/// decisions.
|
||||
pub struct CalendarService {
|
||||
calendar_storage: Arc<CalendarStorageAdapter>,
|
||||
/// ReBAC engine — every user-facing method calls `authz.require`
|
||||
/// with the appropriate `Permission`. `create_calendar` also
|
||||
/// uses it to seed an Owner grant for the caller so the common
|
||||
/// "owning my own calendar" case takes a single indexed
|
||||
/// role_grants lookup.
|
||||
authz: Arc<PgAclEngine>,
|
||||
}
|
||||
|
||||
impl CalendarService {
|
||||
pub fn new(calendar_storage: Arc<CalendarStorageAdapter>) -> Self {
|
||||
Self { calendar_storage }
|
||||
pub fn new(calendar_storage: Arc<CalendarStorageAdapter>, authz: Arc<PgAclEngine>) -> Self {
|
||||
Self {
|
||||
calendar_storage,
|
||||
authz,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `calendar_id` and enforce `permission` on `Resource::Calendar(uuid)`.
|
||||
/// On denial `authz.require` returns `NotFound` (anti-enum — same
|
||||
/// shape as "no such calendar") and emits the `authz.denied` audit
|
||||
/// line. Returns the parsed UUID on success so the caller doesn't
|
||||
/// have to parse it a second time.
|
||||
async fn require_calendar_perm(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<Uuid, DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid ID"))?;
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
permission,
|
||||
Resource::Calendar(uuid),
|
||||
)
|
||||
.await?;
|
||||
Ok(uuid)
|
||||
}
|
||||
|
||||
/// Check `permission` on a calendar without throwing. Used by the
|
||||
/// read paths that also allow a public-calendar bypass — they need
|
||||
/// a bool, not a `Result<(), NotFound>`.
|
||||
async fn has_calendar_perm(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid ID"))?;
|
||||
self.authz
|
||||
.check(
|
||||
Subject::User(caller_id),
|
||||
permission,
|
||||
Resource::Calendar(uuid),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +90,30 @@ impl CalendarUseCase for CalendarService {
|
||||
calendar: CreateCalendarDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
self.calendar_storage
|
||||
// No pre-write gate: creating a calendar is a personal act
|
||||
// (like creating a folder in your own drive). Storage stamps
|
||||
// `owner_id = user_id`; we then seed an Owner role_grant so
|
||||
// the engine's cache warms on first-read.
|
||||
let created = self
|
||||
.calendar_storage
|
||||
.create_calendar(calendar, user_id)
|
||||
.await
|
||||
.await?;
|
||||
let calendar_uuid = Uuid::parse_str(&created.id).map_err(|_| {
|
||||
DomainError::internal_error("Calendar", "storage returned invalid calendar id")
|
||||
})?;
|
||||
// `set_role` is idempotent on the `(subject, resource)` unique
|
||||
// key — a re-run (rare — only if storage retried) is a no-op.
|
||||
// `granted_by = user_id` is the self-seeded creation event.
|
||||
self.authz
|
||||
.set_role(
|
||||
user_id,
|
||||
Subject::User(user_id),
|
||||
Role::Owner,
|
||||
Resource::Calendar(calendar_uuid),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
async fn update_calendar(
|
||||
@@ -37,35 +122,28 @@ impl CalendarUseCase for CalendarService {
|
||||
update: UpdateCalendarDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
self.require_calendar_perm(calendar_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to update this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage
|
||||
.update_calendar(calendar_id, update)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_calendar(&self, calendar_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
let uuid = self
|
||||
.require_calendar_perm(calendar_id, user_id, Permission::Delete)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to delete this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.delete_calendar(calendar_id).await
|
||||
self.calendar_storage.delete_calendar(calendar_id).await?;
|
||||
// Wipe every grant on this calendar so a re-used UUID (impossible
|
||||
// today but cheap to defend against) doesn't inherit stale ACLs.
|
||||
// The storage DELETE won't cascade to `storage.role_grants` — the
|
||||
// legacy `caldav.calendar_shares` had an FK, `role_grants`
|
||||
// doesn't (it's cross-schema).
|
||||
let _ = self
|
||||
.authz
|
||||
.revoke_all_for_resource(Resource::Calendar(uuid))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_calendar(
|
||||
@@ -74,28 +152,52 @@ impl CalendarUseCase for CalendarService {
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view this calendar",
|
||||
));
|
||||
// Public-calendar bypass: anonymous-ish read. `check` returns
|
||||
// bool (no throw); combine with the public flag before
|
||||
// deciding.
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
Ok(calendar)
|
||||
}
|
||||
|
||||
async fn list_my_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
self.calendar_storage.list_calendars_by_owner(user_id).await
|
||||
}
|
||||
// Post-Round-3 semantics: every calendar the caller has any
|
||||
// grant on — owned + shared, one union. The pre-Round-3
|
||||
// `list_calendars_by_owner` returned owner-only; shared
|
||||
// calendars never surfaced through this method. See
|
||||
// `docs/plan/caldav-carddav-migration-to-authz.md`.
|
||||
let grants = self
|
||||
.authz
|
||||
.list_incoming_grants(Subject::User(user_id))
|
||||
.await?;
|
||||
|
||||
async fn list_shared_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
self.calendar_storage
|
||||
.list_calendars_shared_with_user(user_id)
|
||||
.await
|
||||
// Deduplicate — a user can hold multiple grants on the same
|
||||
// calendar (direct + group-inherited). We only need one DTO
|
||||
// per resource.
|
||||
let calendar_ids: HashSet<Uuid> = grants
|
||||
.into_iter()
|
||||
.filter_map(|g| match g.resource {
|
||||
Resource::Calendar(id) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Hydrate DTOs. `get_calendar` misses on trashed / deleted
|
||||
// calendars — those are dropped from the listing rather than
|
||||
// erroring, so a lifecycle-race doesn't turn a PROPFIND into
|
||||
// a 5xx.
|
||||
let mut out = Vec::with_capacity(calendar_ids.len());
|
||||
for id in calendar_ids {
|
||||
if let Ok(dto) = self.calendar_storage.get_calendar(&id.to_string()).await {
|
||||
out.push(dto);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn list_public_calendars(
|
||||
@@ -103,6 +205,8 @@ impl CalendarUseCase for CalendarService {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
// No caller gate: public listing by definition. Storage
|
||||
// filters on `is_public = true`.
|
||||
let limit = limit.unwrap_or(100);
|
||||
let offset = offset.unwrap_or(0);
|
||||
self.calendar_storage
|
||||
@@ -110,90 +214,13 @@ impl CalendarUseCase for CalendarService {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
target_user_id: Uuid,
|
||||
access_level: &str,
|
||||
caller_user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if calendar.owner_id != caller_user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can change sharing settings",
|
||||
));
|
||||
}
|
||||
match access_level {
|
||||
"read" | "write" | "owner" => {}
|
||||
_ => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
format!(
|
||||
"Invalid access level: {}. Valid values are: read, write, owner",
|
||||
access_level
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
self.calendar_storage
|
||||
.share_calendar(calendar_id, target_user_id, access_level)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
target_user_id: Uuid,
|
||||
caller_user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if calendar.owner_id != caller_user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can change sharing settings",
|
||||
));
|
||||
}
|
||||
self.calendar_storage
|
||||
.remove_calendar_sharing(calendar_id, target_user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<(String, String)>, DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if calendar.owner_id != user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can view sharing settings",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.get_calendar_shares(calendar_id).await
|
||||
}
|
||||
|
||||
async fn create_event(
|
||||
&self,
|
||||
event: CreateEventDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to add events to this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.create_event(event).await
|
||||
}
|
||||
|
||||
@@ -202,17 +229,8 @@ impl CalendarUseCase for CalendarService {
|
||||
event: CreateEventICalDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to add events to this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.create_event_from_ical(event).await
|
||||
}
|
||||
|
||||
@@ -223,33 +241,15 @@ impl CalendarUseCase for CalendarService {
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to update events in this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.update_event(event_id, update).await
|
||||
}
|
||||
|
||||
async fn delete_event(&self, event_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Delete)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to delete events in this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.delete_event(event_id).await
|
||||
}
|
||||
|
||||
@@ -259,20 +259,17 @@ impl CalendarUseCase for CalendarService {
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self
|
||||
.calendar_storage
|
||||
.get_calendar(&event.calendar_id)
|
||||
.await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
// Same public-calendar bypass as `get_calendar`.
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(&event.calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Event", event_id));
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
@@ -283,17 +280,13 @@ impl CalendarUseCase for CalendarService {
|
||||
ical_uid: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
self.calendar_storage
|
||||
.find_event_by_ical_uid(calendar_id, ical_uid)
|
||||
@@ -306,17 +299,13 @@ impl CalendarUseCase for CalendarService {
|
||||
ical_uids: &[String],
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
if ical_uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -333,17 +322,13 @@ impl CalendarUseCase for CalendarService {
|
||||
offset: Option<i64>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
if limit.is_some() || offset.is_some() {
|
||||
let limit = limit.unwrap_or(100);
|
||||
@@ -365,17 +350,13 @@ impl CalendarUseCase for CalendarService {
|
||||
end: DateTime<Utc>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
self.calendar_storage
|
||||
.get_events_in_time_range(calendar_id, &start, &end)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -249,6 +249,20 @@ impl DriveManagementService {
|
||||
.set_role(caller_id, subject, role, resource, expires_at)
|
||||
.await?;
|
||||
|
||||
// Drop the entire drive-role cache for this drive so the new
|
||||
// grant is visible on the very next `check` — without this, a
|
||||
// caller that gets Owner via `POST /api/drives/{id}/members`
|
||||
// then immediately acts on drive content (WebDAV cross-drive
|
||||
// MOVE, admin-driven cleanup, drive management) hits the
|
||||
// stale "no role for this subject on this drive" entry
|
||||
// seeded at some earlier `check`. TTL rescues eventually,
|
||||
// but the storage_cleanup_check.sh drain pattern hits this
|
||||
// race within a single test-second and fails on `authz.denied`
|
||||
// for admin's cascade to files inside.
|
||||
self.authz
|
||||
.invalidate_drive_role_cache_for_drive(drive_id)
|
||||
.await;
|
||||
|
||||
// D6 §11: canonical `drive.member_added` audit event covers
|
||||
// every successful membership write (add + role-refresh, since
|
||||
// the underlying `set_role` is UPSERT — distinguishing the two
|
||||
@@ -312,6 +326,15 @@ impl DriveManagementService {
|
||||
|
||||
self.authz.clear_role(subject, resource).await?;
|
||||
|
||||
// Mirror of `set_member_role`'s cache invalidation: after
|
||||
// clearing a role we MUST drop the `drive_role_cache` entries
|
||||
// targeting this drive, otherwise the just-removed subject's
|
||||
// former role stays visible until TTL expires. Same anti-drift
|
||||
// reason as the sibling add path above.
|
||||
self.authz
|
||||
.invalidate_drive_role_cache_for_drive(drive_id)
|
||||
.await;
|
||||
|
||||
// D6 §11: canonical `drive.member_removed` audit event covers
|
||||
// every successful removal (owner-driven or admin bypass).
|
||||
// `via_admin` replaces the separate
|
||||
@@ -448,7 +471,7 @@ impl DriveManagementService {
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
partial: crate::domain::entities::drive::DrivePolicies,
|
||||
partial: serde_json::Value,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DomainError> {
|
||||
let merged = self
|
||||
.drive_repo
|
||||
@@ -474,6 +497,8 @@ impl DriveManagementService {
|
||||
forbid_public_links = merged.forbid_public_links,
|
||||
forbid_cross_drive_move = merged.forbid_cross_drive_move,
|
||||
forbid_owner_role_change = merged.forbid_owner_role_change,
|
||||
include_in_photo_index = merged.include_in_photo_index,
|
||||
include_in_music_index = merged.include_in_music_index,
|
||||
"📜 drive policies updated",
|
||||
);
|
||||
Ok(merged)
|
||||
|
||||
@@ -9,10 +9,12 @@ use crate::application::dtos::favorites_dto::{
|
||||
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoriteResourceRow,
|
||||
FavoritesCursor,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
use crate::common::errors::Result;
|
||||
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject};
|
||||
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
/// Implementation of the FavoritesUseCase for managing user favorites.
|
||||
///
|
||||
@@ -20,12 +22,22 @@ use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
||||
/// accessing the database directly, following hexagonal architecture.
|
||||
pub struct FavoritesService {
|
||||
repo: Arc<FavoritesPgRepository>,
|
||||
/// ReBAC engine — enforces `Permission::Read` on the referenced
|
||||
/// file/folder before enrolling it into a user's favorites.
|
||||
/// Without this gate the write path is an information oracle:
|
||||
/// listing endpoints JOIN back to `storage.files/folders` and
|
||||
/// return name/mime/size/drive_id for any UUID the caller was
|
||||
/// able to enroll. See `docs/plan/authz_audit/rest_storage.md`.
|
||||
authorization: Arc<PgAclEngine>,
|
||||
}
|
||||
|
||||
impl FavoritesService {
|
||||
/// Create a new FavoritesService with the given repository port
|
||||
pub fn new(repo: Arc<FavoritesPgRepository>) -> Self {
|
||||
Self { repo }
|
||||
pub fn new(repo: Arc<FavoritesPgRepository>, authorization: Arc<PgAclEngine>) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
authorization,
|
||||
}
|
||||
}
|
||||
|
||||
/// Subset of `(item_id, item_type)` pairs the user has favorited — used to
|
||||
@@ -60,13 +72,15 @@ impl FavoritesUseCase for FavoritesService {
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Favorites",
|
||||
"Item type must be 'file' or 'folder'",
|
||||
));
|
||||
}
|
||||
// AuthZ pre-write: caller must have Read on the referenced
|
||||
// resource. Denial routes through `require` → NotFound
|
||||
// (anti-enum, matches the listing shape) + `authz.denied`
|
||||
// audit line. Without this gate the write path was an
|
||||
// information oracle over the whole tenant.
|
||||
let resource = Resource::parse(item_type, item_id)?;
|
||||
self.authorization
|
||||
.require(Subject::User(user_id), Permission::Read, resource)
|
||||
.await?;
|
||||
|
||||
self.repo.add_favorite(user_id, item_id, item_type).await?;
|
||||
info!(
|
||||
@@ -125,18 +139,17 @@ impl FavoritesUseCase for FavoritesService {
|
||||
user_id
|
||||
);
|
||||
|
||||
// Validate all item types
|
||||
// AuthZ pre-write: caller must have Read on every referenced
|
||||
// resource. Fail the whole batch on the first denial so the
|
||||
// response shape doesn't tell an attacker which items were
|
||||
// valid (partial success would leak the same oracle we
|
||||
// closed on the single-item path). See
|
||||
// `docs/plan/authz_audit/rest_storage.md`.
|
||||
for (item_id, item_type) in items {
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Favorites",
|
||||
format!(
|
||||
"Item type must be 'file' or 'folder' for item '{}'",
|
||||
item_id
|
||||
),
|
||||
));
|
||||
}
|
||||
let resource = Resource::parse(item_type, item_id)?;
|
||||
self.authorization
|
||||
.require(Subject::User(user_id), Permission::Read, resource)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let requested = items.len();
|
||||
|
||||
@@ -43,6 +43,13 @@ pub struct FileManagementService {
|
||||
/// that case the cross-drive move check is skipped (the policy
|
||||
/// is silently off). Production DI wires it in.
|
||||
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
/// Storage-usage service — used to pre-check the destination
|
||||
/// drive's `used_bytes + delta ≤ quota_bytes` invariant on
|
||||
/// cross-drive MOVE, matching the pre-write check the upload path
|
||||
/// already performs. Without it, the check is silently skipped
|
||||
/// (stub/test builders); production DI wires it in.
|
||||
storage_usage:
|
||||
Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
|
||||
}
|
||||
|
||||
impl FileManagementService {
|
||||
@@ -67,6 +74,7 @@ impl FileManagementService {
|
||||
file_lifecycle_hook: None,
|
||||
resource_access_hook: None,
|
||||
drive_repo: None,
|
||||
storage_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +108,18 @@ impl FileManagementService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Wires the storage-usage service so `move_file_with_perms` can
|
||||
/// pre-check the destination drive's quota on cross-drive moves.
|
||||
pub fn with_storage_usage(
|
||||
mut self,
|
||||
storage_usage: Arc<
|
||||
crate::application::services::storage_usage_service::StorageUsageService,
|
||||
>,
|
||||
) -> Self {
|
||||
self.storage_usage = Some(storage_usage);
|
||||
self
|
||||
}
|
||||
|
||||
/// Engine check for a file resource. Parses the id into a `Uuid` and
|
||||
/// requires the specified permission.
|
||||
async fn require_file_perm(
|
||||
@@ -338,12 +358,42 @@ impl FileManagementUseCase for FileManagementService {
|
||||
dst_drive_id,
|
||||
},
|
||||
)?;
|
||||
// Destination drive quota: same pre-write check the
|
||||
// upload path already runs (`file_upload_service.rs`
|
||||
// `check_storage_quota`), applied here so a caller
|
||||
// can't sneak content past the drive cap via MOVE.
|
||||
// Denial → `DomainError::QuotaExceeded` → 507
|
||||
// Insufficient Storage. Skipped when `storage_usage`
|
||||
// isn't wired (stub builders) — same shape as the
|
||||
// upload path's skip semantics.
|
||||
if let Some(storage_usage) = &self.storage_usage
|
||||
&& let Some(size_bytes) = storage_usage.file_bytes(file_uuid).await?
|
||||
&& let Ok(size_u64) = u64::try_from(size_bytes)
|
||||
{
|
||||
storage_usage
|
||||
.check_drive_quota(dst_drive_id, size_u64)
|
||||
.await?;
|
||||
}
|
||||
cross_drive = Some((src_drive_id, dst_drive_id));
|
||||
}
|
||||
}
|
||||
|
||||
let dto = self.move_file(file_id, folder_id, caller_id).await?;
|
||||
|
||||
// Cross-drive move invalidates the file's `owner_cache` entry
|
||||
// in the authz engine — the cache assumed drive_id stability
|
||||
// that no longer holds. Without this call the drive-role
|
||||
// precheck at `check_inner` steers to the (stale) source
|
||||
// drive and legitimate Delete/Update by a destination-drive
|
||||
// role-holder returns 404 for up to the cache TTL.
|
||||
if cross_drive.is_some()
|
||||
&& let Ok(file_uuid) = Uuid::parse_str(file_id)
|
||||
{
|
||||
self.authz
|
||||
.invalidate_owner_cache_for_resource(Resource::File(file_uuid))
|
||||
.await;
|
||||
}
|
||||
|
||||
// D6 §11 audit: emit only when the move actually crossed a
|
||||
// drive boundary. Same-drive moves are too noisy to audit at
|
||||
// info — operators care about the cross-drive case for
|
||||
@@ -375,6 +425,31 @@ impl FileManagementUseCase for FileManagementService {
|
||||
.await?;
|
||||
self.require_target_folder_perm(target_folder_id.as_deref(), Permission::Create, caller_id)
|
||||
.await?;
|
||||
|
||||
// Destination drive quota: COPY creates a new file row that
|
||||
// counts against the destination drive's `used_bytes` even
|
||||
// though blob dedup means no new bytes hit the store. Same
|
||||
// pre-flight shape the delta-upload path already uses.
|
||||
// Skipped when `storage_usage` isn't wired (stub builders) or
|
||||
// `target_folder_id` is None (root namespace — same-drive
|
||||
// semantics inherit the source's cap coverage). Denial →
|
||||
// `QuotaExceeded` → 507.
|
||||
if let (Some(storage_usage), Some(target_folder)) =
|
||||
(&self.storage_usage, target_folder_id.as_deref())
|
||||
{
|
||||
let file_uuid =
|
||||
Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?;
|
||||
let target_folder_uuid = Uuid::parse_str(target_folder)
|
||||
.map_err(|_| DomainError::not_found("Folder", target_folder))?;
|
||||
if let Some(size_bytes) = storage_usage.file_bytes(file_uuid).await?
|
||||
&& let Ok(size_u64) = u64::try_from(size_bytes)
|
||||
{
|
||||
storage_usage
|
||||
.check_drive_quota_by_folder(target_folder_uuid, size_u64)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
self.copy_file(file_id, target_folder_id, new_name.as_deref(), caller_id)
|
||||
.await
|
||||
}
|
||||
@@ -453,6 +528,26 @@ impl FileManagementUseCase for FileManagementService {
|
||||
.await?;
|
||||
self.require_target_folder_perm(target_parent_id.as_deref(), Permission::Create, caller_id)
|
||||
.await?;
|
||||
|
||||
// Destination drive quota: sum the subtree's non-trashed files
|
||||
// and refuse if the destination couldn't hold them. Skipped
|
||||
// when `storage_usage` isn't wired or the target is root
|
||||
// (same rationale as `copy_file_with_perms`).
|
||||
if let (Some(storage_usage), Some(target_parent)) =
|
||||
(&self.storage_usage, target_parent_id.as_deref())
|
||||
{
|
||||
let source_uuid = Uuid::parse_str(source_folder_id)
|
||||
.map_err(|_| DomainError::not_found("Folder", source_folder_id))?;
|
||||
let target_parent_uuid = Uuid::parse_str(target_parent)
|
||||
.map_err(|_| DomainError::not_found("Folder", target_parent))?;
|
||||
let subtree_bytes = storage_usage.folder_subtree_bytes(source_uuid).await?;
|
||||
if let Ok(subtree_u64) = u64::try_from(subtree_bytes) {
|
||||
storage_usage
|
||||
.check_drive_quota_by_folder(target_parent_uuid, subtree_u64)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
self.copy_folder_tree(source_folder_id, target_parent_id, dest_name)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -343,19 +343,17 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
if folder_id.is_some() {
|
||||
// folder id is defined, check permissions
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
self.list_files(folder_id).await
|
||||
} else {
|
||||
// no folder id, get owners's files' root
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_for_owner(folder_id, owner_id)
|
||||
.await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
// Files always have a `folder_id` in the D0+ model — there is no
|
||||
// longer any concept of "root-level files". A `None` from the
|
||||
// caller means the query string was missing `folder_id`; reject
|
||||
// with a clear error rather than returning an empty set from a
|
||||
// meaningless root-level query.
|
||||
if folder_id.is_none() {
|
||||
return Err(DomainError::validation_error("folder_id is required"));
|
||||
}
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
self.list_files(folder_id).await
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
@@ -470,20 +468,21 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
if folder_id.is_some() {
|
||||
// folder id is defined, check permissions
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_batch(folder_id, offset, limit)
|
||||
.await?;
|
||||
return Ok(files.into_iter().map(FileDto::from).collect());
|
||||
}
|
||||
|
||||
// Post-D0: every file lives in a folder — `storage.files.folder_id`
|
||||
// is NOT NULL. `folder_id = None` means the caller is asking for
|
||||
// "root-level files", which by design return an empty set: the
|
||||
// WebDAV synthetic root only lists drive-root folders as
|
||||
// children. Skip the DB round-trip and the pre-D7 owner-fallback
|
||||
// query (which used to hit `_for_owner` and would have driven
|
||||
// the `files.user_id` filter this refactor is retiring).
|
||||
let Some(_) = folder_id else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_batch_for_owner(folder_id, owner_id, offset, limit)
|
||||
.list_files_batch(folder_id, offset, limit)
|
||||
.await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
|
||||
@@ -42,6 +42,16 @@ pub struct FileUploadService {
|
||||
/// `(file_id, blob_hash, content_type)`; the recording side needs the
|
||||
/// `caller_id` the service already has in hand.
|
||||
resource_access_hook: Option<Arc<dyn ResourceAccessHook>>,
|
||||
/// ReBAC engine — enforces `Permission::Update` on
|
||||
/// overwrite-existing and `Permission::Create` on new-file paths
|
||||
/// inside `update_file_streaming_with_perms`. Optional at the
|
||||
/// struct level for the minimal test constructors (`new`,
|
||||
/// `new_with_read`) but the WebDAV/NC/WOPI put paths refuse
|
||||
/// (fail-closed internal error) if this isn't wired. Set by
|
||||
/// either `with_instant_upload` or `with_authorization` — both
|
||||
/// stash the same Arc so DI callers wiring instant upload get
|
||||
/// the streaming gate for free.
|
||||
authorization: Option<Arc<PgAclEngine>>,
|
||||
/// Dependencies of the instant-upload path
|
||||
/// (`create_file_from_owned_blob_with_perms`); `None` in minimal test
|
||||
/// wiring.
|
||||
@@ -66,6 +76,7 @@ impl FileUploadService {
|
||||
content_cache: None,
|
||||
file_lifecycle_hook: None,
|
||||
resource_access_hook: None,
|
||||
authorization: None,
|
||||
instant_upload: None,
|
||||
}
|
||||
}
|
||||
@@ -82,18 +93,34 @@ impl FileUploadService {
|
||||
content_cache: None,
|
||||
file_lifecycle_hook: None,
|
||||
resource_access_hook: None,
|
||||
authorization: None,
|
||||
instant_upload: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires the authorization engine used by
|
||||
/// `update_file_streaming_with_perms` on the WebDAV / NC / WOPI
|
||||
/// PUT path. Independent of `with_instant_upload` so callers can
|
||||
/// enable the streaming gate without also opting into the
|
||||
/// dedup-instant-upload check (test wiring, minimal deployments).
|
||||
pub fn with_authorization(mut self, authz: Arc<PgAclEngine>) -> Self {
|
||||
self.authorization = Some(authz);
|
||||
self
|
||||
}
|
||||
|
||||
/// Wires the authorization engine, dedup index and quota service that
|
||||
/// power the instant-upload path.
|
||||
///
|
||||
/// Also stashes the `authz` handle in `self.authorization` so
|
||||
/// DI callers wiring instant upload get the streaming-put gate
|
||||
/// for free — a single `Arc` clone, no behavioural coupling.
|
||||
pub fn with_instant_upload(
|
||||
mut self,
|
||||
authz: Arc<PgAclEngine>,
|
||||
dedup: Arc<DedupService>,
|
||||
quota: Arc<StorageUsageService>,
|
||||
) -> Self {
|
||||
self.authorization = Some(authz.clone());
|
||||
self.instant_upload = Some(InstantUploadDeps {
|
||||
authz,
|
||||
dedup,
|
||||
@@ -296,7 +323,6 @@ impl FileUploadService {
|
||||
parts.folder_id,
|
||||
parts.created_at,
|
||||
updated_at as u64,
|
||||
parts.owner_id,
|
||||
new_hash,
|
||||
)
|
||||
.map_err(|e| DomainError::internal_error("FileUpload", format!("rebuild entity: {e}")))?;
|
||||
@@ -317,19 +343,22 @@ impl FileUploadService {
|
||||
/// Incremental (`+size`, O(1)) and fire-and-forget on a background task, so
|
||||
/// it adds neither latency nor a `SUM(size)` over the user's whole library
|
||||
/// to the upload path (the previous full recompute was O(N) per upload,
|
||||
/// O(N²) for a bulk upload). Keyed by the file's `owner_id`; drift — e.g.
|
||||
/// deletes, which don't decrement — is reconciled by the periodic sweep. A
|
||||
/// DTO without a resolvable owner is simply left to that sweep.
|
||||
fn maybe_update_storage_usage(&self, file: &FileDto) {
|
||||
/// O(N²) for a bulk upload). Drift — e.g. deletes, which don't decrement —
|
||||
/// is reconciled by the periodic sweep.
|
||||
///
|
||||
/// Post-D7: `file.owner_id` is now nullable and unpopulated on new
|
||||
/// rows, so the envelope owner comes from `caller_id` (the user who
|
||||
/// just did the upload). The user-side delta is guarded by
|
||||
/// `add_user_storage_usage_delta_if_personal` — it only fires when
|
||||
/// the target drive is `kind='personal'`, so a shared-drive upload
|
||||
/// still doesn't touch any user envelope.
|
||||
fn maybe_update_storage_usage(&self, file: &FileDto, caller_id: Uuid) {
|
||||
let Some(storage_service) = &self.storage_usage_service else {
|
||||
return;
|
||||
};
|
||||
let delta = file.size as i64;
|
||||
|
||||
let owner = file
|
||||
.owner_id
|
||||
.as_deref()
|
||||
.and_then(|s| Uuid::parse_str(s).ok());
|
||||
let owner = Some(caller_id);
|
||||
let folder = file
|
||||
.folder_id
|
||||
.as_deref()
|
||||
@@ -410,7 +439,7 @@ impl FileUploadUseCase for FileUploadService {
|
||||
"📡 STREAMING UPLOAD: {} ({} bytes, ID: {})",
|
||||
name, blob.size, dto.id
|
||||
);
|
||||
self.maybe_update_storage_usage(&dto);
|
||||
self.maybe_update_storage_usage(&dto, caller_id);
|
||||
if let Some(hook) = &self.file_lifecycle_hook {
|
||||
hook.on_file_created(&dto.id, &dto.content_hash, &dto.mime_type, blob.is_new_blob);
|
||||
}
|
||||
@@ -422,7 +451,16 @@ impl FileUploadUseCase for FileUploadService {
|
||||
|
||||
/// Swap the content of the file at `path` to an already-ingested blob,
|
||||
/// creating the file when it doesn't exist (WebDAV/NextCloud/WOPI PUT).
|
||||
async fn update_file_streaming(
|
||||
///
|
||||
/// AuthZ (post-Drive audit Round 2 fix): overwrite path requires
|
||||
/// `Update` on the target file; new-file path requires `Create`
|
||||
/// on the parent folder (or on the drive when writing at drive
|
||||
/// root). Fail-closed if the engine wasn't wired — this method
|
||||
/// is the last line of defence between a Viewer/Commenter drive
|
||||
/// member and cross-tenant PUT. See
|
||||
/// `docs/plan/authz_audit/nextcloud.md` and the sibling native
|
||||
/// `/webdav/*` handler.
|
||||
async fn update_file_streaming_with_perms(
|
||||
&self,
|
||||
path: &str,
|
||||
drive_id: Uuid,
|
||||
@@ -431,10 +469,33 @@ impl FileUploadUseCase for FileUploadService {
|
||||
modified_at: Option<i64>,
|
||||
caller_id: Uuid,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
let Some(authz) = &self.authorization else {
|
||||
return Err(DomainError::internal_error(
|
||||
"FileUpload",
|
||||
"update_file_streaming_with_perms called without authorization engine wired",
|
||||
));
|
||||
};
|
||||
|
||||
// Try to find the existing file first
|
||||
if let Some(file_read) = &self.file_read
|
||||
&& let Some(file) = file_read.find_file_by_path(path, drive_id).await?
|
||||
{
|
||||
// Overwrite branch — caller must have `Update` on the
|
||||
// target file. Denial routes through `require` → 404
|
||||
// (anti-enum, matches read-side shape). Before the D7
|
||||
// audit this whole branch ran unchecked; Viewer members
|
||||
// of shared drives could PUT freely.
|
||||
let file_uuid = Uuid::parse_str(file.id()).map_err(|_| {
|
||||
DomainError::internal_error("FileUpload", "invalid file id from repository")
|
||||
})?;
|
||||
authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Update,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let file_id = file.id().to_string();
|
||||
let (new_hash, updated_at) = self
|
||||
.file_write
|
||||
@@ -464,7 +525,6 @@ impl FileUploadUseCase for FileUploadService {
|
||||
parts.folder_id,
|
||||
parts.created_at,
|
||||
updated_at as u64,
|
||||
parts.owner_id,
|
||||
new_hash,
|
||||
)
|
||||
.map_err(|e| {
|
||||
@@ -504,6 +564,32 @@ impl FileUploadUseCase for FileUploadService {
|
||||
None
|
||||
};
|
||||
|
||||
// Create branch — caller must have `Create` on the parent
|
||||
// scope. Two cases:
|
||||
// * `parent_id.is_some()` → caller needs Create on the
|
||||
// parent Folder resource.
|
||||
// * `parent_id.is_none()` → the write lands at the drive
|
||||
// root (either the path was single-segment, or the
|
||||
// parent-folder lookup failed). We require Create on
|
||||
// the Drive itself — bundled with owner/editor/contributor
|
||||
// role_grants, refused for viewer/commenter.
|
||||
let create_resource = match &parent_id {
|
||||
Some(pid) => {
|
||||
let uuid = Uuid::parse_str(pid).map_err(|_| {
|
||||
DomainError::internal_error("FileUpload", "invalid parent folder id")
|
||||
})?;
|
||||
Resource::Folder(uuid)
|
||||
}
|
||||
None => Resource::Drive(drive_id),
|
||||
};
|
||||
authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Create,
|
||||
create_resource,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let is_new_blob = blob.is_new_blob;
|
||||
let created = self
|
||||
.file_write
|
||||
|
||||
@@ -31,6 +31,11 @@ pub struct FolderService {
|
||||
/// that case the cross-drive move check is skipped (the policy is
|
||||
/// silently off). Production DI wires it via `with_drive_repo`.
|
||||
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
/// Storage-usage service — used to pre-check the destination
|
||||
/// drive's `used_bytes + subtree_bytes ≤ quota_bytes` invariant
|
||||
/// on cross-drive MOVE. Silently skipped when unwired (stubs).
|
||||
storage_usage:
|
||||
Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
|
||||
}
|
||||
|
||||
impl FolderService {
|
||||
@@ -45,6 +50,7 @@ impl FolderService {
|
||||
authz,
|
||||
file_lifecycle,
|
||||
drive_repo: None,
|
||||
storage_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +66,19 @@ impl FolderService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Wires the storage-usage service so `move_folder_with_perms`
|
||||
/// can pre-check the destination drive's quota on cross-drive
|
||||
/// folder moves.
|
||||
pub fn with_storage_usage(
|
||||
mut self,
|
||||
storage_usage: Arc<
|
||||
crate::application::services::storage_usage_service::StorageUsageService,
|
||||
>,
|
||||
) -> Self {
|
||||
self.storage_usage = Some(storage_usage);
|
||||
self
|
||||
}
|
||||
|
||||
/// Batch counterpart of `get_folder`: resolve many folder ids in ONE
|
||||
/// query instead of one per id. Like `get_folder` it performs no
|
||||
/// per-folder authorization — both current callers (ACL grant listing,
|
||||
@@ -358,18 +377,18 @@ impl FolderUseCase for FolderService {
|
||||
.await?;
|
||||
return self.list_folders(parent_id).await;
|
||||
}
|
||||
// No parent → list the user's root folders.
|
||||
// No parent → list the caller's readable root folders. The
|
||||
// predicate scopes by drive-membership grants (post-PR-B),
|
||||
// closing the pre-D7 gap where the legacy `user_id` filter
|
||||
// surfaced admin-created folders that admin had no role on.
|
||||
let folders = self
|
||||
.folder_storage
|
||||
.list_folders_by_owner(parent_id, caller_id)
|
||||
.list_root_folders_for_caller(caller_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!(
|
||||
"Failed to list folders for owner '{}' in parent {:?}: {}",
|
||||
caller_id, parent_id, e
|
||||
),
|
||||
format!("Failed to list root folders for caller '{caller_id}': {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(folders.into_iter().map(FolderDto::from).collect())
|
||||
@@ -431,24 +450,23 @@ impl FolderUseCase for FolderService {
|
||||
return self.list_folders_paginated(parent_id, &pagination).await;
|
||||
} else {
|
||||
let (folders, total_items) = self
|
||||
.folder_storage
|
||||
.list_folders_by_owner_paginated(
|
||||
parent_id,
|
||||
owner_id,
|
||||
pagination.offset(),
|
||||
pagination.limit(),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!(
|
||||
"Failed to list folders for owner '{}' with pagination in parent {:?}: {}",
|
||||
owner_id, parent_id, e
|
||||
),
|
||||
.folder_storage
|
||||
.list_root_folders_for_caller_paginated(
|
||||
owner_id,
|
||||
pagination.offset(),
|
||||
pagination.limit(),
|
||||
true,
|
||||
)
|
||||
})?;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!(
|
||||
"Failed to list root folders for caller '{}' with pagination: {}",
|
||||
owner_id, e
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let total = total_items.unwrap_or(folders.len());
|
||||
|
||||
@@ -594,6 +612,19 @@ impl FolderUseCase for FolderService {
|
||||
dst_drive_id,
|
||||
},
|
||||
)?;
|
||||
// Destination drive quota: sum the moved subtree's
|
||||
// non-trashed files and refuse if the destination
|
||||
// couldn't hold them. Same 507 shape as the file
|
||||
// path + upload path — DomainError::QuotaExceeded
|
||||
// maps at the AppError boundary.
|
||||
if let Some(storage_usage) = &self.storage_usage {
|
||||
let subtree_bytes = storage_usage.folder_subtree_bytes(src_folder_uuid).await?;
|
||||
if let Ok(subtree_u64) = u64::try_from(subtree_bytes) {
|
||||
storage_usage
|
||||
.check_drive_quota(dst_drive_id, subtree_u64)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
cross_drive = Some((src_drive_id, dst_drive_id));
|
||||
}
|
||||
}
|
||||
@@ -610,6 +641,17 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Cross-drive move flushes the authz engine's `owner_cache`
|
||||
// — every descendant's cached `Resource → drive_id` mapping
|
||||
// just got stale via the cascade trigger, and we don't (yet)
|
||||
// walk the subtree to invalidate individually. Small perf
|
||||
// cost (single JOIN per resource touched over the next
|
||||
// minute) versus a stale-authz bug where destination-drive
|
||||
// Owner cascades don't apply to moved content.
|
||||
if cross_drive.is_some() {
|
||||
self.authz.invalidate_owner_cache_all().await;
|
||||
}
|
||||
|
||||
// D6 audit: only emit when the move crossed a drive boundary.
|
||||
// The cascade trigger has already propagated drive_id to the
|
||||
// subtree at this point (see migration
|
||||
@@ -1082,17 +1124,18 @@ mod cascade_hook_integration_tests {
|
||||
let blob_hash = blake3::hash(format!("cascade-{label}-{}", Uuid::new_v4()).as_bytes())
|
||||
.to_hex()
|
||||
.to_string();
|
||||
// Post-D7: `user_id` omitted — the column is nullable and
|
||||
// provenance flows through `created_by` / `updated_by`.
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO storage.files
|
||||
(name, user_id, drive_id, folder_id, blob_hash, size, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
||||
(name, drive_id, folder_id, blob_hash, size, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(format!(
|
||||
"rust-test-cascade-{label}-{}",
|
||||
&Uuid::new_v4().to_string()[..8]
|
||||
))
|
||||
.bind(user_id)
|
||||
.bind(drive_id)
|
||||
.bind(folder_id)
|
||||
.bind(&blob_hash)
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
//! Tests for IDOR (Insecure Direct Object Reference) protection.
|
||||
//!
|
||||
//! Verifies that ownership checks at the repository and service layers
|
||||
//! correctly reject access when the caller is not the file owner.
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Mock repositories
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// A simple in-memory mock that maps (file_id → (File, owner_id)).
|
||||
struct MockFileReadPort {
|
||||
/// file_id → (File, owner_id)
|
||||
files: Mutex<HashMap<String, (File, Uuid)>>,
|
||||
}
|
||||
|
||||
impl MockFileReadPort {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
files: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a test file owned by `owner_id`.
|
||||
fn insert(&self, id: &str, name: &str, owner_id: Uuid) {
|
||||
let file = File::new(
|
||||
id.to_string(),
|
||||
name.to_string(),
|
||||
StoragePath::from_string(&format!("/{}", name)),
|
||||
42,
|
||||
"text/plain".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(id.to_string(), (file, owner_id));
|
||||
}
|
||||
}
|
||||
|
||||
impl FileReadPort for MockFileReadPort {
|
||||
async fn get_file(&self, id: &str) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(id)
|
||||
.map(|(f, _)| f.clone())
|
||||
.ok_or_else(|| DomainError::not_found("File", id.to_string()))
|
||||
}
|
||||
|
||||
async fn get_file_or_trashed(&self, id: &str) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(id)
|
||||
.map(|(f, _)| f.clone())
|
||||
.ok_or_else(|| DomainError::not_found("File", id.to_string()))
|
||||
}
|
||||
|
||||
async fn get_file_for_owner(&self, id: &str, owner_id: Uuid) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
match files.get(id) {
|
||||
Some((file, actual_owner)) if *actual_owner == owner_id => Ok(file.clone()),
|
||||
// Return NotFound regardless — do not leak existence
|
||||
_ => Err(DomainError::not_found("File", id.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
_start: u64,
|
||||
_end: Option<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_path(&self, _id: &str) -> Result<StoragePath, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(
|
||||
&self,
|
||||
_path: &str,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> {
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
async fn search_files_paginated(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> Result<(Vec<File>, usize), DomainError> {
|
||||
Ok((Vec::new(), 0))
|
||||
}
|
||||
|
||||
async fn count_files(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn get_folder_id_by_path(
|
||||
&self,
|
||||
_folder_path: &str,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn stream_files_in_subtree(
|
||||
&self,
|
||||
_folder_id: &str,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<File, DomainError>> + Send>>, DomainError> {
|
||||
Ok(Box::pin(futures::stream::empty()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal mock write port — only `move_file` and `rename_file` need real logic.
|
||||
#[allow(dead_code)]
|
||||
struct MockFileWritePort {
|
||||
files: Mutex<HashMap<String, File>>,
|
||||
}
|
||||
|
||||
impl MockFileWritePort {
|
||||
#[allow(dead_code)]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
files: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn insert(&self, id: &str, name: &str) {
|
||||
let file = File::new(
|
||||
id.to_string(),
|
||||
name.to_string(),
|
||||
StoragePath::from_string(&format!("/{}", name)),
|
||||
42,
|
||||
"text/plain".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
self.files.lock().unwrap().insert(id.to_string(), file);
|
||||
}
|
||||
}
|
||||
|
||||
impl FileWritePort for MockFileWritePort {
|
||||
async fn save_file_with_blob(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_blob_hash: &str,
|
||||
_size: u64,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
_target_folder_id: Option<String>,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(file_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id.to_string()))
|
||||
}
|
||||
|
||||
async fn rename_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
_new_name: &str,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(file_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id.to_string()))
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_file_content_with_blob(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_blob_hash: &str,
|
||||
_size: u64,
|
||||
_modified_at: Option<i64>,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<(String, i64), DomainError> {
|
||||
Ok((String::new(), 0))
|
||||
}
|
||||
|
||||
async fn register_file_deferred(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_size: u64,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<(File, PathBuf), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn copy_file(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_target_folder_id: Option<String>,
|
||||
_new_name: Option<&str>,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_to_trash(&self, _file_id: &str, _caller_id: Uuid) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_from_trash(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_original_path: &str,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_file_permanently(&self, _file_id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests — FileReadPort::get_file_for_owner (Repository layer, Solution C)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_for_owner_returns_file_for_correct_owner() {
|
||||
let alice_id = Uuid::new_v4();
|
||||
let repo = MockFileReadPort::new();
|
||||
repo.insert("file-1", "secret.txt", alice_id);
|
||||
|
||||
let result = repo.get_file_for_owner("file-1", alice_id).await;
|
||||
assert!(result.is_ok(), "owner should be able to read own file");
|
||||
assert_eq!(result.unwrap().id(), "file-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_for_owner_rejects_wrong_owner() {
|
||||
let alice_id = Uuid::new_v4();
|
||||
let bob_id = Uuid::new_v4();
|
||||
let repo = MockFileReadPort::new();
|
||||
repo.insert("file-1", "secret.txt", alice_id);
|
||||
|
||||
let result = repo.get_file_for_owner("file-1", bob_id).await;
|
||||
assert!(result.is_err(), "non-owner should be rejected");
|
||||
|
||||
// Must be NotFound, NOT Forbidden — avoids leaking existence
|
||||
let err = result.unwrap_err();
|
||||
let msg = format!("{}", err);
|
||||
assert!(
|
||||
msg.contains("not found") || msg.contains("NotFound"),
|
||||
"error must be NotFound, got: {}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_for_owner_returns_not_found_for_missing_file() {
|
||||
let alice_id = Uuid::new_v4();
|
||||
let repo = MockFileReadPort::new();
|
||||
|
||||
let result = repo.get_file_for_owner("nonexistent", alice_id).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_file_owner_uses_default_impl() {
|
||||
let alice_id = Uuid::new_v4();
|
||||
let bob_id = Uuid::new_v4();
|
||||
let repo = MockFileReadPort::new();
|
||||
repo.insert("file-1", "secret.txt", alice_id);
|
||||
|
||||
// Default impl delegates to get_file_for_owner and maps to ()
|
||||
assert!(repo.verify_file_owner("file-1", alice_id).await.is_ok());
|
||||
assert!(repo.verify_file_owner("file-1", bob_id).await.is_err());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests — FileManagementService _owned methods (Service layer, Solution B)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Note: FileManagementService::with_trash takes concrete types for the write
|
||||
// repository (Arc<FileBlobWriteRepository>). We cannot construct real PG repos
|
||||
// without a database. Instead, we test the verify_owner logic indirectly by
|
||||
// testing the mock-based trait interactions at the port level, and document
|
||||
// that integration tests hitting the real DB are the ultimate verification.
|
||||
//
|
||||
// The tests below verify the *contract*: _owned methods must call
|
||||
// verify_owner before delegating, and verify_owner must fail-closed when
|
||||
// no read repo is available.
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_file_owner_delegates_to_read_port() {
|
||||
// This test verifies the FileReadPort contract that verify_file_owner
|
||||
// returns Ok for the correct owner and Err for others.
|
||||
let user_id = Uuid::new_v4();
|
||||
let attacker_id = Uuid::new_v4();
|
||||
let read = MockFileReadPort::new();
|
||||
read.insert("abc-123", "report.pdf", user_id);
|
||||
|
||||
// Same user → Ok
|
||||
let ok = read.verify_file_owner("abc-123", user_id).await;
|
||||
assert!(ok.is_ok(), "correct owner should pass verify_file_owner");
|
||||
|
||||
// Different user → Err
|
||||
let err = read.verify_file_owner("abc-123", attacker_id).await;
|
||||
assert!(err.is_err(), "wrong owner should fail verify_file_owner");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owned_methods_require_ownership_check_first() {
|
||||
// Simulate what the _owned methods do: verify_owner then delegate.
|
||||
// We test with the mock read port to prove the sequence.
|
||||
let owner_id = Uuid::new_v4();
|
||||
let attacker_id = Uuid::new_v4();
|
||||
let read = MockFileReadPort::new();
|
||||
read.insert("file-1", "data.csv", owner_id);
|
||||
|
||||
// Step 1: verify_owner for correct owner → Ok
|
||||
let step1 = read.verify_file_owner("file-1", owner_id).await;
|
||||
assert!(step1.is_ok());
|
||||
|
||||
// Step 2: verify_owner for attacker → Err, so the move/rename never executes
|
||||
let step2 = read.verify_file_owner("file-1", attacker_id).await;
|
||||
assert!(step2.is_err());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests — Trait-level _owned method stubs (StubFileManagementUseCase)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
use crate::application::ports::file_ports::FileManagementUseCase;
|
||||
use crate::common::stubs::StubFileManagementUseCase;
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_move_file_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileManagementUseCase;
|
||||
let result = stub
|
||||
.move_file_with_perms("file-1", user_id, Some("folder-2".to_string()))
|
||||
.await;
|
||||
assert!(result.is_ok(), "stub should return Ok for move_file_owned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_rename_file_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileManagementUseCase;
|
||||
let result = stub
|
||||
.rename_file_with_perms("file-1", user_id, "new-name.txt")
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"stub should return Ok for rename_file_owned"
|
||||
);
|
||||
}
|
||||
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
use crate::common::stubs::StubFileRetrievalUseCase;
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_get_file_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileRetrievalUseCase;
|
||||
let result = stub.get_file_with_perms("file-1", user_id).await;
|
||||
assert!(result.is_ok(), "stub should return Ok for get_file_owned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_get_file_optimized_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileRetrievalUseCase;
|
||||
let result = stub
|
||||
.get_file_optimized_with_perms("file-1", user_id, true, false)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"stub should return Ok for get_file_optimized_owned"
|
||||
);
|
||||
}
|
||||
@@ -307,20 +307,30 @@ impl MagicLinkInviteService {
|
||||
let (kind, resource_id) = match resource {
|
||||
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
|
||||
Resource::File(id) => (MagicLinkResourceKind::File, id),
|
||||
// Drive sharing — and therefore drive magic-link invitations —
|
||||
// land in D2. The grant DTOs accept `Resource::Drive` from the
|
||||
// wire today (see ResourceTypeDto) but no public API path
|
||||
// actually grants on a drive in D0, so this arm is
|
||||
// defensively unreachable. Treating it as an audit-logged
|
||||
// no-op (grant is in place, mail suppressed) matches the
|
||||
// ineligible-recipient branch above.
|
||||
Resource::Drive(_) => {
|
||||
// Drive / Calendar / AddressBook / Playlist sharing is
|
||||
// out-of-band for the magic-link flow. Drive shares land
|
||||
// through `/api/drives/{id}/members`; Calendar /
|
||||
// AddressBook shares through the Round-3
|
||||
// `/api/(calendars|address-books)/{id}/shares` endpoints;
|
||||
// Playlist shares through `/api/playlists/{id}/share`.
|
||||
// The DTOs accept every `Resource` variant on the wire
|
||||
// (see `ResourceTypeDto`) but only file/folder grants
|
||||
// trigger an invitation email. Treating the other arms
|
||||
// as audit-logged suppressed no-ops keeps the grant in
|
||||
// place while matching the ineligible-recipient branch
|
||||
// above.
|
||||
Resource::Drive(_)
|
||||
| Resource::Calendar(_)
|
||||
| Resource::AddressBook(_)
|
||||
| Resource::Playlist(_) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.invitation_suppressed",
|
||||
reason = "drive_resource_unsupported",
|
||||
reason = "resource_kind_unsupported",
|
||||
user_id = %recipient.id(),
|
||||
"📭 magic-link invitation suppressed: drive resources aren't invitable until D2",
|
||||
resource_kind = %resource.type_str(),
|
||||
"📭 magic-link invitation suppressed: {} resources aren't invitable via email",
|
||||
resource.type_str(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -347,10 +357,13 @@ impl MagicLinkInviteService {
|
||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||
// Unreachable — the early-return above exits before we get
|
||||
// here for a Drive resource. The arm exists only to satisfy
|
||||
// exhaustiveness; if you find this firing, the early-return
|
||||
// was bypassed.
|
||||
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
||||
// here for Drive / Calendar / AddressBook / Playlist
|
||||
// resources. The arms exist only to satisfy exhaustiveness;
|
||||
// if you find any firing, the early-return was bypassed.
|
||||
Resource::Drive(_)
|
||||
| Resource::Calendar(_)
|
||||
| Resource::AddressBook(_)
|
||||
| Resource::Playlist(_) => "server.magic_link.email.kind_folder",
|
||||
};
|
||||
// PR C: render in the recipient's preferred locale (set by UI
|
||||
// switcher, OIDC JIT claim, or inviter inheritance at row
|
||||
|
||||
@@ -39,8 +39,6 @@ pub mod wopi_token_service;
|
||||
#[cfg(test)]
|
||||
mod batch_operations_test;
|
||||
#[cfg(test)]
|
||||
mod idor_protection_test;
|
||||
#[cfg(test)]
|
||||
mod trash_service_test;
|
||||
|
||||
// Re-exportar para facilitar acceso
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -5,17 +6,80 @@ use crate::application::dtos::playlist_dto::{
|
||||
AddTracksDto, AudioMetadataDto, CreatePlaylistDto, PlaylistDto, PlaylistItemDto,
|
||||
PlaylistQueryDto, PlaylistShareInfoDto, ReorderTracksDto, SharePlaylistDto, UpdatePlaylistDto,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::music_ports::{MusicStoragePort, MusicUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::adapters::music_storage_adapter::MusicStorageAdapter;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
/// Music service — the REST entry point for every playlist or audio
|
||||
/// metadata operation. Every method routes through
|
||||
/// `AuthorizationEngine`; the pre-Round-3 `user_has_access` /
|
||||
/// `user_can_write` bespoke helpers on `MusicStorageAdapter` are no
|
||||
/// longer consulted for access decisions.
|
||||
///
|
||||
/// Ownership + sharing live entirely in `storage.role_grants`
|
||||
/// (`resource_type='playlist'`). `audio.playlists.owner_id` stays for
|
||||
/// provenance and legacy queries; `audio.playlist_shares` is
|
||||
/// backfilled and slated for removal in a follow-up migration.
|
||||
pub struct MusicService {
|
||||
storage: Arc<MusicStorageAdapter>,
|
||||
/// ReBAC engine — every user-facing method calls `authz.require`
|
||||
/// with the appropriate `Permission`. `create_playlist` also uses
|
||||
/// it to seed an Owner grant for the caller, so the common
|
||||
/// "owning my own playlist" case takes a single indexed
|
||||
/// role_grants lookup on subsequent reads.
|
||||
authz: Arc<PgAclEngine>,
|
||||
}
|
||||
|
||||
impl MusicService {
|
||||
pub fn new(storage: Arc<MusicStorageAdapter>) -> Self {
|
||||
Self { storage }
|
||||
pub fn new(storage: Arc<MusicStorageAdapter>, authz: Arc<PgAclEngine>) -> Self {
|
||||
Self { storage, authz }
|
||||
}
|
||||
|
||||
/// Parse `playlist_id` and enforce `permission` on
|
||||
/// `Resource::Playlist(uuid)`. On denial `authz.require` returns
|
||||
/// `NotFound` (anti-enum — same shape as "no such playlist") and
|
||||
/// emits the `authz.denied` audit line. Returns the parsed UUID
|
||||
/// on success so the caller doesn't have to parse it a second
|
||||
/// time.
|
||||
async fn require_playlist_perm(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<Uuid, DomainError> {
|
||||
let uuid = Uuid::parse_str(playlist_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid ID"))?;
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
permission,
|
||||
Resource::Playlist(uuid),
|
||||
)
|
||||
.await?;
|
||||
Ok(uuid)
|
||||
}
|
||||
|
||||
/// Check `permission` on a playlist without throwing. Used by the
|
||||
/// read paths that also allow a public-playlist bypass — they
|
||||
/// need a bool, not a `Result<(), NotFound>`.
|
||||
async fn has_playlist_perm(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uuid = Uuid::parse_str(playlist_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid ID"))?;
|
||||
self.authz
|
||||
.check(
|
||||
Subject::User(caller_id),
|
||||
permission,
|
||||
Resource::Playlist(uuid),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +89,26 @@ impl MusicUseCase for MusicService {
|
||||
dto: CreatePlaylistDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<PlaylistDto, DomainError> {
|
||||
self.storage.create_playlist(dto, user_id).await
|
||||
// No pre-write gate: creating a playlist is a personal act.
|
||||
// Storage stamps `owner_id = user_id`; we then seed an Owner
|
||||
// role_grant so subsequent reads hit the same
|
||||
// `storage.role_grants` fast path used everywhere else.
|
||||
let created = self.storage.create_playlist(dto, user_id).await?;
|
||||
let playlist_uuid = Uuid::parse_str(&created.id).map_err(|_| {
|
||||
DomainError::internal_error("Playlist", "storage returned invalid playlist id")
|
||||
})?;
|
||||
// `set_role` is idempotent on the `(subject, resource)` unique
|
||||
// key. `granted_by = user_id` is the self-seeded creation event.
|
||||
self.authz
|
||||
.set_role(
|
||||
user_id,
|
||||
Subject::User(user_id),
|
||||
Role::Owner,
|
||||
Resource::Playlist(playlist_uuid),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
async fn update_playlist(
|
||||
@@ -34,45 +117,25 @@ impl MusicUseCase for MusicService {
|
||||
dto: UpdatePlaylistDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<PlaylistDto, DomainError> {
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to update this playlist",
|
||||
));
|
||||
}
|
||||
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
||||
if !can_write {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You need write access to update this playlist",
|
||||
));
|
||||
}
|
||||
self.require_playlist_perm(playlist_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
self.storage.update_playlist(playlist_id, dto).await
|
||||
}
|
||||
|
||||
async fn delete_playlist(&self, playlist_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
));
|
||||
}
|
||||
};
|
||||
if playlist.owner_id != user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"Only the owner can delete this playlist",
|
||||
));
|
||||
}
|
||||
self.storage.delete_playlist(playlist_id).await
|
||||
let uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Delete)
|
||||
.await?;
|
||||
self.storage.delete_playlist(playlist_id).await?;
|
||||
// Wipe every grant on this playlist so a re-used UUID
|
||||
// (impossible today but cheap to defend against) doesn't
|
||||
// inherit stale ACLs. The storage DELETE won't cascade to
|
||||
// `storage.role_grants` — it's cross-schema.
|
||||
let _ = self
|
||||
.authz
|
||||
.revoke_all_for_resource(Resource::Playlist(uuid))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_playlist(
|
||||
@@ -80,23 +143,22 @@ impl MusicUseCase for MusicService {
|
||||
playlist_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<PlaylistDto, DomainError> {
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to view this playlist",
|
||||
));
|
||||
}
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
match playlist {
|
||||
Some(p) => Ok(p),
|
||||
None => Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
)),
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => return Err(DomainError::not_found("Playlist", playlist_id)),
|
||||
};
|
||||
// Public-playlist bypass: anonymous-ish read. `check` returns
|
||||
// bool (no throw); combine with the public flag before
|
||||
// deciding.
|
||||
let allowed = playlist.is_public
|
||||
|| self
|
||||
.has_playlist_perm(playlist_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Playlist", playlist_id));
|
||||
}
|
||||
Ok(playlist)
|
||||
}
|
||||
|
||||
async fn list_playlists(
|
||||
@@ -109,17 +171,38 @@ impl MusicUseCase for MusicService {
|
||||
let limit = query.limit.unwrap_or(100);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let mut playlists = Vec::new();
|
||||
// Post-Round-3 semantics: playlists the caller has any grant
|
||||
// on come from `list_incoming_grants` — one union of owned +
|
||||
// shared. The pre-Round-3 code fetched them via two separate
|
||||
// queries (`list_playlists_by_owner` + `list_shared_with_user`)
|
||||
// that each read a different table.
|
||||
let grants = self
|
||||
.authz
|
||||
.list_incoming_grants(Subject::User(user_id))
|
||||
.await?;
|
||||
|
||||
let owned = self.storage.list_playlists_by_owner(user_id).await?;
|
||||
playlists.extend(owned);
|
||||
// Deduplicate — a user can hold multiple grants on the same
|
||||
// playlist (direct + group-inherited). We only need one DTO
|
||||
// per resource.
|
||||
let mut playlist_ids: HashSet<Uuid> = grants
|
||||
.into_iter()
|
||||
.filter_map(|g| match g.resource {
|
||||
Resource::Playlist(id) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if include_shared {
|
||||
let shared = self.storage.list_shared_with_user(user_id).await?;
|
||||
for s in shared {
|
||||
if !playlists.iter().any(|p: &PlaylistDto| p.id == s.id) {
|
||||
playlists.push(s);
|
||||
}
|
||||
// `include_shared=false` narrows the listing to owned playlists
|
||||
// only. Owner is a grant like any other in `role_grants`, so we
|
||||
// filter the aggregated set against the owner_id stamped on
|
||||
// each row after hydration — cheaper than a second SQL round-trip.
|
||||
let mut playlists: Vec<PlaylistDto> = Vec::with_capacity(playlist_ids.len());
|
||||
let user_str = user_id.to_string();
|
||||
for id in playlist_ids.drain() {
|
||||
if let Ok(Some(p)) = self.storage.get_playlist(&id.to_string()).await
|
||||
&& (include_shared || p.owner_id == user_str)
|
||||
{
|
||||
playlists.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,26 +224,9 @@ impl MusicUseCase for MusicService {
|
||||
dto: AddTracksDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<PlaylistItemDto>, DomainError> {
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to modify this playlist",
|
||||
));
|
||||
}
|
||||
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
||||
if !can_write {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You need write access to add tracks",
|
||||
));
|
||||
}
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
|
||||
let file_ids: Result<Vec<Uuid>, _> =
|
||||
dto.file_ids.iter().map(|id| Uuid::parse_str(id)).collect();
|
||||
@@ -177,30 +243,12 @@ impl MusicUseCase for MusicService {
|
||||
file_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
let file_uuid = Uuid::parse_str(file_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid file ID")
|
||||
})?;
|
||||
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to modify this playlist",
|
||||
));
|
||||
}
|
||||
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
||||
if !can_write {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You need write access to remove tracks",
|
||||
));
|
||||
}
|
||||
|
||||
self.storage.remove_track(&playlist_uuid, &file_uuid).await
|
||||
}
|
||||
|
||||
@@ -210,26 +258,9 @@ impl MusicUseCase for MusicService {
|
||||
dto: ReorderTracksDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to modify this playlist",
|
||||
));
|
||||
}
|
||||
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
||||
if !can_write {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You need write access to reorder tracks",
|
||||
));
|
||||
}
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
|
||||
let item_ids: Result<Vec<Uuid>, _> =
|
||||
dto.item_ids.iter().map(|id| Uuid::parse_str(id)).collect();
|
||||
@@ -248,16 +279,21 @@ impl MusicUseCase for MusicService {
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to view this playlist",
|
||||
));
|
||||
// Public-playlist bypass mirrors `get_playlist`: readers of a
|
||||
// public playlist can see its tracks. Fetch the playlist row
|
||||
// to inspect `is_public` before deciding.
|
||||
let playlist = self
|
||||
.storage
|
||||
.get_playlist(playlist_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::not_found("Playlist", playlist_id))?;
|
||||
let allowed = playlist.is_public
|
||||
|| self
|
||||
.has_playlist_perm(playlist_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Playlist", playlist_id));
|
||||
}
|
||||
|
||||
self.storage.list_playlist_tracks(&playlist_uuid).await
|
||||
}
|
||||
|
||||
@@ -267,36 +303,33 @@ impl MusicUseCase for MusicService {
|
||||
dto: SharePlaylistDto,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
));
|
||||
}
|
||||
};
|
||||
if playlist.owner_id != caller_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"Only the owner can share this playlist",
|
||||
));
|
||||
}
|
||||
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, caller_id, Permission::Share)
|
||||
.await?;
|
||||
let target_user_id = Uuid::parse_str(&dto.user_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid user ID")
|
||||
})?;
|
||||
let can_write = dto.can_write.unwrap_or(false);
|
||||
|
||||
self.storage
|
||||
.share_playlist(&playlist_uuid, target_user_id, can_write)
|
||||
.await
|
||||
// Legacy `can_write` boolean maps into the role bundle system:
|
||||
// - false → Viewer (Read only)
|
||||
// - true → Editor (Read + Update)
|
||||
// The endpoint stays boolean-shaped for API back-compat; new
|
||||
// integrations should switch to the unified `/api/grants` API
|
||||
// which exposes the full role set.
|
||||
let role = if dto.can_write.unwrap_or(false) {
|
||||
Role::Editor
|
||||
} else {
|
||||
Role::Viewer
|
||||
};
|
||||
self.authz
|
||||
.set_role(
|
||||
caller_id,
|
||||
Subject::User(target_user_id),
|
||||
role,
|
||||
Resource::Playlist(playlist_uuid),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_share(
|
||||
@@ -305,33 +338,18 @@ impl MusicUseCase for MusicService {
|
||||
target_user_id: &str,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
));
|
||||
}
|
||||
};
|
||||
if playlist.owner_id != caller_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"Only the owner can manage sharing",
|
||||
));
|
||||
}
|
||||
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, caller_id, Permission::Share)
|
||||
.await?;
|
||||
let target_uuid = Uuid::parse_str(target_user_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid user ID")
|
||||
})?;
|
||||
|
||||
self.storage.remove_share(&playlist_uuid, target_uuid).await
|
||||
self.authz
|
||||
.clear_role(
|
||||
Subject::User(target_uuid),
|
||||
Resource::Playlist(playlist_uuid),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_playlist_shares(
|
||||
@@ -339,35 +357,26 @@ impl MusicUseCase for MusicService {
|
||||
playlist_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<PlaylistShareInfoDto>, DomainError> {
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
));
|
||||
}
|
||||
};
|
||||
if playlist.owner_id != user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"Only the owner can view sharing info",
|
||||
));
|
||||
}
|
||||
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
|
||||
let shares = self.storage.get_shares(&playlist_uuid).await?;
|
||||
Ok(shares
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Share)
|
||||
.await?;
|
||||
// `list_grants_on_resource` returns every role_grant row for
|
||||
// the playlist. Drop the Owner self-grant seeded at creation
|
||||
// (the caller already knows they own it) and collapse the
|
||||
// role bundle back to a boolean `can_write` for the legacy
|
||||
// DTO shape.
|
||||
let grants = self
|
||||
.authz
|
||||
.list_grants_on_resource(Resource::Playlist(playlist_uuid))
|
||||
.await?;
|
||||
Ok(grants
|
||||
.into_iter()
|
||||
.map(|(uid, can_write)| PlaylistShareInfoDto {
|
||||
user_id: uid.to_string(),
|
||||
can_write,
|
||||
.filter_map(|g| match g.subject {
|
||||
Subject::User(uid) if g.role != Role::Owner => Some(PlaylistShareInfoDto {
|
||||
user_id: uid.to_string(),
|
||||
can_write: g.role.expand().contains(&Permission::Update),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -375,10 +384,23 @@ impl MusicUseCase for MusicService {
|
||||
async fn get_audio_metadata(
|
||||
&self,
|
||||
file_id: &str,
|
||||
_user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Option<AudioMetadataDto>, DomainError> {
|
||||
let file_uuid = Uuid::parse_str(file_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Music", "Invalid file ID"))?;
|
||||
// AuthZ pre-read: caller must have `Read` on the underlying
|
||||
// audio file. Before this check the endpoint returned
|
||||
// metadata for any known file id (cross-tenant IDOR — the
|
||||
// `_user_id` parameter was deliberately unused). `require`
|
||||
// returns 404 on denial to match the anti-enum shape used
|
||||
// everywhere else.
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await?;
|
||||
self.storage.get_audio_metadata(&file_uuid).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ use crate::infrastructure::repositories::pg::FileBlobReadRepository;
|
||||
/// "Places" use case: the caller's geotagged photos aggregated into map
|
||||
/// clusters.
|
||||
///
|
||||
/// Strictly user-scoped — the repository filters `WHERE fi.user_id = $1`, so,
|
||||
/// like [`RecentService`](super::recent_service::RecentService) and the photos
|
||||
/// timeline, it needs no `AuthorizationEngine` check: the `caller_id`
|
||||
/// parameter *is* the access scope.
|
||||
/// Post-§15 the surface follows the Photos scope: drives where the
|
||||
/// caller has Read AND `policies.include_in_photo_index = true`
|
||||
/// (default personal drives materialise the flag at creation).
|
||||
/// Group-membership expansion is handled inline by
|
||||
/// `storage.caller_group_ids(caller)` inside the repo's SQL, so this
|
||||
/// service is a thin coordinate-math wrapper — no engine dependency.
|
||||
pub struct PlacesService {
|
||||
file_read: Arc<FileBlobReadRepository>,
|
||||
}
|
||||
@@ -30,7 +32,8 @@ impl PlacesService {
|
||||
360.0 / (2_f64.powi(z) * 4.0)
|
||||
}
|
||||
|
||||
/// Clustered geotagged photos for `caller_id` within `bounds`.
|
||||
/// Clustered geotagged photos in the caller's Photos-scope drive set,
|
||||
/// within `bounds`.
|
||||
pub async fn clusters(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::recent_dto::{RecentCursor, RecentItemDto, RecentResourceRow};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase};
|
||||
use crate::application::ports::resource_access_hook::ResourceAccessHook;
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
use crate::common::errors::{DomainError, Result};
|
||||
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject};
|
||||
use crate::infrastructure::repositories::pg::RecentItemsPgRepository;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
@@ -16,6 +18,13 @@ use uuid::Uuid;
|
||||
pub struct RecentService {
|
||||
repo: Arc<RecentItemsPgRepository>,
|
||||
max_recent_items: i32,
|
||||
/// ReBAC engine — enforces `Permission::Read` on the referenced
|
||||
/// file/folder before enrolling it into a user's Recent list.
|
||||
/// The listing side JOINs back to `storage.files/folders` and
|
||||
/// returns name/mime/size/drive_id for any enrolled UUID, so
|
||||
/// the write path is an information oracle without this gate.
|
||||
/// See `docs/plan/authz_audit/rest_storage.md`.
|
||||
authorization: Arc<PgAclEngine>,
|
||||
/// Set after construction via [`Self::set_resource_access_hook`].
|
||||
/// The hook is built FROM this service (it wraps an `Arc<Self>`), so
|
||||
/// we can't take it as a constructor arg without circular ownership;
|
||||
@@ -28,10 +37,15 @@ pub struct RecentService {
|
||||
|
||||
impl RecentService {
|
||||
/// Create a new recent items service
|
||||
pub fn new(repo: Arc<RecentItemsPgRepository>, max_recent_items: i32) -> Self {
|
||||
pub fn new(
|
||||
repo: Arc<RecentItemsPgRepository>,
|
||||
authorization: Arc<PgAclEngine>,
|
||||
max_recent_items: i32,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
max_recent_items: max_recent_items.clamp(1, 100),
|
||||
authorization,
|
||||
resource_access_hook: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
@@ -53,6 +67,41 @@ impl RecentService {
|
||||
hook.on_recents_cleared(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record access to an item WITHOUT the pre-write `authz.require`
|
||||
/// gate. Callers must have gated the caller's Read upstream — this
|
||||
/// method exists for the `RecentRecordingHook` fast path: writes
|
||||
/// that reach the hook have already passed a `_with_perms` service
|
||||
/// method (uploads, streams, GETs, etc.), so re-checking here
|
||||
/// would be pure duplicate work AND widen the race window between
|
||||
/// the POST response and the `tokio::spawn`ed upsert (
|
||||
/// `tests/api/recent.hurl` step 7 hits this — the extra SQL
|
||||
/// round-trip pushes the upsert past the client's immediate
|
||||
/// `GET /api/recent/resources`).
|
||||
///
|
||||
/// **Do NOT call this from an externally-reachable handler.** The
|
||||
/// REST endpoint goes through the trait method `record_item_access`
|
||||
/// below, which enforces the Read gate per AGENTS.md convention.
|
||||
pub async fn record_item_access_internal(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
item_id: &str,
|
||||
item_type: &str,
|
||||
) -> Result<()> {
|
||||
// Type validation only — no authz, no resource parse for the
|
||||
// engine (the hook path is already resource-typed by construction).
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
crate::common::errors::ErrorKind::InvalidInput,
|
||||
"RecentItems",
|
||||
"Item type must be 'file' or 'folder'",
|
||||
));
|
||||
}
|
||||
|
||||
self.repo.upsert_access(user_id, item_id, item_type).await?;
|
||||
self.repo.prune(user_id, self.max_recent_items).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl RecentItemsUseCase for RecentService {
|
||||
@@ -87,16 +136,24 @@ impl RecentItemsUseCase for RecentService {
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"RecentItems",
|
||||
"Item type must be 'file' or 'folder'",
|
||||
));
|
||||
}
|
||||
// AuthZ pre-write: caller must have Read on the referenced
|
||||
// resource. Denial routes through `require` → NotFound
|
||||
// (anti-enum) + `authz.denied` audit line. Without this
|
||||
// gate the write path was an information oracle over the
|
||||
// whole tenant via the listing endpoint's JOIN back to
|
||||
// storage.files/folders.
|
||||
//
|
||||
// Internal hook callers (RecentRecordingHook) bypass the
|
||||
// trait entry point and call `record_item_access_internal`
|
||||
// directly — Read has already been enforced upstream on
|
||||
// whatever `_with_perms` service produced the access event.
|
||||
let resource = Resource::parse(item_type, item_id)?;
|
||||
self.authorization
|
||||
.require(Subject::User(user_id), Permission::Read, resource)
|
||||
.await?;
|
||||
|
||||
self.repo.upsert_access(user_id, item_id, item_type).await?;
|
||||
self.repo.prune(user_id, self.max_recent_items).await?;
|
||||
self.record_item_access_internal(user_id, item_id, item_type)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"Successfully recorded access to {} '{}' for user {}",
|
||||
|
||||
@@ -472,11 +472,14 @@ impl RecipientNotificationService {
|
||||
let kind_key = match resource {
|
||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||
// Drives don't generate share notifications in D0 — drive
|
||||
// sharing lands in D2 and gets its own template key. Fall
|
||||
// back to the folder label so any path that does reach
|
||||
// here produces a readable, if generic, mail body.
|
||||
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
||||
// Drive / Calendar / AddressBook / Playlist shares don't
|
||||
// produce email notifications through this path. Fall
|
||||
// back to the folder label so any code that does reach
|
||||
// here still produces a readable (if generic) mail body.
|
||||
Resource::Drive(_)
|
||||
| Resource::Calendar(_)
|
||||
| Resource::AddressBook(_)
|
||||
| Resource::Playlist(_) => "server.magic_link.email.kind_folder",
|
||||
};
|
||||
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
|
||||
// Short form for the subject, long form (with email) for the
|
||||
|
||||
@@ -283,20 +283,10 @@ impl SearchService {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
// Resolve the caller's accessible drive set via the engine
|
||||
// (handles group-mediated drive grants) + the repo lookup.
|
||||
let caller = Subject::User(user_id);
|
||||
let (subject_types, subject_ids) = match authz.expand_subject_for_listing(caller).await {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
tracing::warn!("Content-index: subject expansion failed — degrading to empty: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let accessible_drives: Vec<Uuid> = match drive_repo
|
||||
.list_for_subjects(&subject_types, &subject_ids)
|
||||
.await
|
||||
{
|
||||
// Resolve the caller's accessible drive set. Group-mediated
|
||||
// grants are honoured inline by `storage.caller_group_ids` on
|
||||
// the SQL side, so no Rust-side subject expansion here.
|
||||
let accessible_drives: Vec<Uuid> = match drive_repo.list_readable_by(user_id).await {
|
||||
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!("Content-index: drive lookup failed — degrading to empty: {e}");
|
||||
@@ -338,7 +328,11 @@ impl SearchService {
|
||||
}
|
||||
};
|
||||
match authz
|
||||
.check(caller, Permission::Read, Resource::File(file_uuid))
|
||||
.check(
|
||||
Subject::User(user_id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => verified.push(hit),
|
||||
|
||||
@@ -6,7 +6,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::authorization::{Resource, Role, Subject};
|
||||
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::repositories::pg::DrivePgRepository;
|
||||
use crate::infrastructure::repositories::pg::SharePgRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
@@ -243,6 +243,28 @@ impl ShareUseCase for ShareService {
|
||||
|
||||
self.verify_item_exists(&dto.item_id, &item_type).await?;
|
||||
|
||||
// AuthZ: only callers with `Share` on the resource may mint a
|
||||
// public link. Without this gate, an ex-Viewer who kept a
|
||||
// guessed UUID could launder a temporary read into a
|
||||
// permanent anonymous URL that survives their own grant
|
||||
// revocation. `Permission::Share` is bundled with the
|
||||
// `owner` and `editor` role_grants only. `require` returns
|
||||
// `not_found` on denial (anti-enum, matches the shape used
|
||||
// by every other share route). See `docs/plan/authz_audit/`.
|
||||
let item_uuid_for_authz = Uuid::parse_str(&dto.item_id)
|
||||
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
|
||||
let resource_for_authz = match item_type {
|
||||
ShareItemType::File => Resource::File(item_uuid_for_authz),
|
||||
ShareItemType::Folder => Resource::Folder(item_uuid_for_authz),
|
||||
};
|
||||
self.authorization
|
||||
.require(
|
||||
Subject::User(user_id),
|
||||
Permission::Share,
|
||||
resource_for_authz,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// D5: `forbid_public_links` policy gate. The drive owner can
|
||||
// disable anonymous-link creation on every resource in their
|
||||
// drive without per-resource intervention. Lookup is one JOIN
|
||||
@@ -928,14 +950,6 @@ mod tests {
|
||||
> {
|
||||
Ok(Box::pin(futures::stream::empty()))
|
||||
}
|
||||
|
||||
async fn get_file_for_owner(
|
||||
&self,
|
||||
id: &str,
|
||||
_owner_id: Uuid,
|
||||
) -> Result<crate::domain::entities::file::File, DomainError> {
|
||||
self.get_file(id).await
|
||||
}
|
||||
}
|
||||
|
||||
impl FolderRepository for MockFolderRepository {
|
||||
@@ -983,10 +997,9 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
async fn list_root_folders_for_caller(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1002,10 +1015,9 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner_paginated(
|
||||
async fn list_root_folders_for_caller_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
_offset: usize,
|
||||
_limit: usize,
|
||||
_include_total: bool,
|
||||
|
||||
@@ -213,6 +213,47 @@ impl StorageUsageService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the size in bytes of a single non-trashed file. `None`
|
||||
/// if the file is trashed or absent. Used by cross-drive MOVE to
|
||||
/// know how many bytes will land on the destination drive so the
|
||||
/// pre-move `check_drive_quota` call can fire.
|
||||
pub async fn file_bytes(&self, file_id: Uuid) -> Result<Option<i64>, DomainError> {
|
||||
let row: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT size::bigint FROM storage.files WHERE id = $1 AND NOT is_trashed",
|
||||
)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("StorageUsage", format!("file_bytes: {e}")))?;
|
||||
Ok(row.map(|(s,)| s))
|
||||
}
|
||||
|
||||
/// Sum the sizes of every non-trashed file whose parent folder is
|
||||
/// `folder_id` itself or a descendant of it via the `lpath` ltree.
|
||||
/// Used by cross-drive MOVE to know how many bytes would land on
|
||||
/// the destination drive — necessary for the pre-move
|
||||
/// `check_drive_quota` call.
|
||||
///
|
||||
/// Returns 0 for an empty subtree AND for a non-existent
|
||||
/// `folder_id` (the JOIN silently drops); callers that need to
|
||||
/// distinguish those two cases must probe the folder separately.
|
||||
pub async fn folder_subtree_bytes(&self, folder_id: Uuid) -> Result<i64, DomainError> {
|
||||
let (bytes,): (Option<i64>,) = sqlx::query_as(
|
||||
"SELECT COALESCE(SUM(f.size), 0)::bigint
|
||||
FROM storage.files f
|
||||
JOIN storage.folders fo ON fo.id = f.folder_id
|
||||
WHERE fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1)
|
||||
AND NOT f.is_trashed",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("folder_subtree_bytes: {e}"))
|
||||
})?;
|
||||
Ok(bytes.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Same as [`Self::add_drive_storage_usage_delta`] but resolves
|
||||
/// the drive id from a parent folder id in a single statement.
|
||||
/// Avoids a separate `SELECT drive_id FROM storage.folders` round
|
||||
|
||||
@@ -786,13 +786,9 @@ impl TrashService {
|
||||
/// keeps the two HTTP surfaces semantically consistent and avoids
|
||||
/// duplicating the subject-expansion plumbing.
|
||||
async fn drives_with_delete_for(&self, user_id: Uuid) -> Result<Vec<Uuid>> {
|
||||
let (subject_types, subject_ids) = self
|
||||
.authz
|
||||
.expand_subject_for_listing(Subject::User(user_id))
|
||||
.await?;
|
||||
let drives = self
|
||||
.drive_repo
|
||||
.list_for_subjects(&subject_types, &subject_ids)
|
||||
.list_readable_by(user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
@@ -900,15 +896,7 @@ impl TrashService {
|
||||
// D2b: scope by drives the caller can read (resolved through
|
||||
// role_grants on resource_type='drive', including group-mediated
|
||||
// grants). Empty set → empty page without a SQL round-trip.
|
||||
let (subject_types, subject_ids) = self
|
||||
.authz
|
||||
.expand_subject_for_listing(Subject::User(user_id))
|
||||
.await?;
|
||||
let drive_ids: Vec<Uuid> = match self
|
||||
.drive_repo
|
||||
.list_for_subjects(&subject_types, &subject_ids)
|
||||
.await
|
||||
{
|
||||
let drive_ids: Vec<Uuid> = match self.drive_repo.list_readable_by(user_id).await {
|
||||
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
|
||||
Err(e) => {
|
||||
return Err(DomainError::internal_error(
|
||||
@@ -974,7 +962,6 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
||||
name: row.name.clone(),
|
||||
path,
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
// D2b: the trash listing query now SELECTs `drive_id` (the
|
||||
// unified view exposes it). Surfaced so per-drive grouping
|
||||
// in the `/trash` UI doesn't need an extra lookup per row.
|
||||
@@ -1025,7 +1012,6 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
||||
icon_special_class: std::sync::Arc::from(icon_special_class_for(&row.name, mime)),
|
||||
category: std::sync::Arc::from(category_for(&row.name, mime)),
|
||||
size_formatted: format_file_size(size_bytes),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
etag,
|
||||
|
||||
@@ -554,15 +554,6 @@ impl FileReadPort for MockFileRepository {
|
||||
> {
|
||||
Ok(Box::pin(futures::stream::empty()))
|
||||
}
|
||||
|
||||
async fn get_file_for_owner(
|
||||
&self,
|
||||
id: &str,
|
||||
_owner_id: Uuid,
|
||||
) -> std::result::Result<File, DomainError> {
|
||||
// In this mock, ignore ownership — trash tests don't focus on ownership
|
||||
self.get_file(id).await
|
||||
}
|
||||
}
|
||||
|
||||
impl FileWritePort for MockFileRepository {
|
||||
@@ -745,10 +736,9 @@ impl FolderRepository for MockFolderRepository {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
async fn list_root_folders_for_caller(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
) -> std::result::Result<Vec<Folder>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
@@ -763,10 +753,9 @@ impl FolderRepository for MockFolderRepository {
|
||||
Ok((vec![], Some(0)))
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner_paginated(
|
||||
async fn list_root_folders_for_caller_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
_offset: usize,
|
||||
_limit: usize,
|
||||
_include_total: bool,
|
||||
|
||||
+16
-10
@@ -526,10 +526,12 @@ async fn build_subtree(
|
||||
|
||||
// Level 0 — the subtree's "root" sits inside `mount_under`, not at
|
||||
// parent_id=NULL. drive_id is inherited from the mount point.
|
||||
// Post-D7: `user_id` omitted; `created_by` / `updated_by` bind to
|
||||
// the seed caller.
|
||||
let root: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO storage.folders
|
||||
(name, parent_id, user_id, drive_id, created_by, updated_by)
|
||||
SELECT $1, parent.id, $2, parent.drive_id, $2, $2
|
||||
(name, parent_id, drive_id, created_by, updated_by)
|
||||
SELECT $1, parent.id, parent.drive_id, $2, $2
|
||||
FROM storage.folders parent
|
||||
WHERE parent.id = $3::uuid
|
||||
RETURNING id",
|
||||
@@ -568,13 +570,13 @@ async fn build_subtree(
|
||||
);
|
||||
|
||||
// drive_id derives from the parent folder — same pattern as
|
||||
// file_blob_write_repository's resolve_owner_and_drive helper.
|
||||
// Every parent in `current_level` already has a drive_id set,
|
||||
// so the JOIN is guaranteed to find one.
|
||||
// file_blob_write_repository's resolve_parent_drive helper.
|
||||
// Post-D7: `user_id` omitted; provenance via `created_by` /
|
||||
// `updated_by`.
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"INSERT INTO storage.folders
|
||||
(name, parent_id, user_id, drive_id, created_by, updated_by)
|
||||
SELECT f.name, f.parent_id, $1, parent.drive_id, $1, $1
|
||||
(name, parent_id, drive_id, created_by, updated_by)
|
||||
SELECT f.name, f.parent_id, parent.drive_id, $1, $1
|
||||
FROM UNNEST($2::uuid[], $3::text[]) AS f(parent_id, name)
|
||||
JOIN storage.folders parent ON parent.id = f.parent_id
|
||||
RETURNING id",
|
||||
@@ -653,13 +655,17 @@ async fn insert_files(
|
||||
|
||||
// Post-D0: storage.files.drive_id is NOT NULL — derive it from the
|
||||
// parent folder (same pattern as file_blob_write_repository's
|
||||
// INSERTs and the resolve_owner_and_drive helper). The folder's
|
||||
// INSERTs and the resolve_parent_drive helper). The folder's
|
||||
// drive_id was set during the M2 backfill or by the lifecycle hook
|
||||
// for users provisioned after D0.
|
||||
//
|
||||
// Post-D7: `user_id` omitted; `created_by` / `updated_by` bind to
|
||||
// the seed caller so provenance is preserved.
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.files
|
||||
(name, folder_id, user_id, drive_id, blob_hash, size, mime_type)
|
||||
SELECT f.name, f.folder_id, $1, fo.drive_id, $2, 0, 'text/plain'
|
||||
(name, folder_id, drive_id, blob_hash, size, mime_type,
|
||||
created_by, updated_by)
|
||||
SELECT f.name, f.folder_id, fo.drive_id, $2, 0, 'text/plain', $1, $1
|
||||
FROM UNNEST($3::uuid[], $4::text[]) AS f(folder_id, name)
|
||||
JOIN storage.folders fo ON fo.id = f.folder_id",
|
||||
)
|
||||
|
||||
@@ -915,6 +915,22 @@ pub struct FeaturesConfig {
|
||||
/// deployments don't want them reachable. Env:
|
||||
/// `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS`.
|
||||
pub enable_admin_internal_endpoints: bool,
|
||||
/// Native WebDAV path segment that lists the caller's drives.
|
||||
///
|
||||
/// * Default `"@drive"` — bare `/webdav/` addresses the caller's
|
||||
/// default personal drive (back-compat). Drive listing lives at
|
||||
/// `/webdav/@drive/`; explicit drive at
|
||||
/// `/webdav/@drive/<uuid|name>/…`.
|
||||
/// * `""` (empty) — no default-drive shortcut. Bare `/webdav/`
|
||||
/// returns the drive listing; explicit drive at
|
||||
/// `/webdav/<uuid|name>/…`. Operators who don't want a "default
|
||||
/// drive" concept exposed via WebDAV pick this.
|
||||
/// * Any other string (e.g. `"drives"`) — same shape as the default,
|
||||
/// just with that path segment. Loaded via `trim_matches('/')`
|
||||
/// so operators can safely pass `"/drives/"`.
|
||||
///
|
||||
/// Env: `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`.
|
||||
pub webdav_drive_listing_prefix: String,
|
||||
}
|
||||
|
||||
impl Default for FeaturesConfig {
|
||||
@@ -934,6 +950,10 @@ impl Default for FeaturesConfig {
|
||||
// deployments do NOT need this; the periodic ticker handles
|
||||
// reconciliation transparently.
|
||||
enable_admin_internal_endpoints: false,
|
||||
// Back-compat with pre-multi-drive clients — bare `/webdav/`
|
||||
// maps to the caller's default drive; drive listing is
|
||||
// reachable at `/webdav/@drive/`.
|
||||
webdav_drive_listing_prefix: "@drive".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1505,6 +1525,15 @@ impl AppConfig {
|
||||
config.features.enable_admin_internal_endpoints = val;
|
||||
}
|
||||
|
||||
// Native WebDAV drive-picker path segment. Sanitised by
|
||||
// stripping leading/trailing slashes so operators can pass
|
||||
// `/drives/` or `drives` interchangeably; empty string means
|
||||
// "no default-drive shortcut, `/webdav/` IS the drive listing".
|
||||
// See `FeaturesConfig::webdav_drive_listing_prefix`.
|
||||
if let Ok(raw) = env::var("OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX") {
|
||||
config.features.webdav_drive_listing_prefix = raw.trim_matches('/').to_string();
|
||||
}
|
||||
|
||||
if let Ok(enable_faces) = env::var("OXICLOUD_ENABLE_FACES").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = enable_faces
|
||||
{
|
||||
|
||||
+80
-44
@@ -50,13 +50,13 @@ use crate::application::ports::video_frame_ports::VideoFramePort;
|
||||
use crate::application::services::app_password_service::AppPasswordService;
|
||||
use crate::application::services::blob_lifecycle_service::BlobLifecycleService;
|
||||
use crate::application::services::calendar_service::CalendarService;
|
||||
use crate::application::services::contact_service::ContactService;
|
||||
use crate::application::services::device_auth_service::DeviceAuthService;
|
||||
use crate::application::services::file_lifecycle_service::FileLifecycleService;
|
||||
use crate::application::services::music_service::MusicService;
|
||||
use crate::application::services::storage_usage_service::StorageUsageService;
|
||||
use crate::application::services::wopi_lock_service::WopiLockService;
|
||||
use crate::application::services::wopi_token_service::WopiTokenService;
|
||||
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
||||
use crate::infrastructure::repositories::AppPasswordPgRepository;
|
||||
use crate::infrastructure::repositories::DeviceCodePgRepository;
|
||||
use crate::infrastructure::repositories::pg::{
|
||||
@@ -536,7 +536,12 @@ impl AppServiceFactory {
|
||||
// drive repo every other policy uses. Wired here so
|
||||
// `move_folder_with_perms` can enforce
|
||||
// `forbid_cross_drive_move` without a separate construction path.
|
||||
.with_drive_repo(drive_repo.clone()),
|
||||
.with_drive_repo(drive_repo.clone())
|
||||
// Destination-drive quota pre-check on cross-drive folder
|
||||
// MOVE. Reuses the `check_drive_quota` the upload path
|
||||
// already runs. Without this, a Move that would push the
|
||||
// destination past its cap succeeds silently.
|
||||
.with_storage_usage(storage_usage.clone()),
|
||||
);
|
||||
|
||||
// Built before the upload/management services so the plugin lifecycle
|
||||
@@ -618,7 +623,10 @@ impl AppServiceFactory {
|
||||
// drive repo every other policy uses. Wired here so
|
||||
// `move_file_with_perms` can enforce `forbid_cross_drive_move`
|
||||
// without a separate construction path.
|
||||
.with_drive_repo(drive_repo.clone());
|
||||
.with_drive_repo(drive_repo.clone())
|
||||
// Destination-drive quota pre-check on cross-drive file
|
||||
// MOVE. Same rationale as the folder side above.
|
||||
.with_storage_usage(storage_usage.clone());
|
||||
if let Some(hook) = resource_access_hook.clone() {
|
||||
svc = svc.with_resource_access_hook(hook);
|
||||
}
|
||||
@@ -889,30 +897,49 @@ impl AppServiceFactory {
|
||||
Some(service)
|
||||
}
|
||||
|
||||
/// Creates the favorites service (requires database)
|
||||
pub fn create_favorites_service(&self, db_pool: &Arc<PgPool>) -> Arc<FavoritesService> {
|
||||
/// Creates the favorites service (requires database + authz engine
|
||||
/// for the Read gate on `add_to_favorites` — see the post-Drive
|
||||
/// AuthZ audit).
|
||||
pub fn create_favorites_service(
|
||||
&self,
|
||||
db_pool: &Arc<PgPool>,
|
||||
authorization: &Arc<PgAclEngine>,
|
||||
) -> Arc<FavoritesService> {
|
||||
let repo = Arc::new(
|
||||
crate::infrastructure::repositories::pg::FavoritesPgRepository::new(db_pool.clone()),
|
||||
);
|
||||
let service = Arc::new(FavoritesService::new(repo));
|
||||
let service = Arc::new(FavoritesService::new(repo, authorization.clone()));
|
||||
tracing::info!("Favorites service initialized");
|
||||
service
|
||||
}
|
||||
|
||||
/// Creates the recent items service (requires database)
|
||||
pub fn create_recent_service(&self, db_pool: &Arc<PgPool>) -> Arc<RecentService> {
|
||||
/// Creates the recent items service (requires database + authz
|
||||
/// engine for the Read gate on `record_item_access` — see the
|
||||
/// post-Drive AuthZ audit).
|
||||
pub fn create_recent_service(
|
||||
&self,
|
||||
db_pool: &Arc<PgPool>,
|
||||
authorization: &Arc<PgAclEngine>,
|
||||
) -> Arc<RecentService> {
|
||||
let repo = Arc::new(
|
||||
crate::infrastructure::repositories::pg::RecentItemsPgRepository::new(db_pool.clone()),
|
||||
);
|
||||
let service = Arc::new(RecentService::new(
|
||||
repo, 50, // Maximum recent items per user
|
||||
repo,
|
||||
authorization.clone(),
|
||||
50, // Maximum recent items per user
|
||||
));
|
||||
tracing::info!("Recent items service initialized");
|
||||
service
|
||||
}
|
||||
|
||||
/// Creates the Places (photo map) service. Reuses the existing file-read
|
||||
/// repository — the data is the caller's own geotagged photos.
|
||||
/// repository — the data is the caller's Photos-scope geotagged photos
|
||||
/// (§15: default personal drive + drives with
|
||||
/// `include_in_photo_index = true` AND caller has Read).
|
||||
/// Group-membership expansion is inline in the SQL via
|
||||
/// `storage.caller_group_ids`, so the service needs no AuthZ engine
|
||||
/// handle.
|
||||
pub fn create_places_service(
|
||||
&self,
|
||||
file_read: &Arc<FileBlobReadRepository>,
|
||||
@@ -1156,31 +1183,6 @@ impl AppServiceFactory {
|
||||
let pool = Arc::new(pools.primary);
|
||||
let maintenance_pool = Arc::new(pools.maintenance);
|
||||
|
||||
// Recent service + recording hook are built up-front so the
|
||||
// hook can be threaded into `create_application_services` below.
|
||||
// The file services hold the hook directly so every authorised
|
||||
// `_with_perms` read/write fires into `auth.user_recent_files`
|
||||
// without per-handler wiring. Reordering vs the legacy in-block
|
||||
// creation (further down) is safe: `create_recent_service` only
|
||||
// needs `pool`, which is already in scope.
|
||||
//
|
||||
// The back-edge `recent_service_eager.set_resource_access_hook`
|
||||
// closes the loop so the clear/remove handlers can drop the
|
||||
// hook's in-memory throttle entries — without it a freshly
|
||||
// cleared Recent list refuses to re-record the same file for a
|
||||
// full TTL window, surfacing as "I cleared, opened the file,
|
||||
// and Recent is still empty" (caught by tests/api/recent.hurl
|
||||
// step 8).
|
||||
let recent_service_eager = self.create_recent_service(&pool);
|
||||
let resource_access_hook: Arc<
|
||||
dyn crate::application::ports::resource_access_hook::ResourceAccessHook,
|
||||
> = Arc::new(
|
||||
crate::infrastructure::services::recent_recording_hook::RecentRecordingHook::new(
|
||||
recent_service_eager.clone(),
|
||||
),
|
||||
);
|
||||
recent_service_eager.set_resource_access_hook(resource_access_hook.clone());
|
||||
|
||||
// 1. Core services (PgPool needed for DedupService index)
|
||||
let core = self.create_core_services(&pool, &maintenance_pool).await?;
|
||||
|
||||
@@ -1191,6 +1193,10 @@ impl AppServiceFactory {
|
||||
// because services hold an Arc<PgAclEngine> for ReBAC checks.
|
||||
// SubjectGroupPgRepository is constructed here too so the engine can
|
||||
// expand a user's transitive group set on cache misses.
|
||||
//
|
||||
// Moved above the eager recent-service build so `create_recent_service`
|
||||
// can receive an `Arc<PgAclEngine>` — the Read gate on
|
||||
// `record_item_access` (post-Drive AuthZ audit fix) needs it.
|
||||
let subject_group_repo = Arc::new(
|
||||
crate::infrastructure::repositories::pg::SubjectGroupPgRepository::new(pool.clone()),
|
||||
);
|
||||
@@ -1201,6 +1207,29 @@ impl AppServiceFactory {
|
||||
subject_group_repo.clone(),
|
||||
);
|
||||
|
||||
// Recent service + recording hook are built up-front so the
|
||||
// hook can be threaded into `create_application_services` below.
|
||||
// The file services hold the hook directly so every authorised
|
||||
// `_with_perms` read/write fires into `auth.user_recent_files`
|
||||
// without per-handler wiring.
|
||||
//
|
||||
// The back-edge `recent_service_eager.set_resource_access_hook`
|
||||
// closes the loop so the clear/remove handlers can drop the
|
||||
// hook's in-memory throttle entries — without it a freshly
|
||||
// cleared Recent list refuses to re-record the same file for a
|
||||
// full TTL window, surfacing as "I cleared, opened the file,
|
||||
// and Recent is still empty" (caught by tests/api/recent.hurl
|
||||
// step 8).
|
||||
let recent_service_eager = self.create_recent_service(&pool, &authorization);
|
||||
let resource_access_hook: Arc<
|
||||
dyn crate::application::ports::resource_access_hook::ResourceAccessHook,
|
||||
> = Arc::new(
|
||||
crate::infrastructure::services::recent_recording_hook::RecentRecordingHook::new(
|
||||
recent_service_eager.clone(),
|
||||
),
|
||||
);
|
||||
recent_service_eager.set_resource_access_hook(resource_access_hook.clone());
|
||||
|
||||
// Drive repository — needed both by the lifecycle hook (when auth
|
||||
// is enabled) and by `GET /api/drives` on the final `AppState`,
|
||||
// so declared at the outer scope.
|
||||
@@ -1274,7 +1303,7 @@ impl AppServiceFactory {
|
||||
> = None;
|
||||
|
||||
{
|
||||
let favs = self.create_favorites_service(&pool);
|
||||
let favs = self.create_favorites_service(&pool, &authorization);
|
||||
favorites_service = Some(favs.clone());
|
||||
apps.favorites_service = Some(favs);
|
||||
|
||||
@@ -1529,7 +1558,6 @@ impl AppServiceFactory {
|
||||
people_service,
|
||||
storage_usage_service,
|
||||
calendar_service: None,
|
||||
contact_service: None,
|
||||
calendar_use_case: None,
|
||||
addressbook_use_case: None,
|
||||
contact_use_case: None,
|
||||
@@ -1800,6 +1828,7 @@ impl AppServiceFactory {
|
||||
let calendar_service = Arc::new(
|
||||
crate::application::services::calendar_service::CalendarService::new(
|
||||
calendar_storage,
|
||||
authorization.clone(),
|
||||
),
|
||||
);
|
||||
app_state.calendar_use_case = Some(calendar_service as Arc<CalendarService>);
|
||||
@@ -1816,15 +1845,23 @@ impl AppServiceFactory {
|
||||
pool.clone(),
|
||||
),
|
||||
);
|
||||
// Post-Round-3: symmetric with CalendarService/CalendarStorageAdapter.
|
||||
// * ContactStorageAdapter → pure ContactStoragePort impl
|
||||
// (raw PG storage, no ACL, no sharing).
|
||||
// * ContactService → gates every call through the
|
||||
// AuthorizationEngine, then delegates through the port.
|
||||
// Owns both AddressBookUseCase + ContactUseCase impls.
|
||||
let contact_storage = Arc::new(
|
||||
crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new(
|
||||
address_book_repo,
|
||||
contact_repo,
|
||||
group_repo,
|
||||
)
|
||||
),
|
||||
);
|
||||
app_state.addressbook_use_case = Some(contact_storage.clone());
|
||||
app_state.contact_use_case = Some(contact_storage);
|
||||
let contact_service =
|
||||
Arc::new(ContactService::new(contact_storage, authorization.clone()));
|
||||
app_state.addressbook_use_case = Some(contact_service.clone());
|
||||
app_state.contact_use_case = Some(contact_service);
|
||||
|
||||
tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories");
|
||||
}
|
||||
@@ -1844,7 +1881,7 @@ impl AppServiceFactory {
|
||||
audio_metadata_repo,
|
||||
),
|
||||
);
|
||||
let music_svc = Arc::new(MusicService::new(music_storage));
|
||||
let music_svc = Arc::new(MusicService::new(music_storage, authorization.clone()));
|
||||
app_state.music_service = Some(music_svc);
|
||||
tracing::info!("Music service initialized");
|
||||
}
|
||||
@@ -1993,10 +2030,9 @@ pub struct AppState {
|
||||
pub people_service: Option<Arc<PeopleService>>,
|
||||
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
||||
pub calendar_service: Option<Arc<CalendarService>>,
|
||||
pub contact_service: Option<Arc<ContactStorageAdapter>>,
|
||||
pub calendar_use_case: Option<Arc<CalendarService>>,
|
||||
pub addressbook_use_case: Option<Arc<ContactStorageAdapter>>,
|
||||
pub contact_use_case: Option<Arc<ContactStorageAdapter>>,
|
||||
pub addressbook_use_case: Option<Arc<ContactService>>,
|
||||
pub contact_use_case: Option<Arc<ContactService>>,
|
||||
pub music_service: Option<Arc<MusicService>>,
|
||||
pub wopi_token_service:
|
||||
Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>,
|
||||
|
||||
+5
-11
@@ -145,10 +145,6 @@ impl FileReadPort for StubFileReadPort {
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<File, DomainError>> + Send>>, DomainError> {
|
||||
Ok(Box::pin(futures::stream::empty()))
|
||||
}
|
||||
|
||||
async fn get_file_for_owner(&self, _id: &str, _owner_id: Uuid) -> Result<File, DomainError> {
|
||||
Ok(File::default())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -274,10 +270,9 @@ impl FolderRepository for StubFolderStoragePort {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
async fn list_root_folders_for_caller(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -292,10 +287,9 @@ impl FolderRepository for StubFolderStoragePort {
|
||||
Ok((Vec::new(), Some(0)))
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner_paginated(
|
||||
async fn list_root_folders_for_caller_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
_offset: usize,
|
||||
_limit: usize,
|
||||
_include_total: bool,
|
||||
@@ -506,7 +500,7 @@ impl FileUploadUseCase for StubFileUploadUseCase {
|
||||
Ok(FileDto::default())
|
||||
}
|
||||
|
||||
async fn update_file_streaming(
|
||||
async fn update_file_streaming_with_perms(
|
||||
&self,
|
||||
_path: &str,
|
||||
_drive_id: Uuid,
|
||||
|
||||
@@ -181,6 +181,26 @@ pub struct DrivePolicies {
|
||||
/// writes) and `::remove_member` (refuses Owner removals) when the
|
||||
/// caller is non-admin.
|
||||
pub forbid_owner_role_change: bool,
|
||||
/// Opts this drive into the `/api/photos` timeline (§15). Non-default
|
||||
/// drives are omitted by default so a random shared folder full of
|
||||
/// screenshots doesn't bleed into the personal timeline; owners flip
|
||||
/// this on when the drive genuinely is a photo library (e.g. "Family
|
||||
/// Photos"). Default personal drives get `true` on creation via the
|
||||
/// `PersonalDriveLifecycleHook` + a one-shot backfill for existing
|
||||
/// rows, so the SQL predicate is a single positive rule with no
|
||||
/// per-kind carve-out. Read at `file_blob_read_repository::
|
||||
/// list_media_files` + `list_geo_clusters`. See §15 for the query
|
||||
/// shape and rationale.
|
||||
pub include_in_photo_index: bool,
|
||||
/// Same shape as `include_in_photo_index`, applied to the Music
|
||||
/// library surface (playlists today; a `/api/music/tracks` library
|
||||
/// view later). Symmetric opt-in — Music was originally cross-drive
|
||||
/// via a `forbid_music_index` opt-out, but that mixed-form naming
|
||||
/// created "one include-in, one forbid" confusion and the
|
||||
/// "shared audio is always intentional" claim didn't hold under
|
||||
/// scrutiny (voicemail MP3s in a work drive shouldn't bleed into
|
||||
/// the personal library). See §15.
|
||||
pub include_in_music_index: bool,
|
||||
}
|
||||
|
||||
impl DrivePolicies {
|
||||
|
||||
@@ -22,7 +22,6 @@ pub struct FileParts {
|
||||
pub folder_id: Option<String>,
|
||||
pub created_at: u64,
|
||||
pub modified_at: u64,
|
||||
pub owner_id: Option<Uuid>,
|
||||
/// BLAKE3 content hash. See [`File::content_hash`] for semantics.
|
||||
pub blob_hash: String,
|
||||
/// §14 provenance: original creator. See [`File::created_by`].
|
||||
@@ -70,9 +69,6 @@ pub struct File {
|
||||
/// Last modification timestamp (seconds since UNIX epoch)
|
||||
modified_at: u64,
|
||||
|
||||
/// Owner user ID (from storage.files.user_id)
|
||||
owner_id: Option<Uuid>,
|
||||
|
||||
/// BLAKE3 content hash. Stable across renames/moves, changes only
|
||||
/// when the file's content bytes change. Source of truth for both
|
||||
/// content-addressable storage and the HTTP ETag (via
|
||||
@@ -109,7 +105,6 @@ impl Default for File {
|
||||
folder_id: None,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
owner_id: None,
|
||||
blob_hash: String::new(),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
@@ -150,7 +145,6 @@ impl File {
|
||||
folder_id,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
owner_id: None,
|
||||
blob_hash: String::new(),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
@@ -184,7 +178,6 @@ impl File {
|
||||
folder_id: parent_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
owner_id: None,
|
||||
blob_hash: String::new(),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
@@ -201,7 +194,6 @@ impl File {
|
||||
folder_id: Option<String>,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
owner_id: Option<Uuid>,
|
||||
) -> FileResult<Self> {
|
||||
Self::with_timestamps_and_blob_hash(
|
||||
id,
|
||||
@@ -212,7 +204,6 @@ impl File {
|
||||
folder_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
owner_id,
|
||||
String::new(),
|
||||
)
|
||||
}
|
||||
@@ -227,7 +218,6 @@ impl File {
|
||||
folder_id: Option<String>,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
owner_id: Option<Uuid>,
|
||||
blob_hash: String,
|
||||
) -> FileResult<Self> {
|
||||
Self::with_timestamps_blob_hash_and_provenance(
|
||||
@@ -239,7 +229,6 @@ impl File {
|
||||
folder_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
owner_id,
|
||||
blob_hash,
|
||||
None,
|
||||
None,
|
||||
@@ -259,7 +248,6 @@ impl File {
|
||||
folder_id: Option<String>,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
owner_id: Option<Uuid>,
|
||||
blob_hash: String,
|
||||
created_by: Option<Uuid>,
|
||||
updated_by: Option<Uuid>,
|
||||
@@ -282,7 +270,6 @@ impl File {
|
||||
folder_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
owner_id,
|
||||
blob_hash,
|
||||
created_by,
|
||||
updated_by,
|
||||
@@ -304,7 +291,6 @@ impl File {
|
||||
folder_id: self.folder_id,
|
||||
created_at: self.created_at,
|
||||
modified_at: self.modified_at,
|
||||
owner_id: self.owner_id,
|
||||
blob_hash: self.blob_hash,
|
||||
created_by: self.created_by,
|
||||
updated_by: self.updated_by,
|
||||
@@ -407,10 +393,6 @@ impl File {
|
||||
self.modified_at
|
||||
}
|
||||
|
||||
pub fn owner_id(&self) -> Option<Uuid> {
|
||||
self.owner_id
|
||||
}
|
||||
|
||||
/// User that originally created this file (§14 provenance).
|
||||
/// `None` when the referenced user has been deleted
|
||||
/// (FK is `ON DELETE SET NULL`) or for stub/DTO entities.
|
||||
@@ -455,7 +437,6 @@ impl File {
|
||||
folder_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
owner_id: None,
|
||||
blob_hash: String::new(),
|
||||
// DTO round-trips don't carry provenance; callers needing
|
||||
// it must reload from the repository.
|
||||
@@ -607,7 +588,6 @@ mod tests {
|
||||
None,
|
||||
1_000,
|
||||
2_000,
|
||||
None,
|
||||
"abcdef0123456789ZZZZZZZZ".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -632,7 +612,6 @@ mod tests {
|
||||
None,
|
||||
1_000,
|
||||
2_000,
|
||||
None,
|
||||
"shorthash".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -655,7 +634,6 @@ mod tests {
|
||||
None,
|
||||
1_000,
|
||||
2_000,
|
||||
None,
|
||||
"stable-content-hash".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -25,10 +25,6 @@ pub struct Folder {
|
||||
/// Parent folder ID (None if it's a root folder)
|
||||
parent_id: Option<String>,
|
||||
|
||||
/// Owner user ID — scopes folder visibility per user.
|
||||
/// `None` only for legacy/stub folders; real folders always have an owner.
|
||||
owner_id: Option<Uuid>,
|
||||
|
||||
/// Drive that owns this folder. Post-D0 every `storage.folders` row
|
||||
/// has `drive_id NOT NULL` (M3 migration). Path-based lookups scope
|
||||
/// by this axis (not by `user_id`, which is dropped in D7).
|
||||
@@ -76,7 +72,6 @@ impl Default for Folder {
|
||||
storage_path: StoragePath::from_string("/"),
|
||||
path_string: "/".to_string(),
|
||||
parent_id: None,
|
||||
owner_id: None,
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
@@ -88,26 +83,20 @@ impl Default for Folder {
|
||||
}
|
||||
|
||||
impl Folder {
|
||||
/// Creates a new folder with validation
|
||||
/// Creates a new folder with validation.
|
||||
///
|
||||
/// In-memory constructor: callers that don't supply a `drive_id`
|
||||
/// are by definition stub/legacy paths (tests, pre-D0 fixtures,
|
||||
/// DTO round-trips). Real DB-backed folders flow through
|
||||
/// [`Folder::with_timestamps_and_tree`] which propagates the
|
||||
/// drive scope and §14 provenance from the row.
|
||||
pub fn new(
|
||||
id: String,
|
||||
name: String,
|
||||
storage_path: StoragePath,
|
||||
parent_id: Option<String>,
|
||||
) -> FolderResult<Self> {
|
||||
Self::new_with_owner(id, name, storage_path, parent_id, None)
|
||||
}
|
||||
|
||||
/// Creates a new folder with validation and an explicit owner.
|
||||
pub fn new_with_owner(
|
||||
id: String,
|
||||
name: String,
|
||||
storage_path: StoragePath,
|
||||
parent_id: Option<String>,
|
||||
owner_id: Option<Uuid>,
|
||||
) -> FolderResult<Self> {
|
||||
let name = normalize_storage_name(&name);
|
||||
// Validate folder name
|
||||
if let Err(reason) = validate_storage_name(&name) {
|
||||
return Err(FolderError::InvalidFolderName(format!("{name}: {reason}")));
|
||||
}
|
||||
@@ -117,7 +106,6 @@ impl Folder {
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Store the path string for serialization compatibility
|
||||
let path_string = storage_path.to_string();
|
||||
|
||||
Ok(Self {
|
||||
@@ -126,17 +114,10 @@ impl Folder {
|
||||
storage_path,
|
||||
path_string,
|
||||
parent_id,
|
||||
owner_id,
|
||||
// In-memory constructor: callers that don't supply a
|
||||
// drive_id are by definition stub/legacy paths (tests,
|
||||
// pre-D0 fixtures, DTO round-trips). Real DB-backed
|
||||
// folders flow through `with_timestamps_and_tree`.
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
tree_modified_at: now,
|
||||
// Provenance is unknown for in-memory construction; the DB
|
||||
// reconstruction path supplies real values.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
})
|
||||
@@ -160,34 +141,6 @@ impl Folder {
|
||||
name,
|
||||
storage_path,
|
||||
parent_id,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
created_at,
|
||||
modified_at,
|
||||
modified_at,
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a folder with specific timestamps and owner (legacy
|
||||
/// constructor — `tree_modified_at` defaults to `modified_at`).
|
||||
/// Prefer [`Folder::with_timestamps_and_tree`] for DB reconstruction
|
||||
/// so the rollup ETag reflects descendant activity, not just this
|
||||
/// row's own metadata.
|
||||
pub fn with_timestamps_and_owner(
|
||||
id: String,
|
||||
name: String,
|
||||
storage_path: StoragePath,
|
||||
parent_id: Option<String>,
|
||||
owner_id: Option<Uuid>,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
) -> FolderResult<Self> {
|
||||
Self::with_timestamps_and_tree(
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
parent_id,
|
||||
owner_id,
|
||||
Uuid::nil(),
|
||||
created_at,
|
||||
modified_at,
|
||||
@@ -209,7 +162,6 @@ impl Folder {
|
||||
name: String,
|
||||
storage_path: StoragePath,
|
||||
parent_id: Option<String>,
|
||||
owner_id: Option<Uuid>,
|
||||
drive_id: Uuid,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
@@ -220,7 +172,6 @@ impl Folder {
|
||||
name,
|
||||
storage_path,
|
||||
parent_id,
|
||||
owner_id,
|
||||
drive_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
@@ -239,7 +190,6 @@ impl Folder {
|
||||
name: String,
|
||||
storage_path: StoragePath,
|
||||
parent_id: Option<String>,
|
||||
owner_id: Option<Uuid>,
|
||||
drive_id: Uuid,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
@@ -260,7 +210,6 @@ impl Folder {
|
||||
storage_path,
|
||||
path_string,
|
||||
parent_id,
|
||||
owner_id,
|
||||
drive_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
@@ -299,10 +248,6 @@ impl Folder {
|
||||
self.modified_at
|
||||
}
|
||||
|
||||
pub fn owner_id(&self) -> Option<Uuid> {
|
||||
self.owner_id
|
||||
}
|
||||
|
||||
/// Drive that owns this folder. Path-based lookups scope by
|
||||
/// this axis (post-D0 invariant: `storage.folders.drive_id`
|
||||
/// is `NOT NULL`).
|
||||
@@ -412,7 +357,6 @@ impl Folder {
|
||||
storage_path,
|
||||
path_string: path,
|
||||
parent_id,
|
||||
owner_id: None,
|
||||
// DTO round-trips lose drive_id (FolderDto carries it,
|
||||
// but the legacy `from_dto` signature predates this
|
||||
// change). Callers that need real scoping must reload
|
||||
@@ -460,7 +404,6 @@ impl Folder {
|
||||
storage_path: new_storage_path,
|
||||
path_string: new_path_string,
|
||||
parent_id: self.parent_id.clone(),
|
||||
owner_id: self.owner_id,
|
||||
drive_id: self.drive_id,
|
||||
created_at: self.created_at,
|
||||
modified_at: now,
|
||||
@@ -501,7 +444,6 @@ impl Folder {
|
||||
storage_path: new_storage_path,
|
||||
path_string: new_path_string,
|
||||
parent_id,
|
||||
owner_id: self.owner_id,
|
||||
drive_id: self.drive_id,
|
||||
created_at: self.created_at,
|
||||
modified_at: now,
|
||||
@@ -593,7 +535,6 @@ mod tests {
|
||||
"folder".to_string(),
|
||||
StoragePath::from_string("/folder"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
1_000,
|
||||
2_000,
|
||||
@@ -615,7 +556,6 @@ mod tests {
|
||||
"a".to_string(),
|
||||
StoragePath::from_string("/a"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
0,
|
||||
0,
|
||||
@@ -627,7 +567,6 @@ mod tests {
|
||||
"b".to_string(),
|
||||
StoragePath::from_string("/b"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
0,
|
||||
0,
|
||||
@@ -650,7 +589,6 @@ mod tests {
|
||||
"folder".to_string(),
|
||||
StoragePath::from_string("/folder"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
1_000,
|
||||
2_000,
|
||||
@@ -662,7 +600,6 @@ mod tests {
|
||||
"folder".to_string(),
|
||||
StoragePath::from_string("/folder"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
1_000,
|
||||
2_000,
|
||||
|
||||
@@ -6,6 +6,14 @@ use crate::domain::entities::contact::AddressBook;
|
||||
|
||||
pub type AddressBookRepositoryResult<T> = Result<T, DomainError>;
|
||||
|
||||
/// Repository interface for AddressBook entity operations.
|
||||
///
|
||||
/// Post-Round-3, access-control state lives in `storage.role_grants`.
|
||||
/// The pre-Round-3 methods that read/wrote `carddav.address_book_shares`
|
||||
/// (`get_shared_address_books`, `share_address_book`,
|
||||
/// `unshare_address_book`, `get_address_book_shares`) have been removed
|
||||
/// from this trait, and the backing table was dropped in
|
||||
/// `20260906000002_drop_legacy_share_tables.sql`.
|
||||
pub trait AddressBookRepository: Send + Sync + 'static {
|
||||
async fn create_address_book(
|
||||
&self,
|
||||
@@ -20,28 +28,13 @@ pub trait AddressBookRepository: Send + Sync + 'static {
|
||||
&self,
|
||||
id: &Uuid,
|
||||
) -> AddressBookRepositoryResult<Option<AddressBook>>;
|
||||
/// Direct owner enumeration — same semantics as the calendar
|
||||
/// counterpart. The service layer prefers
|
||||
/// `authz.list_incoming_grants`, but internal maintenance paths
|
||||
/// keep the owner-only lookup available.
|
||||
async fn get_address_books_by_owner(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
) -> AddressBookRepositoryResult<Vec<AddressBook>>;
|
||||
async fn get_shared_address_books(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> AddressBookRepositoryResult<Vec<AddressBook>>;
|
||||
async fn get_public_address_books(&self) -> AddressBookRepositoryResult<Vec<AddressBook>>;
|
||||
async fn share_address_book(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
can_write: bool,
|
||||
) -> AddressBookRepositoryResult<()>;
|
||||
async fn unshare_address_book(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
) -> AddressBookRepositoryResult<()>;
|
||||
async fn get_address_book_shares(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
) -> AddressBookRepositoryResult<Vec<(String, bool)>>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@ use uuid::Uuid;
|
||||
|
||||
pub type CalendarRepositoryResult<T> = Result<T, DomainError>;
|
||||
|
||||
/// Repository interface for Calendar entity operations
|
||||
/// Repository interface for Calendar entity operations.
|
||||
///
|
||||
/// Post-Round-3, access-control state lives in `storage.role_grants` —
|
||||
/// the pre-Round-3 methods that read/wrote `caldav.calendar_shares`
|
||||
/// (`list_calendars_shared_with_user`, `user_has_calendar_access`,
|
||||
/// `share_calendar`, `remove_calendar_sharing`, `get_calendar_shares`)
|
||||
/// have been removed from this trait, and the backing table was dropped
|
||||
/// in `20260906000002_drop_legacy_share_tables.sql`.
|
||||
pub trait CalendarRepository: Send + Sync + 'static {
|
||||
/// Creates a new calendar
|
||||
async fn create_calendar(&self, calendar: Calendar) -> CalendarRepositoryResult<Calendar>;
|
||||
@@ -18,7 +25,11 @@ pub trait CalendarRepository: Send + Sync + 'static {
|
||||
/// Finds a calendar by its ID
|
||||
async fn find_calendar_by_id(&self, id: &Uuid) -> CalendarRepositoryResult<Calendar>;
|
||||
|
||||
/// Lists all calendars for a specific user
|
||||
/// Lists all calendars owned by a specific user. Post-Round-3 the
|
||||
/// service layer prefers `authz.list_incoming_grants` (surfaces
|
||||
/// owned + shared in one union), but this direct lookup remains
|
||||
/// available for internal maintenance / migration paths that need
|
||||
/// owner-only enumeration without going through the engine.
|
||||
async fn list_calendars_by_owner(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
@@ -31,12 +42,6 @@ pub trait CalendarRepository: Send + Sync + 'static {
|
||||
owner_id: Uuid,
|
||||
) -> CalendarRepositoryResult<Calendar>;
|
||||
|
||||
/// Lists calendars shared with a specific user
|
||||
async fn list_calendars_shared_with_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> CalendarRepositoryResult<Vec<Calendar>>;
|
||||
|
||||
/// List public calendars
|
||||
async fn list_public_calendars(
|
||||
&self,
|
||||
@@ -44,13 +49,6 @@ pub trait CalendarRepository: Send + Sync + 'static {
|
||||
offset: i64,
|
||||
) -> CalendarRepositoryResult<Vec<Calendar>>;
|
||||
|
||||
/// Checks if a user has access to a calendar
|
||||
async fn user_has_calendar_access(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
) -> CalendarRepositoryResult<bool>;
|
||||
|
||||
/// Gets a custom property for a calendar
|
||||
async fn get_calendar_property(
|
||||
&self,
|
||||
@@ -78,25 +76,4 @@ pub trait CalendarRepository: Send + Sync + 'static {
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
) -> CalendarRepositoryResult<std::collections::HashMap<String, String>>;
|
||||
|
||||
/// Share calendar with another user
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
access_level: &str,
|
||||
) -> CalendarRepositoryResult<()>;
|
||||
|
||||
/// Remove calendar sharing for a user
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
) -> CalendarRepositoryResult<()>;
|
||||
|
||||
/// Get calendar sharing information (who has access to this calendar)
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
) -> CalendarRepositoryResult<Vec<(String, String)>>;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ pub struct DriveWithRootName {
|
||||
/// of the root folder via JOIN at read time.
|
||||
pub root_folder_name: String,
|
||||
/// Highest role the calling user holds on this drive (direct OR
|
||||
/// group-mediated). Populated by `list_for_subjects` (which already
|
||||
/// group-mediated). Populated by `list_readable_by` (which already
|
||||
/// JOINs `role_grants` for accessibility, so the role is in scope at
|
||||
/// query time). `None` for repo methods called without a caller
|
||||
/// context (`get_by_id`, `get_by_ids`, `find_default_for_user`,
|
||||
@@ -164,17 +164,17 @@ pub trait DriveRepository: Send + Sync + 'static {
|
||||
}
|
||||
|
||||
/// List drives the caller can read, resolved via `role_grants` for
|
||||
/// `resource_type='drive'`. The caller's group memberships are
|
||||
/// expanded by the engine's `subject_match_set`; that expanded set
|
||||
/// is what this method's `subject_ids` argument carries.
|
||||
/// `resource_type='drive'`. Group memberships (direct + transitive)
|
||||
/// are expanded inline by the `storage.caller_group_ids(caller)`
|
||||
/// SQL function — callers pass only the caller's uuid, no
|
||||
/// expansion ceremony.
|
||||
///
|
||||
/// Returns rows in a stable order: default drive first (if any),
|
||||
/// then by display name. The `/api/drives` handler relies on that
|
||||
/// order for the picker UI without a follow-up sort.
|
||||
async fn list_for_subjects(
|
||||
async fn list_readable_by(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
|
||||
|
||||
/// `true` when the drive holds no live (non-trashed) folders other
|
||||
@@ -193,7 +193,7 @@ pub trait DriveRepository: Send + Sync + 'static {
|
||||
/// List every drive on the system, regardless of caller membership.
|
||||
///
|
||||
/// Used by the admin panel's `GET /api/admin/drives`. Distinct from
|
||||
/// `list_for_subjects` (which filters by `role_grants`) because an
|
||||
/// `list_readable_by` (which filters by `role_grants`) because an
|
||||
/// admin who creates a shared drive for someone else has no grant
|
||||
/// on it — but still needs to see, audit, and manage it. The HTTP
|
||||
/// gate (admin-only middleware) is what makes the unrestricted
|
||||
@@ -256,10 +256,18 @@ pub trait DriveRepository: Send + Sync + 'static {
|
||||
///
|
||||
/// Caller is responsible for the `Manage` permission check; this
|
||||
/// method does not re-verify.
|
||||
///
|
||||
/// `partial` is a raw JSON object carrying **only** the keys the
|
||||
/// caller wants to change — the repo passes it verbatim to the
|
||||
/// `policies || $partial` JSONB merge. Using the typed
|
||||
/// `DrivePolicies` here would serialise every field (including
|
||||
/// unset ones as `false`) and clobber other flags on the row;
|
||||
/// keeping the merge on the raw `Value` preserves the
|
||||
/// partial-update semantic the handler documents.
|
||||
async fn update_policies(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
partial: &crate::domain::entities::drive::DrivePolicies,
|
||||
partial: &serde_json::Value,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError>;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,17 @@ use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
use uuid::Uuid;
|
||||
|
||||
// NOTE on `caller_role` for the two listing methods below:
|
||||
// We deliberately do NOT compute or return the caller's role per row.
|
||||
// The frontend already fetches `/api/drives` (which surfaces
|
||||
// `caller_role` per drive) and cross-references by `folder.drive_id` —
|
||||
// see `MoveDialog.svelte` and the config/drive page. Adding
|
||||
// `caller_role` to `FolderDto` would either (a) mean redundant
|
||||
// server-side work for a client-side concern the client already
|
||||
// handles, or (b) drag folder-level grant cascades into the query
|
||||
// which is real cost for a rare edge case. Punted; see
|
||||
// `project_caller_role_on_file_folder_dto` memory.
|
||||
|
||||
/// Domain port for folder persistence.
|
||||
///
|
||||
/// Defines the CRUD and management operations required for
|
||||
@@ -51,13 +62,20 @@ pub trait FolderRepository: Send + Sync + 'static {
|
||||
/// Lists folders within a parent folder
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError>;
|
||||
|
||||
/// Lists root-level folders owned by a specific user.
|
||||
/// For non-root queries (parent_id is Some), ownership is implicit
|
||||
/// because the parent already belongs to the user.
|
||||
async fn list_folders_by_owner(
|
||||
/// Lists root-level folders the caller can read — scoped through
|
||||
/// drive-membership grants (`role_grants` on `resource_type='drive'`)
|
||||
/// rather than the legacy `folders.user_id` column. Group memberships
|
||||
/// are expanded inline by `storage.caller_group_ids($caller)` in the
|
||||
/// SQL. Closes [[bug-root-folder-listing-legacy-user-id]] — root
|
||||
/// folders admin created for other users but has no role on no
|
||||
/// longer surface in the admin's `GET /api/folders`.
|
||||
///
|
||||
/// Non-root queries (parent_id != None) go through `list_folders`
|
||||
/// with the parent already permission-checked at the service layer,
|
||||
/// so this method carries no `parent_id` parameter.
|
||||
async fn list_root_folders_for_caller(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<Folder>, DomainError>;
|
||||
|
||||
/// Lists folders with pagination
|
||||
@@ -69,13 +87,12 @@ pub trait FolderRepository: Send + Sync + 'static {
|
||||
include_total: bool,
|
||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
|
||||
|
||||
/// Lists folders with pagination, scoped to a specific owner.
|
||||
/// Combines the owner filtering of `list_folders_by_owner` with
|
||||
/// the pagination of `list_folders_paginated`.
|
||||
async fn list_folders_by_owner_paginated(
|
||||
/// Paginated companion to `list_root_folders_for_caller` — same
|
||||
/// drive-scoped predicate, adds LIMIT/OFFSET + optional
|
||||
/// window-function COUNT.
|
||||
async fn list_root_folders_for_caller_paginated(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
include_total: bool,
|
||||
@@ -162,31 +179,35 @@ pub trait FolderRepository: Send + Sync + 'static {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Lists all descendant folders in a subtree (ltree-based).
|
||||
/// Lists all descendant folders in a subtree (ltree-based), scoped
|
||||
/// to drives the caller can read.
|
||||
///
|
||||
/// Returns all folders whose lpath is a descendant of the given folder's
|
||||
/// lpath. Used for recursive search — O(1) SQL via GiST index instead
|
||||
/// of O(N) recursive traversal.
|
||||
/// Returns all folders whose lpath is a descendant of the given
|
||||
/// folder's lpath. Used for recursive search — O(1) SQL via GiST
|
||||
/// index instead of O(N) recursive traversal. Drive-membership
|
||||
/// filtering (including group cascade via `caller_group_ids`) is
|
||||
/// applied inline in the SQL.
|
||||
///
|
||||
/// The default implementation returns an empty vec (stubs / mocks).
|
||||
async fn list_descendant_folders(
|
||||
&self,
|
||||
folder_id: &str,
|
||||
name_contains: Option<&str>,
|
||||
user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let _ = (folder_id, name_contains, user_id);
|
||||
let _ = (folder_id, name_contains, caller_id);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Search folders with SQL-level filtering by name, user, and scope.
|
||||
/// Search folders with SQL-level filtering by name and scope,
|
||||
/// restricted to drives the caller can read.
|
||||
///
|
||||
/// - **Non-recursive** (`recursive = false`): searches direct children of
|
||||
/// `parent_id` (or root folders when `None`).
|
||||
/// - **Recursive with `parent_id`**: delegates to `list_descendant_folders`
|
||||
/// (ltree GiST-indexed scan).
|
||||
/// - **Recursive without `parent_id`**: searches ALL folders owned by
|
||||
/// `user_id` with optional name filter in SQL.
|
||||
/// - **Recursive without `parent_id`**: searches ALL folders in drives
|
||||
/// the caller can read, with optional name filter in SQL.
|
||||
///
|
||||
/// The default implementation falls back to `list_folders` + in-memory
|
||||
/// filter so that stubs and mocks compile without changes.
|
||||
@@ -194,13 +215,13 @@ pub trait FolderRepository: Send + Sync + 'static {
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
name_contains: Option<&str>,
|
||||
user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
recursive: bool,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
// Recursive with folder_id → use optimised ltree scan
|
||||
if recursive && let Some(fid) = parent_id {
|
||||
return self
|
||||
.list_descendant_folders(fid, name_contains, user_id)
|
||||
.list_descendant_folders(fid, name_contains, caller_id)
|
||||
.await;
|
||||
}
|
||||
// Fallback: load + filter in memory (stubs / mocks)
|
||||
|
||||
@@ -27,7 +27,7 @@ pub trait TrashRepository: Send + Sync {
|
||||
///
|
||||
/// **Caller contract**: pass only drive UUIDs the caller has
|
||||
/// `Permission::Delete` on (resolved by the service via
|
||||
/// `DriveRepository::list_for_subjects` + role-bundle filter). This
|
||||
/// `DriveRepository::list_readable_by` + role-bundle filter). This
|
||||
/// repository performs no authorization — see
|
||||
/// `TrashService::empty_trash` for the canonical call site.
|
||||
async fn clear_trash(&self, drive_ids: &[Uuid]) -> Result<()>;
|
||||
|
||||
@@ -78,12 +78,24 @@ pub enum Resource {
|
||||
/// membership and policy bag. Added in D0; membership lives in
|
||||
/// `storage.role_grants` (no separate `drive_members` table).
|
||||
Drive(Uuid),
|
||||
// Reserved for future use:
|
||||
// Calendar(Uuid),
|
||||
// Reserved for future use:
|
||||
// AddressBook(Uuid),
|
||||
// Reserved for future use:
|
||||
// Playlist(Uuid),
|
||||
/// A CalDAV calendar. Membership + sharing lives in
|
||||
/// `storage.role_grants` with `resource_type='calendar'` —
|
||||
/// replaces the pre-Round-3 dedicated `caldav.calendar_shares`
|
||||
/// table and the `check_calendar_access` bespoke helper. No
|
||||
/// cascade parent (calendars are top-level per user); the engine
|
||||
/// resolves directly against `role_grants` on the resource.
|
||||
Calendar(Uuid),
|
||||
/// A CardDAV address book. Same shape as `Calendar` —
|
||||
/// `storage.role_grants` with `resource_type='address_book'`
|
||||
/// replaces `carddav.address_book_shares` and the
|
||||
/// `check_address_book_access` bespoke helper.
|
||||
AddressBook(Uuid),
|
||||
/// A music playlist. Same shape as `Calendar`/`AddressBook` —
|
||||
/// `storage.role_grants` with `resource_type='playlist'` replaces
|
||||
/// the pre-Round-3 dedicated `music.playlist_shares` table and the
|
||||
/// bespoke `user_has_access` / `user_can_write` helpers on
|
||||
/// `MusicStorageAdapter`.
|
||||
Playlist(Uuid),
|
||||
}
|
||||
|
||||
impl Resource {
|
||||
@@ -92,18 +104,20 @@ impl Resource {
|
||||
Resource::Folder(_) => "folder",
|
||||
Resource::File(_) => "file",
|
||||
Resource::Drive(_) => "drive",
|
||||
//Resource::Calendar(_) => "calendar",
|
||||
//Resource::AddressBook(_) => "adressbook",
|
||||
//Resource::Playlist(_) => "playlist",
|
||||
Resource::Calendar(_) => "calendar",
|
||||
Resource::AddressBook(_) => "address_book",
|
||||
Resource::Playlist(_) => "playlist",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Uuid {
|
||||
match self {
|
||||
Resource::Folder(id) | Resource::File(id) | Resource::Drive(id) => *id,
|
||||
//| Resource::Calendar(id)
|
||||
//| Resource::AddressBook(id)
|
||||
//| Resource::Playlist(id)
|
||||
Resource::Folder(id)
|
||||
| Resource::File(id)
|
||||
| Resource::Drive(id)
|
||||
| Resource::Calendar(id)
|
||||
| Resource::AddressBook(id)
|
||||
| Resource::Playlist(id) => *id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,12 +126,40 @@ impl Resource {
|
||||
"folder" => Some(Resource::Folder(id)),
|
||||
"file" => Some(Resource::File(id)),
|
||||
"drive" => Some(Resource::Drive(id)),
|
||||
//"calendar" => Some(Resource::Calendar(id)),
|
||||
//"adressbook" => Some(Resource::AddressBook(id)),
|
||||
//"playlist" => Some(Resource::Playlist(id)),
|
||||
"calendar" => Some(Resource::Calendar(id)),
|
||||
"address_book" => Some(Resource::AddressBook(id)),
|
||||
"playlist" => Some(Resource::Playlist(id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `(item_type, item_id)` from an API-facing pair of strings
|
||||
/// (favorites, recent, batch endpoints all take this shape).
|
||||
/// Combines UUID parse + type mapping so callers stay one-line and
|
||||
/// error shapes are identical across surfaces. Returns
|
||||
/// `DomainError::new(InvalidInput, …)` on malformed input; callers
|
||||
/// that need the anti-enum 404 shape do that separately by feeding
|
||||
/// the parsed `Resource` into `authz.require(...)`.
|
||||
pub fn parse(
|
||||
item_type: &str,
|
||||
item_id: &str,
|
||||
) -> Result<Self, crate::common::errors::DomainError> {
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
let uuid = Uuid::parse_str(item_id).map_err(|_| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Resource",
|
||||
format!("Invalid item UUID '{item_id}'"),
|
||||
)
|
||||
})?;
|
||||
Self::from_parts(item_type, uuid).ok_or_else(|| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Resource",
|
||||
format!("Unsupported item type '{item_type}'"),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Resource {
|
||||
@@ -508,11 +550,16 @@ mod tests {
|
||||
#[test]
|
||||
fn resource_roundtrip() {
|
||||
let id = Uuid::new_v4();
|
||||
for r in [Resource::Folder(id), Resource::File(id)] {
|
||||
for r in [
|
||||
Resource::Folder(id),
|
||||
Resource::File(id),
|
||||
Resource::Calendar(id),
|
||||
Resource::AddressBook(id),
|
||||
Resource::Playlist(id),
|
||||
] {
|
||||
let back = Resource::from_parts(r.type_str(), r.id()).unwrap();
|
||||
assert_eq!(r, back);
|
||||
}
|
||||
assert!(Resource::from_parts("calendar", id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -126,17 +126,6 @@ impl CalendarStoragePort for CalendarStorageAdapter {
|
||||
Ok(calendars.into_iter().map(CalendarDto::from).collect())
|
||||
}
|
||||
|
||||
async fn list_calendars_shared_with_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
let calendars = self
|
||||
.calendar_repository
|
||||
.list_calendars_shared_with_user(user_id)
|
||||
.await?;
|
||||
Ok(calendars.into_iter().map(CalendarDto::from).collect())
|
||||
}
|
||||
|
||||
async fn list_public_calendars(
|
||||
&self,
|
||||
limit: i64,
|
||||
@@ -149,78 +138,6 @@ impl CalendarStoragePort for CalendarStorageAdapter {
|
||||
Ok(calendars.into_iter().map(CalendarDto::from).collect())
|
||||
}
|
||||
|
||||
async fn check_calendar_access(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id).map_err(|_| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
"Invalid calendar ID format",
|
||||
)
|
||||
})?;
|
||||
|
||||
self.calendar_repository
|
||||
.user_has_calendar_access(&uuid, user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
// Calendar sharing
|
||||
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
access_level: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id).map_err(|_| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
"Invalid calendar ID format",
|
||||
)
|
||||
})?;
|
||||
|
||||
self.calendar_repository
|
||||
.share_calendar(&uuid, user_id, access_level)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id).map_err(|_| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
"Invalid calendar ID format",
|
||||
)
|
||||
})?;
|
||||
|
||||
self.calendar_repository
|
||||
.remove_calendar_sharing(&uuid, user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
) -> Result<Vec<(String, String)>, DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id).map_err(|_| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
"Invalid calendar ID format",
|
||||
)
|
||||
})?;
|
||||
|
||||
self.calendar_repository.get_calendar_shares(&uuid).await
|
||||
}
|
||||
|
||||
// Calendar properties
|
||||
|
||||
async fn set_calendar_property(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -184,44 +184,6 @@ impl AddressBookRepository for AddressBookPgRepository {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn get_shared_address_books(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> AddressBookRepositoryResult<Vec<AddressBook>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT a.id, a.name, a.owner_id, a.description, a.color, a.is_public, a.created_at, a.updated_at
|
||||
FROM carddav.address_books a
|
||||
INNER JOIN carddav.address_book_shares s ON a.id = s.address_book_id
|
||||
WHERE s.user_id = $1
|
||||
ORDER BY a.name
|
||||
"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::database_error(format!("Failed to get shared address books: {}", e)))?;
|
||||
|
||||
let result = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let owner_id: Uuid = row.get("owner_id");
|
||||
AddressBook::from_raw(
|
||||
row.get("id"),
|
||||
row.get("name"),
|
||||
owner_id.to_string(),
|
||||
row.get("description"),
|
||||
row.get("color"),
|
||||
row.get("is_public"),
|
||||
row.get("created_at"),
|
||||
row.get("updated_at"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn get_public_address_books(&self) -> AddressBookRepositoryResult<Vec<AddressBook>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
@@ -256,79 +218,4 @@ impl AddressBookRepository for AddressBookPgRepository {
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn share_address_book(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
can_write: bool,
|
||||
) -> AddressBookRepositoryResult<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO carddav.address_book_shares (address_book_id, user_id, can_write)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (address_book_id, user_id) DO UPDATE SET can_write = $3
|
||||
"#,
|
||||
)
|
||||
.bind(address_book_id)
|
||||
.bind(user_id)
|
||||
.bind(can_write)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::database_error(format!("Failed to share address book: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unshare_address_book(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
) -> AddressBookRepositoryResult<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM carddav.address_book_shares
|
||||
WHERE address_book_id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(address_book_id)
|
||||
.bind(user_id)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("Failed to unshare address book: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_address_book_shares(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
) -> AddressBookRepositoryResult<Vec<(String, bool)>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT user_id, can_write
|
||||
FROM carddav.address_book_shares
|
||||
WHERE address_book_id = $1
|
||||
ORDER BY user_id
|
||||
"#,
|
||||
)
|
||||
.bind(address_book_id)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("Failed to get address book shares: {}", e))
|
||||
})?;
|
||||
|
||||
let result = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let user_id: Uuid = row.get("user_id");
|
||||
(user_id.to_string(), row.get("can_write"))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,44 +216,6 @@ impl CalendarRepository for CalendarPgRepository {
|
||||
Ok(calendar)
|
||||
}
|
||||
|
||||
async fn list_calendars_shared_with_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> CalendarRepositoryResult<Vec<Calendar>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT c.id, c.name, c.owner_id, c.description, c.color, c.is_public, c.created_at, c.updated_at
|
||||
FROM caldav.calendars c
|
||||
INNER JOIN caldav.calendar_shares s ON c.id = s.calendar_id
|
||||
WHERE s.user_id = $1
|
||||
ORDER BY c.name
|
||||
"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::database_error(format!("Failed to get shared calendars: {}", e)))?;
|
||||
|
||||
let mut calendars = Vec::new();
|
||||
for row in rows {
|
||||
let calendar = Calendar::with_id(
|
||||
row.get("id"),
|
||||
row.get("name"),
|
||||
row.get("owner_id"),
|
||||
row.get("description"),
|
||||
row.get("color"),
|
||||
row.get("created_at"),
|
||||
row.get("updated_at"),
|
||||
)
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("Failed to create calendar object: {}", e))
|
||||
})?;
|
||||
calendars.push(calendar);
|
||||
}
|
||||
|
||||
Ok(calendars)
|
||||
}
|
||||
|
||||
async fn list_public_calendars(
|
||||
&self,
|
||||
limit: i64,
|
||||
@@ -296,112 +258,6 @@ impl CalendarRepository for CalendarPgRepository {
|
||||
Ok(calendars)
|
||||
}
|
||||
|
||||
async fn user_has_calendar_access(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
) -> CalendarRepositoryResult<bool> {
|
||||
// Check if the user is the owner of the calendar or has a share
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM caldav.calendars c
|
||||
WHERE c.id = $1 AND (c.owner_id = $2 OR c.is_public = true)
|
||||
UNION
|
||||
SELECT 1 FROM caldav.calendar_shares s
|
||||
WHERE s.calendar_id = $1 AND s.user_id = $2
|
||||
) as has_access
|
||||
"#,
|
||||
)
|
||||
.bind(calendar_id)
|
||||
.bind(user_id)
|
||||
.fetch_one(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("Failed to check calendar access: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(row.get::<bool, _>("has_access"))
|
||||
}
|
||||
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
access_level: &str,
|
||||
) -> CalendarRepositoryResult<()> {
|
||||
// Validate access level
|
||||
if !["read", "write", "owner"].contains(&access_level) {
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid access level: '{}'. Must be 'read', 'write', or 'owner'",
|
||||
access_level
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO caldav.calendar_shares (calendar_id, user_id, access_level)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (calendar_id, user_id) DO UPDATE SET access_level = $3
|
||||
"#,
|
||||
)
|
||||
.bind(calendar_id)
|
||||
.bind(user_id)
|
||||
.bind(access_level)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::database_error(format!("Failed to share calendar: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
user_id: Uuid,
|
||||
) -> CalendarRepositoryResult<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM caldav.calendar_shares
|
||||
WHERE calendar_id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(calendar_id)
|
||||
.bind(user_id)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::database_error(format!("Failed to unshare calendar: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
) -> CalendarRepositoryResult<Vec<(String, String)>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT user_id, access_level
|
||||
FROM caldav.calendar_shares
|
||||
WHERE calendar_id = $1
|
||||
ORDER BY user_id
|
||||
"#,
|
||||
)
|
||||
.bind(calendar_id)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("Failed to get calendar shares: {}", e))
|
||||
})?;
|
||||
|
||||
let mut shares = Vec::new();
|
||||
for row in rows {
|
||||
shares.push((row.get("user_id"), row.get("access_level")));
|
||||
}
|
||||
|
||||
Ok(shares)
|
||||
}
|
||||
|
||||
async fn get_calendar_property(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! The repo deals only with the `storage.drives` table itself. Drive
|
||||
//! membership lives in `storage.role_grants` (`resource_type='drive'`)
|
||||
//! and is queried through the engine's existing grant paths;
|
||||
//! `list_for_subjects` below resolves `role_grants` → `storage.drives`
|
||||
//! `list_readable_by` below resolves `role_grants` → `storage.drives`
|
||||
//! via a single join.
|
||||
//!
|
||||
//! See `migrations/20260802000000_drives_schema_additive.sql` for the
|
||||
@@ -75,7 +75,7 @@ impl DrivePgRepository {
|
||||
/// is declared owner→viewer (strongest→weakest), so `MIN` picks the
|
||||
/// strongest of the caller's grants on the drive (direct +
|
||||
/// group-mediated collapsed by GROUP BY). Used only by
|
||||
/// `list_for_subjects`.
|
||||
/// `list_readable_by`.
|
||||
fn row_to_drive_with_name_and_role(
|
||||
row: &sqlx::postgres::PgRow,
|
||||
) -> Result<DriveWithRootName, DriveRepositoryError> {
|
||||
@@ -116,11 +116,22 @@ impl DriveRepository for DrivePgRepository {
|
||||
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.begin", e))?;
|
||||
|
||||
// 1. Drive row (root_folder_id NULL — populated in step 3).
|
||||
//
|
||||
// Default personal drives are seeded with `include_in_photo_index`
|
||||
// + `include_in_music_index` = true so the Photos / Music
|
||||
// predicates (§15) can be a single positive rule keyed off the
|
||||
// JSONB flag — no per-kind carve-out needed at query time. Any
|
||||
// future admin PATCH toggling either flag off shows a confirm
|
||||
// dialog in the UI (unusual action; empties the user's Photos
|
||||
// timeline / Music library).
|
||||
let drive_id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO storage.drives
|
||||
(kind, default_for_user, quota_bytes, policies)
|
||||
VALUES ('personal', $1, $2, '{}'::jsonb)
|
||||
VALUES (
|
||||
'personal', $1, $2,
|
||||
'{"include_in_photo_index": true, "include_in_music_index": true}'::jsonb
|
||||
)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
@@ -132,11 +143,16 @@ impl DriveRepository for DrivePgRepository {
|
||||
|
||||
// 2. Root folder. `parent_id IS NULL` makes it a root in the
|
||||
// drive; `drive_id` closes the FK in this direction.
|
||||
//
|
||||
// Post-D7: `user_id` omitted from the INSERT column list —
|
||||
// the column is nullable and no longer written to on new
|
||||
// rows. `created_by` / `updated_by` bind to the owner
|
||||
// (§14 provenance).
|
||||
let folder_id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO storage.folders
|
||||
(name, parent_id, user_id, drive_id, created_by, updated_by)
|
||||
VALUES ('Personal', NULL, $1, $2, $1, $1)
|
||||
(name, parent_id, drive_id, created_by, updated_by)
|
||||
VALUES ('Personal', NULL, $2, $1, $1)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
@@ -233,14 +249,14 @@ impl DriveRepository for DrivePgRepository {
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.drive", e))?;
|
||||
|
||||
// 2. Root folder. The folder's `user_id` carries the admin (legacy
|
||||
// column still NOT NULL during the dual-write window — D7
|
||||
// drops it once `drive_id` is the canonical ownership signal).
|
||||
// 2. Root folder. Post-D7: `user_id` omitted — the column is
|
||||
// nullable and unused on new rows. `created_by` / `updated_by`
|
||||
// bind to `granted_by` (§14 provenance).
|
||||
let folder_id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO storage.folders
|
||||
(name, parent_id, user_id, drive_id, created_by, updated_by)
|
||||
VALUES ($1, NULL, $2, $3, $2, $2)
|
||||
(name, parent_id, drive_id, created_by, updated_by)
|
||||
VALUES ($1, NULL, $3, $2, $2)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
@@ -452,13 +468,15 @@ impl DriveRepository for DrivePgRepository {
|
||||
Self::row_to_drive_with_name(&row)
|
||||
}
|
||||
|
||||
async fn list_for_subjects(
|
||||
async fn list_readable_by(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
|
||||
// Joining role_grants → drives → folders returns every drive the
|
||||
// expanded subject set can read, paired with its display name.
|
||||
// caller can read, paired with its display name. Group
|
||||
// memberships (direct + transitive) are expanded inline by
|
||||
// `storage.caller_group_ids($caller)` — no Rust-side ceremony.
|
||||
//
|
||||
// ORDER BY puts default drives first (so the picker UI doesn't
|
||||
// need a follow-up sort), then alphabetical by name. GROUP BY
|
||||
// collapses duplicate role_grants on the same drive (direct +
|
||||
@@ -470,8 +488,6 @@ impl DriveRepository for DrivePgRepository {
|
||||
// weakest), so MIN returns the strongest. Cast `::text` matches
|
||||
// the codebase convention for reading enum columns into Rust
|
||||
// (see `pg_acl_engine.rs`); `Role::parse` handles the trip back.
|
||||
// Collapses direct + group-mediated grants on the same drive
|
||||
// into one row alongside the existing GROUP BY.
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
|
||||
@@ -484,8 +500,11 @@ impl DriveRepository for DrivePgRepository {
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
WHERE (
|
||||
(g.subject_type = 'user' AND g.subject_id = $1)
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN
|
||||
(SELECT storage.caller_group_ids($1)))
|
||||
)
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
GROUP BY d.id, d.kind, d.default_for_user, d.root_folder_id,
|
||||
d.quota_bytes, d.used_bytes, d.policies,
|
||||
@@ -494,16 +513,10 @@ impl DriveRepository for DrivePgRepository {
|
||||
LOWER(f.name) ASC
|
||||
"#,
|
||||
)
|
||||
.bind(
|
||||
subject_types
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.bind(subject_ids)
|
||||
.bind(caller_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?;
|
||||
.map_err(|e| Self::map_sqlx_err("list_readable_by", e))?;
|
||||
|
||||
rows.iter()
|
||||
.map(Self::row_to_drive_with_name_and_role)
|
||||
@@ -636,16 +649,17 @@ impl DriveRepository for DrivePgRepository {
|
||||
async fn update_policies(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
partial: &crate::domain::entities::drive::DrivePolicies,
|
||||
partial: &serde_json::Value,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError> {
|
||||
// JSONB-level merge (`||`) keeps unknown keys already on disk —
|
||||
// the column remains the canonical bag (see
|
||||
// `DrivePolicies::from_value` — typed read is lenient, untyped
|
||||
// write is preserving). RETURNING surfaces the post-merge bag so
|
||||
// the audit log shows what the row actually carries afterwards.
|
||||
let partial_json = serde_json::to_value(partial).map_err(|e| {
|
||||
DriveRepositoryError::StorageError(format!("serialise partial policies: {e}"))
|
||||
})?;
|
||||
// write is preserving). The caller passes a raw `Value` with
|
||||
// ONLY the keys it wants to change (never a full `DrivePolicies`
|
||||
// round-trip, which would serialise all-false defaults into the
|
||||
// merge and clobber other flags). RETURNING surfaces the
|
||||
// post-merge bag so the audit log shows what the row actually
|
||||
// carries afterwards.
|
||||
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
||||
"UPDATE storage.drives \
|
||||
SET policies = policies || $2, \
|
||||
@@ -654,7 +668,7 @@ impl DriveRepository for DrivePgRepository {
|
||||
RETURNING policies",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.bind(&partial_json)
|
||||
.bind(partial)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| Self::map_sqlx_err("update_policies", e))?;
|
||||
|
||||
@@ -41,8 +41,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
WHEN uf.item_type = 'folder' THEN fld.path
|
||||
WHEN uf.item_type = 'file' THEN COALESCE(pfld.path || '/' || f.name, f.name)
|
||||
ELSE NULL
|
||||
END AS "item_path",
|
||||
COALESCE(f.user_id, fld.user_id)::TEXT AS "owner_id"
|
||||
END AS "item_path"
|
||||
FROM auth.user_favorites uf
|
||||
LEFT JOIN storage.files f ON uf.item_type = 'file'
|
||||
AND f.id = uf.item_id::UUID
|
||||
@@ -82,7 +81,6 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
parent_id: row.try_get("parent_id").ok(),
|
||||
modified_at: row.try_get("modified_at").ok(),
|
||||
item_path: row.try_get("item_path").ok(),
|
||||
owner_id: row.try_get("owner_id").ok(),
|
||||
// Temporary defaults; with_display_fields() computes the real values
|
||||
icon_class: String::new(),
|
||||
icon_special_class: String::new(),
|
||||
@@ -309,10 +307,18 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
-1::bigint AS size,
|
||||
fld.created_at AS resource_created_at,
|
||||
fld.updated_at AS modified_at,
|
||||
fld.user_id AS owner_id,
|
||||
fld.drive_id AS drive_id,
|
||||
NULL::text AS blob_hash,
|
||||
(fld.user_id = $1::uuid) AS is_owner,
|
||||
fld.created_by AS created_by,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
AND g.resource_id = fld.drive_id
|
||||
AND g.role = 'owner'
|
||||
AND g.subject_type = 'user'
|
||||
AND g.subject_id = $1::uuid
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
) AS is_owner,
|
||||
uf.created_at AS favorited_at,
|
||||
fld.path::text AS resource_path,
|
||||
LOWER(fld.name) AS sort_str,
|
||||
@@ -333,10 +339,18 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
f.size::bigint,
|
||||
f.created_at AS resource_created_at,
|
||||
f.updated_at AS modified_at,
|
||||
f.user_id AS owner_id,
|
||||
f.drive_id AS drive_id,
|
||||
f.blob_hash,
|
||||
(f.user_id = $1::uuid) AS is_owner,
|
||||
f.created_by AS created_by,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
AND g.resource_id = f.drive_id
|
||||
AND g.role = 'owner'
|
||||
AND g.subject_type = 'user'
|
||||
AND g.subject_id = $1::uuid
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
) AS is_owner,
|
||||
uf.created_at AS favorited_at,
|
||||
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
|
||||
LOWER(f.name) AS sort_str,
|
||||
@@ -489,7 +503,8 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
};
|
||||
|
||||
let user_join = if need_user_join {
|
||||
"LEFT JOIN auth.users u ON u.id = r.owner_id"
|
||||
// Post-D7: `owner_id` retired; join by `created_by`.
|
||||
"LEFT JOIN auth.users u ON u.id = r.created_by"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
@@ -506,7 +521,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
SELECT
|
||||
r.resource_type, r.resource_id, r.name, r.parent_id,
|
||||
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
||||
r.owner_id, r.drive_id, r.is_owner, r.favorited_at, r.resource_path,
|
||||
r.drive_id, r.is_owner, r.favorited_at, r.resource_path,
|
||||
r.sort_str, r.type_order, r.folder_first{username_col}
|
||||
FROM resources r
|
||||
{user_join}
|
||||
@@ -577,7 +592,6 @@ LIMIT $6"
|
||||
size,
|
||||
resource_created_at: row.get("resource_created_at"),
|
||||
modified_at: row.get("modified_at"),
|
||||
owner_id: row.get("owner_id"),
|
||||
drive_id: row.get("drive_id"),
|
||||
blob_hash: row.try_get("blob_hash").ok(),
|
||||
is_owner: row.try_get("is_owner").unwrap_or(false),
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//! materialized path column), so no recursive CTEs or N+1 queries are needed.
|
||||
|
||||
/// Row shape returned by media-file queries (avoids `clippy::type_complexity`).
|
||||
/// Post-D7-step-6: `storage.files.user_id` dropped, so it's no
|
||||
/// longer projected.
|
||||
type MediaFileRow = (
|
||||
String, // id
|
||||
String, // name
|
||||
@@ -18,7 +20,6 @@ type MediaFileRow = (
|
||||
i64, // created_at
|
||||
i64, // updated_at
|
||||
String, // blob_hash
|
||||
Option<Uuid>, // user_id
|
||||
Option<Uuid>, // created_by (§14 provenance)
|
||||
Option<Uuid>, // updated_by (§14 provenance)
|
||||
i64, // sort_date
|
||||
@@ -43,10 +44,44 @@ use crate::domain::services::path_service::StoragePath;
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// SQL `EXISTS (…)` predicate — true when the caller (bound to `$1`) has
|
||||
/// any active `role_grants` on the drive owning `fi` (the aliased file
|
||||
/// row). Group memberships (direct + transitive) are expanded inline via
|
||||
/// `storage.caller_group_ids($1)` (recursive; see migration
|
||||
/// `20260901000002_caller_group_ids_function.sql`).
|
||||
///
|
||||
/// Used by every drive-scoped file search query in this repo:
|
||||
/// - `search_files_paginated`
|
||||
/// - `search_files_in_subtree`
|
||||
///
|
||||
/// **Alias contract**: queries splicing this in MUST alias
|
||||
/// `storage.files` as `fi`. `$1` is reserved for `caller_id`; other bind
|
||||
/// params start at `$2`.
|
||||
///
|
||||
/// This mirrors — but is intentionally not shared with — the folder
|
||||
/// variant in `folder_db_repository.rs` (aliased `fo.drive_id`) and the
|
||||
/// drive-listing shapes in `drive_pg_repository`/`list_media_files`.
|
||||
/// When the grant model changes, update all sites in parallel.
|
||||
const CALLER_CAN_READ_DRIVE: &str = "EXISTS (\
|
||||
SELECT 1 \
|
||||
FROM storage.role_grants g \
|
||||
WHERE g.resource_type = 'drive' \
|
||||
AND g.resource_id = fi.drive_id \
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW()) \
|
||||
AND ( \
|
||||
(g.subject_type = 'user' AND g.subject_id = $1) \
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN \
|
||||
(SELECT storage.caller_group_ids($1))) \
|
||||
) \
|
||||
)";
|
||||
|
||||
/// Type alias for file metadata rows from SQL queries.
|
||||
/// Fields: id, name, folder_id, folder_path, size, mime_type,
|
||||
/// created_at, updated_at, blob_hash, user_id, created_by, updated_by.
|
||||
/// created_at, updated_at, blob_hash, created_by, updated_by.
|
||||
/// `created_by` / `updated_by` are the §14 provenance columns.
|
||||
/// Post-D7-step-6: `storage.files.user_id` dropped, so it's no
|
||||
/// longer part of the tuple; `row_to_file` populates the entity's
|
||||
/// legacy `user_id` field with `None`.
|
||||
type FileRow = (
|
||||
String,
|
||||
String,
|
||||
@@ -59,7 +94,6 @@ type FileRow = (
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
);
|
||||
|
||||
/// Append the optional type/date/size filters from `criteria` to
|
||||
@@ -200,7 +234,7 @@ impl FileBlobReadRepository {
|
||||
&self,
|
||||
ids: &[String],
|
||||
criteria: &SearchCriteriaDto,
|
||||
user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<File>, DomainError> {
|
||||
// Index hits are externally produced strings — parse defensively.
|
||||
let uuid_ids: Vec<Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
|
||||
@@ -208,9 +242,15 @@ impl FileBlobReadRepository {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Post-PR-B: drive-membership scoping via
|
||||
// [`CALLER_CAN_READ_DRIVE`] (bound to `$1`) replaces the legacy
|
||||
// `fi.user_id = $caller` predicate. Group grants are honoured
|
||||
// inline through `storage.caller_group_ids`.
|
||||
//
|
||||
// Bind order: $1 = caller_id, $2 = ids array, $3.. = criteria.
|
||||
let mut conditions: Vec<String> = vec![
|
||||
"fi.id = ANY($1)".to_string(),
|
||||
"fi.user_id = $2".to_string(),
|
||||
CALLER_CAN_READ_DRIVE.to_string(),
|
||||
"fi.id = ANY($2)".to_string(),
|
||||
"fi.is_trashed = false".to_string(),
|
||||
];
|
||||
let mut bind_idx = 2u32;
|
||||
@@ -234,7 +274,7 @@ impl FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
|
||||
fi.blob_hash, \
|
||||
fi.user_id, \
|
||||
\
|
||||
fi.created_by, fi.updated_by \
|
||||
FROM storage.files fi \
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
|
||||
@@ -242,8 +282,8 @@ impl FileBlobReadRepository {
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<_, FileRow>(&sql)
|
||||
.bind(uuid_ids)
|
||||
.bind(user_id);
|
||||
.bind(caller_id)
|
||||
.bind(uuid_ids);
|
||||
if let Some(folder_id) = criteria.folder_id.as_deref() {
|
||||
query = query.bind(folder_id);
|
||||
}
|
||||
@@ -255,10 +295,8 @@ impl FileBlobReadRepository {
|
||||
|
||||
rows.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
|
||||
Self::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
|
||||
)
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| {
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
@@ -286,7 +324,7 @@ impl FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
|
||||
fi.blob_hash, \
|
||||
fi.user_id, \
|
||||
\
|
||||
fi.created_by, fi.updated_by \
|
||||
FROM storage.files fi \
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
|
||||
@@ -301,10 +339,8 @@ impl FileBlobReadRepository {
|
||||
|
||||
rows.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
|
||||
Self::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
|
||||
)
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| {
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
@@ -316,20 +352,6 @@ impl FileBlobReadRepository {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the user_id (owner) for a given file ID.
|
||||
/// Mirrors `FolderDbRepository::get_folder_user_id`.
|
||||
/// Used by the AuthorizationEngine for owner short-circuit.
|
||||
pub async fn get_file_user_id(&self, file_id: &str) -> Result<uuid::Uuid, DomainError> {
|
||||
sqlx::query_scalar::<_, uuid::Uuid>("SELECT user_id FROM storage.files WHERE id = $1::uuid")
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("user_id lookup: {e}"))
|
||||
})?
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id))
|
||||
}
|
||||
|
||||
/// Returns `drive_id` for a given file. Drives the permission-floor
|
||||
/// short-circuit in `PgAclEngine::check_inner` — drive membership is
|
||||
/// the baseline floor per `drive.md §5`.
|
||||
@@ -386,7 +408,6 @@ impl FileBlobReadRepository {
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
blob_hash: String,
|
||||
owner_id: Option<Uuid>,
|
||||
created_by: Option<Uuid>,
|
||||
updated_by: Option<Uuid>,
|
||||
) -> Result<File, DomainError> {
|
||||
@@ -400,7 +421,6 @@ impl FileBlobReadRepository {
|
||||
folder_id,
|
||||
created_at as u64,
|
||||
modified_at as u64,
|
||||
owner_id,
|
||||
blob_hash,
|
||||
created_by,
|
||||
updated_by,
|
||||
@@ -445,12 +465,32 @@ impl FileBlobReadRepository {
|
||||
///
|
||||
/// Uses the denormalised `media_sort_date` column (synced from
|
||||
/// `file_metadata.captured_at` by trigger) so no JOIN with
|
||||
/// `file_metadata` is needed. The partial index
|
||||
/// `idx_files_media_timeline` covers the full query: filter + ORDER BY
|
||||
/// in a single Index Scan — O(LIMIT) not O(N).
|
||||
/// `file_metadata` is needed. The partial covering index
|
||||
/// `idx_files_media_timeline_by_drive` (migration 20260901000001)
|
||||
/// keys on `(drive_id, media_sort_date DESC)` filtered on non-trashed
|
||||
/// image/video rows — Postgres does one IndexScan per in-scope
|
||||
/// drive_id already ordered by capture date, so LIMIT stops the scan
|
||||
/// early. Same O(LIMIT) shape as the pre-D7 `user_id`-keyed hot path.
|
||||
///
|
||||
/// Scope (`docs/plan/drive.md` §15): drives with
|
||||
/// `policies.include_in_photo_index = true` where the caller has a
|
||||
/// direct grant (`subject_type = 'user'`) OR a grant on a group they
|
||||
/// belong to transitively. Group membership is expanded inline by the
|
||||
/// `storage.caller_group_ids(caller)` SQL function (migration
|
||||
/// `20260901000002_caller_group_ids_function.sql`) — no ceremony at
|
||||
/// the handler layer, no cross-space ambiguity from the earlier
|
||||
/// parallel-arrays pattern.
|
||||
///
|
||||
/// Default personal drives always match because the flag is
|
||||
/// materialised to `true` at drive creation (see
|
||||
/// `DriveRepository::create_personal_drive_atomic` + the backfill
|
||||
/// migration `20260901000000_default_personal_photo_music_flags.sql`)
|
||||
/// — no per-kind carve-out needed. Non-default drives (secondary
|
||||
/// personals, shared drives) surface here only after their owner
|
||||
/// flips the flag on via the admin "Manage policies" modal.
|
||||
pub async fn list_media_files(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
before: Option<i64>,
|
||||
limit: i64,
|
||||
) -> Result<(Vec<File>, Vec<i64>, Vec<(Option<i32>, Option<i32>)>), DomainError> {
|
||||
@@ -461,14 +501,27 @@ impl FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
|
||||
fi.created_by, fi.updated_by,
|
||||
EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date,
|
||||
fm.width, fm.height
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id
|
||||
WHERE fi.user_id = $1
|
||||
WHERE fi.drive_id IN (
|
||||
SELECT d.id
|
||||
FROM storage.drives d
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
WHERE (
|
||||
(g.subject_type = 'user' AND g.subject_id = $1)
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN
|
||||
(SELECT storage.caller_group_ids($1)))
|
||||
)
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND (d.policies->>'include_in_photo_index')::boolean = true
|
||||
)
|
||||
AND NOT fi.is_trashed
|
||||
AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%')
|
||||
AND ($2::bigint IS NULL
|
||||
@@ -477,7 +530,7 @@ impl FileBlobReadRepository {
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(owner_id)
|
||||
.bind(caller_id)
|
||||
.bind(before)
|
||||
.bind(limit)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
@@ -488,9 +541,9 @@ impl FileBlobReadRepository {
|
||||
let mut sort_dates = Vec::with_capacity(rows.len());
|
||||
let mut dims = Vec::with_capacity(rows.len());
|
||||
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, sd, w, h) in rows {
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, sd, w, h) in rows {
|
||||
files.push(Self::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub,
|
||||
)?);
|
||||
sort_dates.push(sd);
|
||||
dims.push((w, h));
|
||||
@@ -500,12 +553,20 @@ impl FileBlobReadRepository {
|
||||
}
|
||||
|
||||
/// Aggregate the caller's geotagged photos into grid cells of side `cell`
|
||||
/// (degrees) within `bounds`. Plain SQL (no PostGIS), scoped to `user_id`.
|
||||
/// Returns one cluster per non-empty cell with its centroid, photo count
|
||||
/// and a representative photo id (for the cluster thumbnail).
|
||||
/// (degrees) within `bounds`. Plain SQL (no PostGIS).
|
||||
///
|
||||
/// Scope: same `include_in_photo_index` predicate as
|
||||
/// `list_media_files` (§15). Places is the map view over the same
|
||||
/// content set the Photos timeline shows, so the two surfaces MUST
|
||||
/// agree on drive scope. Group membership is expanded inline by
|
||||
/// `storage.caller_group_ids(caller)`.
|
||||
///
|
||||
/// This query is a per-cell aggregate (group by rounded lat/lng
|
||||
/// bucket) rather than an ORDER BY / LIMIT hot path — the plain
|
||||
/// `idx_files_drive_id` is sufficient to seek by drive.
|
||||
pub async fn list_geo_clusters(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
bounds: GeoBounds,
|
||||
cell: f64,
|
||||
) -> Result<Vec<GeoCluster>, DomainError> {
|
||||
@@ -517,7 +578,20 @@ impl FileBlobReadRepository {
|
||||
min(fm.file_id::text) AS sample_id
|
||||
FROM storage.file_metadata fm
|
||||
JOIN storage.files fi ON fi.id = fm.file_id
|
||||
WHERE fi.user_id = $1
|
||||
WHERE fi.drive_id IN (
|
||||
SELECT d.id
|
||||
FROM storage.drives d
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
WHERE (
|
||||
(g.subject_type = 'user' AND g.subject_id = $1)
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN
|
||||
(SELECT storage.caller_group_ids($1)))
|
||||
)
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND (d.policies->>'include_in_photo_index')::boolean = true
|
||||
)
|
||||
AND NOT fi.is_trashed
|
||||
AND fm.latitude IS NOT NULL
|
||||
AND fm.longitude IS NOT NULL
|
||||
@@ -526,7 +600,7 @@ impl FileBlobReadRepository {
|
||||
GROUP BY round(fm.longitude / $6), round(fm.latitude / $6)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(caller_id)
|
||||
.bind(bounds.west)
|
||||
.bind(bounds.east)
|
||||
.bind(bounds.south)
|
||||
@@ -564,7 +638,6 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
i64, // created_at
|
||||
i64, // updated_at
|
||||
String, // blob_hash
|
||||
Option<Uuid>, // user_id (owner)
|
||||
Option<Uuid>, // created_by (§14)
|
||||
Option<Uuid>, // updated_by (§14)
|
||||
),
|
||||
@@ -575,7 +648,6 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
@@ -593,7 +665,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
self.hash_cache.insert(id.to_string(), row.8.clone());
|
||||
|
||||
Self::row_to_file(
|
||||
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11,
|
||||
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -613,7 +685,6 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>, // created_by (§14)
|
||||
Option<Uuid>, // updated_by (§14)
|
||||
),
|
||||
@@ -624,7 +695,6 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
@@ -639,55 +709,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
|
||||
self.hash_cache.insert(id.to_string(), row.8.clone());
|
||||
Self::row_to_file(
|
||||
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11,
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_file_for_owner(&self, id: &str, owner_id: Uuid) -> Result<File, DomainError> {
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String, // id
|
||||
String, // name
|
||||
Option<String>, // folder_id
|
||||
Option<String>, // folder path
|
||||
i64, // size
|
||||
String, // mime_type
|
||||
i64, // created_at
|
||||
i64, // updated_at
|
||||
String, // blob_hash
|
||||
Option<Uuid>, // user_id (owner)
|
||||
Option<Uuid>, // created_by (§14)
|
||||
Option<Uuid>, // updated_by (§14)
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
fi.size, fi.mime_type,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.id = $1::uuid
|
||||
AND fi.user_id = $2
|
||||
AND NOT fi.is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(owner_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("get_for_owner: {e}")))?
|
||||
// Return NotFound (not Forbidden) to avoid leaking file existence
|
||||
.ok_or_else(|| DomainError::not_found("File", id))?;
|
||||
|
||||
self.hash_cache.insert(id.to_string(), row.8.clone());
|
||||
|
||||
Self::row_to_file(
|
||||
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11,
|
||||
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -701,7 +723,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
@@ -720,7 +742,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
@@ -735,72 +757,8 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
|
||||
rows.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
|
||||
Self::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
|
||||
)
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// User-scoped file listing — adds `AND fi.user_id = $2` to prevent
|
||||
/// cross-user data leakage in the REST API (`list_files_query`).
|
||||
async fn list_files_for_owner(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
) -> Result<Vec<File>, DomainError> {
|
||||
let rows: Vec<FileRow> = if let Some(fid) = folder_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
fi.size, fi.mime_type,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
|
||||
AND fi.user_id = $2
|
||||
ORDER BY fi.name
|
||||
"#,
|
||||
)
|
||||
.bind(fid)
|
||||
.bind(owner_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
fi.size, fi.mime_type,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
|
||||
AND fi.user_id = $1
|
||||
ORDER BY fi.name
|
||||
"#,
|
||||
)
|
||||
.bind(owner_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_for_owner: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
|
||||
Self::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
|
||||
)
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| {
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
@@ -829,7 +787,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
@@ -851,7 +809,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
@@ -869,82 +827,8 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
|
||||
rows.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
|
||||
Self::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
|
||||
)
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// User-scoped paginated file listing — adds `AND fi.user_id = $4` to
|
||||
/// prevent cross-user data leakage in WebDAV PROPFIND.
|
||||
async fn list_files_batch_for_owner(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> Result<Vec<File>, DomainError> {
|
||||
let rows: Vec<FileRow> = if let Some(fid) = folder_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
fi.size, fi.mime_type,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
|
||||
AND fi.user_id = $4
|
||||
ORDER BY fi.name
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
.bind(fid)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.bind(owner_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
fi.size, fi.mime_type,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
|
||||
AND fi.user_id = $3
|
||||
ORDER BY fi.name
|
||||
LIMIT $1 OFFSET $2
|
||||
"#,
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.bind(owner_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("list_batch_for_owner: {e}"))
|
||||
})?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
|
||||
Self::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
|
||||
)
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| {
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
@@ -1094,7 +978,6 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>, // created_by (§14)
|
||||
Option<Uuid>, // updated_by (§14)
|
||||
),
|
||||
@@ -1105,8 +988,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
fi.created_by, fi.updated_by
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.name = $1 AND fi.folder_id IS NULL
|
||||
@@ -1134,7 +1016,6 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>, // created_by (§14)
|
||||
Option<Uuid>, // updated_by (§14)
|
||||
),
|
||||
@@ -1145,8 +1026,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
fi.created_by, fi.updated_by
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fo.path = $1 AND fi.name = $2
|
||||
@@ -1163,7 +1043,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(Self::row_to_file(
|
||||
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, r.10, r.11,
|
||||
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, r.10,
|
||||
)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
@@ -1183,8 +1063,8 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
let stream = async_stream::try_stream! {
|
||||
let mut row_stream = sqlx::query_as::<_, (
|
||||
String, String, Option<String>, Option<String>,
|
||||
i64, String, i64, i64, String, Option<Uuid>,
|
||||
Option<Uuid>, Option<Uuid>,
|
||||
i64, String, i64, i64, String,
|
||||
Option<Uuid>, Option<Uuid>, // created_by, updated_by (§14)
|
||||
)>(
|
||||
r#"
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
@@ -1192,8 +1072,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
fi.created_by, fi.updated_by
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid)
|
||||
@@ -1207,9 +1086,9 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
while let Some(row) = row_stream.try_next().await.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("subtree stream: {e}"))
|
||||
})? {
|
||||
let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub) = row;
|
||||
let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub) = row;
|
||||
let file = FileBlobReadRepository::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub,
|
||||
)?;
|
||||
yield file;
|
||||
}
|
||||
@@ -1223,11 +1102,15 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
/// Uses `COUNT(*) OVER()` window function to return the total matching
|
||||
/// count alongside the paginated rows in a **single query** — no separate
|
||||
/// COUNT round-trip.
|
||||
///
|
||||
/// Post-PR-B: scoped by drive-membership (via [`CALLER_CAN_READ_DRIVE`])
|
||||
/// rather than the legacy `fi.user_id = $caller` predicate. Group
|
||||
/// grants are honoured inline through `storage.caller_group_ids`.
|
||||
async fn search_files_paginated(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(Vec<File>, usize), DomainError> {
|
||||
let offset = criteria.offset as i64;
|
||||
let limit = criteria.limit as i64;
|
||||
@@ -1245,10 +1128,10 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
|
||||
// ── Build dynamic WHERE + bind indices ───────────────────────────
|
||||
let mut conditions: Vec<String> = vec![
|
||||
"fi.user_id = $1".to_string(),
|
||||
CALLER_CAN_READ_DRIVE.to_string(),
|
||||
"fi.is_trashed = false".to_string(),
|
||||
];
|
||||
let mut bind_idx = 1u32; // $1 = user_id
|
||||
let mut bind_idx = 1u32; // $1 = caller_id
|
||||
|
||||
if folder_id.is_some() {
|
||||
bind_idx += 1;
|
||||
@@ -1272,7 +1155,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
|
||||
fi.blob_hash, \
|
||||
fi.user_id, \
|
||||
\
|
||||
fi.created_by, fi.updated_by, \
|
||||
COUNT(*) OVER() AS total_count \
|
||||
FROM storage.files fi \
|
||||
@@ -1295,13 +1178,12 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>, // created_by (§14)
|
||||
Option<Uuid>, // updated_by (§14)
|
||||
i64,
|
||||
i64, // total_count
|
||||
),
|
||||
>(&sql)
|
||||
.bind(user_id);
|
||||
.bind(caller_id);
|
||||
|
||||
if let Some(fid) = folder_id {
|
||||
query = query.bind(fid);
|
||||
@@ -1320,15 +1202,13 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?;
|
||||
|
||||
// total_count is the same in every row; 0 when result set is empty.
|
||||
let total_count = rows.first().map_or(0, |r| r.12) as usize;
|
||||
let total_count = rows.first().map_or(0, |r| r.11) as usize;
|
||||
|
||||
let files = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, _total)| {
|
||||
Self::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
|
||||
)
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total)| {
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
@@ -1346,16 +1226,20 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
///
|
||||
/// Uses `COUNT(*) OVER()` to return the total count alongside the
|
||||
/// paginated rows — no separate COUNT round-trip.
|
||||
///
|
||||
/// Post-PR-B: scoped by drive-membership (via [`CALLER_CAN_READ_DRIVE`])
|
||||
/// rather than the legacy `fi.user_id = $caller` predicate — same
|
||||
/// group-cascade semantics as `search_files_paginated`.
|
||||
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> {
|
||||
// When no root folder specified, delegate to existing paginated search
|
||||
let root_id = match root_folder_id {
|
||||
None => {
|
||||
return self.search_files_paginated(None, criteria, user_id).await;
|
||||
return self.search_files_paginated(None, criteria, caller_id).await;
|
||||
}
|
||||
Some(id) => id,
|
||||
};
|
||||
@@ -1376,10 +1260,10 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
|
||||
// ── Build dynamic WHERE clauses ──
|
||||
let mut conditions = Vec::new();
|
||||
let mut bind_idx = 2u32; // $1 = user_id, $2 = root_folder_id
|
||||
let mut bind_idx = 2u32; // $1 = caller_id, $2 = root_folder_id
|
||||
|
||||
conditions.push("fi.is_trashed = false".to_string());
|
||||
conditions.push("fi.user_id = $1".to_string());
|
||||
conditions.push(CALLER_CAN_READ_DRIVE.to_string());
|
||||
conditions.push(
|
||||
"fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $2::uuid)".to_string(),
|
||||
);
|
||||
@@ -1403,7 +1287,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
|
||||
fi.blob_hash, \
|
||||
fi.user_id, \
|
||||
\
|
||||
fi.created_by, fi.updated_by, \
|
||||
COUNT(*) OVER() AS total_count \
|
||||
FROM storage.files fi \
|
||||
@@ -1426,13 +1310,12 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>, // created_by (§14)
|
||||
Option<Uuid>, // updated_by (§14)
|
||||
i64,
|
||||
i64, // total_count
|
||||
),
|
||||
>(&sql)
|
||||
.bind(user_id)
|
||||
.bind(caller_id)
|
||||
.bind(root_id);
|
||||
|
||||
if let Some(name) = &criteria.name_contains
|
||||
@@ -1449,15 +1332,13 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
DomainError::internal_error("FileBlobRead", format!("subtree search: {e}"))
|
||||
})?;
|
||||
|
||||
let total_count = rows.first().map_or(0, |r| r.12) as usize;
|
||||
let total_count = rows.first().map_or(0, |r| r.11) as usize;
|
||||
|
||||
let files = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, _total)| {
|
||||
Self::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
|
||||
)
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total)| {
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
@@ -1473,10 +1354,10 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<usize, DomainError> {
|
||||
let (_, count) = self
|
||||
.search_files_paginated(folder_id, criteria, user_id)
|
||||
.search_files_paginated(folder_id, criteria, caller_id)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
@@ -1499,7 +1380,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
@@ -1529,7 +1410,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash,
|
||||
fi.user_id,
|
||||
|
||||
fi.created_by, fi.updated_by
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
@@ -1555,10 +1436,8 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
|
||||
rows.into_iter()
|
||||
.map(
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
|
||||
Self::row_to_file(
|
||||
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
|
||||
)
|
||||
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| {
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
|
||||
@@ -104,7 +104,6 @@ impl FileBlobWriteRepository {
|
||||
mime_type: String,
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
owner_id: Option<Uuid>,
|
||||
blob_hash: String,
|
||||
created_by: Option<Uuid>,
|
||||
updated_by: Option<Uuid>,
|
||||
@@ -119,7 +118,6 @@ impl FileBlobWriteRepository {
|
||||
folder_id,
|
||||
created_at as u64,
|
||||
modified_at as u64,
|
||||
owner_id,
|
||||
blob_hash,
|
||||
created_by,
|
||||
updated_by,
|
||||
@@ -127,30 +125,24 @@ impl FileBlobWriteRepository {
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
|
||||
}
|
||||
|
||||
/// Derive `(user_id, drive_id)` from the parent folder. Both are
|
||||
/// needed during the D0 dual-write window: `user_id` for the legacy
|
||||
/// column (dropped in D7) and `drive_id` for the new owning-drive
|
||||
/// reference.
|
||||
async fn resolve_owner_and_drive(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
) -> Result<(Uuid, Uuid), DomainError> {
|
||||
/// Derive `drive_id` from the parent folder. Post-D7: only the
|
||||
/// drive is needed — the legacy `user_id` column is no longer
|
||||
/// written on new rows.
|
||||
async fn resolve_parent_drive(&self, folder_id: Option<&str>) -> Result<Uuid, DomainError> {
|
||||
match folder_id {
|
||||
Some(fid) => {
|
||||
let row: Option<(Uuid, Uuid)> = sqlx::query_as::<_, (Uuid, Uuid)>(
|
||||
"SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid",
|
||||
)
|
||||
.bind(fid)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobWrite", format!("parent lookup: {e}"))
|
||||
})?;
|
||||
row.ok_or_else(|| DomainError::not_found("Folder", fid))
|
||||
}
|
||||
Some(fid) => sqlx::query_scalar::<_, Uuid>(
|
||||
"SELECT drive_id FROM storage.folders WHERE id = $1::uuid",
|
||||
)
|
||||
.bind(fid)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobWrite", format!("parent lookup: {e}"))
|
||||
})?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", fid)),
|
||||
None => Err(DomainError::internal_error(
|
||||
"FileBlobWrite",
|
||||
"folder_id is required to determine file owner",
|
||||
"folder_id is required to determine the target drive",
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -290,20 +282,22 @@ impl FileBlobWriteRepository {
|
||||
// attempt's error falls through untouched so the 23505 mapping holds
|
||||
// (a retried INSERT can legitimately lose to a concurrent identical
|
||||
// upload).
|
||||
// Post-D7: `user_id` omitted from the INSERT column list and the
|
||||
// parent CTE. `drive_id` alone is the inherit-from-parent axis;
|
||||
// provenance is `created_by` / `updated_by` (§14).
|
||||
let result = retry_on_deadlock("files.insert", || {
|
||||
sqlx::query_as::<_, (String, Uuid, String, i64, i64, Option<Uuid>, Option<Uuid>)>(
|
||||
sqlx::query_as::<_, (String, String, i64, i64, Option<Uuid>, Option<Uuid>)>(
|
||||
r#"
|
||||
WITH parent AS (
|
||||
SELECT id, user_id, drive_id, path FROM storage.folders WHERE id = $2::uuid
|
||||
SELECT id, drive_id, path FROM storage.folders WHERE id = $2::uuid
|
||||
)
|
||||
INSERT INTO storage.files
|
||||
(name, folder_id, user_id, drive_id, blob_hash, size,
|
||||
(name, folder_id, drive_id, blob_hash, size,
|
||||
mime_type, category_order, created_by, updated_by)
|
||||
SELECT $1, parent.id, parent.user_id, parent.drive_id, $3, $4,
|
||||
SELECT $1, parent.id, parent.drive_id, $3, $4,
|
||||
$5, $6, $7, $7
|
||||
FROM parent
|
||||
RETURNING id::text,
|
||||
user_id,
|
||||
(SELECT path FROM parent),
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
@@ -322,71 +316,70 @@ impl FileBlobWriteRepository {
|
||||
})
|
||||
.await;
|
||||
|
||||
let (id, user_id, folder_path, created_at, updated_at, created_by, updated_by) =
|
||||
match result {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
|
||||
tracing::error!(
|
||||
"Blob orphaned after missing parent folder — hash: {}, err: {}",
|
||||
&blob_hash[..12],
|
||||
rollback_err
|
||||
);
|
||||
}
|
||||
return Err(DomainError::not_found("Folder", fid));
|
||||
let (id, folder_path, created_at, updated_at, created_by, updated_by) = match result {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
|
||||
tracing::error!(
|
||||
"Blob orphaned after missing parent folder — hash: {}, err: {}",
|
||||
&blob_hash[..12],
|
||||
rollback_err
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
|
||||
tracing::error!(
|
||||
"Blob orphaned after failed INSERT — hash: {}, err: {}",
|
||||
&blob_hash[..12],
|
||||
rollback_err
|
||||
);
|
||||
}
|
||||
if let sqlx::Error::Database(ref db_err) = e
|
||||
&& db_err.code().as_deref() == Some("23505")
|
||||
{
|
||||
// Idempotent re-upload: if the conflicting file already
|
||||
// holds IDENTICAL content (same folder, same name, same
|
||||
// blob hash), treat this as success and return that file
|
||||
// instead of erroring. Re-uploading a partially-uploaded
|
||||
// folder then becomes a clean no-op for everything that
|
||||
// already landed — only the genuinely missing files
|
||||
// transfer — instead of surfacing hundreds of spurious
|
||||
// "already exists" failures. The duplicate blob reference
|
||||
// taken during ingest was just released above, so the
|
||||
// existing file's own reference is the only one (correct);
|
||||
// a different-content clash still returns the conflict.
|
||||
match self.fetch_identical_file(fid, &name, blob_hash).await {
|
||||
Ok(Some(existing)) => {
|
||||
tracing::info!(
|
||||
"♻️ IDEMPOTENT UPLOAD: {} already present, identical content (hash: {})",
|
||||
name,
|
||||
&blob_hash[..12]
|
||||
);
|
||||
return Ok(existing);
|
||||
}
|
||||
Ok(None) => {} // genuine conflict (different content)
|
||||
Err(lookup_err) => {
|
||||
tracing::warn!(
|
||||
"idempotency lookup failed for {} (hash {}): {} — returning conflict",
|
||||
name,
|
||||
&blob_hash[..12],
|
||||
lookup_err
|
||||
);
|
||||
}
|
||||
return Err(DomainError::not_found("Folder", fid));
|
||||
}
|
||||
Err(e) => {
|
||||
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
|
||||
tracing::error!(
|
||||
"Blob orphaned after failed INSERT — hash: {}, err: {}",
|
||||
&blob_hash[..12],
|
||||
rollback_err
|
||||
);
|
||||
}
|
||||
if let sqlx::Error::Database(ref db_err) = e
|
||||
&& db_err.code().as_deref() == Some("23505")
|
||||
{
|
||||
// Idempotent re-upload: if the conflicting file already
|
||||
// holds IDENTICAL content (same folder, same name, same
|
||||
// blob hash), treat this as success and return that file
|
||||
// instead of erroring. Re-uploading a partially-uploaded
|
||||
// folder then becomes a clean no-op for everything that
|
||||
// already landed — only the genuinely missing files
|
||||
// transfer — instead of surfacing hundreds of spurious
|
||||
// "already exists" failures. The duplicate blob reference
|
||||
// taken during ingest was just released above, so the
|
||||
// existing file's own reference is the only one (correct);
|
||||
// a different-content clash still returns the conflict.
|
||||
match self.fetch_identical_file(fid, &name, blob_hash).await {
|
||||
Ok(Some(existing)) => {
|
||||
tracing::info!(
|
||||
"♻️ IDEMPOTENT UPLOAD: {} already present, identical content (hash: {})",
|
||||
name,
|
||||
&blob_hash[..12]
|
||||
);
|
||||
return Ok(existing);
|
||||
}
|
||||
Ok(None) => {} // genuine conflict (different content)
|
||||
Err(lookup_err) => {
|
||||
tracing::warn!(
|
||||
"idempotency lookup failed for {} (hash {}): {} — returning conflict",
|
||||
name,
|
||||
&blob_hash[..12],
|
||||
lookup_err
|
||||
);
|
||||
}
|
||||
return Err(DomainError::already_exists(
|
||||
"File",
|
||||
format!("'{name}' already exists in this folder"),
|
||||
));
|
||||
}
|
||||
return Err(DomainError::internal_error(
|
||||
"FileBlobWrite",
|
||||
format!("insert: {e}"),
|
||||
return Err(DomainError::already_exists(
|
||||
"File",
|
||||
format!("'{name}' already exists in this folder"),
|
||||
));
|
||||
}
|
||||
};
|
||||
return Err(DomainError::internal_error(
|
||||
"FileBlobWrite",
|
||||
format!("insert: {e}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"📡 STREAMING WRITE: {} ({} bytes, hash: {})",
|
||||
@@ -404,7 +397,6 @@ impl FileBlobWriteRepository {
|
||||
content_type,
|
||||
created_at,
|
||||
updated_at,
|
||||
Some(user_id),
|
||||
blob_hash.to_string(),
|
||||
created_by,
|
||||
updated_by,
|
||||
@@ -421,11 +413,12 @@ impl FileBlobWriteRepository {
|
||||
name: &str,
|
||||
blob_hash: &str,
|
||||
) -> Result<Option<File>, DomainError> {
|
||||
// Post-D7: `f.user_id` is nullable on new rows; use
|
||||
// `Option<Uuid>` to accept NULL.
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
Uuid,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
@@ -436,7 +429,7 @@ impl FileBlobWriteRepository {
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
SELECT f.id::text, f.user_id, fo.path,
|
||||
SELECT f.id::text, fo.path,
|
||||
EXTRACT(EPOCH FROM f.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM f.updated_at)::bigint,
|
||||
f.created_by, f.updated_by, f.size, f.mime_type
|
||||
@@ -460,7 +453,6 @@ impl FileBlobWriteRepository {
|
||||
|
||||
let Some((
|
||||
id,
|
||||
user_id,
|
||||
folder_path,
|
||||
created_at,
|
||||
updated_at,
|
||||
@@ -482,7 +474,6 @@ impl FileBlobWriteRepository {
|
||||
mime_type,
|
||||
created_at,
|
||||
updated_at,
|
||||
Some(user_id),
|
||||
blob_hash.to_string(),
|
||||
created_by,
|
||||
updated_by,
|
||||
@@ -535,11 +526,10 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
>(
|
||||
r#"
|
||||
WITH dest AS (
|
||||
SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid
|
||||
SELECT drive_id FROM storage.folders WHERE id = $1::uuid
|
||||
)
|
||||
UPDATE storage.files f
|
||||
SET folder_id = $1::uuid,
|
||||
user_id = COALESCE((SELECT user_id FROM dest), f.user_id),
|
||||
drive_id = COALESCE((SELECT drive_id FROM dest), f.drive_id),
|
||||
updated_at = NOW(),
|
||||
updated_by = $3
|
||||
@@ -568,7 +558,6 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
row.4,
|
||||
row.5,
|
||||
row.6,
|
||||
None,
|
||||
String::new(),
|
||||
row.7,
|
||||
row.8,
|
||||
@@ -611,29 +600,27 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
>(
|
||||
r#"
|
||||
WITH src AS (
|
||||
SELECT name, folder_id, user_id, blob_hash, size, mime_type, category_order
|
||||
SELECT name, folder_id, blob_hash, size, mime_type, category_order
|
||||
FROM storage.files
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
),
|
||||
-- The destination folder may differ from the source's
|
||||
-- folder (when $2 is set); derive drive_id from the
|
||||
-- DESTINATION so cross-drive copies land in the right
|
||||
-- drive. Files in personal drives only copy within the
|
||||
-- same drive today, but the join makes the migration
|
||||
-- future-proof for D2's cross-drive copy story.
|
||||
-- drive. Post-D7: `user_id` no longer projected — the
|
||||
-- column is not written on new rows.
|
||||
dest_folder AS (
|
||||
SELECT id, user_id, drive_id
|
||||
SELECT id, drive_id
|
||||
FROM storage.folders
|
||||
WHERE id = COALESCE($2::uuid,
|
||||
(SELECT folder_id FROM src))
|
||||
),
|
||||
new_file AS (
|
||||
INSERT INTO storage.files
|
||||
(name, folder_id, user_id, drive_id, blob_hash, size,
|
||||
(name, folder_id, drive_id, blob_hash, size,
|
||||
mime_type, category_order, created_by, updated_by)
|
||||
SELECT COALESCE($3::text, src.name),
|
||||
dest_folder.id,
|
||||
dest_folder.user_id,
|
||||
dest_folder.drive_id,
|
||||
src.blob_hash,
|
||||
src.size,
|
||||
@@ -718,7 +705,6 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
row.4,
|
||||
row.5,
|
||||
row.6,
|
||||
None,
|
||||
row.7,
|
||||
row.8,
|
||||
row.9,
|
||||
@@ -781,7 +767,6 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
row.4,
|
||||
row.5,
|
||||
row.6,
|
||||
None,
|
||||
String::new(),
|
||||
row.7,
|
||||
row.8,
|
||||
@@ -837,23 +822,21 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
size: u64,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(File, PathBuf), DomainError> {
|
||||
let (user_id, drive_id) = self.resolve_owner_and_drive(folder_id.as_deref()).await?;
|
||||
let drive_id = self.resolve_parent_drive(folder_id.as_deref()).await?;
|
||||
|
||||
// For deferred registration we use a placeholder hash.
|
||||
// The write-behind cache will call update_file_content later.
|
||||
let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
// §14: `created_by = $9 = updated_by = caller_id`. The legacy
|
||||
// `user_id` column (dropped in D7) stays bound to the parent
|
||||
// folder's owner; only the two provenance columns flip to the
|
||||
// caller — see save_file_with_blob_impl.
|
||||
// Post-D7: `user_id` omitted from the INSERT column list.
|
||||
// §14: `created_by = $8 = updated_by = caller_id`.
|
||||
let row = retry_on_deadlock("files.insert_deferred", || {
|
||||
sqlx::query_as::<_, (String, i64, i64, Option<Uuid>, Option<Uuid>)>(
|
||||
r#"
|
||||
INSERT INTO storage.files
|
||||
(name, folder_id, user_id, drive_id, blob_hash, size,
|
||||
(name, folder_id, drive_id, blob_hash, size,
|
||||
mime_type, category_order, created_by, updated_by)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $9)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $8)
|
||||
RETURNING id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
@@ -863,7 +846,6 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&folder_id)
|
||||
.bind(user_id)
|
||||
.bind(drive_id)
|
||||
.bind(placeholder_hash)
|
||||
.bind(size as i64)
|
||||
@@ -885,7 +867,6 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
content_type,
|
||||
row.1,
|
||||
row.2,
|
||||
Some(user_id),
|
||||
String::new(),
|
||||
row.3,
|
||||
row.4,
|
||||
|
||||
@@ -21,20 +21,23 @@ use crate::domain::services::authorization::ResourceKind;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
/// Type alias for folder metadata rows from SQL queries.
|
||||
/// Tuple order: id, name, path, parent_id, user_id, drive_id,
|
||||
/// created_at, modified_at, tree_modified_at, created_by, updated_by.
|
||||
/// Tuple order: id, name, path, parent_id, drive_id, created_at,
|
||||
/// modified_at, tree_modified_at, created_by, updated_by.
|
||||
/// The trailing `tree_modified_at` feeds [`Folder::etag`] — every
|
||||
/// SELECT here must include `EXTRACT(EPOCH FROM tree_modified_at)::bigint`.
|
||||
/// `drive_id` is the post-D0 `NOT NULL` scope axis for path-based
|
||||
/// lookups. `created_by` / `updated_by` are the §14 provenance
|
||||
/// columns, nullable because the FK is `ON DELETE SET NULL`.
|
||||
///
|
||||
/// Post-D7-step-6: `storage.folders.user_id` dropped, so the tuple
|
||||
/// no longer carries it. The domain entity's `user_id` field is
|
||||
/// populated with `None` at `row_to_folder` construction.
|
||||
type FolderRow = (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Uuid,
|
||||
Uuid,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
@@ -43,14 +46,14 @@ type FolderRow = (
|
||||
);
|
||||
|
||||
/// Type alias for paginated folder rows (includes total_count as
|
||||
/// the last element after the §14 provenance columns).
|
||||
/// the last element after the §14 provenance columns). Same
|
||||
/// column set as [`FolderRow`] plus the trailing count.
|
||||
type FolderRowPaginated = (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Uuid,
|
||||
Uuid,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
@@ -59,21 +62,38 @@ type FolderRowPaginated = (
|
||||
i64,
|
||||
);
|
||||
|
||||
/// Type alias for folder rows with optional user_id.
|
||||
/// Includes the §14 provenance columns `created_by` / `updated_by`.
|
||||
type FolderRowOptUser = (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<Uuid>,
|
||||
Uuid,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
);
|
||||
/// SQL `EXISTS (…)` predicate — true when the caller (bound to `$1`) has
|
||||
/// any active `role_grants` on the drive owning `fo` (the aliased folder
|
||||
/// row). Group memberships (direct + transitive) are expanded inline via
|
||||
/// `storage.caller_group_ids($1)` (recursive; see migration
|
||||
/// `20260901000002_caller_group_ids_function.sql`).
|
||||
///
|
||||
/// Used by every drive-scoped folder query in this repo:
|
||||
/// - `list_root_folders_for_caller` / `_paginated`
|
||||
/// - `search_folders` (all three branches)
|
||||
/// - `list_descendant_folders`
|
||||
///
|
||||
/// **Alias contract**: queries splicing this in MUST alias
|
||||
/// `storage.folders` as `fo`. `$1` is reserved for `caller_id`; other
|
||||
/// bind params start at `$2`.
|
||||
///
|
||||
/// This mirrors — but is not shared with — the drive-membership shape in
|
||||
/// `drive_pg_repository::list_readable_by` (uses `JOIN` on `d.id`) and
|
||||
/// the media-scoping subqueries in `file_blob_read_repository` (use
|
||||
/// `IN (SELECT d.id …)` with the `include_in_photo_index` policy
|
||||
/// filter). When the grant model changes, update all sites in parallel.
|
||||
const CALLER_CAN_READ_DRIVE: &str = "EXISTS (\
|
||||
SELECT 1 \
|
||||
FROM storage.role_grants g \
|
||||
WHERE g.resource_type = 'drive' \
|
||||
AND g.resource_id = fo.drive_id \
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW()) \
|
||||
AND ( \
|
||||
(g.subject_type = 'user' AND g.subject_id = $1) \
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN \
|
||||
(SELECT storage.caller_group_ids($1))) \
|
||||
) \
|
||||
)";
|
||||
|
||||
/// PostgreSQL-backed folder repository.
|
||||
///
|
||||
@@ -115,7 +135,6 @@ impl FolderDbRepository {
|
||||
name: String,
|
||||
path: String,
|
||||
parent_id: Option<String>,
|
||||
user_id: Option<Uuid>,
|
||||
drive_id: Uuid,
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
@@ -129,7 +148,6 @@ impl FolderDbRepository {
|
||||
name,
|
||||
storage_path,
|
||||
parent_id,
|
||||
user_id,
|
||||
drive_id,
|
||||
created_at as u64,
|
||||
modified_at as u64,
|
||||
@@ -153,7 +171,7 @@ impl FolderDbRepository {
|
||||
|
||||
let rows = sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -168,9 +186,7 @@ impl FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("get_folders_by_ids: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7, r.8, r.9, r.10)
|
||||
})
|
||||
.map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -182,13 +198,19 @@ impl FolderRepository for FolderDbRepository {
|
||||
parent_id: Option<String>,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Folder, DomainError> {
|
||||
// Derive (user_id, drive_id) from parent folder in one round-trip.
|
||||
// Root-level folders require the caller to have set up the home
|
||||
// drive beforehand (done during user registration via the
|
||||
// lifecycle hook).
|
||||
let (user_id, drive_id): (Uuid, Uuid) = if let Some(ref pid) = parent_id {
|
||||
sqlx::query_as::<_, (Uuid, Uuid)>(
|
||||
"SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid",
|
||||
// Derive `drive_id` from the parent folder. Root-level folders
|
||||
// are reserved for the atomic drive-creation transaction in
|
||||
// `DrivePgRepository::create_personal_drive_atomic` (see
|
||||
// `docs/plan/drive.md` §3) — the no-orphan-root-folder trigger
|
||||
// enforces this at the DB level.
|
||||
//
|
||||
// Post-D7: only `drive_id` is fetched from the parent. The
|
||||
// legacy `user_id` column is no longer written to on new rows
|
||||
// (migration `20260902000000_files_folders_user_id_nullable.sql`);
|
||||
// provenance flows through `created_by` / `updated_by` (§14).
|
||||
let drive_id: Uuid = if let Some(ref pid) = parent_id {
|
||||
sqlx::query_scalar::<_, Uuid>(
|
||||
"SELECT drive_id FROM storage.folders WHERE id = $1::uuid",
|
||||
)
|
||||
.bind(pid)
|
||||
.fetch_optional(self.pool())
|
||||
@@ -205,20 +227,20 @@ impl FolderRepository for FolderDbRepository {
|
||||
));
|
||||
};
|
||||
|
||||
// D0 dual-write: drive_id alongside user_id (drops in D7); plus
|
||||
// §14 provenance — `created_by` / `updated_by` bind to the caller
|
||||
// ($5), NOT to the parent folder's `user_id`. Pre-D2 they're
|
||||
// silently equivalent (only the parent's owner can write); the
|
||||
// distinction matters once shared drives let an Editor mutate
|
||||
// a folder owned by someone else.
|
||||
// Post-D7: no `user_id` in the INSERT column list — the column
|
||||
// is nullable and copied rows / new rows leave it NULL.
|
||||
// `created_by` / `updated_by` carry §14 provenance (both bind to
|
||||
// the caller — pre-D2 that's silently the parent's owner too,
|
||||
// but the distinction matters once shared drives let an Editor
|
||||
// mutate a folder owned by someone else).
|
||||
//
|
||||
// RETURNING also surfaces the two provenance columns so the
|
||||
// built entity / DTO carries fresh values without a re-read.
|
||||
// RETURNING surfaces the two provenance columns so the built
|
||||
// entity / DTO carries fresh values without a re-read.
|
||||
let row = sqlx::query_as::<_, (String, String, i64, i64, i64, Option<Uuid>, Option<Uuid>)>(
|
||||
r#"
|
||||
INSERT INTO storage.folders
|
||||
(name, parent_id, user_id, drive_id, created_by, updated_by)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $5)
|
||||
(name, parent_id, drive_id, created_by, updated_by)
|
||||
VALUES ($1, $2::uuid, $3, $4, $4)
|
||||
RETURNING id::text,
|
||||
path,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
@@ -230,7 +252,6 @@ impl FolderRepository for FolderDbRepository {
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&parent_id)
|
||||
.bind(user_id)
|
||||
.bind(drive_id)
|
||||
.bind(caller_id)
|
||||
.fetch_one(self.pool())
|
||||
@@ -248,25 +269,16 @@ impl FolderRepository for FolderDbRepository {
|
||||
})?;
|
||||
|
||||
Self::row_to_folder(
|
||||
row.0,
|
||||
name,
|
||||
row.1,
|
||||
parent_id,
|
||||
Some(user_id),
|
||||
drive_id,
|
||||
row.2,
|
||||
row.3,
|
||||
row.4,
|
||||
row.0, name, row.1, parent_id, drive_id, row.2, row.3, row.4,
|
||||
// Fresh from RETURNING — caller_id was bound to both columns.
|
||||
row.5,
|
||||
row.6,
|
||||
row.5, row.6,
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
||||
let row = sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -282,17 +294,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||
|
||||
Self::row_to_folder(
|
||||
row.0,
|
||||
row.1,
|
||||
row.2,
|
||||
row.3,
|
||||
Some(row.4),
|
||||
row.5,
|
||||
row.6,
|
||||
row.7,
|
||||
row.8,
|
||||
row.9,
|
||||
row.10,
|
||||
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -318,7 +320,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
// wrapper scoping post-D0).
|
||||
let row = sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -335,17 +337,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
.ok_or_else(|| DomainError::not_found("Folder", lookup))?;
|
||||
|
||||
Self::row_to_folder(
|
||||
row.0,
|
||||
row.1,
|
||||
row.2,
|
||||
row.3,
|
||||
Some(row.4),
|
||||
row.5,
|
||||
row.6,
|
||||
row.7,
|
||||
row.8,
|
||||
row.9,
|
||||
row.10,
|
||||
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -354,7 +346,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -370,7 +362,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -386,57 +378,56 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
|
||||
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_folders_by_owner(
|
||||
async fn list_root_folders_for_caller(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(owner_id)
|
||||
// Drive-scoped root-folder listing: return every root folder
|
||||
// whose drive the caller has any role_grant on. Group
|
||||
// memberships (direct + transitive) resolve inline via
|
||||
// `storage.caller_group_ids($1)`.
|
||||
//
|
||||
// Closes `bug_root_folder_listing_legacy_user_id`: pre-D7 this
|
||||
// query filtered on `folders.user_id = $caller`, which returned
|
||||
// rows admin had created for other users' drives without ever
|
||||
// getting a role on them. The drive-membership predicate below
|
||||
// makes the "admin's own listing" correct without a separate
|
||||
// filter.
|
||||
//
|
||||
// `caller_role` is NOT surfaced here — see the memory
|
||||
// `project_caller_role_on_file_folder_dto` and the note at the
|
||||
// top of `folder_repository.rs`. Frontend cross-references
|
||||
// `/api/drives::caller_role` via `folder.drive_id`.
|
||||
let sql = format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.parent_id IS NULL \
|
||||
AND NOT fo.is_trashed \
|
||||
AND {CALLER_CAN_READ_DRIVE} \
|
||||
ORDER BY fo.name"
|
||||
);
|
||||
let rows: Vec<FolderRow> = sqlx::query_as(&sql)
|
||||
.bind(caller_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.bind(owner_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FolderDb", format!("list_root_folders: {e}"))
|
||||
})?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
|
||||
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -455,7 +446,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
let rows: Vec<FolderRowPaginated> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -475,7 +466,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -496,90 +487,67 @@ impl FolderRepository for FolderDbRepository {
|
||||
|
||||
// total_count is identical in every row; 0 when the result set is empty.
|
||||
let total = if include_total {
|
||||
Some(rows.first().map_or(0, |r| r.11) as usize)
|
||||
Some(rows.first().map_or(0, |r| r.10) as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
|
||||
},
|
||||
)
|
||||
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect();
|
||||
Ok((folders?, total))
|
||||
}
|
||||
|
||||
/// Paginated folder listing filtered by owner — single query with
|
||||
/// `COUNT(*) OVER()` to avoid a separate COUNT round-trip.
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_folders_by_owner_paginated(
|
||||
/// Paginated companion to `list_root_folders_for_caller` — same
|
||||
/// drive-membership predicate, adds LIMIT/OFFSET and an optional
|
||||
/// window-function COUNT so total pages can be surfaced without a
|
||||
/// second round-trip.
|
||||
async fn list_root_folders_for_caller_paginated(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
include_total: bool,
|
||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||
let rows: Vec<FolderRowPaginated> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
|
||||
ORDER BY name
|
||||
LIMIT $3 OFFSET $4
|
||||
"#,
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(owner_id)
|
||||
let sql = format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by, \
|
||||
COUNT(*) OVER() AS total_count \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.parent_id IS NULL \
|
||||
AND NOT fo.is_trashed \
|
||||
AND {CALLER_CAN_READ_DRIVE} \
|
||||
ORDER BY fo.name \
|
||||
LIMIT $2 OFFSET $3"
|
||||
);
|
||||
let rows: Vec<FolderRowPaginated> = sqlx::query_as(&sql)
|
||||
.bind(caller_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
|
||||
ORDER BY name
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
.bind(owner_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FolderDb", format!("list_root_folders_paginated: {e}"))
|
||||
})?;
|
||||
|
||||
let total = if include_total {
|
||||
Some(rows.first().map_or(0, |r| r.11) as usize)
|
||||
Some(rows.first().map_or(0, |r| r.10) as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
|
||||
},
|
||||
)
|
||||
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect();
|
||||
Ok((folders?, total))
|
||||
}
|
||||
@@ -607,7 +575,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
UPDATE storage.folders
|
||||
SET name = $1, updated_at = NOW(), updated_by = $3
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
RETURNING id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
RETURNING id::text, name, path, parent_id::text, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -631,17 +599,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||
|
||||
Self::row_to_folder(
|
||||
row.0,
|
||||
row.1,
|
||||
row.2,
|
||||
row.3,
|
||||
Some(row.4),
|
||||
row.5,
|
||||
row.6,
|
||||
row.7,
|
||||
row.8,
|
||||
row.9,
|
||||
row.10,
|
||||
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -678,7 +636,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
updated_at = NOW(),
|
||||
updated_by = $3
|
||||
WHERE f.id = $2::uuid AND NOT f.is_trashed
|
||||
RETURNING f.id::text, f.name, f.path, f.parent_id::text, f.user_id, f.drive_id,
|
||||
RETURNING f.id::text, f.name, f.path, f.parent_id::text, f.drive_id,
|
||||
EXTRACT(EPOCH FROM f.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM f.updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM f.tree_modified_at)::bigint,
|
||||
@@ -695,17 +653,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||
|
||||
Self::row_to_folder(
|
||||
row.0,
|
||||
row.1,
|
||||
row.2,
|
||||
row.3,
|
||||
Some(row.4),
|
||||
row.5,
|
||||
row.6,
|
||||
row.7,
|
||||
row.8,
|
||||
row.9,
|
||||
row.10,
|
||||
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -988,7 +936,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_subtree_folders(&self, folder_id: &str) -> Result<Vec<Folder>, DomainError> {
|
||||
let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
fo.user_id, fo.drive_id, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
@@ -998,7 +946,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
|
||||
ORDER BY fo.path";
|
||||
|
||||
let rows: Vec<FolderRowOptUser> = sqlx::query_as(sql)
|
||||
let rows: Vec<FolderRow> = sqlx::query_as(sql)
|
||||
.bind(folder_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
@@ -1007,8 +955,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
})?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
|
||||
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1018,19 +966,22 @@ impl FolderRepository for FolderDbRepository {
|
||||
///
|
||||
/// - Non-recursive: `WHERE parent_id = $1 AND user_id = $2 [AND LIKE]`
|
||||
/// - Recursive + folder_id: delegates to `list_descendant_folders`
|
||||
/// - Recursive + no folder_id: `WHERE user_id = $1 [AND LIKE]`
|
||||
/// - Recursive + no folder_id: drive-scoped `EXISTS role_grants` [AND LIKE]
|
||||
///
|
||||
/// Post-PR-B: filters by drive-membership grants inline (via
|
||||
/// `caller_group_ids`) instead of `user_id = $caller`.
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn search_folders(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
name_contains: Option<&str>,
|
||||
user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
recursive: bool,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
// Recursive with folder scope → existing optimised ltree scan
|
||||
if recursive && let Some(fid) = parent_id {
|
||||
return self
|
||||
.list_descendant_folders(fid, name_contains, user_id)
|
||||
.list_descendant_folders(fid, name_contains, caller_id)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -1049,30 +1000,30 @@ impl FolderRepository for FolderDbRepository {
|
||||
};
|
||||
|
||||
if recursive {
|
||||
// Recursive, no folder scope → ALL user folders
|
||||
// Recursive, no folder scope → ALL folders in caller's readable drives
|
||||
let sql = format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
fo.user_id, fo.drive_id, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.user_id = $1 \
|
||||
WHERE {CALLER_CAN_READ_DRIVE} \
|
||||
AND fo.is_trashed = false \
|
||||
{name_clause} \
|
||||
ORDER BY fo.name"
|
||||
);
|
||||
|
||||
let rows: Vec<FolderRowOptUser> = if let Some(ref pattern) = name_pattern {
|
||||
let rows: Vec<FolderRow> = if let Some(ref pattern) = name_pattern {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(user_id)
|
||||
.bind(caller_id)
|
||||
.bind(pattern)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(user_id)
|
||||
.bind(caller_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
@@ -1080,96 +1031,100 @@ impl FolderRepository for FolderDbRepository {
|
||||
|
||||
return rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
|
||||
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
// Non-recursive: direct children of parent_id, filtered by user
|
||||
// Non-recursive: direct children of parent_id, restricted to drives
|
||||
// the caller can read (parent_id already establishes the subtree).
|
||||
let sql = if parent_id.is_some() {
|
||||
format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
fo.user_id, fo.drive_id, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.parent_id = $1::uuid \
|
||||
AND fo.user_id = $2 \
|
||||
WHERE fo.parent_id = $2::uuid \
|
||||
AND {CALLER_CAN_READ_DRIVE} \
|
||||
AND fo.is_trashed = false \
|
||||
{name_clause} \
|
||||
ORDER BY fo.name"
|
||||
)
|
||||
} else {
|
||||
// Root folders: parent_id IS NULL, reindex params ($1=user_id, $2=pattern)
|
||||
// Root folders: parent_id IS NULL, params ($1=caller_id, $2=pattern)
|
||||
let name_clause_root = match name_contains {
|
||||
Some(name) if name.len() >= 3 => " AND fo.name ILIKE $2",
|
||||
_ => "",
|
||||
};
|
||||
format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
fo.user_id, fo.drive_id, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.parent_id IS NULL \
|
||||
AND fo.user_id = $1 \
|
||||
AND {CALLER_CAN_READ_DRIVE} \
|
||||
AND fo.is_trashed = false \
|
||||
{name_clause_root} \
|
||||
ORDER BY fo.name"
|
||||
)
|
||||
};
|
||||
|
||||
let rows: Vec<FolderRowOptUser> = if let Some(pid) = parent_id {
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
if let Some(ref pattern) = name_pattern {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(caller_id)
|
||||
.bind(pid)
|
||||
.bind(user_id)
|
||||
.bind(pattern)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(caller_id)
|
||||
.bind(pid)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
} else if let Some(ref pattern) = name_pattern {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(user_id)
|
||||
.bind(caller_id)
|
||||
.bind(pattern)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(user_id)
|
||||
.bind(caller_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
|
||||
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Lists all descendant folders in a subtree using ltree GiST index.
|
||||
/// Lists all descendant folders in a subtree using ltree GiST index,
|
||||
/// scoped to drives the caller can read.
|
||||
///
|
||||
/// Single SQL query: `fo.lpath <@ (root's lpath)` fetches the entire
|
||||
/// subtree in one indexed scan. Optional name filter is pushed to SQL.
|
||||
/// subtree in one indexed scan. Post-PR-B: drive-membership filter
|
||||
/// inline via `caller_group_ids`, replacing the legacy
|
||||
/// `fo.user_id = $caller` predicate.
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_descendant_folders(
|
||||
&self,
|
||||
folder_id: &str,
|
||||
name_contains: Option<&str>,
|
||||
user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let (where_extra, name_pattern) = match name_contains {
|
||||
Some(name) if name.len() >= 3 => {
|
||||
@@ -1180,13 +1135,13 @@ impl FolderRepository for FolderDbRepository {
|
||||
|
||||
let sql = format!(
|
||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
fo.user_id, fo.drive_id, \
|
||||
fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.user_id = $1 \
|
||||
WHERE {CALLER_CAN_READ_DRIVE} \
|
||||
AND fo.is_trashed = false \
|
||||
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $2::uuid) \
|
||||
AND fo.id != $2::uuid \
|
||||
@@ -1194,16 +1149,16 @@ impl FolderRepository for FolderDbRepository {
|
||||
ORDER BY fo.name"
|
||||
);
|
||||
|
||||
let rows: Vec<FolderRowOptUser> = if let Some(ref pattern) = name_pattern {
|
||||
let rows: Vec<FolderRow> = if let Some(ref pattern) = name_pattern {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(user_id)
|
||||
.bind(caller_id)
|
||||
.bind(folder_id)
|
||||
.bind(pattern)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(user_id)
|
||||
.bind(caller_id)
|
||||
.bind(folder_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
@@ -1211,8 +1166,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
|
||||
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1230,7 +1185,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -1257,7 +1212,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
SELECT id::text, name, path, parent_id::text, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
@@ -1284,8 +1239,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
|
||||
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1359,16 +1314,6 @@ impl FolderRepository for FolderDbRepository {
|
||||
// ── Extra helpers for blob-storage bootstrap ──
|
||||
|
||||
impl FolderDbRepository {
|
||||
/// Returns user_id for a given folder. Used by file repositories.
|
||||
pub async fn get_folder_user_id(&self, folder_id: &str) -> Result<Uuid, DomainError> {
|
||||
sqlx::query_scalar::<_, Uuid>("SELECT user_id FROM storage.folders WHERE id = $1::uuid")
|
||||
.bind(folder_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("user_id lookup: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", folder_id))
|
||||
}
|
||||
|
||||
/// Returns `drive_id` for a given folder. Drives the new permission-floor
|
||||
/// short-circuit in `PgAclEngine::check_inner` (a caller with any role
|
||||
/// on the folder's drive automatically passes the check — drive
|
||||
@@ -1382,22 +1327,6 @@ impl FolderDbRepository {
|
||||
.ok_or_else(|| DomainError::not_found("Folder", folder_id))
|
||||
}
|
||||
|
||||
/// Verifies that `folder_id` is owned by `owner_id`.
|
||||
///
|
||||
/// Returns `DomainError::not_found(...)` for both "folder missing" and
|
||||
/// "folder owned by someone else" — same error to avoid leaking the
|
||||
/// existence of resources belonging to other users.
|
||||
pub async fn verify_owner(&self, folder_id: &str, owner_id: Uuid) -> Result<(), DomainError> {
|
||||
let actual = self.get_folder_user_id(folder_id).await?;
|
||||
if actual != owner_id {
|
||||
return Err(DomainError::not_found(
|
||||
"Folder",
|
||||
"Target folder not found or access denied",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cursor-paginated combined listing of sub-folders and files inside
|
||||
/// `parent_id`, sorted by `order_by`.
|
||||
///
|
||||
@@ -1434,7 +1363,6 @@ impl FolderDbRepository {
|
||||
-1::bigint AS size,
|
||||
f.created_at,
|
||||
f.updated_at AS modified_at,
|
||||
f.user_id,
|
||||
f.drive_id,
|
||||
NULL::text AS blob_hash,
|
||||
LOWER(f.name) AS sort_str,
|
||||
@@ -1454,7 +1382,6 @@ impl FolderDbRepository {
|
||||
fm.size::bigint,
|
||||
fm.created_at,
|
||||
fm.updated_at AS modified_at,
|
||||
fm.user_id,
|
||||
fm.drive_id,
|
||||
fm.blob_hash,
|
||||
LOWER(fm.name) AS sort_str,
|
||||
@@ -1580,7 +1507,7 @@ impl FolderDbRepository {
|
||||
let sql = format!(
|
||||
"WITH resources AS ({cte_inner}) \
|
||||
SELECT resource_type, id, name, folder_id, mime_type, size, \
|
||||
created_at, modified_at, user_id, drive_id, blob_hash, \
|
||||
created_at, modified_at, drive_id, blob_hash, \
|
||||
sort_str, type_order, folder_first \
|
||||
FROM resources \
|
||||
{where_clause} \
|
||||
@@ -1589,7 +1516,7 @@ impl FolderDbRepository {
|
||||
);
|
||||
|
||||
// Row: (resource_type, id, name, folder_id, mime_type, size,
|
||||
// created_at, modified_at, user_id, drive_id, blob_hash,
|
||||
// created_at, modified_at, drive_id, blob_hash,
|
||||
// sort_str, type_order, folder_first)
|
||||
type Row = (
|
||||
String,
|
||||
@@ -1600,8 +1527,7 @@ impl FolderDbRepository {
|
||||
i64,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
Uuid,
|
||||
Uuid,
|
||||
Uuid, // drive_id
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
@@ -1632,12 +1558,11 @@ impl FolderDbRepository {
|
||||
size: r.5,
|
||||
created_at: r.6,
|
||||
modified_at: r.7,
|
||||
owner_id: r.8,
|
||||
drive_id: r.9,
|
||||
blob_hash: r.10,
|
||||
sort_str: r.11,
|
||||
type_order: r.12,
|
||||
folder_first: r.13,
|
||||
drive_id: r.8,
|
||||
blob_hash: r.9,
|
||||
sort_str: r.10,
|
||||
type_order: r.11,
|
||||
folder_first: r.12,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -206,6 +206,20 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
||||
// ── Build the UNION ALL CTE ─────────────────────────────────────────
|
||||
let mut cte_branches: Vec<&str> = Vec::new();
|
||||
|
||||
// Post-D7: `is_owner` means "the caller holds an Owner
|
||||
// role_grant on the drive owning this row". Personal drives:
|
||||
// the single-owner invariant makes this trivially true for the
|
||||
// owner and false for anyone else. Shared drives: multiple
|
||||
// Owners possible; each of them gets `true`. Used only to gate
|
||||
// whether the handler exposes the full path (path-hierarchy
|
||||
// hiding for share recipients — see `recent_handler.rs`).
|
||||
//
|
||||
// The `created_by` projection is separate — §14 provenance,
|
||||
// used for the "Owner" column and the owner sort's username
|
||||
// JOIN. The two signals genuinely differ post-D2: e.g. Bob
|
||||
// (Editor on Alice's shared drive) making a file has
|
||||
// `created_by = Bob` but `is_owner = false` because Alice owns
|
||||
// the drive.
|
||||
let folder_branch = r#"
|
||||
SELECT
|
||||
'folder'::text AS resource_type,
|
||||
@@ -216,10 +230,18 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
||||
-1::bigint AS size,
|
||||
fld.created_at AS resource_created_at,
|
||||
fld.updated_at AS modified_at,
|
||||
fld.user_id AS owner_id,
|
||||
fld.drive_id AS drive_id,
|
||||
NULL::text AS blob_hash,
|
||||
(fld.user_id = $1::uuid) AS is_owner,
|
||||
fld.created_by AS created_by,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
AND g.resource_id = fld.drive_id
|
||||
AND g.role = 'owner'
|
||||
AND g.subject_type = 'user'
|
||||
AND g.subject_id = $1::uuid
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
) AS is_owner,
|
||||
ur.accessed_at AS accessed_at,
|
||||
fld.path::text AS resource_path,
|
||||
LOWER(fld.name) AS sort_str,
|
||||
@@ -240,10 +262,18 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
||||
f.size::bigint,
|
||||
f.created_at AS resource_created_at,
|
||||
f.updated_at AS modified_at,
|
||||
f.user_id AS owner_id,
|
||||
f.drive_id AS drive_id,
|
||||
f.blob_hash,
|
||||
(f.user_id = $1::uuid) AS is_owner,
|
||||
f.created_by AS created_by,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
AND g.resource_id = f.drive_id
|
||||
AND g.role = 'owner'
|
||||
AND g.subject_type = 'user'
|
||||
AND g.subject_id = $1::uuid
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
) AS is_owner,
|
||||
ur.accessed_at AS accessed_at,
|
||||
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
|
||||
LOWER(f.name) AS sort_str,
|
||||
@@ -394,7 +424,9 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
||||
};
|
||||
|
||||
let user_join = if need_user_join {
|
||||
"LEFT JOIN auth.users u ON u.id = r.owner_id"
|
||||
// Post-D7: `owner_id` column retired; use `created_by`
|
||||
// (§14 provenance) as the "owner" identity for the sort.
|
||||
"LEFT JOIN auth.users u ON u.id = r.created_by"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
@@ -411,7 +443,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
|
||||
SELECT
|
||||
r.resource_type, r.resource_id, r.name, r.parent_id,
|
||||
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
||||
r.owner_id, r.drive_id, r.is_owner, r.accessed_at, r.resource_path,
|
||||
r.drive_id, r.is_owner, r.accessed_at, r.resource_path,
|
||||
r.sort_str, r.type_order, r.folder_first{username_col}
|
||||
FROM resources r
|
||||
{user_join}
|
||||
@@ -486,7 +518,6 @@ LIMIT $6"
|
||||
size,
|
||||
resource_created_at: row.get("resource_created_at"),
|
||||
modified_at: row.get("modified_at"),
|
||||
owner_id: row.get("owner_id"),
|
||||
drive_id: row.get("drive_id"),
|
||||
blob_hash: row.try_get("blob_hash").ok(),
|
||||
is_owner: row.try_get("is_owner").unwrap_or(false),
|
||||
|
||||
@@ -123,26 +123,45 @@ impl TrashRepository for TrashDbRepository {
|
||||
}
|
||||
|
||||
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> {
|
||||
let rows =
|
||||
sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>, String)>(
|
||||
r#"
|
||||
SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at,
|
||||
// Post-D7: the `WHERE t.user_id = $1` filter is gone — the
|
||||
// `user_id` column was dropped from `storage.{files,folders}`
|
||||
// and the view no longer projects it. Scope is drive-membership
|
||||
// via role_grants; group memberships expand inline through
|
||||
// `storage.caller_group_ids`. Same predicate shape as
|
||||
// `list_root_folders_for_caller` / the file listings.
|
||||
//
|
||||
// Legacy method — the paginated `list_resources_paged` is the
|
||||
// modern shape and takes explicit drive_ids from the service
|
||||
// layer.
|
||||
let rows = sqlx::query_as::<_, (Uuid, String, String, Option<DateTime<Utc>>, String)>(
|
||||
r#"
|
||||
SELECT t.id, t.name, t.item_type, t.trashed_at,
|
||||
COALESCE(p.path || '/' || t.name, t.name) AS original_path
|
||||
FROM storage.trash_items t
|
||||
LEFT JOIN storage.folders p ON p.id = t.original_parent_id
|
||||
WHERE t.user_id = $1
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
AND g.resource_id = t.drive_id
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND (
|
||||
(g.subject_type = 'user' AND g.subject_id = $1)
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN
|
||||
(SELECT storage.caller_group_ids($1)))
|
||||
)
|
||||
)
|
||||
ORDER BY t.trashed_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?;
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, name, item_type, uid, trashed_at, path)| {
|
||||
self.row_to_trashed_item(id, name, item_type, uid, trashed_at, path)
|
||||
.map(|(id, name, item_type, trashed_at, path)| {
|
||||
self.row_to_trashed_item(id, name, item_type, *user_id, trashed_at, path)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -153,9 +172,16 @@ impl TrashRepository for TrashDbRepository {
|
||||
// …)` in the service callers (`restore_item`, `delete_permanently`).
|
||||
// The drive precheck in `pg_acl_engine` then resolves Owner-on-drive
|
||||
// → Delete-permission for items in shared drives.
|
||||
let row = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>, String)>(
|
||||
//
|
||||
// Post-D7: `t.user_id` no longer exists — the column is dropped
|
||||
// from `storage.{files,folders}` and no longer projected by the
|
||||
// view. The entity's `user_id` field is still non-optional;
|
||||
// synthesize `Uuid::nil()`. AuthZ decisions don't consult this
|
||||
// field — they've already resolved the caller's role on the
|
||||
// target's drive.
|
||||
let row = sqlx::query_as::<_, (Uuid, String, String, Option<DateTime<Utc>>, String)>(
|
||||
r#"
|
||||
SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at,
|
||||
SELECT t.id, t.name, t.item_type, t.trashed_at,
|
||||
COALESCE(p.path || '/' || t.name, t.name) AS original_path
|
||||
FROM storage.trash_items t
|
||||
LEFT JOIN storage.folders p ON p.id = t.original_parent_id
|
||||
@@ -167,8 +193,8 @@ impl TrashRepository for TrashDbRepository {
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?;
|
||||
|
||||
Ok(row.map(|(id, name, item_type, uid, trashed_at, path)| {
|
||||
self.row_to_trashed_item(id, name, item_type, uid, trashed_at, path)
|
||||
Ok(row.map(|(id, name, item_type, trashed_at, path)| {
|
||||
self.row_to_trashed_item(id, name, item_type, Uuid::nil(), trashed_at, path)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -313,7 +339,6 @@ impl TrashDbRepository {
|
||||
-1::bigint AS size,
|
||||
fld.created_at AS resource_created_at,
|
||||
fld.updated_at AS modified_at,
|
||||
fld.user_id AS owner_id,
|
||||
fld.drive_id AS drive_id,
|
||||
NULL::text AS blob_hash,
|
||||
fld.trashed_at AS trashed_at,
|
||||
@@ -340,7 +365,6 @@ impl TrashDbRepository {
|
||||
f.size::bigint AS size,
|
||||
f.created_at AS resource_created_at,
|
||||
f.updated_at AS modified_at,
|
||||
f.user_id AS owner_id,
|
||||
f.drive_id AS drive_id,
|
||||
f.blob_hash,
|
||||
f.trashed_at AS trashed_at,
|
||||
@@ -472,7 +496,7 @@ impl TrashDbRepository {
|
||||
SELECT
|
||||
r.resource_type, r.resource_id, r.name, r.parent_id,
|
||||
r.mime_type, r.size, r.resource_created_at, r.modified_at,
|
||||
r.owner_id, r.drive_id, r.trashed_at, r.deletion_date, r.resource_path,
|
||||
r.drive_id, r.trashed_at, r.deletion_date, r.resource_path,
|
||||
r.sort_str, r.type_order, r.folder_first
|
||||
FROM resources r
|
||||
{keyset}
|
||||
@@ -529,7 +553,6 @@ LIMIT $6"
|
||||
size,
|
||||
resource_created_at: row.get("resource_created_at"),
|
||||
modified_at: row.get("modified_at"),
|
||||
owner_id: row.get("owner_id"),
|
||||
drive_id: row.get("drive_id"),
|
||||
blob_hash: row.try_get("blob_hash").ok(),
|
||||
trashed_at,
|
||||
|
||||
@@ -608,17 +608,26 @@ impl DedupService {
|
||||
}
|
||||
|
||||
/// Of `hashes` (distinct), the subset `caller_id` may claim without
|
||||
/// uploading bytes: chunks referenced by manifests of the caller's
|
||||
/// files (live or trashed), or directly referenced as (legacy)
|
||||
/// whole-file blobs. Backed by the GIN index on
|
||||
/// uploading bytes: chunks referenced by manifests of files in drives
|
||||
/// where the caller holds a **writable role** (owner / editor /
|
||||
/// contributor), or directly referenced as (legacy) whole-file blobs
|
||||
/// under the same predicate. Backed by the GIN index on
|
||||
/// `chunk_manifests.chunk_hashes`.
|
||||
///
|
||||
/// Post-D7 (`project_d7_policy_calls` LOCKED design): entitlement is
|
||||
/// drive-membership + writable-role, not the legacy `user_id`
|
||||
/// filter. Viewers/commenters are excluded — they can't legitimately
|
||||
/// upload content into a drive, so they can't claim
|
||||
/// "already-uploaded" via dedup. Group memberships (direct +
|
||||
/// transitive) are expanded inline through
|
||||
/// `storage.caller_group_ids($2)`.
|
||||
///
|
||||
/// Trashed files count as ownership: a trashed file's content is still
|
||||
/// the caller's (restorable until trash-empty), so a re-upload of the
|
||||
/// same content should hit the dedup fast path instead of forcing the
|
||||
/// caller to re-send bytes they already have on the server. Must stay
|
||||
/// in lockstep with [`pin_claimable_chunks`], which actually bumps the
|
||||
/// ref_count using the same entitlement set.
|
||||
/// under the caller's writable scope (restorable until trash-empty),
|
||||
/// so a re-upload of the same content should hit the dedup fast path
|
||||
/// instead of forcing the caller to re-send bytes they already have
|
||||
/// on the server. Must stay in lockstep with [`pin_claimable_chunks`],
|
||||
/// which actually bumps the ref_count using the same entitlement set.
|
||||
pub async fn claimable_chunks(
|
||||
&self,
|
||||
caller_id: uuid::Uuid,
|
||||
@@ -633,13 +642,35 @@ impl DedupService {
|
||||
SELECT 1
|
||||
FROM storage.files f
|
||||
JOIN storage.chunk_manifests m ON m.file_hash = f.blob_hash
|
||||
WHERE f.user_id = $2
|
||||
AND m.chunk_hashes @> ARRAY[c.h]
|
||||
WHERE m.chunk_hashes @> ARRAY[c.h]
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
AND g.resource_id = f.drive_id
|
||||
AND g.role IN ('owner', 'editor', 'contributor')
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND (
|
||||
(g.subject_type = 'user' AND g.subject_id = $2)
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN
|
||||
(SELECT storage.caller_group_ids($2)))
|
||||
)
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM storage.files f2
|
||||
WHERE f2.user_id = $2
|
||||
AND f2.blob_hash = c.h
|
||||
WHERE f2.blob_hash = c.h
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
AND g.resource_id = f2.drive_id
|
||||
AND g.role IN ('owner', 'editor', 'contributor')
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND (
|
||||
(g.subject_type = 'user' AND g.subject_id = $2)
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN
|
||||
(SELECT storage.caller_group_ids($2)))
|
||||
)
|
||||
)
|
||||
)",
|
||||
)
|
||||
.bind(hashes)
|
||||
@@ -651,17 +682,23 @@ impl DedupService {
|
||||
}
|
||||
|
||||
/// Pin one reference on each of `hashes` (distinct) that the caller is
|
||||
/// entitled to claim — owned chunks (see [`claimable_chunks`]) or
|
||||
/// unreferenced orphans (`ref_count = 0`, the just-uploaded state).
|
||||
/// entitled to claim — writably-scoped chunks (see [`claimable_chunks`])
|
||||
/// or unreferenced orphans (`ref_count = 0`, the just-uploaded state).
|
||||
/// One statement: entitlement check and bump are atomic per row, so a
|
||||
/// concurrent last-reference delete can never be resurrected and a
|
||||
/// non-entitled hash is simply not returned.
|
||||
///
|
||||
/// Entitlement includes files in trash: a trashed file is still owned
|
||||
/// by the user, the content is still theirs to re-reference, and the
|
||||
/// race with trash-empty is handled the same way as `add_reference` —
|
||||
/// if GC has already deleted the blob row, the UPDATE affects 0 rows
|
||||
/// and the hash is simply absent from the returned set.
|
||||
/// Post-D7 (`project_d7_policy_calls` LOCKED): entitlement uses the
|
||||
/// same drive-membership + writable-role predicate as
|
||||
/// [`claimable_chunks`] — MUST STAY IN LOCKSTEP with that query.
|
||||
/// Group memberships resolve through `storage.caller_group_ids($2)`.
|
||||
///
|
||||
/// Entitlement includes files in trash: a trashed file is still
|
||||
/// within the caller's writable scope, the content is still theirs
|
||||
/// to re-reference, and the race with trash-empty is handled the
|
||||
/// same way as `add_reference` — if GC has already deleted the blob
|
||||
/// row, the UPDATE affects 0 rows and the hash is simply absent from
|
||||
/// the returned set.
|
||||
///
|
||||
/// Returns the set actually pinned; the caller compares against its
|
||||
/// input and reports the difference as `still_missing`.
|
||||
@@ -682,13 +719,35 @@ impl DedupService {
|
||||
SELECT 1
|
||||
FROM storage.files f
|
||||
JOIN storage.chunk_manifests m ON m.file_hash = f.blob_hash
|
||||
WHERE f.user_id = $2
|
||||
AND m.chunk_hashes @> ARRAY[b.hash::text]
|
||||
WHERE m.chunk_hashes @> ARRAY[b.hash::text]
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
AND g.resource_id = f.drive_id
|
||||
AND g.role IN ('owner', 'editor', 'contributor')
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND (
|
||||
(g.subject_type = 'user' AND g.subject_id = $2)
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN
|
||||
(SELECT storage.caller_group_ids($2)))
|
||||
)
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM storage.files f2
|
||||
WHERE f2.user_id = $2
|
||||
AND f2.blob_hash = b.hash
|
||||
WHERE f2.blob_hash = b.hash
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
AND g.resource_id = f2.drive_id
|
||||
AND g.role IN ('owner', 'editor', 'contributor')
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND (
|
||||
(g.subject_type = 'user' AND g.subject_id = $2)
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN
|
||||
(SELECT storage.caller_group_ids($2)))
|
||||
)
|
||||
)
|
||||
) )
|
||||
RETURNING b.hash",
|
||||
)
|
||||
@@ -1068,11 +1127,36 @@ impl DedupService {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns `true` if `user_id` owns at least one (even trashed) file that
|
||||
/// references the blob identified by `hash`.
|
||||
/// Returns `true` if the caller has a **writable role** on at least one
|
||||
/// drive containing a (possibly trashed) file that references the blob
|
||||
/// identified by `hash`.
|
||||
///
|
||||
/// Post-D7 (`project_d7_policy_calls` LOCKED): same
|
||||
/// drive-membership + writable-role predicate as
|
||||
/// [`claimable_chunks`] / [`pin_claimable_chunks`] — MUST stay in
|
||||
/// lockstep with them. Group memberships (direct + transitive)
|
||||
/// expand inline via `storage.caller_group_ids($2)`. Viewers /
|
||||
/// commenters are excluded — they can't legitimately upload into
|
||||
/// a drive, so they can't claim "already-uploaded" via dedup.
|
||||
pub async fn user_owns_blob_reference(&self, hash: &str, user_id: &str) -> bool {
|
||||
sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM storage.files WHERE blob_hash = $1 AND user_id = $2::uuid)",
|
||||
"SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM storage.files f
|
||||
WHERE f.blob_hash = $1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
AND g.resource_id = f.drive_id
|
||||
AND g.role IN ('owner', 'editor', 'contributor')
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND (
|
||||
(g.subject_type = 'user' AND g.subject_id = $2::uuid)
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN
|
||||
(SELECT storage.caller_group_ids($2::uuid)))
|
||||
)
|
||||
)
|
||||
)",
|
||||
)
|
||||
.bind(hash)
|
||||
.bind(user_id)
|
||||
@@ -1082,13 +1166,14 @@ impl DedupService {
|
||||
}
|
||||
|
||||
/// Batch variant of [`Self::user_owns_blob_reference`]: given candidate
|
||||
/// hashes, return the subset the user already references — in ONE query
|
||||
/// (backed by `idx_files_blob_hash`). Lets a client hash a whole upload set
|
||||
/// and learn which files it can skip with a single round trip instead of
|
||||
/// one probe per file.
|
||||
/// hashes, return the subset the caller can already reference — in ONE
|
||||
/// query (backed by `idx_files_blob_hash`). Lets a client hash a whole
|
||||
/// upload set and learn which files it can skip with a single round trip
|
||||
/// instead of one probe per file.
|
||||
///
|
||||
/// User-scoped, exactly like the single check: only the caller's own blobs
|
||||
/// are returned, so it cannot probe whether *other* users hold a blob.
|
||||
/// Post-D7: same drive-membership + writable-role predicate as the
|
||||
/// single check. Anti-enumeration is preserved — only hashes present
|
||||
/// in a drive the caller can write to come back.
|
||||
pub async fn user_owned_blob_references(
|
||||
&self,
|
||||
hashes: &[String],
|
||||
@@ -1098,8 +1183,21 @@ impl DedupService {
|
||||
return Vec::new();
|
||||
}
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT DISTINCT blob_hash FROM storage.files \
|
||||
WHERE blob_hash = ANY($1) AND user_id = $2::uuid",
|
||||
"SELECT DISTINCT f.blob_hash
|
||||
FROM storage.files f
|
||||
WHERE f.blob_hash = ANY($1)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g
|
||||
WHERE g.resource_type = 'drive'
|
||||
AND g.resource_id = f.drive_id
|
||||
AND g.role IN ('owner', 'editor', 'contributor')
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND (
|
||||
(g.subject_type = 'user' AND g.subject_id = $2::uuid)
|
||||
OR (g.subject_type = 'group' AND g.subject_id IN
|
||||
(SELECT storage.caller_group_ids($2::uuid)))
|
||||
)
|
||||
)",
|
||||
)
|
||||
.bind(hashes)
|
||||
.bind(user_id)
|
||||
@@ -2949,19 +3047,20 @@ mod rechunk_integration_tests {
|
||||
.await
|
||||
.expect("insert legacy blob row");
|
||||
|
||||
let (user_id, drive_id) = seed_user(pool).await;
|
||||
let (_user_id, drive_id) = seed_user(pool).await;
|
||||
let mut file_ids = Vec::new();
|
||||
for i in 0..n_files {
|
||||
let name = format!(
|
||||
"rust-test-rechunk-{label}-{}-{i}",
|
||||
&Uuid::new_v4().to_string()[..8]
|
||||
);
|
||||
// Post-D7: `user_id` omitted — column is nullable and unused
|
||||
// on new rows.
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.files (name, user_id, drive_id, blob_hash, size)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
||||
"INSERT INTO storage.files (name, drive_id, blob_hash, size)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(user_id)
|
||||
.bind(drive_id)
|
||||
.bind(&hash)
|
||||
.bind(data.len() as i64)
|
||||
@@ -3261,22 +3360,23 @@ mod delta_upload_integration_tests {
|
||||
async fn seed_owned_content(
|
||||
svc: &DedupService,
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
_user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
data: &[u8],
|
||||
label: &str,
|
||||
) -> (String, Vec<String>, Uuid) {
|
||||
let file_hash = blake3::hash(data).to_hex().to_string();
|
||||
|
||||
// Post-D7: `user_id` omitted — column is nullable and unused on
|
||||
// new rows.
|
||||
let file_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO storage.files (name, user_id, drive_id, blob_hash, size)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
||||
"INSERT INTO storage.files (name, drive_id, blob_hash, size)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(format!(
|
||||
"rust-test-delta-{label}-{}",
|
||||
&Uuid::new_v4().to_string()[..8]
|
||||
))
|
||||
.bind(user_id)
|
||||
.bind(drive_id)
|
||||
.bind(&file_hash)
|
||||
.bind(data.len() as i64)
|
||||
|
||||
@@ -15,6 +15,8 @@ use crate::application::dtos::display_helpers::{
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::entities::folder::Folder;
|
||||
|
||||
/// Result of resolving a WebDAV path — either a folder or a file.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -33,14 +35,20 @@ impl PathResolverService {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Resolve `path` to a folder or file **owned by `user_id`**.
|
||||
/// Resolve `path` to a folder or file **within the given drive**.
|
||||
///
|
||||
/// Adds `AND fo.user_id = $4` / `AND fi.user_id = $4` so that one
|
||||
/// user can never resolve another user's resources.
|
||||
pub async fn resolve_path_for_user(
|
||||
/// Filters on `fo.drive_id = $4` / `fi.drive_id = $4`. Callers
|
||||
/// pre-resolve which drive they're operating in — native WebDAV
|
||||
/// derives it from the caller's default drive
|
||||
/// (`resolve_drive_id_for_native_webdav`); NC WebDAV takes it from
|
||||
/// the URL-selected chroot (`chroot.drive_id`). Shared by both
|
||||
/// surfaces so the single-query UNION ALL optimisation lands
|
||||
/// consistently and no path lookup keys on the doomed
|
||||
/// `storage.{files,folders}.user_id` column.
|
||||
pub async fn resolve_path_in_drive(
|
||||
&self,
|
||||
path: &str,
|
||||
user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
) -> Result<ResolvedResource, DomainError> {
|
||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||
if path.is_empty() {
|
||||
@@ -55,6 +63,13 @@ impl PathResolverService {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Widened SELECT: also fetches `blob_hash` (for file ETag) and
|
||||
// `tree_modified_at` (for folder ETag). Both share the same
|
||||
// canonical formulas as the rest of the codebase — see
|
||||
// [`File::compute_etag`] and [`Folder::compute_etag`]. Without
|
||||
// these two extra columns the resolver used to emit empty
|
||||
// ETag strings, and NC's `If-Match` round-trips broke
|
||||
// (see the F6b regression on `test_nc_put_mkcol_blake3.sh`).
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
@@ -63,34 +78,37 @@ impl PathResolverService {
|
||||
String, // name
|
||||
String, // path
|
||||
Option<String>, // parent_id
|
||||
Option<String>, // user_id
|
||||
Uuid, // drive_id
|
||||
i64, // created_at
|
||||
i64, // modified_at
|
||||
Option<i64>, // size
|
||||
Option<String>, // mime_type
|
||||
Option<String>, // folder_id
|
||||
Option<String>, // blob_hash (files only)
|
||||
Option<i64>, // tree_modified_at (folders only)
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
SELECT resource_type, id, name, path, parent_id, user_id, drive_id,
|
||||
created_at, modified_at, size, mime_type, folder_id
|
||||
SELECT resource_type, id, name, path, parent_id, drive_id,
|
||||
created_at, modified_at, size, mime_type, folder_id,
|
||||
blob_hash, tree_modified_at
|
||||
FROM (
|
||||
SELECT 'folder'::text AS resource_type,
|
||||
fo.id::text,
|
||||
fo.name,
|
||||
fo.path,
|
||||
fo.parent_id::text,
|
||||
fo.user_id::text,
|
||||
fo.drive_id,
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint AS created_at,
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at,
|
||||
NULL::bigint AS size,
|
||||
NULL::text AS mime_type,
|
||||
NULL::text AS folder_id
|
||||
NULL::text AS folder_id,
|
||||
NULL::text AS blob_hash,
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint AS tree_modified_at
|
||||
FROM storage.folders fo
|
||||
WHERE fo.path = $1 AND NOT fo.is_trashed
|
||||
AND fo.user_id = $4
|
||||
AND fo.drive_id = $4
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -103,13 +121,14 @@ impl PathResolverService {
|
||||
ELSE fi.name
|
||||
END AS path,
|
||||
NULL::text AS parent_id,
|
||||
fi.user_id::text,
|
||||
fi.drive_id,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint AS created_at,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at,
|
||||
fi.size,
|
||||
fi.mime_type,
|
||||
fi.folder_id::text
|
||||
fi.folder_id::text,
|
||||
fi.blob_hash,
|
||||
NULL::bigint AS tree_modified_at
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.name = $2
|
||||
@@ -118,7 +137,7 @@ impl PathResolverService {
|
||||
OR fo.path = $3
|
||||
)
|
||||
AND NOT fi.is_trashed
|
||||
AND fi.user_id = $4
|
||||
AND fi.drive_id = $4
|
||||
) sub
|
||||
LIMIT 1
|
||||
"#,
|
||||
@@ -126,10 +145,10 @@ impl PathResolverService {
|
||||
.bind(path) // $1
|
||||
.bind(filename) // $2
|
||||
.bind(&folder_path) // $3
|
||||
.bind(user_id) // $4
|
||||
.bind(drive_id) // $4
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_for_user: {e}")))?
|
||||
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_in_drive: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Resource", path))?;
|
||||
|
||||
let (
|
||||
@@ -138,45 +157,46 @@ impl PathResolverService {
|
||||
name,
|
||||
res_path,
|
||||
parent_id,
|
||||
uid,
|
||||
drive_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
size,
|
||||
mime_type,
|
||||
folder_id,
|
||||
blob_hash,
|
||||
tree_modified_at,
|
||||
) = row;
|
||||
|
||||
match resource_type.as_str() {
|
||||
"folder" => Ok(ResolvedResource::Folder(FolderDto {
|
||||
etag: id.clone(),
|
||||
id,
|
||||
name: name.clone(),
|
||||
path: res_path,
|
||||
parent_id,
|
||||
owner_id: uid,
|
||||
drive_id,
|
||||
created_at: created_at as u64,
|
||||
modified_at: modified_at as u64,
|
||||
is_root: false,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
// §14 provenance not selected by this resolver path —
|
||||
// it's used for existence/type discrimination, not
|
||||
// detailed DTO emission. Callers that need provenance
|
||||
// reload through the repo.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
})),
|
||||
"folder" => {
|
||||
let tree_mod = tree_modified_at.unwrap_or(modified_at) as u64;
|
||||
Ok(ResolvedResource::Folder(FolderDto {
|
||||
etag: Folder::compute_etag(&id, tree_mod),
|
||||
id,
|
||||
name: name.clone(),
|
||||
path: res_path,
|
||||
parent_id,
|
||||
drive_id,
|
||||
created_at: created_at as u64,
|
||||
modified_at: modified_at as u64,
|
||||
is_root: false,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
// §14 provenance not selected by this resolver path —
|
||||
// it's used for existence/type discrimination, not
|
||||
// detailed DTO emission. Callers that need provenance
|
||||
// reload through the repo.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
}))
|
||||
}
|
||||
_ => {
|
||||
let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
let sz = size.unwrap_or(0) as u64;
|
||||
// `content_hash`/`etag` are empty here: this resolver
|
||||
// path doesn't select `blob_hash` from SQL — callers
|
||||
// are doing existence/type discrimination, not ETag
|
||||
// emission. If a caller ever needs an ETag from this
|
||||
// codepath, widen the SELECT and populate properly.
|
||||
let hash = blob_hash.unwrap_or_default();
|
||||
let modified_at_u = modified_at as u64;
|
||||
let etag = File::compute_etag(&hash, modified_at_u);
|
||||
Ok(ResolvedResource::File(FileDto {
|
||||
id,
|
||||
name: name.clone(),
|
||||
@@ -185,15 +205,14 @@ impl PathResolverService {
|
||||
mime_type: Arc::from(&*mime),
|
||||
folder_id,
|
||||
created_at: created_at as u64,
|
||||
modified_at: modified_at as u64,
|
||||
modified_at: modified_at_u,
|
||||
icon_class: Arc::from(icon_class_for(&name, &mime)),
|
||||
icon_special_class: Arc::from(icon_special_class_for(&name, &mime)),
|
||||
category: Arc::from(category_for(&name, &mime)),
|
||||
size_formatted: format_file_size(sz),
|
||||
owner_id: uid,
|
||||
sort_date: None,
|
||||
content_hash: String::new(),
|
||||
etag: String::new(),
|
||||
content_hash: hash,
|
||||
etag,
|
||||
// §14 provenance not selected by this resolver path
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
@@ -203,7 +222,10 @@ impl PathResolverService {
|
||||
}
|
||||
|
||||
/// Returns `true` if the resource at `path` belongs to `user_id`.
|
||||
pub async fn exists_for_user(&self, path: &str, user_id: Uuid) -> Result<bool, DomainError> {
|
||||
/// Check whether `path` resolves to a folder or file within the
|
||||
/// given drive. Companion to `resolve_path_in_drive` — same scope
|
||||
/// filter, existence-only projection.
|
||||
pub async fn exists_in_drive(&self, path: &str, drive_id: Uuid) -> Result<bool, DomainError> {
|
||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||
if path.is_empty() {
|
||||
return Ok(false);
|
||||
@@ -221,7 +243,7 @@ impl PathResolverService {
|
||||
r#"
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM storage.folders
|
||||
WHERE path = $1 AND NOT is_trashed AND user_id = $4
|
||||
WHERE path = $1 AND NOT is_trashed AND drive_id = $4
|
||||
) OR EXISTS(
|
||||
SELECT 1
|
||||
FROM storage.files fi
|
||||
@@ -229,18 +251,18 @@ impl PathResolverService {
|
||||
WHERE fi.name = $2
|
||||
AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3)
|
||||
AND NOT fi.is_trashed
|
||||
AND fi.user_id = $4
|
||||
AND fi.drive_id = $4
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(path)
|
||||
.bind(filename)
|
||||
.bind(&folder_path)
|
||||
.bind(user_id)
|
||||
.bind(drive_id)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("PathResolver", format!("exists_for_user: {e}"))
|
||||
DomainError::internal_error("PathResolver", format!("exists_in_drive: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(exists)
|
||||
|
||||
@@ -283,6 +283,62 @@ impl PgAclEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the `owner_cache` entry for `resource`. Called after any
|
||||
/// operation that changes which drive a file/folder belongs to —
|
||||
/// the pre-D6 comment on `owner_cache` ("a resource's owner is
|
||||
/// immutable") stopped being true when cross-drive MOVE landed.
|
||||
///
|
||||
/// Without this call, admin (or any other role holder) on the
|
||||
/// destination drive gets `authz.denied` when acting on the moved
|
||||
/// resource: the cached (stale) `Resource → src_drive_id` lookup
|
||||
/// steers the drive-role precheck at `check_inner` toward the
|
||||
/// SOURCE drive where the caller has no role, and the fallback
|
||||
/// per-resource cascade doesn't cover drive-level grants. TTL
|
||||
/// backstops eventually (5 min), but every write path that MOVEs
|
||||
/// content across drives MUST invalidate here so authz observes
|
||||
/// the new drive on the next check.
|
||||
pub async fn invalidate_owner_cache_for_resource(&self, resource: Resource) {
|
||||
self.owner_cache.invalidate(&resource).await;
|
||||
}
|
||||
|
||||
/// Bulk cousin of [`Self::invalidate_owner_cache_for_resource`] —
|
||||
/// clears the entire `owner_cache`. Called by folder cross-drive
|
||||
/// MOVE where the moved subtree's descendants each carry their
|
||||
/// own stale entry, and we don't (yet) walk the subtree to
|
||||
/// invalidate them individually. The cache repopulates lazily on
|
||||
/// next access; the overhead is a single JOIN per file/folder
|
||||
/// touched in the following minute or two, versus a stale-authz
|
||||
/// bug that returned `NotFound` for legitimate Delete.
|
||||
pub async fn invalidate_owner_cache_all(&self) {
|
||||
self.owner_cache.invalidate_all();
|
||||
}
|
||||
|
||||
/// Sibling of [`Self::invalidate_drive_role_cache_for_drive`] keyed by
|
||||
/// subject rather than drive. Used by the user-deleted lifecycle hook
|
||||
/// to reap every cached "user X → drive Y = role R" entry after the
|
||||
/// user row (and its DB-cascade-cleared role_grants) is gone. Without
|
||||
/// this call the entry lingers until TTL; in practice auth rejection
|
||||
/// on the deleted user's tokens fires first, but leaving stale
|
||||
/// authorisation rows in the cache is poor hygiene and would surface
|
||||
/// as an issue if a session survived (e.g. long-lived Basic Auth via
|
||||
/// app password) or if a same-uuid user were ever recreated.
|
||||
pub async fn invalidate_drive_role_cache_for_subject(&self, subject: Subject) {
|
||||
if let Err(err) = self
|
||||
.drive_role_cache
|
||||
.invalidate_entries_if(move |key, _v| key.0 == subject)
|
||||
{
|
||||
tracing::error!(
|
||||
target: "oxicloud::authz",
|
||||
event = "authz.cache_invalidation_failed",
|
||||
cache = "drive_role_cache",
|
||||
subject = ?subject,
|
||||
error = %err,
|
||||
"drive_role_cache cannot be bulk-invalidated by subject — \
|
||||
cache builder is missing support_invalidation_closures()",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand a user subject into the set of subject UUIDs that should match
|
||||
/// in `access_grants`: the user's own UUID, every group the user is
|
||||
/// transitively a member of, and (for internal users only) the implicit
|
||||
@@ -377,10 +433,15 @@ impl PgAclEngine {
|
||||
|
||||
/// Public wrapper around `subject_match_set` for callers that need
|
||||
/// the expanded `(subject_types, subject_ids)` pair without invoking
|
||||
/// the engine's full `check`/`require` pipeline. Used by
|
||||
/// `GET /api/drives` (and future drive-aware listing surfaces) to
|
||||
/// ask the `DriveRepository` for every drive the caller can read,
|
||||
/// reusing the engine's cached group-expansion logic.
|
||||
/// the engine's full `check`/`require` pipeline.
|
||||
///
|
||||
/// **Retained for legacy callers only** — new listing queries embed
|
||||
/// the `storage.caller_group_ids` PostgreSQL function inline (see
|
||||
/// migration `20260901000002_caller_group_ids_function.sql`) and
|
||||
/// take a bare `caller_id: Uuid` instead of the pre-expanded arrays.
|
||||
/// The engine's Moka cache still backs the fast path for per-request
|
||||
/// AuthZ decisions (`check_inner`, `drive_role_cache`) where the
|
||||
/// same subject is looked up repeatedly.
|
||||
pub async fn expand_subject_for_listing(
|
||||
&self,
|
||||
subject: Subject,
|
||||
@@ -418,11 +479,23 @@ impl PgAclEngine {
|
||||
/// Returns the `drive_id` for a File / Folder. Drives don't have a parent
|
||||
/// drive — this returns `NotFound` for `Resource::Drive` and the caller
|
||||
/// must not invoke it on Drive resources.
|
||||
///
|
||||
/// `Resource::Calendar`, `Resource::AddressBook` and
|
||||
/// `Resource::Playlist` are top-level per user with no drive
|
||||
/// ancestor; they also return `NotFound` and the engine
|
||||
/// short-circuits to a direct `role_grants` lookup (no drive
|
||||
/// precheck applies).
|
||||
async fn drive_of(&self, resource: Resource) -> Result<Uuid, DomainError> {
|
||||
match resource {
|
||||
Resource::Folder(id) => self.folder_repo.get_folder_drive_id(&id.to_string()).await,
|
||||
Resource::File(id) => self.file_repo.get_file_drive_id(&id.to_string()).await,
|
||||
Resource::Drive(_) => Err(DomainError::not_found("Drive", resource.id().to_string())),
|
||||
Resource::Drive(_)
|
||||
| Resource::Calendar(_)
|
||||
| Resource::AddressBook(_)
|
||||
| Resource::Playlist(_) => Err(DomainError::not_found(
|
||||
resource.type_str(),
|
||||
resource.id().to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,6 +531,49 @@ impl PgAclEngine {
|
||||
/// permission — see `roles_implying()`.
|
||||
///
|
||||
/// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade.
|
||||
/// Direct grant lookup with no cascade — used for top-level
|
||||
/// resources whose ACL lives entirely on their own row
|
||||
/// (`Resource::Calendar`, `Resource::AddressBook`). Same
|
||||
/// role-array + subject-set shape as the cascade helpers so a
|
||||
/// caller's group memberships still resolve, but no ltree /
|
||||
/// folder ancestry / drive precheck applies. Calendars and
|
||||
/// address books have no parent to inherit from.
|
||||
async fn direct_grant_exists(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
permission: Permission,
|
||||
resource_type: &'static str,
|
||||
resource_id: Uuid,
|
||||
counters: &QueryCounters,
|
||||
) -> Result<bool, DomainError> {
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
let roles = Self::roles_implying_strings(permission);
|
||||
let exists: Option<i32> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT 1
|
||||
FROM storage.role_grants g
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
AND g.role = ANY($3::storage.grant_role[])
|
||||
AND g.resource_type = $4
|
||||
AND g.resource_id = $5
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(subject_types)
|
||||
.bind(subject_ids)
|
||||
.bind(&roles)
|
||||
.bind(resource_type)
|
||||
.bind(resource_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("direct grant: {e}")))?;
|
||||
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
async fn folder_cascade_grant_exists(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
@@ -758,6 +874,52 @@ impl PgAclEngine {
|
||||
.await?
|
||||
.is_some_and(|r| r.expand().contains(&permission)))
|
||||
}
|
||||
// Top-level resources with no cascade parent — the ACL
|
||||
// lives entirely on their own `role_grants` rows. Owner is
|
||||
// an explicit grant seeded at MKCALENDAR / address-book
|
||||
// create time (Round 3 phase 2 migration), so the common
|
||||
// "owner accessing their own calendar" case is one SQL
|
||||
// round-trip — no drive_role_cache short-circuit (no
|
||||
// drive), no cascade.
|
||||
Resource::Calendar(id) => {
|
||||
let (subject_types, subject_ids) =
|
||||
self.subject_match_set(subject, counters).await?;
|
||||
self.direct_grant_exists(
|
||||
&subject_types,
|
||||
&subject_ids,
|
||||
permission,
|
||||
"calendar",
|
||||
id,
|
||||
counters,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Resource::AddressBook(id) => {
|
||||
let (subject_types, subject_ids) =
|
||||
self.subject_match_set(subject, counters).await?;
|
||||
self.direct_grant_exists(
|
||||
&subject_types,
|
||||
&subject_ids,
|
||||
permission,
|
||||
"address_book",
|
||||
id,
|
||||
counters,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Resource::Playlist(id) => {
|
||||
let (subject_types, subject_ids) =
|
||||
self.subject_match_set(subject, counters).await?;
|
||||
self.direct_grant_exists(
|
||||
&subject_types,
|
||||
&subject_ids,
|
||||
permission,
|
||||
"playlist",
|
||||
id,
|
||||
counters,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2094,8 +2256,19 @@ impl UserLifecycleHook for AuthzCacheLifecycleHook {
|
||||
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DomainError> {
|
||||
// No DB writes here — just memory invalidation. `_tx` is
|
||||
// intentionally ignored.
|
||||
// intentionally ignored. The DB cascade
|
||||
// (`trg_cleanup_role_grants_user`) already dropped every
|
||||
// role_grants row for this subject; we mirror that cleanup on
|
||||
// both authz caches:
|
||||
// 1. `user_groups_cache` — recomputed group expansion.
|
||||
// 2. `drive_role_cache` — cached "user X → drive Y = role R"
|
||||
// entries seeded by prior authz checks. Without this
|
||||
// the deleted user's role stays visible in-process for
|
||||
// up to the cache TTL (~30 s).
|
||||
self.engine.invalidate_user_groups_cache(user.id()).await;
|
||||
self.engine
|
||||
.invalidate_drive_role_cache_for_subject(Subject::User(user.id()))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ use std::time::Duration;
|
||||
use moka::sync::Cache;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::recent_ports::RecentItemsUseCase;
|
||||
use crate::application::ports::resource_access_hook::ResourceAccessHook;
|
||||
use crate::application::services::recent_service::RecentService;
|
||||
|
||||
@@ -85,7 +84,16 @@ impl ResourceAccessHook for RecentRecordingHook {
|
||||
let recent = Arc::clone(&self.recent);
|
||||
let (caller_id, file_id) = key;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = recent.record_item_access(caller_id, &file_id, "file").await {
|
||||
// Fast path: skip the trait's `authz.require(Read, …)`
|
||||
// (upstream `_with_perms` service already gated). The
|
||||
// extra SQL round-trip pushes the upsert past the client's
|
||||
// immediate `GET /api/recent/resources` in
|
||||
// `tests/api/recent.hurl` step 7 — the whole reason for
|
||||
// the internal variant.
|
||||
if let Err(e) = recent
|
||||
.record_item_access_internal(caller_id, &file_id, "file")
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "oxicloud::recent",
|
||||
caller_id = %caller_id,
|
||||
|
||||
@@ -242,30 +242,35 @@ impl ContentIndexWorker {
|
||||
|
||||
// Authoritative state re-read: a queued 'upsert' whose row vanished
|
||||
// or got trashed in the meantime becomes a delete.
|
||||
let files: Vec<(Uuid, String, String, String, String, String, i64)> =
|
||||
if upsert_candidates.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT fi.id, fi.user_id::text, fi.drive_id::text, fi.name,
|
||||
fi.blob_hash, fi.mime_type, fi.size
|
||||
FROM storage.files fi
|
||||
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
|
||||
)
|
||||
.bind(&upsert_candidates)
|
||||
.fetch_all(self.maintenance_pool.as_ref())
|
||||
.await?
|
||||
};
|
||||
//
|
||||
// Post-D7: `fi.user_id` is dropped — no longer projected. The
|
||||
// Tantivy `user_id` field survives as defence-in-depth but now
|
||||
// always indexes `""`. Every query is Must-scoped by `drive_id`.
|
||||
// (file_id, drive_id, name, blob_hash, mime, size).
|
||||
type FileIndexRow = (Uuid, String, String, String, String, i64);
|
||||
let files: Vec<FileIndexRow> = if upsert_candidates.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT fi.id, fi.drive_id::text, fi.name,
|
||||
fi.blob_hash, fi.mime_type, fi.size
|
||||
FROM storage.files fi
|
||||
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
|
||||
)
|
||||
.bind(&upsert_candidates)
|
||||
.fetch_all(self.maintenance_pool.as_ref())
|
||||
.await?
|
||||
};
|
||||
let found: HashSet<Uuid> = files.iter().map(|f| f.0).collect();
|
||||
deletes.extend(upsert_candidates.iter().filter(|id| !found.contains(id)));
|
||||
|
||||
// Per-blob text: batch-read the extraction cache, extract misses.
|
||||
let wanted_hashes: Vec<String> = files
|
||||
.iter()
|
||||
.filter(|(_, _, _, name, _, mime, size)| {
|
||||
.filter(|(_, _, name, _, mime, size)| {
|
||||
text_extractor::supports(name, mime) && *size as u64 <= self.max_extract_file_bytes
|
||||
})
|
||||
.map(|f| f.4.clone())
|
||||
.map(|f| f.3.clone())
|
||||
.collect();
|
||||
let mut text_by_hash: HashMap<String, Option<String>> = HashMap::new();
|
||||
if !wanted_hashes.is_empty() {
|
||||
@@ -282,7 +287,7 @@ impl ContentIndexWorker {
|
||||
}
|
||||
|
||||
let mut records = Vec::with_capacity(files.len());
|
||||
for (file_id, user_id, drive_id, name, blob_hash, mime, size) in files {
|
||||
for (file_id, drive_id, name, blob_hash, mime, size) in files {
|
||||
let supported = text_extractor::supports(&name, &mime);
|
||||
let content = if !supported {
|
||||
None
|
||||
@@ -301,7 +306,7 @@ impl ContentIndexWorker {
|
||||
.map(|t| truncate_on_char(t, PREVIEW_BYTES));
|
||||
records.push(IndexDocRecord {
|
||||
file_id: file_id.to_string(),
|
||||
user_id,
|
||||
user_id: String::new(),
|
||||
drive_id,
|
||||
name,
|
||||
content,
|
||||
|
||||
@@ -243,7 +243,10 @@ fn collect_xml_text<R: std::io::BufRead>(
|
||||
}
|
||||
match xml.read_event_into(&mut buf) {
|
||||
Ok(Event::Text(t)) => {
|
||||
if let Ok(decoded) = t.xml_content() {
|
||||
// quick-xml 0.41+ makes XmlVersion explicit on xml_content()
|
||||
// so callers pick 1.0 vs 1.1 entity-normalization rules. Text
|
||||
// extraction is version-agnostic — 1.0 is the sane default.
|
||||
if let Ok(decoded) = t.xml_content(quick_xml::XmlVersion::Implicit1_0) {
|
||||
out.push_str(&decoded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1479,7 +1479,7 @@ impl crate::application::ports::blob_lifecycle::BlobLifecycleHook for ThumbnailS
|
||||
for format in [ThumbnailFormat::Webp, ThumbnailFormat::Jpeg] {
|
||||
let path =
|
||||
root.join(size.dir_name())
|
||||
.join(format!("{}.{}", &blob_hash, format.ext()));
|
||||
.join(format!("{}.{}", blob_hash, format.ext()));
|
||||
if tokio::fs::metadata(&path).await.is_ok() {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@ const MAX_LOCK_TIMEOUT_SECS: u64 = 86_400; // 24 hours
|
||||
pub struct LockEntry {
|
||||
pub info: LockInfo,
|
||||
pub path: String,
|
||||
/// The user who acquired the lock. `None` for entries seeded by
|
||||
/// unit tests or refresh paths that don't carry a caller (the
|
||||
/// refresh flow rebuilds from the existing entry without a new
|
||||
/// caller context, so we preserve whatever was there). RFC 4918
|
||||
/// §9.11's "MUST be requested by the owner" rule for UNLOCK is
|
||||
/// enforced by comparing this against the caller in
|
||||
/// `handle_unlock`.
|
||||
pub caller_user_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
/// Per-entry expiration policy for the `by_path` cache.
|
||||
@@ -110,7 +118,12 @@ impl WebDavLockStore {
|
||||
/// - The existing lock is exclusive (blocks any new lock), or
|
||||
/// - The new lock is exclusive and any lock already exists (RFC 4918 §7.8).
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn acquire(&self, path: &str, info: LockInfo) -> Result<LockEntry, LockEntry> {
|
||||
pub fn acquire(
|
||||
&self,
|
||||
path: &str,
|
||||
info: LockInfo,
|
||||
caller_user_id: Option<uuid::Uuid>,
|
||||
) -> Result<LockEntry, LockEntry> {
|
||||
if let Some(existing) = self.by_path.get(path) {
|
||||
// Exclusive existing lock → blocks everything.
|
||||
// New exclusive lock → blocked by any existing lock (shared or exclusive).
|
||||
@@ -123,6 +136,7 @@ impl WebDavLockStore {
|
||||
let entry = LockEntry {
|
||||
info,
|
||||
path: path.to_owned(),
|
||||
caller_user_id,
|
||||
};
|
||||
self.by_token
|
||||
.insert(entry.info.token.clone(), path.to_owned());
|
||||
@@ -132,6 +146,7 @@ impl WebDavLockStore {
|
||||
let entry = LockEntry {
|
||||
info,
|
||||
path: path.to_owned(),
|
||||
caller_user_id,
|
||||
};
|
||||
|
||||
// `LockExpiry` derives the TTL from `entry.info.timeout` on insert —
|
||||
@@ -254,6 +269,7 @@ mod tests {
|
||||
LockEntry {
|
||||
info: lock_info(token, timeout, LockScope::Exclusive),
|
||||
path: "/file.txt".to_owned(),
|
||||
caller_user_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +321,7 @@ mod tests {
|
||||
let store = WebDavLockStore::new(16);
|
||||
let info = lock_info("urn:token-1", Some("Second-600"), LockScope::Exclusive);
|
||||
|
||||
let acquired = store.acquire("/a.txt", info).expect("acquire");
|
||||
let acquired = store.acquire("/a.txt", info, None).expect("acquire");
|
||||
assert_eq!(acquired.info.token, "urn:token-1");
|
||||
|
||||
// Resolvable by both indexes.
|
||||
@@ -332,12 +348,14 @@ mod tests {
|
||||
.acquire(
|
||||
"/a.txt",
|
||||
lock_info("urn:token-1", Some("Second-600"), LockScope::Exclusive),
|
||||
None,
|
||||
)
|
||||
.expect("first acquire");
|
||||
|
||||
let conflict = store.acquire(
|
||||
"/a.txt",
|
||||
lock_info("urn:token-2", Some("Second-600"), LockScope::Exclusive),
|
||||
None,
|
||||
);
|
||||
assert!(conflict.is_err());
|
||||
// The original holder is returned so the caller can report it.
|
||||
@@ -351,6 +369,7 @@ mod tests {
|
||||
.acquire(
|
||||
"/a.txt",
|
||||
lock_info("urn:token-1", Some("Infinite"), LockScope::Exclusive),
|
||||
None,
|
||||
)
|
||||
.expect("acquire");
|
||||
|
||||
|
||||
@@ -203,7 +203,10 @@ impl WopiDiscoveryService {
|
||||
|
||||
for attr in e.attributes().flatten() {
|
||||
let value = attr
|
||||
.decode_and_unescape_value(reader.decoder())
|
||||
.decoded_and_normalized_value(
|
||||
quick_xml::XmlVersion::Implicit1_0,
|
||||
reader.decoder(),
|
||||
)
|
||||
.map(|value| value.into_owned())
|
||||
.unwrap_or_else(|_| String::from_utf8_lossy(&attr.value).to_string());
|
||||
|
||||
|
||||
@@ -346,6 +346,18 @@ async fn handle_propfind(
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(response_body))
|
||||
.unwrap())
|
||||
} else if first_is_uuid {
|
||||
// Path segment IS a UUID but the calendar isn't
|
||||
// accessible to the caller — could be another
|
||||
// owner's calendar or genuinely missing. Return
|
||||
// 404 (anti-enum, matches every other OxiCloud
|
||||
// surface post-D7). The pre-Round-3 fall-through
|
||||
// silently listed the caller's OWN calendars,
|
||||
// which was misleading (the URL claimed one calendar,
|
||||
// response returned unrelated ones) and violated
|
||||
// the anti-enumeration contract audited in
|
||||
// `docs/plan/authz_audit/caldav_carddav_wopi.md`.
|
||||
Err(AppError::not_found("Calendar not found"))
|
||||
} else {
|
||||
// Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/)
|
||||
// List all calendars for this user
|
||||
|
||||
@@ -33,8 +33,8 @@ use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType
|
||||
use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto};
|
||||
use crate::application::dtos::contact_dto::CreateContactVCardDto;
|
||||
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
||||
use crate::application::services::contact_service::ContactService;
|
||||
use crate::common::di::AppState;
|
||||
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
||||
|
||||
@@ -177,7 +177,7 @@ fn extract_user(req: &Request<Body>) -> Result<AuthUser, AppError> {
|
||||
.ok_or_else(|| AppError::unauthorized("Authentication required"))
|
||||
}
|
||||
|
||||
fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactStorageAdapter>, AppError> {
|
||||
fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactService>, AppError> {
|
||||
state.addressbook_use_case.as_ref().ok_or_else(|| {
|
||||
AppError::new(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
@@ -187,7 +187,7 @@ fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactStorageAdapte
|
||||
})
|
||||
}
|
||||
|
||||
fn get_contact_service(state: &AppState) -> Result<&Arc<ContactStorageAdapter>, AppError> {
|
||||
fn get_contact_service(state: &AppState) -> Result<&Arc<ContactService>, AppError> {
|
||||
state.contact_use_case.as_ref().ok_or_else(|| {
|
||||
AppError::new(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
|
||||
@@ -19,8 +19,8 @@ use crate::application::dtos::contact_dto::{
|
||||
use crate::application::dtos::user_dto::UserDto;
|
||||
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
|
||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||
use crate::application::services::contact_service::ContactService;
|
||||
use crate::domain::errors::ErrorKind;
|
||||
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
const SYSTEM_BOOK_ID: &str = "system";
|
||||
@@ -28,7 +28,7 @@ const SYSTEM_BOOK_ID: &str = "system";
|
||||
/// Combined state for the contacts REST API.
|
||||
#[derive(Clone)]
|
||||
pub struct ContactsApiState {
|
||||
pub contact_service: Arc<ContactStorageAdapter>,
|
||||
pub contact_service: Arc<ContactService>,
|
||||
pub auth_service: Option<Arc<AuthApplicationService>>,
|
||||
/// When false, the virtual "system" address book (OxiCloud users) is hidden.
|
||||
pub expose_system_users: bool,
|
||||
|
||||
@@ -48,23 +48,7 @@ pub async fn list_drives(
|
||||
) -> impl IntoResponse {
|
||||
let caller_id = auth_user.id;
|
||||
|
||||
let (subject_types, subject_ids) = match state
|
||||
.authorization
|
||||
.expand_subject_for_listing(Subject::User(caller_id))
|
||||
.await
|
||||
{
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
error!("list_drives: subject expansion failed: {e}");
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match state
|
||||
.drive_repo
|
||||
.list_for_subjects(&subject_types, &subject_ids)
|
||||
.await
|
||||
{
|
||||
match state.drive_repo.list_readable_by(caller_id).await {
|
||||
Ok(drives) => {
|
||||
let dtos: Vec<DriveDto> = drives.into_iter().map(DriveDto::from).collect();
|
||||
(StatusCode::OK, Json(dtos)).into_response()
|
||||
@@ -416,6 +400,10 @@ pub struct UpdateDrivePoliciesDto {
|
||||
pub forbid_cross_drive_move: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub forbid_owner_role_change: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub include_in_photo_index: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub include_in_music_index: Option<bool>,
|
||||
}
|
||||
|
||||
/// `PATCH /api/drives/{id}/policies` — **OxiCloud-admin only** policy
|
||||
@@ -494,18 +482,22 @@ pub async fn update_drive_policies(
|
||||
serde_json::Value::Bool(v),
|
||||
);
|
||||
}
|
||||
if let Some(v) = dto.include_in_photo_index {
|
||||
partial_obj.insert("include_in_photo_index".into(), serde_json::Value::Bool(v));
|
||||
}
|
||||
if let Some(v) = dto.include_in_music_index {
|
||||
partial_obj.insert("include_in_music_index".into(), serde_json::Value::Bool(v));
|
||||
}
|
||||
// Pass the raw JSON straight through so the JSONB `||` merge in
|
||||
// the repo only touches keys the caller supplied. Round-tripping
|
||||
// via `DrivePolicies` (which has `#[serde(default)]`) would
|
||||
// silently fill every omitted field with `false` — the merge
|
||||
// would then clobber every unmentioned policy on the row.
|
||||
let partial_value = serde_json::Value::Object(partial_obj);
|
||||
let partial: crate::domain::entities::drive::DrivePolicies =
|
||||
match serde_json::from_value(partial_value) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return AppError::bad_request(format!("invalid policy body: {e}")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match state
|
||||
.drive_management_service
|
||||
.update_policies(auth_user.id, drive_id, partial)
|
||||
.update_policies(auth_user.id, drive_id, partial_value)
|
||||
.await
|
||||
{
|
||||
Ok(merged) => (StatusCode::OK, axum::Json(merged)).into_response(),
|
||||
|
||||
@@ -6,7 +6,7 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
use tracing::info;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::application::dtos::display_helpers::{
|
||||
@@ -66,7 +66,8 @@ pub async fn add_favorite(
|
||||
Json(serde_json::json!({
|
||||
"error": "Item type must be 'file' or 'folder'"
|
||||
})),
|
||||
);
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match favorites_service
|
||||
@@ -81,16 +82,14 @@ pub async fn add_favorite(
|
||||
"message": "Item added to favorites"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error adding to favorites: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "Failed to add to favorites"
|
||||
})),
|
||||
)
|
||||
}
|
||||
// Route through AppError so the `DomainError::kind` maps to the
|
||||
// right status code (NotFound → 404 anti-enum for the pre-write
|
||||
// authz gate, InvalidInput → 400 for a malformed UUID, etc.).
|
||||
// A hardcoded 500 here would mask the 404 the Round 1 AuthZ
|
||||
// fix relies on.
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +128,7 @@ pub async fn remove_favorite(
|
||||
"message": "Item removed from favorites"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
info!("Item {} '{}' was not in favorites", item_type, item_id);
|
||||
(
|
||||
@@ -137,17 +137,12 @@ pub async fn remove_favorite(
|
||||
"message": "Item was not in favorites"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error removing from favorites: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "Failed to remove from favorites"
|
||||
})),
|
||||
)
|
||||
}
|
||||
// Same rationale as `add_favorite` — preserve DomainError→HTTP
|
||||
// status mapping instead of collapsing every error to 500.
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +210,6 @@ pub async fn list_favorites_resources(
|
||||
name: row.name.clone(),
|
||||
path,
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
drive_id: row.drive_id,
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
@@ -265,7 +259,6 @@ pub async fn list_favorites_resources(
|
||||
)),
|
||||
category: std::sync::Arc::from(category_for(&row.name, mime)),
|
||||
size_formatted: format_file_size(size_bytes),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
etag,
|
||||
@@ -349,15 +342,10 @@ pub async fn batch_add_favorites(
|
||||
);
|
||||
(StatusCode::OK, Json(serde_json::json!(result))).into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error in batch add favorites: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "Failed to batch add favorites"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
// Preserve DomainError→HTTP status mapping — the Round 1
|
||||
// AuthZ fix relies on a per-item NotFound propagating out
|
||||
// of the batch. A hardcoded 500 would mask the 404 that
|
||||
// signals a cross-tenant probe.
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +106,12 @@ impl FolderHandler {
|
||||
Self::list_folders_scoped(service, None, &auth_user).await
|
||||
}
|
||||
|
||||
/// Internal helper: lists folders scoped to the authenticated user.
|
||||
/// Uses `list_folders_for_owner` — the DB query filters by `user_id`,
|
||||
/// so no data from other users ever leaves the database.
|
||||
/// Internal helper: lists folders the authenticated caller can Read.
|
||||
/// Post-PR-B, `list_root_folders_for_caller` scopes via
|
||||
/// drive-membership grants (`role_grants` + group cascade via
|
||||
/// `storage.caller_group_ids`) instead of the legacy `folders.user_id`
|
||||
/// filter, so folders in shared drives the caller belongs to
|
||||
/// surface here too.
|
||||
async fn list_folders_scoped(
|
||||
service: AppState,
|
||||
parent_id: Option<&str>,
|
||||
@@ -501,7 +504,6 @@ pub async fn list_folder_resources(
|
||||
name: row.name.clone(),
|
||||
path: String::new(), // cleared — share recipients must not see hierarchy
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
drive_id: row.drive_id,
|
||||
created_at: row.created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
@@ -550,7 +552,6 @@ pub async fn list_folder_resources(
|
||||
icon_special_class: Arc::from(icon_special_class_for(&row.name, mime)),
|
||||
category: Arc::from(category_for(&row.name, mime)),
|
||||
size_formatted: format_file_size(size_bytes),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
etag,
|
||||
|
||||
@@ -113,6 +113,14 @@ pub async fn create_grant(
|
||||
.get_by_id(id)
|
||||
.await
|
||||
.map(|d| d.drive.typed_policies()),
|
||||
// Calendars, address books and playlists live outside the
|
||||
// drive hierarchy (top-level per user), so no drive-level
|
||||
// policy gates apply. If per-resource policies ever ship for
|
||||
// these kinds, they'll live on the resource itself, not on a
|
||||
// drive; the default-empty bag is the right no-op here.
|
||||
Resource::Calendar(_) | Resource::AddressBook(_) | Resource::Playlist(_) => {
|
||||
Ok(crate::domain::entities::drive::DrivePolicies::default())
|
||||
}
|
||||
};
|
||||
let drive_policies = match drive_policies {
|
||||
Ok(p) => p,
|
||||
|
||||
@@ -63,13 +63,13 @@ pub async fn list_photos(
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<PhotosQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let user_id = auth_user.id;
|
||||
let caller_id = auth_user.id;
|
||||
let limit = params.limit.unwrap_or(200).clamp(1, 500);
|
||||
|
||||
let file_read = &state.repositories.file_read_repository;
|
||||
|
||||
match file_read
|
||||
.list_media_files(user_id, params.before, limit)
|
||||
.list_media_files(caller_id, params.before, limit)
|
||||
.await
|
||||
{
|
||||
Ok((files, sort_dates, dims)) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
use tracing::info;
|
||||
|
||||
use crate::application::dtos::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
@@ -70,16 +70,10 @@ pub async fn record_item_access(
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error recording access in recents: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "Failed to record access"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
// Preserve DomainError→HTTP status mapping — the Round 1
|
||||
// AuthZ fix relies on the NotFound from `authz.require`
|
||||
// propagating as 404 (anti-enum), not being masked as 500.
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,16 +124,9 @@ pub async fn remove_from_recent(
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error removing from recents: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "Failed to remove from recents"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
// Same rationale as `record_item_access` — preserve the
|
||||
// DomainError→HTTP mapping instead of collapsing to 500.
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,16 +157,9 @@ pub async fn clear_recent_items(
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error clearing recent items: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "Failed to clear recent items"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
// Same rationale as `record_item_access` — preserve the
|
||||
// DomainError→HTTP mapping instead of collapsing to 500.
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +226,6 @@ pub async fn list_recent_resources(
|
||||
name: row.name.clone(),
|
||||
path,
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
drive_id: row.drive_id,
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
@@ -294,7 +273,6 @@ pub async fn list_recent_resources(
|
||||
)),
|
||||
category: std::sync::Arc::from(category_for(&row.name, mime)),
|
||||
size_formatted: format_file_size(size_bytes),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
etag,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,10 +20,13 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
|
||||
use crate::application::services::wopi_lock_service::WopiLockService;
|
||||
use crate::application::services::wopi_token_service::WopiTokenService;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService;
|
||||
|
||||
/// Shared state for WOPI handlers.
|
||||
@@ -64,6 +67,37 @@ pub struct CheckFileInfoResponse {
|
||||
pub close_url: String,
|
||||
}
|
||||
|
||||
/// Enforce that the WOPI caller (`claims.sub`) still has `perm` on the
|
||||
/// file at redemption time — not just at token-mint time.
|
||||
///
|
||||
/// **Why every verb needs this.** WOPI tokens are validated locally
|
||||
/// (HMAC over claims), so a token that was legitimately minted stays
|
||||
/// verify-able until its TTL. If a grant is revoked after mint, or the
|
||||
/// token was minted for view but is used to POST content, the token's
|
||||
/// signature alone doesn't catch it. This helper re-checks against the
|
||||
/// live authorization engine on every verb — the memory note
|
||||
/// `wopi-authz-bypass` calls out the class of bugs this fences.
|
||||
///
|
||||
/// Returns 404 (anti-enumeration — same shape as "file doesn't exist")
|
||||
/// on both bad UUID and authorization denial. The engine emits a
|
||||
/// structured `audit` line on denial internally, so ops sees the real
|
||||
/// reason without the attacker being able to distinguish "gone" from
|
||||
/// "revoked".
|
||||
async fn require_wopi_perm(
|
||||
authz: &PgAclEngine,
|
||||
caller_sub: &str,
|
||||
file_id: &str,
|
||||
perm: Permission,
|
||||
) -> Result<(uuid::Uuid, uuid::Uuid), StatusCode> {
|
||||
let caller_uuid = uuid::Uuid::parse_str(caller_sub).map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
let file_uuid = uuid::Uuid::parse_str(file_id).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
authz
|
||||
.require(Subject::User(caller_uuid), perm, Resource::File(file_uuid))
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
Ok((caller_uuid, file_uuid))
|
||||
}
|
||||
|
||||
/// GET /wopi/files/{file_id} — CheckFileInfo
|
||||
async fn check_file_info(
|
||||
Path(file_id): Path<String>,
|
||||
@@ -82,6 +116,19 @@ async fn check_file_info(
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
// Redemption-time authz: even with a valid token, the caller must
|
||||
// still hold Read on this file. Catches revoked-grant-mid-session.
|
||||
if let Err(status) = require_wopi_perm(
|
||||
state.app_state.authorization.as_ref(),
|
||||
&claims.sub,
|
||||
&file_id,
|
||||
Permission::Read,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return status.into_response();
|
||||
}
|
||||
|
||||
// Fetch file metadata
|
||||
let file = match state
|
||||
.app_state
|
||||
@@ -99,16 +146,40 @@ async fn check_file_info(
|
||||
.map(|dt| dt.to_rfc3339())
|
||||
.unwrap_or_default();
|
||||
|
||||
// `user_can_write` = actual current Update permission ∧ token's
|
||||
// can_write flag. If the caller's Update was revoked since the
|
||||
// token was minted (e.g. their grant was downgraded from Editor
|
||||
// to Viewer), the editor sees the file as read-only and won't
|
||||
// even attempt PutFile. The stricter `require_wopi_perm(Update)`
|
||||
// in put_file is the actual gate; this field is a UI hint.
|
||||
let can_write_now = claims.can_write
|
||||
&& state
|
||||
.app_state
|
||||
.authorization
|
||||
.check(
|
||||
Subject::User(uuid::Uuid::parse_str(&claims.sub).unwrap_or(uuid::Uuid::nil())),
|
||||
Permission::Update,
|
||||
Resource::File(uuid::Uuid::parse_str(&file_id).unwrap_or(uuid::Uuid::nil())),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
let response = CheckFileInfoResponse {
|
||||
base_file_name: file.name.clone(),
|
||||
owner_id: file.owner_id.clone().unwrap_or_else(|| claims.sub.clone()),
|
||||
// WOPI's `OwnerId` field is required. Post-D7 the DTO no
|
||||
// longer carries `owner_id`; fall back to `created_by`
|
||||
// (§14 provenance) with the requesting user as a final default.
|
||||
owner_id: file
|
||||
.created_by
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_else(|| claims.sub.clone()),
|
||||
size: file.size,
|
||||
user_id: claims.sub.clone(),
|
||||
version: file.modified_at.to_string(),
|
||||
supports_locks: true,
|
||||
supports_update: claims.can_write,
|
||||
supports_update: can_write_now,
|
||||
supports_rename: false,
|
||||
user_can_write: claims.can_write,
|
||||
user_can_write: can_write_now,
|
||||
user_friendly_name: claims.username.clone(),
|
||||
post_message_origin: state.public_base_url.clone(),
|
||||
last_modified_time: last_modified,
|
||||
@@ -139,6 +210,18 @@ async fn get_file(
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
// Redemption-time authz — see require_wopi_perm docstring.
|
||||
if let Err(status) = require_wopi_perm(
|
||||
state.app_state.authorization.as_ref(),
|
||||
&claims.sub,
|
||||
&file_id,
|
||||
Permission::Read,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return status.into_response();
|
||||
}
|
||||
|
||||
match state
|
||||
.app_state
|
||||
.applications
|
||||
@@ -178,6 +261,21 @@ async fn put_file(
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
// Redemption-time authz: the token says the caller could write when
|
||||
// it was minted, but Update permission may have been revoked since.
|
||||
// Re-check now so a stale write-capable token can't survive a
|
||||
// downgrade / share removal / drive-membership change until its TTL.
|
||||
if let Err(status) = require_wopi_perm(
|
||||
state.app_state.authorization.as_ref(),
|
||||
&claims.sub,
|
||||
&file_id,
|
||||
Permission::Update,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return status.into_response();
|
||||
}
|
||||
|
||||
// Check lock
|
||||
let request_lock = headers
|
||||
.get("X-WOPI-Lock")
|
||||
@@ -258,7 +356,7 @@ async fn put_file(
|
||||
.app_state
|
||||
.applications
|
||||
.file_upload_service
|
||||
.update_file_streaming(
|
||||
.update_file_streaming_with_perms(
|
||||
&file.path,
|
||||
drive_id,
|
||||
ingested.stored(),
|
||||
@@ -296,6 +394,22 @@ async fn file_operations(
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
// Every lock op mutates shared state (LOCK / UNLOCK / REFRESH_LOCK
|
||||
// change the lock; GET_LOCK reads it but the read is only useful
|
||||
// to a caller who could subsequently take a write action — so gate
|
||||
// on Update uniformly rather than splitting per-op). A Viewer with
|
||||
// a stale token must not be able to hold or contend for a lock.
|
||||
if let Err(status) = require_wopi_perm(
|
||||
state.app_state.authorization.as_ref(),
|
||||
&claims.sub,
|
||||
&file_id,
|
||||
Permission::Update,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return status.into_response();
|
||||
}
|
||||
|
||||
let override_header = headers
|
||||
.get("X-WOPI-Override")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
@@ -368,25 +482,71 @@ pub struct EditorUrlResponse {
|
||||
pub access_token_ttl: i64,
|
||||
}
|
||||
|
||||
/// Determines if `caller_id` can access `file_id` and with what permissions.
|
||||
/// Resolve the WOPI mint target: gate on real permissions and derive
|
||||
/// the `can_write` flag from the caller's ACTUAL Update rights.
|
||||
///
|
||||
/// Uses the SQL-level ownership check (`get_file_owned`) so that files
|
||||
/// belonging to other users — or non-existent files — both return `NOT_FOUND`,
|
||||
/// avoiding existence-leak oracles.
|
||||
/// Prior behaviour used a naive `requested_action != "view"` heuristic
|
||||
/// so a Viewer clicking "Edit in Collabora" received a write-capable
|
||||
/// token, promoting themselves to Editor for the token's TTL. The
|
||||
/// memory note `wopi-authz-bypass` fix #12 calls this out explicitly.
|
||||
///
|
||||
/// Returns `(FileDto, can_write)` on success.
|
||||
/// Contract:
|
||||
///
|
||||
/// 1. **Read** is the bar to open the file in any mode. If the caller
|
||||
/// has no Read grant, return 404 (anti-enum — same shape as "no such
|
||||
/// file").
|
||||
/// 2. **Update** determines the returned `can_write` bit — INDEPENDENT
|
||||
/// of what the client's `requested_action` said. A Viewer who
|
||||
/// requested `action=edit` gets `can_write=false` and Collabora
|
||||
/// opens in view mode; the token stays authorised for view-only
|
||||
/// ops and put_file will 404 at redemption regardless.
|
||||
/// 3. `requested_action == "view"` is respected as a downgrade — an
|
||||
/// Editor can explicitly request view mode (co-browsing a doc
|
||||
/// without accidentally editing) and get `can_write=false`.
|
||||
///
|
||||
/// The `PgAclEngine::require`/`check` calls emit structured audit
|
||||
/// lines on denial (`authz.denied` event), so a Viewer's "edit"
|
||||
/// attempt shows up in the audit stream as a rejected Update check.
|
||||
async fn authorize_wopi_access<S: FileRetrievalUseCase>(
|
||||
authz: &PgAclEngine,
|
||||
file_retrieval: &S,
|
||||
file_id: &str,
|
||||
caller_id: uuid::Uuid,
|
||||
requested_action: &str,
|
||||
) -> Result<(crate::application::dtos::file_dto::FileDto, bool), StatusCode> {
|
||||
let file = file_retrieval
|
||||
.get_file_with_perms(file_id, caller_id)
|
||||
let file_uuid = uuid::Uuid::parse_str(file_id).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Step 1 — Read is required to even open the file.
|
||||
authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
// Owner verified — grant write unless explicitly requesting view-only.
|
||||
let can_write = requested_action != "view";
|
||||
|
||||
let file = file_retrieval
|
||||
.get_file(file_id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Step 2 — can_write reflects real Update, not the client's
|
||||
// action-string. `check` returns bool without throwing; failure
|
||||
// just means the caller lacks Update, so we degrade the token to
|
||||
// read-only. Deliberately no `require` here — a Viewer opening
|
||||
// the file is legitimate; only the write claim is suppressed.
|
||||
let has_update = authz
|
||||
.check(
|
||||
Subject::User(caller_id),
|
||||
Permission::Update,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
// Step 3 — allow explicit view-mode downgrade for Editors.
|
||||
let can_write = has_update && requested_action != "view";
|
||||
Ok((file, can_write))
|
||||
}
|
||||
|
||||
@@ -403,6 +563,7 @@ pub async fn get_editor_url(
|
||||
let username = &auth_user.username;
|
||||
// Verify the caller owns the file (SQL-level check, no existence leak).
|
||||
let (file, can_write) = match authorize_wopi_access(
|
||||
state.app_state.authorization.as_ref(),
|
||||
state.app_state.applications.file_retrieval_service.as_ref(),
|
||||
¶ms.file_id,
|
||||
user_id,
|
||||
@@ -488,7 +649,8 @@ async fn host_page(
|
||||
Ok(u) => u,
|
||||
Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
let file = match authorize_wopi_access(
|
||||
let (file, can_write_now) = match authorize_wopi_access(
|
||||
state.app_state.authorization.as_ref(),
|
||||
state.app_state.applications.file_retrieval_service.as_ref(),
|
||||
&file_id,
|
||||
caller_uuid,
|
||||
@@ -496,7 +658,7 @@ async fn host_page(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((f, _)) => f,
|
||||
Ok((f, cw)) => (f, cw),
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
@@ -513,11 +675,15 @@ async fn host_page(
|
||||
_ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
};
|
||||
|
||||
// Use the freshly-computed `can_write_now` (real Update permission
|
||||
// ∧ requested_action) rather than the incoming token's `can_write`
|
||||
// flag. Otherwise a Viewer who somehow reached this host page with
|
||||
// a stale edit-capable token would get another one re-minted.
|
||||
let (token, ttl) = match state.token_service.generate_token(
|
||||
&file_id,
|
||||
&claims.sub,
|
||||
&claims.username,
|
||||
claims.can_write,
|
||||
can_write_now,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
|
||||
@@ -422,13 +422,17 @@ pub async fn handle_search(
|
||||
|
||||
let mut entries: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// Map file results
|
||||
// TODO(D1): drop the hardcoded "Personal/" prefix and read the
|
||||
// caller's default-drive root folder name from `drives.root_folder_id`
|
||||
// instead. Correct for D0-provisioned default drives; secondary
|
||||
// drives keep their original root name.
|
||||
// Map file results.
|
||||
//
|
||||
// `strip_drive_root_segment` handles both default and secondary
|
||||
// drives — post-D0 the first path segment is the drive's root
|
||||
// folder name (`"Personal"` for D0-provisioned defaults, the
|
||||
// original sibling-root name for M2 backfilled secondaries).
|
||||
// Read-scope is upstream in `state.applications.search_service`;
|
||||
// this handler only formats display paths.
|
||||
for file in &results.files {
|
||||
let display_path = file.path.strip_prefix("Personal/").unwrap_or(&file.path);
|
||||
let display_path =
|
||||
crate::interfaces::nextcloud::webdav_handler::strip_drive_root_segment(&file.path);
|
||||
let display_path = format!("/{}", display_path);
|
||||
|
||||
let numeric_id = file_id_map.get(&file.id).copied();
|
||||
@@ -452,12 +456,10 @@ pub async fn handle_search(
|
||||
}));
|
||||
}
|
||||
|
||||
// Map folder results — same TODO(D1) as above.
|
||||
// Map folder results — same drive-agnostic strip as above.
|
||||
for folder in &results.folders {
|
||||
let display_path = folder
|
||||
.path
|
||||
.strip_prefix("Personal/")
|
||||
.unwrap_or(&folder.path);
|
||||
let display_path =
|
||||
crate::interfaces::nextcloud::webdav_handler::strip_drive_root_segment(&folder.path);
|
||||
let display_path = format!("/{}", display_path);
|
||||
|
||||
entries.push(json!({
|
||||
|
||||
@@ -11,11 +11,14 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailPort, ThumbnailSize};
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PreviewParams {
|
||||
@@ -89,9 +92,28 @@ pub async fn handle_preview(
|
||||
}
|
||||
};
|
||||
|
||||
// Verify the authenticated user owns this file
|
||||
let user_id_str = user.id.to_string();
|
||||
if file.owner_id.as_deref() != Some(user_id_str.as_str()) {
|
||||
// Verify the authenticated user can Read this file. Anti-enum: any
|
||||
// AuthZ denial surfaces as 404 (same shape as "unknown file" above),
|
||||
// and the engine emits an `authz.denied` audit line internally.
|
||||
let file_uuid = match Uuid::parse_str(&file.id) {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("File not found"))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
if state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("File not found"))
|
||||
|
||||
@@ -63,6 +63,15 @@ async fn handle_filter_files(
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
let url_user = &session.raw_username;
|
||||
// Chroot-scope the response: NC's `oc:filter-files` REPORT is a
|
||||
// single-drive surface (the client PROPFINDs favorites under its
|
||||
// "home" URL and has no cross-drive concept). Favorites that live
|
||||
// in another drive the caller is a member of are dropped from
|
||||
// this response; they're still reachable via REST
|
||||
// `/api/favorites/resources`. `session.require_chroot()` is safe
|
||||
// here — the REPORT verb only reaches this handler through a
|
||||
// path-scoped route.
|
||||
let chroot = session.require_chroot()?;
|
||||
let fav_svc = match state.favorites_service.as_ref() {
|
||||
Some(svc) => svc,
|
||||
None => return Ok(empty_multistatus()),
|
||||
@@ -85,11 +94,11 @@ async fn handle_filter_files(
|
||||
// All items in this response are favorites.
|
||||
let favorite_ids: HashSet<String> = favorites.iter().map(|f| f.item_id.clone()).collect();
|
||||
|
||||
// TODO(D1): replace the hardcoded "Personal/" prefix with the
|
||||
// caller's default-drive root folder name read from
|
||||
// `drives.root_folder_id`. Correct for D0-provisioned default
|
||||
// drives; secondary drives keep their original root name.
|
||||
let home_prefix = "Personal/";
|
||||
// `home_prefix` is unused after the chroot-aware strip
|
||||
// (see `strip_home_prefix`); kept as a positional argument in
|
||||
// the emit calls below for signature stability with the
|
||||
// report-handler tests and the parallel search-pass caller.
|
||||
let home_prefix = "";
|
||||
|
||||
// Pass 1: resolve the favorited DTOs in two batch queries (was one
|
||||
// get_* per favorite — up to N serial round-trips on a sync client's
|
||||
@@ -156,7 +165,17 @@ async fn handle_filter_files(
|
||||
// multi-drive `~{drive}` form is echoed back to the client;
|
||||
// owner-id stays canonical via `&user.username`.
|
||||
for file in &files {
|
||||
let subpath = strip_home_prefix(&file.path, home_prefix);
|
||||
// Skip favorites that live outside the caller's chroot
|
||||
// (other-drive favorites); reachable via REST if needed.
|
||||
let Some(subpath) = strip_home_prefix(chroot, &file.path, home_prefix) else {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::nc",
|
||||
"REPORT filter-files: dropping cross-chroot favorite '{}' at '{}'",
|
||||
file.id,
|
||||
file.path,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let href = nc_href(url_user, subpath);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
@@ -174,7 +193,15 @@ async fn handle_filter_files(
|
||||
}
|
||||
|
||||
for folder in &folders {
|
||||
let subpath = strip_home_prefix(&folder.path, home_prefix);
|
||||
let Some(subpath) = strip_home_prefix(chroot, &folder.path, home_prefix) else {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::nc",
|
||||
"REPORT filter-files: dropping cross-chroot favorite folder '{}' at '{}'",
|
||||
folder.id,
|
||||
folder.path,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let href = format!("{}/", nc_href(url_user, subpath));
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
@@ -210,9 +237,13 @@ async fn handle_search(
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
// Validate chroot up-front (path-scoped handler); `resolve_scope_folder`
|
||||
// below re-pulls it from the session for the path-mapping step.
|
||||
session.require_chroot()?;
|
||||
// Chroot-scope the response: NC's search REPORT is a single-drive
|
||||
// surface. Results that live outside the chroot (other drives the
|
||||
// caller is a member of) are dropped from the multistatus and
|
||||
// recorded at debug — reachable via REST search if needed.
|
||||
// `resolve_scope_folder` below re-pulls chroot from the session
|
||||
// for the path-mapping step.
|
||||
let chroot = session.require_chroot()?;
|
||||
let url_user = &session.raw_username;
|
||||
let search_svc = match state.applications.search_service.as_ref() {
|
||||
Some(svc) => svc,
|
||||
@@ -244,10 +275,9 @@ async fn handle_search(
|
||||
|
||||
let nc = state.nextcloud.as_ref();
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
// TODO(D1): same as the favorites pass above — replace the
|
||||
// hardcoded "Personal/" with the caller's actual default-drive
|
||||
// root folder name from `drives.root_folder_id`.
|
||||
let home_prefix = "Personal/";
|
||||
// See the favorites pass above: `home_prefix` is unused after the
|
||||
// chroot-aware strip, kept only for signature stability.
|
||||
let home_prefix = "";
|
||||
|
||||
// No favorite checking for search results -- pass an empty set.
|
||||
let favorite_ids: HashSet<String> = HashSet::new();
|
||||
@@ -269,7 +299,15 @@ async fn handle_search(
|
||||
|
||||
// Files.
|
||||
for file in &files {
|
||||
let subpath = strip_home_prefix(&file.path, home_prefix);
|
||||
let Some(subpath) = strip_home_prefix(chroot, &file.path, home_prefix) else {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::nc",
|
||||
"REPORT search: dropping cross-chroot file '{}' at '{}'",
|
||||
file.id,
|
||||
file.path,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let href = nc_href(url_user, subpath);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
@@ -288,7 +326,15 @@ async fn handle_search(
|
||||
|
||||
// Folders.
|
||||
for folder in &folders {
|
||||
let subpath = strip_home_prefix(&folder.path, home_prefix);
|
||||
let Some(subpath) = strip_home_prefix(chroot, &folder.path, home_prefix) else {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::nc",
|
||||
"REPORT search: dropping cross-chroot folder '{}' at '{}'",
|
||||
folder.id,
|
||||
folder.path,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let href = format!("{}/", nc_href(url_user, subpath));
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
@@ -345,7 +391,6 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes
|
||||
.into(),
|
||||
category: category_for(&fr.name, &fr.mime_type).to_string().into(),
|
||||
size_formatted: format_file_size(fr.size),
|
||||
owner_id: None,
|
||||
sort_date: None,
|
||||
content_hash: fr.blob_hash.clone(),
|
||||
etag,
|
||||
@@ -365,7 +410,6 @@ fn folder_dto_from_search(
|
||||
name: sr.name.clone(),
|
||||
path: sr.path.clone(),
|
||||
parent_id: sr.parent_id.clone(),
|
||||
owner_id: None,
|
||||
drive_id: sr.drive_id,
|
||||
created_at: sr.created_at,
|
||||
modified_at: sr.modified_at,
|
||||
@@ -552,7 +596,19 @@ fn extract_subpath_from_scope(href: &str, url_user: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip the `My Folder - {username}/` prefix to get the DAV subpath.
|
||||
fn strip_home_prefix<'a>(path: &'a str, prefix: &str) -> &'a str {
|
||||
path.strip_prefix(prefix).unwrap_or(path)
|
||||
/// Strip the caller's chroot prefix from an internal path so the
|
||||
/// caller-facing DAV subpath is chroot-relative. Delegates to
|
||||
/// `webdav_handler::strip_chroot_prefix` — chroot-aware, multi-segment
|
||||
/// safe, and rejects items outside the chroot. Callers must decide
|
||||
/// per-response whether an out-of-chroot item is dropped or falls
|
||||
/// back to the naive strip.
|
||||
///
|
||||
/// See `strip_chroot_prefix` for the full contract. The `_prefix`
|
||||
/// legacy arg stays for signature stability with the emit helpers.
|
||||
fn strip_home_prefix<'a>(
|
||||
chroot: &crate::application::dtos::folder_dto::FolderDto,
|
||||
path: &'a str,
|
||||
_prefix: &str,
|
||||
) -> Option<&'a str> {
|
||||
crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(chroot, path)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,14 @@ async fn handle_propfind(
|
||||
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = &session.user;
|
||||
// Chroot-scope the trashbin view: `get_trash_items(user.id)`
|
||||
// spans every drive the caller is a member of, but NC's
|
||||
// trashbin surface is a single-drive concept from the client's
|
||||
// POV. Items outside the chroot are dropped from the multistatus
|
||||
// (see `write_trashbin_multistatus` → `strip_home_prefix` →
|
||||
// `webdav_handler::strip_chroot_prefix`) and remain reachable
|
||||
// via REST `/api/trash/resources`.
|
||||
let chroot = session.require_chroot()?;
|
||||
let trash_svc = state
|
||||
.trash_service
|
||||
.as_ref()
|
||||
@@ -95,7 +103,7 @@ async fn handle_propfind(
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
write_trashbin_multistatus(&mut buf, &items, &user.username, file_id_svc)
|
||||
write_trashbin_multistatus(&mut buf, &items, &user.username, chroot, file_id_svc)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
||||
|
||||
@@ -259,18 +267,23 @@ fn mime_from_name(name: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Strip the home-folder prefix from an original path to produce the
|
||||
/// Nextcloud-relative original location.
|
||||
/// Strip the caller's chroot prefix from an original path to produce
|
||||
/// the Nextcloud-relative original-location value.
|
||||
///
|
||||
/// TODO(D1): replace the hardcoded "Personal/" with the caller's actual
|
||||
/// default-drive root folder name read from `drives.root_folder_id`.
|
||||
/// Correct for D0-provisioned default drives; secondary drives keep
|
||||
/// their original root name. The `_username` arg stays for now so the
|
||||
/// upcoming dynamic lookup has a way to identify the caller.
|
||||
fn strip_home_prefix<'a>(original_path: &'a str, _username: &str) -> &'a str {
|
||||
original_path
|
||||
.strip_prefix("Personal/")
|
||||
.unwrap_or(original_path)
|
||||
/// Delegates to `webdav_handler::strip_chroot_prefix` — chroot-aware,
|
||||
/// multi-segment safe, and returns `None` when the item is outside
|
||||
/// the chroot (e.g. a trashed item in another drive the caller is a
|
||||
/// member of). The `_username` arg stays for signature stability
|
||||
/// with call sites that thread it; the strip itself no longer uses it.
|
||||
///
|
||||
/// See the doc on `strip_chroot_prefix` for the AuthZ caveat — this
|
||||
/// is a display helper, not an ownership check.
|
||||
fn strip_home_prefix<'a>(
|
||||
original_path: &'a str,
|
||||
_username: &str,
|
||||
chroot: &crate::application::dtos::folder_dto::FolderDto,
|
||||
) -> Option<&'a str> {
|
||||
crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(chroot, original_path)
|
||||
}
|
||||
|
||||
// ────────────── Trashbin PROPFIND XML Generation ──────────────
|
||||
@@ -280,10 +293,16 @@ use crate::application::services::nextcloud_file_id_service::NextcloudFileIdServ
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin.
|
||||
///
|
||||
/// `chroot` scopes the response — items whose original path is outside
|
||||
/// the chroot (other drives the caller is a member of) are dropped
|
||||
/// silently. NC's trashbin surface is single-drive from the client's
|
||||
/// perspective; cross-drive items remain reachable via REST.
|
||||
async fn write_trashbin_multistatus<W: std::io::Write>(
|
||||
writer: W,
|
||||
items: &[TrashedItemDto],
|
||||
username: &str,
|
||||
chroot: &crate::application::dtos::folder_dto::FolderDto,
|
||||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
) -> Result<(), String> {
|
||||
let mut xml = Writer::new(writer);
|
||||
@@ -315,9 +334,24 @@ async fn write_trashbin_multistatus<W: std::io::Write>(
|
||||
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
|
||||
id_map.extend(folder_id_map);
|
||||
|
||||
// Individual trashed items.
|
||||
// Individual trashed items — skip those whose original path is
|
||||
// outside the chroot (other-drive trash reachable via REST).
|
||||
for item in items {
|
||||
write_trash_item_response(&mut xml, item, username, file_id_svc, &id_map)?;
|
||||
if crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(
|
||||
chroot,
|
||||
&item.original_path,
|
||||
)
|
||||
.is_none()
|
||||
{
|
||||
tracing::debug!(
|
||||
target: "oxicloud::nc",
|
||||
"trashbin PROPFIND: dropping cross-chroot item '{}' at '{}'",
|
||||
item.id,
|
||||
item.original_path,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
write_trash_item_response(&mut xml, item, username, chroot, file_id_svc, &id_map)?;
|
||||
}
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||
@@ -363,10 +397,18 @@ fn write_trash_root_response<W: std::io::Write>(
|
||||
}
|
||||
|
||||
/// Write a single trashed item as a `<d:response>` element.
|
||||
///
|
||||
/// Caller is expected to have already verified the item is inside
|
||||
/// `chroot` — see the guard in `write_trashbin_multistatus`. This
|
||||
/// function trusts the invariant and expects `strip_home_prefix` to
|
||||
/// return `Some(_)`; if it ever returns `None` (chroot drift between
|
||||
/// the guard and the emit, defensive-only), the original-location
|
||||
/// falls back to an empty string.
|
||||
fn write_trash_item_response<W: std::io::Write>(
|
||||
xml: &mut Writer<W>,
|
||||
item: &TrashedItemDto,
|
||||
username: &str,
|
||||
chroot: &crate::application::dtos::folder_dto::FolderDto,
|
||||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
id_map: &HashMap<String, i64>,
|
||||
) -> Result<(), String> {
|
||||
@@ -427,7 +469,7 @@ fn write_trash_item_response<W: std::io::Write>(
|
||||
write_text_element(xml, "nc:trashbin-filename", &item.name)?;
|
||||
|
||||
// nc:trashbin-original-location
|
||||
let original_location = strip_home_prefix(&item.original_path, username);
|
||||
let original_location = strip_home_prefix(&item.original_path, username, chroot).unwrap_or("");
|
||||
write_text_element(xml, "nc:trashbin-original-location", original_location)?;
|
||||
|
||||
// nc:trashbin-deletion-time
|
||||
|
||||
@@ -367,12 +367,14 @@ async fn handle_assemble(
|
||||
let chroot = session.require_chroot()?;
|
||||
let drive_id = chroot.drive_id;
|
||||
|
||||
// TODO(D1): read the caller's default-drive root folder name from
|
||||
// `drives.root_folder_id` instead of hardcoding "Personal". The
|
||||
// constant is correct for every default personal drive provisioned
|
||||
// by the D0 lifecycle hook, but secondary drives (M2 backfill from
|
||||
// SQL-created sibling root folders) keep their original name.
|
||||
let internal_path = format!("Personal/{}", dest_subpath.trim_matches('/'));
|
||||
// Route through `nc_to_internal_path(chroot, …)` so the write
|
||||
// lands under the caller's actual default-drive root (not the
|
||||
// literal "Personal" folder). Post-D3 chroot resolution puts the
|
||||
// correct FolderDto — including the drive's real root name — on
|
||||
// the NcSession; secondary drives with SQL-provisioned sibling
|
||||
// root names now work.
|
||||
let internal_path =
|
||||
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, &dest_subpath)?;
|
||||
|
||||
let filename = filename_from_path(&dest_subpath).to_string();
|
||||
let ingested = ingest_stream_to_cas(
|
||||
@@ -393,7 +395,7 @@ async fn handle_assemble(
|
||||
|
||||
let etag: Option<String> = if existing.is_ok() {
|
||||
let dto = upload_service
|
||||
.update_file_streaming(
|
||||
.update_file_streaming_with_perms(
|
||||
&internal_path,
|
||||
drive_id,
|
||||
ingested.stored(),
|
||||
@@ -412,7 +414,8 @@ async fn handle_assemble(
|
||||
Some((p, n)) => (p, n),
|
||||
None => ("", dest_subpath.as_str()),
|
||||
};
|
||||
let parent_internal = format!("Personal/{}", parent_sub.trim_matches('/'));
|
||||
let parent_internal =
|
||||
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, parent_sub)?;
|
||||
let parent_internal = parent_internal.trim_end_matches('/');
|
||||
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::application::adapters::webdav_adapter::{
|
||||
PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property,
|
||||
};
|
||||
use crate::application::dtos::pagination::PaginationRequestDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
||||
use crate::application::ports::file_ports::{
|
||||
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
|
||||
@@ -25,6 +26,8 @@ use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::common::di::AppState;
|
||||
use crate::common::mime_detect::filename_from_path;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||
use crate::infrastructure::services::webdav_dead_property_store::ResourceRef;
|
||||
use crate::interfaces::api::handlers::webdav_handler::{
|
||||
PROPFIND_BATCH_SIZE, file_dead_props, folder_dead_props, streamed_file_dead_props,
|
||||
@@ -82,6 +85,78 @@ pub fn nc_to_internal_path(chroot: &FolderDto, subpath: &str) -> Result<String,
|
||||
Ok(format!("{}/{}", chroot.path, subpath))
|
||||
}
|
||||
|
||||
/// Strip the caller's chroot prefix from an internal
|
||||
/// `storage.folders.path` so the DAV subpath surfaced to the NC
|
||||
/// client is chroot-relative. Handles multi-segment chroots
|
||||
/// correctly (e.g. a future `"Personal/folderA/subfolder"` chroot
|
||||
/// against an item at `"Personal/folderA/subfolder/file.txt"`
|
||||
/// returns `"file.txt"`, not `"folderA/subfolder/file.txt"`).
|
||||
///
|
||||
/// Returns `None` when the path is NOT inside the chroot. Callers
|
||||
/// should skip such items from the response (they belong to a
|
||||
/// different drive or the caller's read scope has drifted) — do NOT
|
||||
/// fall back to a naive segment strip, which would surface a
|
||||
/// misleading display path.
|
||||
///
|
||||
/// **Defensive but not an AuthZ boundary.** Every current caller
|
||||
/// reaches items through a `_with_perms` method upstream that
|
||||
/// already gates Read; this helper is the display-string layer
|
||||
/// that also serves as a "does this item belong under the chroot"
|
||||
/// sanity check.
|
||||
pub fn strip_chroot_prefix<'a>(chroot: &FolderDto, internal_path: &'a str) -> Option<&'a str> {
|
||||
// Normalize both sides: `FolderDto.path` comes from
|
||||
// `StoragePath::to_string()` which prepends a leading `/`
|
||||
// (e.g. `"/Personal"`), but DB-side paths coming from
|
||||
// `storage.folders.path` (composed by the `compute_folder_path`
|
||||
// trigger) never have a leading slash. Trim both so `"/Personal"`
|
||||
// vs `"Personal/g9-tree"` matches the intended prefix.
|
||||
let root = chroot.path.trim_matches('/');
|
||||
if root.is_empty() {
|
||||
// Guard against a mis-set chroot with an empty root path —
|
||||
// stripping "" from anything would return the whole path.
|
||||
return None;
|
||||
}
|
||||
let path = internal_path.trim_start_matches('/');
|
||||
let rest = path.strip_prefix(root)?;
|
||||
// Reject a partial prefix match — a chroot of "Personal" must
|
||||
// not match an item at "PersonalSecrets/…".
|
||||
match rest.strip_prefix('/') {
|
||||
Some(subpath) => Some(subpath),
|
||||
// Item path equals the chroot exactly — the chroot itself
|
||||
// (i.e. a folder) is not a legitimate response item, so
|
||||
// treat as an empty subpath.
|
||||
None if rest.is_empty() => Some(""),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Naive fallback: strip the first path segment from an internal
|
||||
/// `storage.folders.path`. Post-D0 every path starts with its drive's
|
||||
/// root folder name (single segment), so for the current schema this
|
||||
/// gives the drive-relative subpath.
|
||||
///
|
||||
/// Use this ONLY when the caller doesn't have a chroot in scope
|
||||
/// (e.g. OCS unified search, whose results legitimately span every
|
||||
/// drive the caller has Read on — no single chroot covers them all).
|
||||
/// Every path-scoped NC handler that DOES have `session` in scope
|
||||
/// should prefer [`strip_chroot_prefix`] — it validates the item
|
||||
/// belongs under the chroot instead of trusting the schema
|
||||
/// invariant, and it survives a future composed chroot like
|
||||
/// `"Personal/folderA/subfolder"`.
|
||||
///
|
||||
/// **Not an AuthZ boundary.** Same caveat as `strip_chroot_prefix`
|
||||
/// — AuthZ is enforced upstream via `_with_perms` methods; this
|
||||
/// helper only formats display strings.
|
||||
///
|
||||
/// Returns `""` when the path is a single segment (i.e. the drive
|
||||
/// root itself, which is never a legitimate item target).
|
||||
pub fn strip_drive_root_segment(internal_path: &str) -> &str {
|
||||
match internal_path.split_once('/') {
|
||||
Some((_root, rest)) => rest,
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Nextcloud DAV href for a **collection** (folder). Always
|
||||
/// terminates with `/` — RFC 4918 §5.2 requires collection URLs to end
|
||||
/// in a slash, and the Nextcloud desktop client strictly enforces this
|
||||
@@ -235,76 +310,124 @@ async fn handle_propfind(
|
||||
|
||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||
|
||||
let folder_service = &state.applications.folder_service;
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
|
||||
// Try to resolve as folder first.
|
||||
let folder_result = folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await;
|
||||
|
||||
if let Ok(folder) = folder_result {
|
||||
// It's a folder — stream the multistatus: children are fetched in
|
||||
// pages and serialized chunk by chunk, so memory stays O(batch)
|
||||
// regardless of how many entries the folder holds.
|
||||
//
|
||||
// Multi-drive POC: the hrefs in the response must echo the
|
||||
// wire form (`{user}~{drive}`) the client requested, so we
|
||||
// pass `url_user` (not `user.username`) as the streaming
|
||||
// function's username arg. Refining the owner-id usages
|
||||
// back to the canonical username is deferred to the
|
||||
// NcSession commit.
|
||||
return Ok(build_nc_streaming_propfind(
|
||||
state.clone(),
|
||||
folder,
|
||||
depth,
|
||||
user.id,
|
||||
url_user.to_string(),
|
||||
subpath.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Not a folder — try as a file.
|
||||
let file_result = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await;
|
||||
if let Ok(file) = file_result {
|
||||
// Batch-check favorites for this single file.
|
||||
let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||||
let items: Vec<(&str, &str)> = vec![(&file.id, "file")];
|
||||
fav_svc
|
||||
.batch_check_favorites(user.id, &items)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
|
||||
let nc = state.nextcloud.as_ref();
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
let dead_props = file_dead_props(&state, &file).await;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
write_nc_file_multistatus(
|
||||
&mut buf,
|
||||
&file,
|
||||
url_user,
|
||||
&user.username,
|
||||
subpath,
|
||||
file_id_svc,
|
||||
(&favorite_ids, &dead_props),
|
||||
)
|
||||
// Single-query path resolution (drive-scoped) — same shared
|
||||
// resolver as native `/webdav/…`. Post-D7 the resolver is not
|
||||
// owner-scoped, so we `authz.require(Read, …)` on the returned
|
||||
// resource explicitly before emitting the multistatus.
|
||||
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
||||
.ok_or_else(|| AppError::not_found("Resource not found"))?;
|
||||
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(buf))
|
||||
.unwrap());
|
||||
match resolved {
|
||||
ResolvedResource::Folder(folder) => {
|
||||
let folder_uuid = Uuid::parse_str(&folder.id)
|
||||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::Folder(folder_uuid),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// It's a folder — stream the multistatus: children are fetched in
|
||||
// pages and serialized chunk by chunk, so memory stays O(batch)
|
||||
// regardless of how many entries the folder holds.
|
||||
//
|
||||
// Multi-drive POC: the hrefs in the response must echo the
|
||||
// wire form (`{user}~{drive}`) the client requested, so we
|
||||
// pass `url_user` (not `user.username`) as the streaming
|
||||
// function's username arg. Refining the owner-id usages
|
||||
// back to the canonical username is deferred to the
|
||||
// NcSession commit.
|
||||
Ok(build_nc_streaming_propfind(
|
||||
state.clone(),
|
||||
folder,
|
||||
depth,
|
||||
user.id,
|
||||
url_user.to_string(),
|
||||
subpath.to_string(),
|
||||
))
|
||||
}
|
||||
ResolvedResource::File(file) => {
|
||||
let file_uuid =
|
||||
Uuid::parse_str(&file.id).map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Batch-check favorites for this single file.
|
||||
let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||||
let items: Vec<(&str, &str)> = vec![(&file.id, "file")];
|
||||
fav_svc
|
||||
.batch_check_favorites(user.id, &items)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
|
||||
let nc = state.nextcloud.as_ref();
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
|
||||
let dead_props = file_dead_props(&state, &file).await;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
write_nc_file_multistatus(
|
||||
&mut buf,
|
||||
&file,
|
||||
url_user,
|
||||
&user.username,
|
||||
subpath,
|
||||
file_id_svc,
|
||||
(&favorite_ids, &dead_props),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(buf))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::not_found("Resource not found"))
|
||||
/// NC-surface path resolution: try the single-query resolver, fall back
|
||||
/// to the double-query `get_*_by_path` pair when the resolver isn't
|
||||
/// configured. Same shape and drive-scope as the native surface —
|
||||
/// callers `authz.require(…)` on the returned resource.
|
||||
async fn nc_resolve_or_fallback(
|
||||
state: &Arc<AppState>,
|
||||
internal_path: &str,
|
||||
drive_id: Uuid,
|
||||
) -> Option<ResolvedResource> {
|
||||
if let Some(resolver) = &state.path_resolver
|
||||
&& let Ok(r) = resolver
|
||||
.resolve_path_in_drive(internal_path, drive_id)
|
||||
.await
|
||||
{
|
||||
return Some(r);
|
||||
}
|
||||
let folder_service = &state.applications.folder_service;
|
||||
if let Ok(folder) = folder_service
|
||||
.get_folder_by_path(internal_path, drive_id)
|
||||
.await
|
||||
{
|
||||
return Some(ResolvedResource::Folder(folder));
|
||||
}
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
if let Ok(file) = file_service.get_file_by_path(internal_path, drive_id).await {
|
||||
return Some(ResolvedResource::File(file));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ──────────────────── GET ────────────────────
|
||||
@@ -325,27 +448,50 @@ async fn handle_get(
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
let user = &session.user;
|
||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
// Check if path is a folder first (NC clients use GET as existence check)
|
||||
if folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
// Single-query path resolution. NC clients use GET on a folder as
|
||||
// an existence probe (returns 200 empty); file GETs serve content.
|
||||
// Post-D7 the resolver is drive-scoped, so both branches
|
||||
// `authz.require(Read, …)` before responding.
|
||||
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("DAV", "1, 3")
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
.ok_or_else(|| AppError::not_found("File not found"))?;
|
||||
|
||||
let file = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("File not found"))?;
|
||||
let file = match resolved {
|
||||
ResolvedResource::Folder(folder) => {
|
||||
let folder_uuid =
|
||||
Uuid::parse_str(&folder.id).map_err(|_| AppError::not_found("File not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::Folder(folder_uuid),
|
||||
)
|
||||
.await?;
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("DAV", "1, 3")
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
ResolvedResource::File(f) => {
|
||||
let file_uuid =
|
||||
Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("File not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await?;
|
||||
f
|
||||
}
|
||||
};
|
||||
|
||||
// ETag comes from `FileDto::etag` (populated from `File::etag()`
|
||||
// in the `From<File>` impl) — single source of truth, so GET,
|
||||
@@ -412,27 +558,47 @@ async fn handle_head(
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
let user = &session.user;
|
||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
// Check if path is a folder (NC clients use HEAD as existence check)
|
||||
if folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
// Single-query path resolution. Both branches `authz.require(Read, …)`
|
||||
// on the returned resource before responding.
|
||||
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("DAV", "1, 3")
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
.ok_or_else(|| AppError::not_found("File not found"))?;
|
||||
|
||||
let file = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::not_found("File not found"))?;
|
||||
let file = match resolved {
|
||||
ResolvedResource::Folder(folder) => {
|
||||
let folder_uuid =
|
||||
Uuid::parse_str(&folder.id).map_err(|_| AppError::not_found("File not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::Folder(folder_uuid),
|
||||
)
|
||||
.await?;
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("DAV", "1, 3")
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
ResolvedResource::File(f) => {
|
||||
let file_uuid =
|
||||
Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("File not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await?;
|
||||
f
|
||||
}
|
||||
};
|
||||
|
||||
let modified_at =
|
||||
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
|
||||
@@ -496,25 +662,38 @@ async fn handle_proppatch(
|
||||
// silently no-opping on a nonexistent path would be a foot-gun —
|
||||
// matches the native `/webdav/` handler's contract.
|
||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
let (resource_ref, item_id, item_type, is_collection) = if let Ok(file) = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
let id = Uuid::parse_str(&file.id)
|
||||
.map_err(|e| AppError::internal_error(format!("File id is not a UUID: {e}")))?;
|
||||
(ResourceRef::File(id), file.id, "file", false)
|
||||
} else if let Ok(folder) = folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
let id = Uuid::parse_str(&folder.id)
|
||||
.map_err(|e| AppError::internal_error(format!("Folder id is not a UUID: {e}")))?;
|
||||
(ResourceRef::Folder(id), folder.id, "folder", true)
|
||||
} else {
|
||||
return Err(AppError::not_found("Resource not found"));
|
||||
};
|
||||
// Single-query path resolution — PROPPATCH may target either a
|
||||
// folder or a file. Post-D7 the resolver is drive-scoped, so we
|
||||
// `authz.require(Read, …)` on the returned resource before
|
||||
// reading its type. The favorite mutation below itself doesn't
|
||||
// require additional authz (favorites are per-user; the caller can
|
||||
// favourite any resource they can see).
|
||||
let (resource_ref, item_id, item_type, is_collection) =
|
||||
match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await {
|
||||
Some(ResolvedResource::File(file)) => {
|
||||
let id = Uuid::parse_str(&file.id)
|
||||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(Subject::User(user.id), Permission::Read, Resource::File(id))
|
||||
.await?;
|
||||
(ResourceRef::File(id), file.id, "file", false)
|
||||
}
|
||||
Some(ResolvedResource::Folder(folder)) => {
|
||||
let id = Uuid::parse_str(&folder.id)
|
||||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::Folder(id),
|
||||
)
|
||||
.await?;
|
||||
(ResourceRef::Folder(id), folder.id, "folder", true)
|
||||
}
|
||||
None => return Err(AppError::not_found("Resource not found")),
|
||||
};
|
||||
|
||||
let ops = WebDavAdapter::parse_proppatch(body_bytes.reader())
|
||||
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?;
|
||||
@@ -745,7 +924,7 @@ async fn handle_put(
|
||||
// Single streaming path — handles both update and create internally,
|
||||
// swapping the file row onto the already-ingested blob.
|
||||
let stored = upload_service
|
||||
.update_file_streaming(
|
||||
.update_file_streaming_with_perms(
|
||||
&internal_path,
|
||||
chroot.drive_id,
|
||||
ingested.stored(),
|
||||
@@ -865,74 +1044,79 @@ async fn handle_delete(
|
||||
let chroot = session.require_chroot()?;
|
||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
|
||||
// Prefer soft-delete (move to trash) when trash service is available.
|
||||
// This is what Nextcloud clients expect — items appear in the trashbin.
|
||||
if let Some(trash_svc) = state.trash_service.as_ref() {
|
||||
if let Ok(folder) = folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
trash_svc
|
||||
.move_to_trash(&folder.id, "folder", user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to trash folder: {}", e)))?;
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
if let Ok(file) = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
trash_svc
|
||||
.move_to_trash(&file.id, "file", user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to trash file: {}", e)))?;
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
return Err(AppError::not_found("Resource not found"));
|
||||
}
|
||||
|
||||
// Fallback: hard delete when trash service is not available.
|
||||
let file_mgmt = &state.applications.file_management_service;
|
||||
|
||||
if let Ok(folder) = folder_service
|
||||
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||
// Single-query path resolution. Post-D7 the resolver is drive-scoped,
|
||||
// so we `authz.require(Read, …)` on the returned resource before
|
||||
// dispatching. The actual delete is authorised as `Permission::Delete`
|
||||
// inside the downstream service (`trash_svc.move_to_trash` /
|
||||
// `delete_folder_with_perms` / `delete_file_with_perms` all take
|
||||
// `caller_id`).
|
||||
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
folder_service
|
||||
.delete_folder_with_perms(&folder.id, user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
|
||||
.ok_or_else(|| AppError::not_found("Resource not found"))?;
|
||||
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
match resolved {
|
||||
ResolvedResource::Folder(folder) => {
|
||||
let folder_uuid = Uuid::parse_str(&folder.id)
|
||||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::Folder(folder_uuid),
|
||||
)
|
||||
.await?;
|
||||
if let Some(trash_svc) = state.trash_service.as_ref() {
|
||||
trash_svc
|
||||
.move_to_trash(&folder.id, "folder", user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to trash folder: {}", e))
|
||||
})?;
|
||||
} else {
|
||||
folder_service
|
||||
.delete_folder_with_perms(&folder.id, user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to delete folder: {}", e))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
ResolvedResource::File(file) => {
|
||||
let file_uuid =
|
||||
Uuid::parse_str(&file.id).map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await?;
|
||||
if let Some(trash_svc) = state.trash_service.as_ref() {
|
||||
trash_svc
|
||||
.move_to_trash(&file.id, "file", user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to trash file: {}", e))
|
||||
})?;
|
||||
} else {
|
||||
let file_mgmt = &state.applications.file_management_service;
|
||||
file_mgmt
|
||||
.delete_file_with_perms(&file.id, user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to delete file: {}", e))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(file) = file_service
|
||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||
.await
|
||||
{
|
||||
file_mgmt
|
||||
.delete_file_with_perms(&file.id, user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?;
|
||||
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
Err(AppError::not_found("Resource not found"))
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// ──────────────────── MOVE ────────────────────
|
||||
@@ -980,21 +1164,18 @@ async fn handle_move(
|
||||
let file_mgmt = &state.applications.file_management_service;
|
||||
|
||||
// ── Destination-collision precondition (RFC 4918 §9.9.4) ──────────
|
||||
// Resolved once up-front so the file/folder branches below don't
|
||||
// each have to repeat the check. `dest_existed_before` becomes the
|
||||
// 204-vs-201 selector at response time.
|
||||
// Single-query probe via the shared resolver — the destination is
|
||||
// either a file, a folder, or absent. `dest_existed_before`
|
||||
// becomes the 204-vs-201 selector at response time. Post-D7 the
|
||||
// resolver is drive-scoped; on the overwrite path we
|
||||
// `authz.require(Read, …)` explicitly and the downstream delete
|
||||
// enforces `Permission::Delete`.
|
||||
let dest_internal_precheck = nc_to_internal_path(chroot, &dest_subpath)?;
|
||||
let dest_existing_file = file_service
|
||||
.get_file_by_path(&dest_internal_precheck, chroot.drive_id)
|
||||
.await
|
||||
.ok();
|
||||
let dest_existing_folder = folder_service
|
||||
.get_folder_by_path(&dest_internal_precheck, chroot.drive_id)
|
||||
.await
|
||||
.ok();
|
||||
let dest_existed_before = dest_existing_file.is_some() || dest_existing_folder.is_some();
|
||||
let dest_existing =
|
||||
nc_resolve_or_fallback(&state, &dest_internal_precheck, chroot.drive_id).await;
|
||||
let dest_existed_before = dest_existing.is_some();
|
||||
|
||||
if dest_existed_before {
|
||||
if let Some(existing) = dest_existing {
|
||||
if overwrite_forbidden {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::PRECONDITION_FAILED)
|
||||
@@ -1005,23 +1186,51 @@ async fn handle_move(
|
||||
// then proceed with the move. Trashing is fine: per RFC the source
|
||||
// resource appears at the destination URI; what happens to the
|
||||
// overwritten one is up to the server.
|
||||
if let Some(existing_file) = &dest_existing_file {
|
||||
file_mgmt
|
||||
.delete_and_cleanup_with_perms(&existing_file.id, user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to overwrite destination file: {}", e))
|
||||
match existing {
|
||||
ResolvedResource::File(existing_file) => {
|
||||
let file_uuid = Uuid::parse_str(&existing_file.id).map_err(|_| {
|
||||
AppError::internal_error("Failed to overwrite destination file")
|
||||
})?;
|
||||
} else if let Some(existing_folder) = &dest_existing_folder {
|
||||
folder_service
|
||||
.delete_folder_with_perms(&existing_folder.id, user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!(
|
||||
"Failed to overwrite destination folder: {}",
|
||||
e
|
||||
))
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await?;
|
||||
file_mgmt
|
||||
.delete_and_cleanup_with_perms(&existing_file.id, user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!(
|
||||
"Failed to overwrite destination file: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
ResolvedResource::Folder(existing_folder) => {
|
||||
let folder_uuid = Uuid::parse_str(&existing_folder.id).map_err(|_| {
|
||||
AppError::internal_error("Failed to overwrite destination folder")
|
||||
})?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::Folder(folder_uuid),
|
||||
)
|
||||
.await?;
|
||||
folder_service
|
||||
.delete_folder_with_perms(&existing_folder.id, user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!(
|
||||
"Failed to overwrite destination folder: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1681,7 +1890,6 @@ mod tests {
|
||||
name: path.rsplit('/').next().unwrap_or("").to_string(),
|
||||
path: path.to_string(),
|
||||
parent_id: None,
|
||||
owner_id: None,
|
||||
// Test stub — path mapper doesn't read drive_id.
|
||||
drive_id: uuid::Uuid::nil(),
|
||||
created_at: 0,
|
||||
@@ -1744,6 +1952,92 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── strip_chroot_prefix ──
|
||||
//
|
||||
// Regression guard for the "chroot.path has a leading slash from
|
||||
// StoragePath::to_string() but DB-side original_path doesn't" trap
|
||||
// that broke the NC trashbin PROPFIND after Round 2 rolled out.
|
||||
// Also pins the composed-chroot behaviour Ed asked about.
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_default_drive_root() {
|
||||
// FolderDto.path carries a leading slash (StoragePath Display);
|
||||
// DB paths do not. Both must normalise to the same prefix.
|
||||
let chroot = stub_folder("/Personal");
|
||||
assert_eq!(
|
||||
strip_chroot_prefix(&chroot, "Personal/g9-tree"),
|
||||
Some("g9-tree")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_deep_path() {
|
||||
let chroot = stub_folder("/Personal");
|
||||
assert_eq!(
|
||||
strip_chroot_prefix(&chroot, "Personal/inner/deep.txt"),
|
||||
Some("inner/deep.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_out_of_chroot_returns_none() {
|
||||
// Items on a different drive (whose root isn't "Personal")
|
||||
// must NOT be surfaced under the caller's chroot.
|
||||
let chroot = stub_folder("/Personal");
|
||||
assert_eq!(strip_chroot_prefix(&chroot, "team-drive/report.pdf"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_rejects_partial_prefix_match() {
|
||||
// "Personal" is a prefix substring of "PersonalSecrets" but
|
||||
// NOT a path-segment prefix — must reject.
|
||||
let chroot = stub_folder("/Personal");
|
||||
assert_eq!(
|
||||
strip_chroot_prefix(&chroot, "PersonalSecrets/foo.txt"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_composed_chroot() {
|
||||
// The future composed-chroot case Ed raised: chroot points at
|
||||
// a subfolder inside a drive. The strip must remove the ENTIRE
|
||||
// composed prefix, not just the first segment.
|
||||
let chroot = stub_folder("/Personal/folderA/subfolder");
|
||||
assert_eq!(
|
||||
strip_chroot_prefix(&chroot, "Personal/folderA/subfolder/foo.txt"),
|
||||
Some("foo.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_composed_chroot_sibling_leaks_blocked() {
|
||||
// Same composed chroot, but the item lives in a sibling
|
||||
// subfolder — must be rejected, not naively strip 1 segment.
|
||||
let chroot = stub_folder("/Personal/folderA/subfolder");
|
||||
assert_eq!(
|
||||
strip_chroot_prefix(&chroot, "Personal/folderA/other/foo.txt"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_chroot_root_itself() {
|
||||
// Item path equals chroot exactly — legitimate for a PROPFIND
|
||||
// Depth:0 on the chroot itself. Subpath is empty.
|
||||
let chroot = stub_folder("/Personal");
|
||||
assert_eq!(strip_chroot_prefix(&chroot, "Personal"), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_chroot_prefix_empty_chroot_returns_none() {
|
||||
// Defensive: a mis-set chroot with an empty path must not
|
||||
// strip anything (stripping "" from any path would return
|
||||
// the whole path — a silent leak).
|
||||
let chroot = stub_folder("/");
|
||||
assert_eq!(strip_chroot_prefix(&chroot, "Personal/foo.txt"), None);
|
||||
}
|
||||
|
||||
// ── nc_href ──
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user