Merge upstream/main into feat/external-file-mounts
Resolve conflicts between the external-file-mounts feature and upstream's D5/D7 refactor (per-file provenance, keyset pagination, cross-drive move gates, resource-access hook, folder-cascade lifecycle hook). Key resolutions: - FolderService::new now takes (repo, authz, file_lifecycle, mount_router); all callers + DI updated. - FileRetrievalService / FileManagementService keep both the mount_router and the new resource_access_hook / drive_repo / storage_usage wiring. - list_files_batch_with_perms: adapt the mount branch from offset- to keyset (after_name) pagination, mirroring paginate_mount_entries. - download_file_impl: keep upstream's &HeaderMap + `impl IntoResponse + use<>` signature, retain the mount-download branch. - Mount DTOs: the retired `owner_id` field maps onto created_by/updated_by (the mount owner) — the fields the frontend now uses for owner display. - admin/+page.svelte: keep upstream's user-delete modal + the 'mounts' tab. - Bump memmap2 0.9.10 -> 0.9.11 (RUSTSEC critical advisory fix) and regenerate Cargo.lock against the merged Cargo.toml.
This commit is contained in:
@@ -49,6 +49,48 @@ pub struct Calendar {
|
||||
custom_properties: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Owned decomposition of a [`Calendar`] (mirrors `FileParts`/`UserParts`).
|
||||
/// Lets `CalendarDto::from` MOVE the heap fields — notably the
|
||||
/// `custom_properties` map — instead of cloning them on every CalDAV discovery
|
||||
/// listing (benches/ROUND20.md §A4).
|
||||
pub struct CalendarParts {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub owner_id: Uuid,
|
||||
pub description: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub custom_properties: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Calendar {
|
||||
/// Decompose into [`CalendarParts`], moving every owned field out
|
||||
/// (exhaustive destructure — compiler-checked against added fields).
|
||||
pub fn into_parts(self) -> CalendarParts {
|
||||
let Calendar {
|
||||
id,
|
||||
name,
|
||||
owner_id,
|
||||
description,
|
||||
color,
|
||||
created_at,
|
||||
updated_at,
|
||||
custom_properties,
|
||||
} = self;
|
||||
CalendarParts {
|
||||
id,
|
||||
name,
|
||||
owner_id,
|
||||
description,
|
||||
color,
|
||||
created_at,
|
||||
updated_at,
|
||||
custom_properties,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Calendar {
|
||||
/**
|
||||
* Creates a new calendar with the given properties.
|
||||
|
||||
+1093
-155
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,48 @@ pub struct AddressBook {
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Owned decomposition of an [`AddressBook`] (mirrors `FileParts`/`UserParts`).
|
||||
/// Lets `AddressBookDto::from` MOVE `name`/`description`/`color`/`owner_id`
|
||||
/// instead of cloning them on every CardDAV discovery listing
|
||||
/// (benches/ROUND20.md §A4).
|
||||
pub struct AddressBookParts {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub owner_id: String,
|
||||
pub description: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub is_public: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl AddressBook {
|
||||
/// Decompose into [`AddressBookParts`], moving every owned field out
|
||||
/// (exhaustive destructure — compiler-checked against added fields).
|
||||
pub fn into_parts(self) -> AddressBookParts {
|
||||
let AddressBook {
|
||||
id,
|
||||
name,
|
||||
owner_id,
|
||||
description,
|
||||
color,
|
||||
is_public,
|
||||
created_at,
|
||||
updated_at,
|
||||
} = self;
|
||||
AddressBookParts {
|
||||
id,
|
||||
name,
|
||||
owner_id,
|
||||
description,
|
||||
color,
|
||||
is_public,
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AddressBook {
|
||||
/// Creates a new AddressBook with generated id and timestamps
|
||||
pub fn new(
|
||||
@@ -402,6 +444,9 @@ impl Contact {
|
||||
pub fn push_phone(&mut self, p: Phone) {
|
||||
self.phone.push(p);
|
||||
}
|
||||
pub fn push_address(&mut self, a: Address) {
|
||||
self.address.push(a);
|
||||
}
|
||||
pub fn set_email(&mut self, email: Vec<Email>) {
|
||||
self.email = email;
|
||||
}
|
||||
@@ -417,6 +462,9 @@ impl Contact {
|
||||
pub fn phone_is_empty(&self) -> bool {
|
||||
self.phone.is_empty()
|
||||
}
|
||||
pub fn address_is_empty(&self) -> bool {
|
||||
self.address.is_empty()
|
||||
}
|
||||
|
||||
// --- Consuming methods for ownership transfer ---
|
||||
pub fn into_email(self) -> Vec<Email> {
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::Subject;
|
||||
|
||||
/// Drive kind discriminant. Mirrors the `storage.drives.kind` CHECK
|
||||
/// constraint values.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -128,9 +131,387 @@ impl Drive {
|
||||
self.default_for_user == Some(user_id)
|
||||
}
|
||||
|
||||
/// Typed view of `policies` for enforcement code. Lenient deserialise:
|
||||
/// unknown keys are preserved on disk (the column stays the canonical
|
||||
/// JSONB bag) but ignored here, missing keys default to `false`.
|
||||
/// See `docs/plan/drive.md` §8.
|
||||
pub fn typed_policies(&self) -> DrivePolicies {
|
||||
DrivePolicies::from_value(&self.policies)
|
||||
}
|
||||
|
||||
/// `true` if this drive is a personal drive of any kind (default or
|
||||
/// secondary). Encapsulates the kind check at the call site.
|
||||
pub fn is_personal(&self) -> bool {
|
||||
matches!(self.kind, DriveKind::Personal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed mirror of the `policies` JSONB. Five known keys; the JSONB column
|
||||
/// remains the source of truth and may carry unknown keys verbatim — this
|
||||
/// struct is a read view for enforcement and a write view for the policy
|
||||
/// PATCH endpoint. Every field defaults to `false` (everything allowed)
|
||||
/// so a freshly-created drive doesn't need a populated policy bag.
|
||||
///
|
||||
/// See `docs/plan/drive.md` §8 for the enforcement matrix
|
||||
/// (which callsite each key is checked at).
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct DrivePolicies {
|
||||
/// Disables per-resource grants on resources in this drive. Drive-level
|
||||
/// membership (Owner/Editor/Viewer) still works. Enforced at
|
||||
/// `grant_handler::create_grant`.
|
||||
pub forbid_sharing: bool,
|
||||
/// Blocks grants whose subject has `users.is_external = true`. Enforced
|
||||
/// at `magic_link_invite_service::resolve_or_create_recipient` and
|
||||
/// `grant_handler::create_grant`.
|
||||
pub forbid_external_sharing: bool,
|
||||
/// Blocks anonymous-link (token-share) creation on resources in this
|
||||
/// drive. Enforced at `share_service::create_shared_link`.
|
||||
pub forbid_public_links: bool,
|
||||
/// Blocks MOVE when `src.drive_id != dst.drive_id`. Enforced at the
|
||||
/// move endpoints. Lands paired with D6's cross-drive move work.
|
||||
pub forbid_cross_drive_move: bool,
|
||||
/// Locks the Owner-role membership set: no owner can be added,
|
||||
/// removed, or demoted by another owner — only OxiCloud admin can
|
||||
/// change the Owner roster. Editor / Viewer mutations by remaining
|
||||
/// owners are unaffected. Personal drives are already
|
||||
/// single-owner-immutable via `refuse_if_personal`, so this policy
|
||||
/// only adds value on shared drives. Enforced at
|
||||
/// `DriveManagementService::set_member_role` (refuses Owner role
|
||||
/// 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,
|
||||
/// **Full freeze / legal-hold.** When `true`, every mutation on
|
||||
/// resources in this drive is refused — user-initiated and
|
||||
/// background alike. Compliance-grade guarantee:
|
||||
///
|
||||
/// - User-initiated: enforced at `PgAclEngine::check_inner`, which
|
||||
/// short-circuits `Create` / `Update` / `Delete` / `Share`
|
||||
/// permissions on any resource in a read-only drive. Read still
|
||||
/// passes. Manage-on-Drive still passes so admins can un-freeze.
|
||||
/// - Background jobs: the periodic trash-retention purge and
|
||||
/// orphan-upload sweep filter out read-only drives at SELECT
|
||||
/// time (SQL-side `JOIN storage.drives … WHERE (policies->>
|
||||
/// 'read_only')::boolean IS NOT TRUE`). Retention clock keeps
|
||||
/// ticking; on unfreeze, the next sweep tick catches up.
|
||||
///
|
||||
/// Applies to both personal and shared drives — a user winding
|
||||
/// down their account, freezing a secondary personal archive, or
|
||||
/// putting a shared drive on legal hold all use the same knob.
|
||||
/// Mutation is admin-only via `PATCH /api/drives/{id}/policies`
|
||||
/// (per §8 — same carve-out as every other policy).
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
impl DrivePolicies {
|
||||
/// Parse from the raw JSONB. Lenient — unknown keys are dropped from
|
||||
/// the typed view but remain in the source `serde_json::Value`. A
|
||||
/// malformed bag (e.g. wrong type) falls back to the all-false default
|
||||
/// rather than refusing the read; enforcement code never panics on
|
||||
/// existing data.
|
||||
pub fn from_value(value: &serde_json::Value) -> Self {
|
||||
// Deserialize straight from the borrowed `Value` (`T::deserialize(&Value)`,
|
||||
// via serde_json's `Deserializer for &Value`) instead of
|
||||
// `serde_json::from_value(value.clone())` — the old form cloned the ENTIRE
|
||||
// policies DOM before walking it, on every drive-policy read (move/copy,
|
||||
// shared-link creation, grant). Byte-identical (same derived `Deserialize`
|
||||
// impl); the lenient `unwrap_or_default` fallback is unchanged.
|
||||
// (benches/ROUND23.md §J2)
|
||||
use serde::Deserialize as _;
|
||||
Self::deserialize(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// D5 `forbid_public_links` gate, used by every entry point that
|
||||
/// mints an anonymous token-share on a resource in this drive
|
||||
/// (`share_service::create_shared_link` today; future protocol
|
||||
/// surfaces — e.g. NextCloud OCS share — must call this too). The
|
||||
/// gate owns the decision + audit + canonical error so the
|
||||
/// rejection shape stays in lockstep across surfaces. See
|
||||
/// `docs/plan/drive.md` §8.
|
||||
///
|
||||
/// Returns `Ok(())` when the policy is off; emits a
|
||||
/// `share.rejected` audit line and returns
|
||||
/// `OperationNotSupported` when on.
|
||||
pub fn refuse_public_links(&self, ctx: PublicLinkGateContext) -> Result<(), DomainError> {
|
||||
if !self.forbid_public_links {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "share.rejected",
|
||||
reason = "forbid_public_links",
|
||||
caller_id = %ctx.caller_id,
|
||||
item_type = ctx.item_type,
|
||||
item_id = %ctx.item_id,
|
||||
"👮🏻♂️ public-link creation refused: forbid_public_links",
|
||||
);
|
||||
Err(DomainError::operation_not_supported(
|
||||
"Share",
|
||||
"This drive does not allow public links.",
|
||||
))
|
||||
}
|
||||
|
||||
/// D5 `forbid_sharing` gate: refuses **per-resource** grants on
|
||||
/// resources in this drive when the policy is on. Drive-level
|
||||
/// membership stays unaffected — otherwise a drive that disables
|
||||
/// sharing would also become uneditable except by the original
|
||||
/// owner. The semantic the plan §8 commits to is "no fine-grained
|
||||
/// sharing of individual files; access happens through drive
|
||||
/// membership only."
|
||||
///
|
||||
/// Enforced at `grant_handler::create_grant` for File / Folder
|
||||
/// resources. The Drive-resource branch of `/api/grants` and the
|
||||
/// `/api/drives/{id}/members` routes deliberately don't call this
|
||||
/// gate.
|
||||
///
|
||||
/// Returns `Ok(())` when the policy is off; emits a
|
||||
/// `grant.rejected` audit line and returns `OperationNotSupported`
|
||||
/// when on.
|
||||
pub fn refuse_sharing(&self, ctx: SharingGateContext) -> Result<(), DomainError> {
|
||||
if !self.forbid_sharing {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "grant.rejected",
|
||||
reason = "forbid_sharing",
|
||||
caller_id = %ctx.caller_id,
|
||||
resource_type = ctx.resource_type,
|
||||
resource_id = %ctx.resource_id,
|
||||
"👮🏻♂️ per-resource grant refused: forbid_sharing",
|
||||
);
|
||||
Err(DomainError::operation_not_supported(
|
||||
"Grant",
|
||||
"This drive does not allow per-resource sharing.",
|
||||
))
|
||||
}
|
||||
|
||||
/// D5 `forbid_owner_role_change` gate: refuses Owner-role mutations
|
||||
/// (adding a new Owner, demoting an existing Owner, or removing
|
||||
/// one) when the caller isn't OxiCloud admin and the policy is on.
|
||||
/// Membership of non-Owner roles is unaffected.
|
||||
///
|
||||
/// Enforced at `DriveManagementService::set_member_role` (refuses
|
||||
/// Owner role writes) and `::remove_member` (refuses removing an
|
||||
/// Owner subject). Skipped when `caller_is_admin = true` — the
|
||||
/// policy exists to constrain owners, not the tenant operator.
|
||||
/// Personal drives never reach this gate because
|
||||
/// `refuse_if_personal` rejects every member mutation upstream.
|
||||
///
|
||||
/// Returns `Ok(())` when the policy is off or the caller is admin;
|
||||
/// emits a `drive_membership.rejected` audit line and returns
|
||||
/// `OperationNotSupported` otherwise.
|
||||
pub fn refuse_owner_role_change(
|
||||
&self,
|
||||
ctx: OwnerRoleChangeGateContext,
|
||||
) -> Result<(), DomainError> {
|
||||
if !self.forbid_owner_role_change {
|
||||
return Ok(());
|
||||
}
|
||||
if ctx.caller_is_admin {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive_membership.rejected",
|
||||
reason = "forbid_owner_role_change",
|
||||
operation = ctx.operation,
|
||||
caller_id = %ctx.caller_id,
|
||||
drive_id = %ctx.drive_id,
|
||||
subject_type = ctx.subject_type,
|
||||
subject_id = %ctx.subject_id,
|
||||
"👮🏻♂️ owner-role mutation refused: forbid_owner_role_change",
|
||||
);
|
||||
Err(DomainError::operation_not_supported(
|
||||
"Drive",
|
||||
"This drive's Owner membership is locked — only OxiCloud admin can change owners.",
|
||||
))
|
||||
}
|
||||
|
||||
/// D5 `forbid_cross_drive_move` gate: refuses MOVE when
|
||||
/// `src.drive_id != dst.drive_id`. The policy lives on the SOURCE
|
||||
/// drive — its owner decides whether content can leave. Targets'
|
||||
/// owners already gate inbound moves via the `Create` permission
|
||||
/// on the destination folder, so a symmetric check would be
|
||||
/// redundant.
|
||||
///
|
||||
/// Enforced at `file_management_service::move_file_with_perms`
|
||||
/// and `folder_service::move_folder_with_perms`. The handler
|
||||
/// doesn't see this gate — it lives in the service layer per
|
||||
/// the AuthZ architecture rule in CLAUDE.md.
|
||||
///
|
||||
/// Returns `Ok(())` when the policy is off; emits a
|
||||
/// `move.rejected` audit line and returns `OperationNotSupported`
|
||||
/// when on.
|
||||
pub fn refuse_cross_drive_move(
|
||||
&self,
|
||||
ctx: CrossDriveMoveGateContext,
|
||||
) -> Result<(), DomainError> {
|
||||
if !self.forbid_cross_drive_move {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "move.rejected",
|
||||
reason = "forbid_cross_drive_move",
|
||||
caller_id = %ctx.caller_id,
|
||||
resource_type = ctx.resource_type,
|
||||
resource_id = %ctx.resource_id,
|
||||
src_drive_id = %ctx.src_drive_id,
|
||||
dst_drive_id = %ctx.dst_drive_id,
|
||||
"👮🏻♂️ cross-drive move refused: forbid_cross_drive_move",
|
||||
);
|
||||
Err(DomainError::operation_not_supported(
|
||||
"Move",
|
||||
"This drive does not allow moving content out to another drive.",
|
||||
))
|
||||
}
|
||||
|
||||
/// D5 `forbid_external_sharing` gate, shared by every entry point
|
||||
/// that creates a grant on a resource in this drive
|
||||
/// (`grant_handler::create_grant`,
|
||||
/// `DriveManagementService::set_member_role`). Each caller
|
||||
/// resolves `is_external` from whichever source naturally fits
|
||||
/// (the just-created `User` entity in the email path, a
|
||||
/// `get_user_flags` probe in the user-by-id path); the gate
|
||||
/// itself owns the decision + audit + canonical error so the
|
||||
/// shape stays in lockstep across surfaces. See `docs/plan/drive.md` §8.
|
||||
///
|
||||
/// Returns `Ok(())` when the subject is allowed (policy off, subject
|
||||
/// is not a User, or the User is not external). Returns
|
||||
/// `OperationNotSupported` after emitting a `grant.rejected` audit
|
||||
/// line otherwise.
|
||||
pub fn refuse_external_sharing(
|
||||
&self,
|
||||
subject: Subject,
|
||||
is_external: bool,
|
||||
ctx: ExternalSharingGateContext,
|
||||
) -> Result<(), DomainError> {
|
||||
if !self.forbid_external_sharing {
|
||||
return Ok(());
|
||||
}
|
||||
let Subject::User(uid) = subject else {
|
||||
return Ok(());
|
||||
};
|
||||
if !is_external {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "grant.rejected",
|
||||
reason = "forbid_external_sharing",
|
||||
stage = ctx.stage,
|
||||
caller_id = %ctx.caller_id,
|
||||
subject_id = %uid,
|
||||
drive_id = ?ctx.drive_id,
|
||||
resource_type = ?ctx.resource_type,
|
||||
resource_id = ?ctx.resource_id,
|
||||
"👮🏻♂️ grant refused: forbid_external_sharing",
|
||||
);
|
||||
Err(DomainError::operation_not_supported(
|
||||
"Grant",
|
||||
"This drive does not allow external sharing.",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit / identity context for [`DrivePolicies::refuse_owner_role_change`].
|
||||
///
|
||||
/// Carries the subject (the user/group whose Owner status is being
|
||||
/// added, removed, or demoted) and the calling operation tag
|
||||
/// (`"set_member_role"` or `"remove_member"`) so the audit log
|
||||
/// pinpoints exactly which mutation the policy refused.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct OwnerRoleChangeGateContext {
|
||||
pub caller_id: Uuid,
|
||||
pub caller_is_admin: bool,
|
||||
pub drive_id: Uuid,
|
||||
pub operation: &'static str,
|
||||
pub subject_type: &'static str,
|
||||
pub subject_id: Uuid,
|
||||
}
|
||||
|
||||
/// Audit / identity context for [`DrivePolicies::refuse_cross_drive_move`].
|
||||
///
|
||||
/// Carries the source and destination drive ids so the audit log
|
||||
/// captures exactly which boundary the refused move would cross —
|
||||
/// useful when investigating whether someone is probing the gate or
|
||||
/// genuinely trying to organize content.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CrossDriveMoveGateContext {
|
||||
pub caller_id: Uuid,
|
||||
/// `"file"` or `"folder"`.
|
||||
pub resource_type: &'static str,
|
||||
pub resource_id: Uuid,
|
||||
pub src_drive_id: Uuid,
|
||||
pub dst_drive_id: Uuid,
|
||||
}
|
||||
|
||||
/// Audit / identity context for [`DrivePolicies::refuse_sharing`].
|
||||
///
|
||||
/// Only File / Folder resources reach this gate — the per-resource
|
||||
/// grant surface. Drive-resource grants go through
|
||||
/// `set_member_role` and aren't subject to `forbid_sharing`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SharingGateContext {
|
||||
pub caller_id: Uuid,
|
||||
/// `"file"` or `"folder"`.
|
||||
pub resource_type: &'static str,
|
||||
pub resource_id: Uuid,
|
||||
}
|
||||
|
||||
/// Audit / identity context for [`DrivePolicies::refuse_public_links`].
|
||||
///
|
||||
/// Single callsite today (`share_service::create_shared_link`), but the
|
||||
/// struct is the explicit contract so future surfaces (NextCloud OCS
|
||||
/// share, WebDAV public-link sigil, …) land with the same shape.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PublicLinkGateContext {
|
||||
pub caller_id: Uuid,
|
||||
/// `"file"` or `"folder"` — the share target's resource kind.
|
||||
pub item_type: &'static str,
|
||||
pub item_id: Uuid,
|
||||
}
|
||||
|
||||
/// Audit / identity context for [`DrivePolicies::refuse_external_sharing`].
|
||||
///
|
||||
/// Two callsites with different identifiers naturally fill this in:
|
||||
/// - `grant_handler` (File/Folder branch): `drive_id = None`,
|
||||
/// `resource_type` + `resource_id` set
|
||||
/// - `DriveManagementService::set_member_role`: `drive_id` set,
|
||||
/// `resource_type` + `resource_id = None`
|
||||
///
|
||||
/// All three appear in the audit log so a single grep on
|
||||
/// `grant.rejected reason=forbid_external_sharing` surfaces every
|
||||
/// refusal regardless of entry point.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ExternalSharingGateContext {
|
||||
pub caller_id: Uuid,
|
||||
/// Distinguishes the call site for log aggregators. Known values
|
||||
/// today: `"late_user"` (grant_handler), `"drive_member"`
|
||||
/// (set_member_role). New entry points pick a fresh string.
|
||||
pub stage: &'static str,
|
||||
pub drive_id: Option<Uuid>,
|
||||
pub resource_type: Option<&'static str>,
|
||||
pub resource_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ pub enum UserError {
|
||||
ValidationError(String),
|
||||
/// Authentication error
|
||||
AuthenticationError(String),
|
||||
/// Upgrade path: the user is already internal — cannot re-upgrade.
|
||||
/// Surfaced by the service as `error_type = "AlreadyInternal"`.
|
||||
AlreadyInternal,
|
||||
}
|
||||
|
||||
impl Display for UserError {
|
||||
@@ -88,6 +91,7 @@ impl Display for UserError {
|
||||
UserError::InvalidPassword(msg) => write!(f, "Invalid password: {}", msg),
|
||||
UserError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
|
||||
UserError::AuthenticationError(msg) => write!(f, "Authentication error: {}", msg),
|
||||
UserError::AlreadyInternal => write!(f, "User is already an internal account"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,19 @@ impl BoundingBox {
|
||||
}
|
||||
}
|
||||
|
||||
/// A face box for the lightbox tagging overlay — the narrow projection of a
|
||||
/// persisted [`Face`] that the People API's `faces_for_file` needs (`id`,
|
||||
/// `person_id`, `bbox`). Fetching this instead of a full [`Face`] keeps the
|
||||
/// 2 KiB `embedding` BYTEA (plus det_score/quality/blob_hash/created_at) off
|
||||
/// the wire on every lightbox open of a face-tagged photo. See
|
||||
/// benches/ROUND14.md §Q1.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FaceBox {
|
||||
pub id: Uuid,
|
||||
pub person_id: Option<Uuid>,
|
||||
pub bbox: BoundingBox,
|
||||
}
|
||||
|
||||
/// A face produced by the analyzer but not yet persisted: where it is, how
|
||||
/// confident the detector was, an optional quality score, and a 512-d,
|
||||
/// L2-normalized embedding.
|
||||
|
||||
+82
-55
@@ -1,7 +1,7 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::services::path_service::{
|
||||
StoragePath, normalize_storage_name, validate_storage_name,
|
||||
StoragePath, normalize_storage_name_owned, validate_storage_name,
|
||||
};
|
||||
|
||||
// Re-export entity errors from the centralized module
|
||||
@@ -16,13 +16,11 @@ pub struct FileParts {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub storage_path: StoragePath,
|
||||
pub path_string: String,
|
||||
pub size: u64,
|
||||
pub mime_type: String,
|
||||
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`].
|
||||
@@ -49,12 +47,11 @@ pub struct File {
|
||||
/// Name of the file including extension
|
||||
name: String,
|
||||
|
||||
/// Path to the file in the domain model
|
||||
/// Path to the file in the domain model. Owns the canonical joined
|
||||
/// string; `path_string()` borrows it (the separate duplicate field
|
||||
/// was removed in ROUND11 §20 along with per-segment allocations).
|
||||
storage_path: StoragePath,
|
||||
|
||||
/// String representation of the path for API compatibility
|
||||
path_string: String,
|
||||
|
||||
/// Size of the file in bytes
|
||||
size: u64,
|
||||
|
||||
@@ -70,9 +67,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
|
||||
@@ -103,13 +97,11 @@ impl Default for File {
|
||||
id: "stub-id".to_string(),
|
||||
name: "stub-file.txt".to_string(),
|
||||
storage_path: StoragePath::from_string("/"),
|
||||
path_string: "/".to_string(),
|
||||
size: 0,
|
||||
mime_type: "application/octet-stream".to_string(),
|
||||
folder_id: None,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
owner_id: None,
|
||||
blob_hash: String::new(),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
@@ -127,7 +119,7 @@ impl File {
|
||||
mime_type: String,
|
||||
folder_id: Option<String>,
|
||||
) -> FileResult<Self> {
|
||||
let name = normalize_storage_name(&name);
|
||||
let name = normalize_storage_name_owned(name);
|
||||
if let Err(reason) = validate_storage_name(&name) {
|
||||
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
|
||||
}
|
||||
@@ -137,20 +129,15 @@ impl File {
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Store the path string for serialization compatibility
|
||||
let path_string = storage_path.to_string();
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
path_string,
|
||||
size,
|
||||
mime_type,
|
||||
folder_id,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
owner_id: None,
|
||||
blob_hash: String::new(),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
@@ -166,25 +153,20 @@ impl File {
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
) -> FileResult<Self> {
|
||||
let name = normalize_storage_name(&name);
|
||||
let name = normalize_storage_name_owned(name);
|
||||
if let Err(reason) = validate_storage_name(&name) {
|
||||
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
|
||||
}
|
||||
|
||||
// Store the path string for serialization compatibility
|
||||
let path_string = storage_path.to_string();
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
path_string,
|
||||
size: 0, // Folders have zero size
|
||||
mime_type: "directory".to_string(), // Standard MIME type for directories
|
||||
folder_id: parent_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
owner_id: None,
|
||||
blob_hash: String::new(),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
@@ -201,7 +183,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 +193,6 @@ impl File {
|
||||
folder_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
owner_id,
|
||||
String::new(),
|
||||
)
|
||||
}
|
||||
@@ -227,7 +207,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 +218,6 @@ impl File {
|
||||
folder_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
owner_id,
|
||||
blob_hash,
|
||||
None,
|
||||
None,
|
||||
@@ -259,30 +237,74 @@ 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>,
|
||||
) -> FileResult<Self> {
|
||||
let name = normalize_storage_name(&name);
|
||||
let name = normalize_storage_name_owned(name);
|
||||
if let Err(reason) = validate_storage_name(&name) {
|
||||
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
size,
|
||||
mime_type,
|
||||
folder_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
blob_hash,
|
||||
created_by,
|
||||
updated_by,
|
||||
})
|
||||
}
|
||||
|
||||
/// PG-row constructor: the per-listing-row hot path.
|
||||
///
|
||||
/// Builds `storage_path` **and** `path_string` in one pass from the
|
||||
/// materialized folder path via
|
||||
/// [`StoragePath::from_folder_and_name`], instead of the old chain
|
||||
/// (`format!` temp → `from_string` split → `Display` re-join) that
|
||||
/// allocated the full path three times per row. The owned `name` is
|
||||
/// NFC-normalized without the always-copy of the borrowing variant
|
||||
/// (DB rows are NFC by invariant, so this is a zero-alloc check).
|
||||
///
|
||||
/// The path is built from the raw incoming name and the name field is
|
||||
/// normalized afterwards — the exact observable sequence of the old
|
||||
/// `make_file_path` + constructor pair, byte-identical for every
|
||||
/// input (for DB rows the two names coincide: stored names are NFC).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_materialized_row(
|
||||
id: String,
|
||||
name: String,
|
||||
folder_path: Option<&str>,
|
||||
size: u64,
|
||||
mime_type: String,
|
||||
folder_id: Option<String>,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
blob_hash: String,
|
||||
created_by: Option<Uuid>,
|
||||
updated_by: Option<Uuid>,
|
||||
) -> FileResult<Self> {
|
||||
let storage_path = StoragePath::from_folder_and_name(folder_path, &name);
|
||||
|
||||
let name = normalize_storage_name_owned(name);
|
||||
if let Err(reason) = validate_storage_name(&name) {
|
||||
return Err(FileError::InvalidFileName(format!("{name}: {reason}")));
|
||||
}
|
||||
|
||||
// Store the path string for serialization compatibility
|
||||
let path_string = storage_path.to_string();
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
path_string,
|
||||
size,
|
||||
mime_type,
|
||||
folder_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
owner_id,
|
||||
blob_hash,
|
||||
created_by,
|
||||
updated_by,
|
||||
@@ -298,13 +320,11 @@ impl File {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
storage_path: self.storage_path,
|
||||
path_string: self.path_string,
|
||||
size: self.size,
|
||||
mime_type: self.mime_type,
|
||||
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,
|
||||
@@ -366,8 +386,25 @@ impl File {
|
||||
/// formula here changes it everywhere — that is the property
|
||||
/// we want.
|
||||
pub fn compute_etag(blob_hash: &str, modified_at: u64) -> String {
|
||||
let prefix: String = blob_hash.chars().take(16).collect();
|
||||
format!("{}-{}", prefix, modified_at)
|
||||
use std::fmt::Write as _;
|
||||
|
||||
// Byte index just past the 16th char (whole string when shorter).
|
||||
// `blob_hash` is lowercase hex ASCII in practice, so this is
|
||||
// effectively `min(len, 16)`, but `char_indices` keeps the slice
|
||||
// char-boundary-safe for exotic fixture values — byte-identical
|
||||
// to the old `chars().take(16).collect::<String>()` without the
|
||||
// intermediate allocation.
|
||||
let end = match blob_hash.char_indices().nth(16) {
|
||||
Some((i, _)) => i,
|
||||
None => blob_hash.len(),
|
||||
};
|
||||
|
||||
// Single allocation: prefix + '-' + up to 20 digits (u64::MAX).
|
||||
let mut etag = String::with_capacity(end + 1 + 20);
|
||||
etag.push_str(&blob_hash[..end]);
|
||||
etag.push('-');
|
||||
let _ = write!(etag, "{modified_at}");
|
||||
etag
|
||||
}
|
||||
|
||||
// Getters
|
||||
@@ -384,7 +421,7 @@ impl File {
|
||||
}
|
||||
|
||||
pub fn path_string(&self) -> &str {
|
||||
&self.path_string
|
||||
self.storage_path.as_str()
|
||||
}
|
||||
|
||||
pub fn size(&self) -> u64 {
|
||||
@@ -407,10 +444,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.
|
||||
@@ -437,25 +470,24 @@ impl File {
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
) -> Self {
|
||||
// Create storage_path from string
|
||||
let storage_path = StoragePath::from_string(&path);
|
||||
// Adopt the DTO path (canonical inputs are reused with zero
|
||||
// copies; non-canonical ones are normalized like from_string did).
|
||||
let storage_path = StoragePath::from_joined(path);
|
||||
|
||||
// Create directly without validation to avoid errors in DTO
|
||||
// conversions. Still NFC-normalize so even DTO-reconstructed
|
||||
// entities maintain the storage invariant.
|
||||
let name = normalize_storage_name(&name);
|
||||
let name = normalize_storage_name_owned(name);
|
||||
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
path_string: path,
|
||||
size,
|
||||
mime_type,
|
||||
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.
|
||||
@@ -468,7 +500,7 @@ impl File {
|
||||
|
||||
/// Creates a new version of the file with updated name
|
||||
pub fn with_name(mut self, new_name: String) -> FileResult<Self> {
|
||||
let new_name = normalize_storage_name(&new_name);
|
||||
let new_name = normalize_storage_name_owned(new_name);
|
||||
if let Err(reason) = validate_storage_name(&new_name) {
|
||||
return Err(FileError::InvalidFileName(format!("{new_name}: {reason}")));
|
||||
}
|
||||
@@ -487,7 +519,6 @@ impl File {
|
||||
// Consume `self` and mutate in place — only the path, name and mtime
|
||||
// change; id / mime_type / folder_id / blob_hash are carried over
|
||||
// without the per-field clone the old `&self` builder paid.
|
||||
self.path_string = new_storage_path.to_string();
|
||||
self.storage_path = new_storage_path;
|
||||
self.name = new_name;
|
||||
self.modified_at = now;
|
||||
@@ -512,7 +543,6 @@ impl File {
|
||||
.as_secs();
|
||||
|
||||
// Consume `self`: only the path, folder_id and mtime change.
|
||||
self.path_string = new_storage_path.to_string();
|
||||
self.storage_path = new_storage_path;
|
||||
self.folder_id = folder_id;
|
||||
self.modified_at = now;
|
||||
@@ -607,7 +637,6 @@ mod tests {
|
||||
None,
|
||||
1_000,
|
||||
2_000,
|
||||
None,
|
||||
"abcdef0123456789ZZZZZZZZ".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -632,7 +661,6 @@ mod tests {
|
||||
None,
|
||||
1_000,
|
||||
2_000,
|
||||
None,
|
||||
"shorthash".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -655,7 +683,6 @@ mod tests {
|
||||
None,
|
||||
1_000,
|
||||
2_000,
|
||||
None,
|
||||
"stable-content-hash".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
+124
-106
@@ -1,12 +1,35 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::services::path_service::{
|
||||
StoragePath, normalize_storage_name, validate_storage_name,
|
||||
StoragePath, normalize_storage_name_owned, validate_storage_name,
|
||||
};
|
||||
|
||||
// Re-export entity errors from the centralized module
|
||||
pub use super::entity_errors::{FolderError, FolderResult};
|
||||
|
||||
/// Owned parts of a [`Folder`] entity, produced by [`Folder::into_parts()`].
|
||||
///
|
||||
/// Consuming a `Folder` into `FolderParts` **moves** every field without
|
||||
/// cloning, eliminating the 3-4 heap allocations that previously occurred
|
||||
/// when converting `Folder → FolderDto` via `.to_string()` on each getter.
|
||||
/// Mirrors [`super::file::FileParts`].
|
||||
pub struct FolderParts {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub storage_path: StoragePath,
|
||||
pub parent_id: Option<String>,
|
||||
/// Drive that owns this folder. See [`Folder::drive_id`].
|
||||
pub drive_id: Uuid,
|
||||
pub created_at: u64,
|
||||
pub modified_at: u64,
|
||||
/// Descendant-rollup timestamp. See [`Folder::tree_modified_at`].
|
||||
pub tree_modified_at: u64,
|
||||
/// §14 provenance: original creator. See [`Folder::created_by`].
|
||||
pub created_by: Option<Uuid>,
|
||||
/// §14 provenance: most recent mutator. See [`Folder::updated_by`].
|
||||
pub updated_by: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Represents a folder entity in the domain
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Folder {
|
||||
@@ -16,19 +39,13 @@ pub struct Folder {
|
||||
/// Name of the folder
|
||||
name: String,
|
||||
|
||||
/// Path to the folder in the domain model
|
||||
/// Path to the folder in the domain model. Owns the canonical joined
|
||||
/// string; `path_string()` borrows it (ROUND11 §20).
|
||||
storage_path: StoragePath,
|
||||
|
||||
/// String representation of the path (for API compatibility)
|
||||
path_string: String,
|
||||
|
||||
/// 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).
|
||||
@@ -74,9 +91,7 @@ impl Default for Folder {
|
||||
id: "stub-id".to_string(),
|
||||
name: "stub-folder".to_string(),
|
||||
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 +103,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
|
||||
let name = normalize_storage_name_owned(name);
|
||||
if let Err(reason) = validate_storage_name(&name) {
|
||||
return Err(FolderError::InvalidFolderName(format!("{name}: {reason}")));
|
||||
}
|
||||
@@ -117,26 +126,15 @@ impl Folder {
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Store the path string for serialization compatibility
|
||||
let path_string = storage_path.to_string();
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
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 +158,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 +179,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 +189,6 @@ impl Folder {
|
||||
name,
|
||||
storage_path,
|
||||
parent_id,
|
||||
owner_id,
|
||||
drive_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
@@ -239,7 +207,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,
|
||||
@@ -247,20 +214,16 @@ impl Folder {
|
||||
created_by: Option<Uuid>,
|
||||
updated_by: Option<Uuid>,
|
||||
) -> FolderResult<Self> {
|
||||
let name = normalize_storage_name(&name);
|
||||
let name = normalize_storage_name_owned(name);
|
||||
if let Err(reason) = validate_storage_name(&name) {
|
||||
return Err(FolderError::InvalidFolderName(format!("{name}: {reason}")));
|
||||
}
|
||||
|
||||
let path_string = storage_path.to_string();
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
path_string,
|
||||
parent_id,
|
||||
owner_id,
|
||||
drive_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
@@ -270,6 +233,68 @@ impl Folder {
|
||||
})
|
||||
}
|
||||
|
||||
/// PG-row constructor: the per-listing-row hot path.
|
||||
///
|
||||
/// Takes the materialized `storage.folders.path` column by value and
|
||||
/// splits it once via [`StoragePath::from_joined`] — when the stored
|
||||
/// path is already canonical (every row the repository writes), the
|
||||
/// input `String` is reused as `path_string` with zero copies,
|
||||
/// replacing the old `from_string` split + `Display` re-join pair.
|
||||
/// The owned `name` is NFC-normalized without the always-copy of the
|
||||
/// borrowing variant (DB rows are NFC by invariant).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_materialized_row(
|
||||
id: String,
|
||||
name: String,
|
||||
path: String,
|
||||
parent_id: Option<String>,
|
||||
drive_id: Uuid,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
tree_modified_at: u64,
|
||||
created_by: Option<Uuid>,
|
||||
updated_by: Option<Uuid>,
|
||||
) -> FolderResult<Self> {
|
||||
let name = normalize_storage_name_owned(name);
|
||||
if let Err(reason) = validate_storage_name(&name) {
|
||||
return Err(FolderError::InvalidFolderName(format!("{name}: {reason}")));
|
||||
}
|
||||
|
||||
let storage_path = StoragePath::from_joined(path);
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
parent_id,
|
||||
drive_id,
|
||||
created_at,
|
||||
modified_at,
|
||||
tree_modified_at,
|
||||
created_by,
|
||||
updated_by,
|
||||
})
|
||||
}
|
||||
|
||||
/// Consume the entity and return all fields by ownership.
|
||||
///
|
||||
/// Use this when converting `Folder` into a DTO to avoid cloning
|
||||
/// every `String` field (saves 3-4 heap allocations per folder).
|
||||
pub fn into_parts(self) -> FolderParts {
|
||||
FolderParts {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
storage_path: self.storage_path,
|
||||
parent_id: self.parent_id,
|
||||
drive_id: self.drive_id,
|
||||
created_at: self.created_at,
|
||||
modified_at: self.modified_at,
|
||||
tree_modified_at: self.tree_modified_at,
|
||||
created_by: self.created_by,
|
||||
updated_by: self.updated_by,
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
@@ -284,7 +309,7 @@ impl Folder {
|
||||
}
|
||||
|
||||
pub fn path_string(&self) -> &str {
|
||||
&self.path_string
|
||||
self.storage_path.as_str()
|
||||
}
|
||||
|
||||
pub fn parent_id(&self) -> Option<&str> {
|
||||
@@ -299,10 +324,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`).
|
||||
@@ -381,8 +402,25 @@ impl Folder {
|
||||
/// changed; the folder's own value stays untouched
|
||||
/// (self-exclusion).
|
||||
pub fn compute_etag(id: &str, tree_modified_at: u64) -> String {
|
||||
let prefix: String = id.chars().take(16).collect();
|
||||
format!("{}-{}", prefix, tree_modified_at)
|
||||
use std::fmt::Write as _;
|
||||
|
||||
// Byte index just past the 16th char (whole string when shorter).
|
||||
// `id` is a UUID string (ASCII) in practice, so this is
|
||||
// effectively `min(len, 16)`, but `char_indices` keeps the slice
|
||||
// char-boundary-safe for exotic fixture values — byte-identical
|
||||
// to the old `chars().take(16).collect::<String>()` without the
|
||||
// intermediate allocation.
|
||||
let end = match id.char_indices().nth(16) {
|
||||
Some((i, _)) => i,
|
||||
None => id.len(),
|
||||
};
|
||||
|
||||
// Single allocation: prefix + '-' + up to 20 digits (u64::MAX).
|
||||
let mut etag = String::with_capacity(end + 1 + 20);
|
||||
etag.push_str(&id[..end]);
|
||||
etag.push('-');
|
||||
let _ = write!(etag, "{tree_modified_at}");
|
||||
etag
|
||||
}
|
||||
|
||||
/// Creates a new Folder instance from a DTO
|
||||
@@ -395,8 +433,8 @@ impl Folder {
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
) -> Self {
|
||||
// Create storage_path from the string
|
||||
let storage_path = StoragePath::from_string(&path);
|
||||
// Adopt the DTO path (canonical inputs reused with zero copies).
|
||||
let storage_path = StoragePath::from_joined(path);
|
||||
|
||||
// Create directly without validation to avoid errors in DTO
|
||||
// conversions. Still NFC-normalize so DTO-reconstructed
|
||||
@@ -405,14 +443,12 @@ impl Folder {
|
||||
// round-trips lose the real rollup signal, so callers that
|
||||
// need a freshly-rolled-up etag must reload from the
|
||||
// repository.
|
||||
let name = normalize_storage_name(&name);
|
||||
let name = normalize_storage_name_owned(name);
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
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
|
||||
@@ -432,7 +468,7 @@ impl Folder {
|
||||
|
||||
/// Creates a new version of the folder with updated name
|
||||
pub fn with_name(&self, new_name: String) -> FolderResult<Self> {
|
||||
let new_name = normalize_storage_name(&new_name);
|
||||
let new_name = normalize_storage_name_owned(new_name);
|
||||
if let Err(reason) = validate_storage_name(&new_name) {
|
||||
return Err(FolderError::InvalidFolderName(format!(
|
||||
"{new_name}: {reason}"
|
||||
@@ -446,9 +482,6 @@ impl Folder {
|
||||
None => StoragePath::from_string(&new_name),
|
||||
};
|
||||
|
||||
// Update string representation
|
||||
let new_path_string = new_storage_path.to_string();
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
@@ -458,9 +491,7 @@ impl Folder {
|
||||
id: self.id.clone(),
|
||||
name: new_name,
|
||||
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,
|
||||
@@ -487,9 +518,6 @@ impl Folder {
|
||||
None => StoragePath::from_string(&self.name), // Root
|
||||
};
|
||||
|
||||
// Update string representation
|
||||
let new_path_string = new_storage_path.to_string();
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
@@ -499,9 +527,7 @@ impl Folder {
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
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,
|
||||
@@ -515,12 +541,9 @@ impl Folder {
|
||||
pub fn get_absolute_path<P: AsRef<std::path::Path>>(&self, root_path: P) -> std::path::PathBuf {
|
||||
let mut result = std::path::PathBuf::from(root_path.as_ref());
|
||||
|
||||
// Skip leading '/' from path_string to avoid creating absolute path incorrectly
|
||||
let relative_path = if self.path_string.starts_with('/') {
|
||||
&self.path_string[1..]
|
||||
} else {
|
||||
&self.path_string
|
||||
};
|
||||
// Skip leading '/' to avoid creating an absolute path incorrectly
|
||||
let path_string = self.storage_path.as_str();
|
||||
let relative_path = path_string.strip_prefix('/').unwrap_or(path_string);
|
||||
|
||||
if !relative_path.is_empty() {
|
||||
result.push(relative_path);
|
||||
@@ -593,7 +616,6 @@ mod tests {
|
||||
"folder".to_string(),
|
||||
StoragePath::from_string("/folder"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
1_000,
|
||||
2_000,
|
||||
@@ -615,7 +637,6 @@ mod tests {
|
||||
"a".to_string(),
|
||||
StoragePath::from_string("/a"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
0,
|
||||
0,
|
||||
@@ -627,7 +648,6 @@ mod tests {
|
||||
"b".to_string(),
|
||||
StoragePath::from_string("/b"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
0,
|
||||
0,
|
||||
@@ -650,7 +670,6 @@ mod tests {
|
||||
"folder".to_string(),
|
||||
StoragePath::from_string("/folder"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
1_000,
|
||||
2_000,
|
||||
@@ -662,7 +681,6 @@ mod tests {
|
||||
"folder".to_string(),
|
||||
StoragePath::from_string("/folder"),
|
||||
None,
|
||||
None,
|
||||
Uuid::nil(),
|
||||
1_000,
|
||||
2_000,
|
||||
|
||||
@@ -180,13 +180,18 @@ impl TryFrom<&str> for ShareItemType {
|
||||
type Error = ShareError;
|
||||
|
||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"file" => Ok(ShareItemType::File),
|
||||
"folder" => Ok(ShareItemType::Folder),
|
||||
_ => Err(ShareError::ValidationError(format!(
|
||||
// ASCII case-insensitive compare against the two literals instead of a
|
||||
// throwaway Unicode `to_lowercase()` String — byte-identical acceptance
|
||||
// for the ASCII targets "file"/"folder" (1 → 0 allocs/call).
|
||||
if s.eq_ignore_ascii_case("file") {
|
||||
Ok(ShareItemType::File)
|
||||
} else if s.eq_ignore_ascii_case("folder") {
|
||||
Ok(ShareItemType::Folder)
|
||||
} else {
|
||||
Err(ShareError::ValidationError(format!(
|
||||
"Invalid item type: {}",
|
||||
s
|
||||
))),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@ pub enum TrashedItemType {
|
||||
Folder,
|
||||
}
|
||||
|
||||
/// Owned decomposition of a [`TrashedItem`] (see
|
||||
/// [`TrashedItem::into_parts`]).
|
||||
pub struct TrashedItemParts {
|
||||
pub id: Uuid,
|
||||
pub original_id: Uuid,
|
||||
pub item_type: TrashedItemType,
|
||||
pub name: String,
|
||||
pub original_path: String,
|
||||
pub trashed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrashedItem {
|
||||
id: Uuid,
|
||||
@@ -98,6 +109,20 @@ impl TrashedItem {
|
||||
self.deletion_date
|
||||
}
|
||||
|
||||
/// Decompose into owned parts for DTO conversion — moves `name` /
|
||||
/// `original_path` instead of the getter clones `to_dto` used to make
|
||||
/// per trash row (benches/ROUND11.md; the File/Folder/Contact pattern).
|
||||
pub fn into_parts(self) -> TrashedItemParts {
|
||||
TrashedItemParts {
|
||||
id: self.id,
|
||||
original_id: self.original_id,
|
||||
item_type: self.item_type,
|
||||
name: self.name,
|
||||
original_path: self.original_path,
|
||||
trashed_at: self.trashed_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn days_until_deletion(&self) -> i64 {
|
||||
let now = Utc::now();
|
||||
(self.deletion_date - now).num_days().max(0)
|
||||
|
||||
+174
-4
@@ -11,12 +11,20 @@ pub enum UserRole {
|
||||
User,
|
||||
}
|
||||
|
||||
impl UserRole {
|
||||
/// Canonical wire/DB spelling — the single source the `Display` impl
|
||||
/// and every hot-path role render go through (no format machinery).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
UserRole::Admin => "admin",
|
||||
UserRole::User => "user",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UserRole {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match self {
|
||||
UserRole::Admin => write!(f, "admin"),
|
||||
UserRole::User => write!(f, "user"),
|
||||
}
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,9 +115,108 @@ pub struct User {
|
||||
/// and opts out, subsequent shares from other granters honor the
|
||||
/// flag.
|
||||
notify_on_share: bool,
|
||||
/// Opaque UI preferences bag (PR — this session). Stored as JSONB
|
||||
/// on `auth.users.ui_preferences`; the server NEVER inspects the
|
||||
/// contents. This is the SPA's cross-device backing store for pure
|
||||
/// UI toggles (hide-dotfiles, view mode, sidebar collapse, …).
|
||||
///
|
||||
/// Merge semantics live in the repo layer: `PATCH /me/profile` does
|
||||
/// a SHALLOW merge via `ui_preferences || $1::jsonb`, so partial
|
||||
/// writes from one device don't clobber keys set on another.
|
||||
///
|
||||
/// Load-bearing rule: if a preference EVER becomes something the
|
||||
/// server reads (like `preferred_locale` did), promote it out of
|
||||
/// this bag into a typed column. Keep this field for UI-only
|
||||
/// toggles.
|
||||
///
|
||||
/// Invariant: always a JSON object (enforced by the schema CHECK
|
||||
/// `users_ui_preferences_is_object`). Empty bag is `{}`, never
|
||||
/// `null` or missing.
|
||||
ui_preferences: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Owned decomposition of a [`User`] (mirrors `FileParts` / `FolderParts` /
|
||||
/// `ContactParts`). Lets a consumer MOVE the heap fields out instead of cloning
|
||||
/// them through the borrowing accessors — notably `image` (a data URI up to
|
||||
/// 512 KiB) and `ui_preferences` (a JSON tree). See `UserDto::from`
|
||||
/// (benches/ROUND20.md §A2).
|
||||
pub struct UserParts {
|
||||
pub id: Uuid,
|
||||
pub username: Option<String>,
|
||||
pub email: String,
|
||||
pub password_hash: Option<String>,
|
||||
pub role: UserRole,
|
||||
pub storage_quota_bytes: i64,
|
||||
pub storage_used_bytes: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
pub active: bool,
|
||||
pub oidc_provider: Option<String>,
|
||||
pub oidc_subject: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub is_external: bool,
|
||||
pub given_name: Option<String>,
|
||||
pub family_name: Option<String>,
|
||||
pub email_verified_at: Option<DateTime<Utc>>,
|
||||
pub preferred_locale: Option<String>,
|
||||
pub notify_on_share: bool,
|
||||
pub ui_preferences: serde_json::Value,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Decompose into [`UserParts`], moving every owned field out. The
|
||||
/// exhaustive destructure is compiler-checked, so a future field can't be
|
||||
/// silently dropped.
|
||||
pub fn into_parts(self) -> UserParts {
|
||||
let User {
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
role,
|
||||
storage_quota_bytes,
|
||||
storage_used_bytes,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_login_at,
|
||||
active,
|
||||
oidc_provider,
|
||||
oidc_subject,
|
||||
image,
|
||||
is_external,
|
||||
given_name,
|
||||
family_name,
|
||||
email_verified_at,
|
||||
preferred_locale,
|
||||
notify_on_share,
|
||||
ui_preferences,
|
||||
} = self;
|
||||
UserParts {
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
role,
|
||||
storage_quota_bytes,
|
||||
storage_used_bytes,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_login_at,
|
||||
active,
|
||||
oidc_provider,
|
||||
oidc_subject,
|
||||
image,
|
||||
is_external,
|
||||
given_name,
|
||||
family_name,
|
||||
email_verified_at,
|
||||
preferred_locale,
|
||||
notify_on_share,
|
||||
ui_preferences,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new user.
|
||||
///
|
||||
/// One unified constructor for every kind of user (internal, OIDC-linked,
|
||||
@@ -205,6 +312,11 @@ impl User {
|
||||
// `users_notify_on_share` mirrors this for rows reconstructed
|
||||
// from disk without going through `new`.
|
||||
notify_on_share: true,
|
||||
// Empty bag on creation. The SPA writes into it via
|
||||
// `PATCH /me/profile { ui_preferences: {...} }` after
|
||||
// login. Never NULL — the DB CHECK enforces JSON object
|
||||
// shape.
|
||||
ui_preferences: serde_json::json!({}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -249,6 +361,7 @@ impl User {
|
||||
email_verified_at: None,
|
||||
preferred_locale: None,
|
||||
notify_on_share: true,
|
||||
ui_preferences: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +387,10 @@ impl User {
|
||||
email_verified_at: Option<DateTime<Utc>>,
|
||||
preferred_locale: Option<String>,
|
||||
notify_on_share: bool,
|
||||
// Opaque UI-preferences bag. Callers reading from the DB pass
|
||||
// `row.get("ui_preferences")`; tests that don't care can pass
|
||||
// `serde_json::json!({})`.
|
||||
ui_preferences: serde_json::Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -296,6 +413,7 @@ impl User {
|
||||
email_verified_at,
|
||||
preferred_locale,
|
||||
notify_on_share,
|
||||
ui_preferences,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +601,47 @@ impl User {
|
||||
}
|
||||
}
|
||||
|
||||
/// Promote a currently-external user to an internal account.
|
||||
/// Atomically flips the invariant-linked fields:
|
||||
/// * `is_external` → false
|
||||
/// * `password_hash` → provided (Some) or preserved (None)
|
||||
/// * `storage_quota_bytes` → quota (external users had 0; DB CHECK
|
||||
/// `users_external_no_storage` enforces the pair before this call
|
||||
/// and would refuse a non-zero quota on an external row — the
|
||||
/// write MUST flip `is_external` first, which happens
|
||||
/// transactionally at persist time via the sqlx UPDATE).
|
||||
///
|
||||
/// Password is `Option<String>` because the service allows password-
|
||||
/// less upgrades when magic-link login is available on the
|
||||
/// deployment. When `None`, `password_hash` stays as it was (either
|
||||
/// NULL, or a hash left over from an admin-created invitation —
|
||||
/// externals don't authenticate with it either way).
|
||||
///
|
||||
/// Refuses if the caller is already internal — the upgrade path
|
||||
/// only makes sense on `is_external = true` users. Service pre-
|
||||
/// checks `user.is_external()` before calling; this guard is
|
||||
/// belt-and-braces against a race.
|
||||
///
|
||||
/// Admin combo is impossible by construction: external + admin was
|
||||
/// refused at creation (see `User::new`), so a promoted external
|
||||
/// user always retains their `UserRole::User` — role isn't changed.
|
||||
pub fn promote_to_internal(
|
||||
&mut self,
|
||||
password_hash: Option<String>,
|
||||
storage_quota_bytes: i64,
|
||||
) -> UserResult<()> {
|
||||
if !self.is_external {
|
||||
return Err(UserError::AlreadyInternal);
|
||||
}
|
||||
self.is_external = false;
|
||||
if let Some(hash) = password_hash {
|
||||
self.password_hash = Some(hash);
|
||||
}
|
||||
self.storage_quota_bytes = storage_quota_bytes;
|
||||
self.updated_at = Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_image(&mut self, image: Option<String>) {
|
||||
self.image = image;
|
||||
self.updated_at = Utc::now();
|
||||
@@ -536,6 +695,16 @@ impl User {
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Opaque UI preferences bag. Read-only accessor for the DTO
|
||||
/// conversion; mutation goes through the repo's shallow-merge SQL
|
||||
/// (`UserPgRepository::update_ui_preferences`) rather than a
|
||||
/// setter here — the DB is authoritative on the merged state
|
||||
/// because two devices can PATCH concurrently and the merge has
|
||||
/// to happen at write time, not at read time.
|
||||
pub fn ui_preferences(&self) -> &serde_json::Value {
|
||||
&self.ui_preferences
|
||||
}
|
||||
|
||||
/// Claim or change the username. Runs the same validation as the
|
||||
/// constructor — callers must still ensure uniqueness at the repo
|
||||
/// level. Bumps `updated_at`. Used by the post-create profile-edit
|
||||
@@ -722,6 +891,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
serde_json::json!({}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+54
-16
@@ -33,22 +33,45 @@ pub enum ErrorKind {
|
||||
DatabaseError,
|
||||
/// Storage quota exceeded
|
||||
QuotaExceeded,
|
||||
/// State conflict — the request is well-formed and permitted, but
|
||||
/// the resource is in a state that refuses it (e.g. "drive must
|
||||
/// be empty before delete"). Maps to HTTP 409. Distinct from
|
||||
/// `AlreadyExists` (which is a uniqueness violation) so audit
|
||||
/// readers can tell them apart.
|
||||
Conflict,
|
||||
/// RFC 7232 precondition failure — a caller-supplied conditional
|
||||
/// (If-Match, or an internal compare-and-swap standing in for one)
|
||||
/// did not hold against the resource's current state. Maps to
|
||||
/// HTTP 412. Distinct from `Conflict` (409): this is specifically
|
||||
/// "the state you thought you were writing against has moved."
|
||||
PreconditionFailed,
|
||||
}
|
||||
|
||||
impl ErrorKind {
|
||||
/// Stable human-readable name; `Display` delegates here so the two can
|
||||
/// never drift. Being `&'static` it lets the HTTP error path borrow the
|
||||
/// value instead of allocating per response (benches/ROUND11.md §9).
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ErrorKind::NotFound => "Not Found",
|
||||
ErrorKind::AlreadyExists => "Already Exists",
|
||||
ErrorKind::InvalidInput => "Invalid Input",
|
||||
ErrorKind::AccessDenied => "Access Denied",
|
||||
ErrorKind::Timeout => "Timeout",
|
||||
ErrorKind::InternalError => "Internal Error",
|
||||
ErrorKind::NotImplemented => "Not Implemented",
|
||||
ErrorKind::UnsupportedOperation => "Unsupported Operation",
|
||||
ErrorKind::DatabaseError => "Database Error",
|
||||
ErrorKind::QuotaExceeded => "Quota Exceeded",
|
||||
ErrorKind::Conflict => "Conflict",
|
||||
ErrorKind::PreconditionFailed => "Precondition Failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ErrorKind {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
match self {
|
||||
ErrorKind::NotFound => write!(f, "Not Found"),
|
||||
ErrorKind::AlreadyExists => write!(f, "Already Exists"),
|
||||
ErrorKind::InvalidInput => write!(f, "Invalid Input"),
|
||||
ErrorKind::AccessDenied => write!(f, "Access Denied"),
|
||||
ErrorKind::Timeout => write!(f, "Timeout"),
|
||||
ErrorKind::InternalError => write!(f, "Internal Error"),
|
||||
ErrorKind::NotImplemented => write!(f, "Not Implemented"),
|
||||
ErrorKind::UnsupportedOperation => write!(f, "Unsupported Operation"),
|
||||
ErrorKind::DatabaseError => write!(f, "Database Error"),
|
||||
ErrorKind::QuotaExceeded => write!(f, "Quota Exceeded"),
|
||||
}
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,11 +107,14 @@ impl DomainError {
|
||||
/// Creates an entity not found error
|
||||
pub fn not_found<S: Into<String>>(entity_type: &'static str, entity_id: S) -> Self {
|
||||
let id = entity_id.into();
|
||||
// Message first, then move the id — the old `Some(id.clone())`
|
||||
// paid an extra allocation on every 404 construction.
|
||||
let message = format!("{} not found: {}", entity_type, id);
|
||||
Self {
|
||||
kind: ErrorKind::NotFound,
|
||||
entity_type,
|
||||
entity_id: Some(id.clone()),
|
||||
message: format!("{} not found: {}", entity_type, id),
|
||||
entity_id: Some(id),
|
||||
message,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
@@ -96,11 +122,12 @@ impl DomainError {
|
||||
/// Creates an entity already exists error
|
||||
pub fn already_exists<S: Into<String>>(entity_type: &'static str, entity_id: S) -> Self {
|
||||
let id = entity_id.into();
|
||||
let message = format!("{} already exists: {}", entity_type, id);
|
||||
Self {
|
||||
kind: ErrorKind::AlreadyExists,
|
||||
entity_type,
|
||||
entity_id: Some(id.clone()),
|
||||
message: format!("{} already exists: {}", entity_type, id),
|
||||
entity_id: Some(id),
|
||||
message,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
@@ -176,6 +203,17 @@ impl DomainError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a precondition-failed error (RFC 7232 / CAS mismatch)
|
||||
pub fn precondition_failed<S: Into<String>>(entity_type: &'static str, message: S) -> Self {
|
||||
Self {
|
||||
kind: ErrorKind::PreconditionFailed,
|
||||
entity_type,
|
||||
entity_id: None,
|
||||
message: message.into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a validation error
|
||||
pub fn validation_error<S: Into<String>>(message: S) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -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,
|
||||
@@ -16,32 +24,25 @@ pub trait AddressBookRepository: Send + Sync + 'static {
|
||||
address_book: AddressBook,
|
||||
) -> AddressBookRepositoryResult<AddressBook>;
|
||||
async fn delete_address_book(&self, id: &Uuid) -> AddressBookRepositoryResult<()>;
|
||||
/// Batch sibling of `get_address_book_by_id`: one `= ANY($1)`
|
||||
/// round-trip for a page of grant-derived ids. Missing ids drop
|
||||
/// out; ordering is not guaranteed.
|
||||
async fn get_address_books_by_ids(
|
||||
&self,
|
||||
ids: &[Uuid],
|
||||
) -> AddressBookRepositoryResult<Vec<AddressBook>>;
|
||||
|
||||
async fn get_address_book_by_id(
|
||||
&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)>>;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,23 @@ pub trait CalendarEventRepository: Send + Sync + 'static {
|
||||
/// Finds a calendar event by its ID
|
||||
async fn find_event_by_id(&self, id: &Uuid) -> CalendarEventRepositoryResult<CalendarEvent>;
|
||||
|
||||
/// Narrow projection of `find_event_by_id` for authorization gates:
|
||||
/// just the owning `calendar_id`, without dragging the full row —
|
||||
/// notably `ical_data`, the raw iCalendar body — off the wire.
|
||||
async fn find_calendar_id_by_event_id(&self, id: &Uuid) -> CalendarEventRepositoryResult<Uuid>;
|
||||
|
||||
/// Cursor stream over every event of `calendar_id` in bundle order:
|
||||
/// rows sorted by `(first occurrence per UID, uid, master-first,
|
||||
/// start_time)` so a recurring master + its exception overrides
|
||||
/// arrive adjacent and bundles appear in the first-appearance order
|
||||
/// the buffered `start_time` listing produced. ONE scan+sort on the
|
||||
/// server; the streaming CalDAV emitters cut pages at UID
|
||||
/// boundaries so only a page of rows is ever resident.
|
||||
fn stream_events_uid_order(
|
||||
&self,
|
||||
calendar_id: Uuid,
|
||||
) -> futures::stream::BoxStream<'static, CalendarEventRepositoryResult<CalendarEvent>>;
|
||||
|
||||
/// Lists all events in a specific calendar
|
||||
async fn list_events_by_calendar(
|
||||
&self,
|
||||
@@ -46,13 +63,35 @@ pub trait CalendarEventRepository: Send + Sync + 'static {
|
||||
end: &DateTime<Utc>,
|
||||
) -> CalendarEventRepositoryResult<Vec<CalendarEvent>>;
|
||||
|
||||
/// Finds an event by its iCalendar UID in a specific calendar
|
||||
/// Finds an event by its iCalendar UID in a specific calendar.
|
||||
///
|
||||
/// **Master-only lookup.** Filters `recurrence_id IS NULL` so the
|
||||
/// return value is unambiguous — the row that clients treat as
|
||||
/// "the event with this UID" is the master. Per-instance override
|
||||
/// rows share the UID but live under
|
||||
/// `find_event_by_ical_uid_and_recurrence_id` (see #528).
|
||||
async fn find_event_by_ical_uid(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
ical_uid: &str,
|
||||
) -> CalendarEventRepositoryResult<Option<CalendarEvent>>;
|
||||
|
||||
/// Finds a specific per-instance exception override for a recurring
|
||||
/// master (RFC 5545 §3.8.4.4). `recurrence_id` pinpoints which
|
||||
/// occurrence of the master with the given UID is being targeted;
|
||||
/// returns `None` if no override has been PUT for that instance
|
||||
/// yet — which the PUT handler then uses to decide insert vs.
|
||||
/// update.
|
||||
///
|
||||
/// The row is guaranteed unique by the partial index
|
||||
/// `idx_calendar_events_exception_unique`.
|
||||
async fn find_event_by_ical_uid_and_recurrence_id(
|
||||
&self,
|
||||
calendar_id: &Uuid,
|
||||
ical_uid: &str,
|
||||
recurrence_id: &DateTime<Utc>,
|
||||
) -> CalendarEventRepositoryResult<Option<CalendarEvent>>;
|
||||
|
||||
/// Finds the events matching any of the given iCalendar UIDs in one
|
||||
/// indexed query (`ical_uid = ANY(...)`). Used by CalDAV multiget so a
|
||||
/// request for a handful of events never pays for the whole calendar.
|
||||
|
||||
@@ -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,17 @@ 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
|
||||
/// Batch sibling of [`Self::find_calendar_by_id`]: one `= ANY($1)`
|
||||
/// round-trip for a page of grant-derived ids. Missing ids drop out
|
||||
/// (no per-id NotFound), matching the listing carve-out for
|
||||
/// deleted/trashed races. Ordering is not guaranteed.
|
||||
async fn find_calendars_by_ids(&self, ids: &[Uuid]) -> CalendarRepositoryResult<Vec<Calendar>>;
|
||||
|
||||
/// 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 +48,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 +55,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 +82,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)>>;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ pub trait ContactRepository: Send + Sync + 'static {
|
||||
address_book_id: &Uuid,
|
||||
uids: &[String],
|
||||
) -> ContactRepositoryResult<Vec<Contact>>;
|
||||
/// Cursor stream over every contact of the book in the listing
|
||||
/// order (`full_name, first_name, last_name`) — ONE scan+sort on
|
||||
/// the server; the streaming CardDAV emitters page over it.
|
||||
fn stream_contacts_by_book(
|
||||
&self,
|
||||
address_book_id: Uuid,
|
||||
) -> futures::stream::BoxStream<'static, ContactRepositoryResult<Contact>>;
|
||||
|
||||
async fn get_contacts_by_address_book(
|
||||
&self,
|
||||
address_book_id: &Uuid,
|
||||
@@ -68,6 +76,10 @@ pub trait ContactGroupRepository: Send + Sync + 'static {
|
||||
) -> ContactRepositoryResult<()>;
|
||||
async fn get_contacts_in_group(&self, group_id: &Uuid)
|
||||
-> ContactRepositoryResult<Vec<Contact>>;
|
||||
/// Membership count only — for group summaries that don't need the
|
||||
/// contacts hydrated (each row carries the full vCard TEXT plus three
|
||||
/// JSONB arrays; counting must not pay for any of that).
|
||||
async fn count_contacts_in_group(&self, group_id: &Uuid) -> ContactRepositoryResult<i64>;
|
||||
async fn get_groups_for_contact(
|
||||
&self,
|
||||
contact_id: &Uuid,
|
||||
|
||||
@@ -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,23 +164,63 @@ 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(
|
||||
/// Returned as `Arc<Vec<…>>`: warm hits are a refcount bump straight
|
||||
/// off the per-user cache instead of a deep clone of every row's
|
||||
/// Strings — this runs per DAV request with an explicit drive
|
||||
/// selector.
|
||||
async fn list_readable_by(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
subject_ids: &[Uuid],
|
||||
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
|
||||
caller_id: Uuid,
|
||||
) -> Result<std::sync::Arc<Vec<DriveWithRootName>>, DriveRepositoryError>;
|
||||
|
||||
/// `true` when the drive holds no live (non-trashed) folders other
|
||||
/// than its own root and no live files at all. Used by
|
||||
/// `DriveManagementService::delete_drive` to enforce the
|
||||
/// "empty-before-delete" rule — owners must clear / trash the
|
||||
/// content first so a single click can't wipe a populated drive.
|
||||
async fn is_empty(&self, drive_id: Uuid) -> Result<bool, DriveRepositoryError>;
|
||||
|
||||
/// Drop the cached readable-drive list for one user. Called by
|
||||
/// service-layer code paths that mutate state affecting a specific
|
||||
/// caller's drive listing (grant writes, membership changes) but
|
||||
/// don't reach through the drive-repo itself. Default no-op — the
|
||||
/// no-cache stubs need no plumbing.
|
||||
async fn invalidate_readable_for_user(&self, _user_id: Uuid) {}
|
||||
|
||||
/// Drop every cached readable-drive list. Called when the affected
|
||||
/// user set is unknown at this layer — group-subject grants, drive
|
||||
/// deletion, policy edits, root-folder renames (drive.name is
|
||||
/// sourced from the root folder, so a rename affects the listing
|
||||
/// for every user with a grant on the drive). Default no-op.
|
||||
fn invalidate_readable_all(&self) {}
|
||||
|
||||
/// Drop every entry in the "default drive per user" cache. Called
|
||||
/// from paths that mutate a drive's display name or its root
|
||||
/// folder id at the concrete cache level (root-folder rename is
|
||||
/// the only one today). Same class of bug as
|
||||
/// `invalidate_readable_all` — the cache holds a `DriveWithRootName`
|
||||
/// with `root_folder_name` baked in, so a rename would otherwise
|
||||
/// stay stale for the cache TTL. Default no-op.
|
||||
fn invalidate_default_drive_all(&self) {}
|
||||
|
||||
/// Hard-delete a drive: its `role_grants` rows, its root folder,
|
||||
/// and the drive row itself, in one transaction. Caller is
|
||||
/// responsible for ensuring `is_empty` first; this method does
|
||||
/// **not** re-check. Returns `NotFound` if the drive id is gone.
|
||||
async fn delete_atomic(&self, drive_id: Uuid) -> Result<(), DriveRepositoryError>;
|
||||
|
||||
/// 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
|
||||
@@ -191,6 +231,104 @@ pub trait DriveRepository: Send + Sync + 'static {
|
||||
/// necessarily a member, so the per-drive role would be misleading
|
||||
/// here.
|
||||
async fn list_all(&self) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
|
||||
|
||||
/// Resolve a file's owning drive policies in one round-trip. Used by
|
||||
/// D5 enforcement points (`forbid_public_links`, `forbid_sharing`, …)
|
||||
/// to gate per-resource actions without a separate file-lookup +
|
||||
/// drive-lookup pair.
|
||||
///
|
||||
/// Returns `NotFound` when the file id is gone or its `drive_id`
|
||||
/// doesn't resolve to a drive row (a state the no-orphan triggers
|
||||
/// prevent in production, but the caller should still propagate the
|
||||
/// 404 cleanly).
|
||||
async fn get_policies_for_file(
|
||||
&self,
|
||||
file_id: Uuid,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError>;
|
||||
|
||||
/// Resolve a folder's owning drive policies in one round-trip. Same
|
||||
/// shape as [`Self::get_policies_for_file`].
|
||||
async fn get_policies_for_folder(
|
||||
&self,
|
||||
folder_id: Uuid,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError>;
|
||||
|
||||
/// Resolve a file's owning drive id + its drive's policies in one
|
||||
/// round-trip. Used by D5 `forbid_cross_drive_move` enforcement —
|
||||
/// the move-file service needs both pieces (drive id to compare
|
||||
/// against the destination, policies to gate). Returns `NotFound`
|
||||
/// when the file row or its drive_id doesn't resolve.
|
||||
async fn get_drive_id_and_policies_for_file(
|
||||
&self,
|
||||
file_id: Uuid,
|
||||
) -> Result<(Uuid, crate::domain::entities::drive::DrivePolicies), DriveRepositoryError>;
|
||||
|
||||
/// Same as [`Self::get_drive_id_and_policies_for_file`] for folders.
|
||||
async fn get_drive_id_and_policies_for_folder(
|
||||
&self,
|
||||
folder_id: Uuid,
|
||||
) -> Result<(Uuid, crate::domain::entities::drive::DrivePolicies), DriveRepositoryError>;
|
||||
|
||||
/// Resolve just the drive id of a folder — fast PK probe used by
|
||||
/// the cross-drive-move gate to identify the move destination
|
||||
/// (where we don't need policies, just the discriminator). Returns
|
||||
/// `NotFound` when the folder row doesn't exist.
|
||||
async fn drive_id_for_folder(&self, folder_id: Uuid) -> Result<Uuid, DriveRepositoryError>;
|
||||
|
||||
/// Merge the given partial policy bag into the drive's existing
|
||||
/// `policies` JSONB, returning the updated bag. JSONB-level merge
|
||||
/// preserves unknown keys already present on disk (the column stays
|
||||
/// the canonical bag — see `DrivePolicies::from_value`). `caller_id`
|
||||
/// is recorded for the audit log emitted at the service layer.
|
||||
///
|
||||
/// 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: &serde_json::Value,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError>;
|
||||
|
||||
/// Set the drive-level storage quota on a **shared** drive.
|
||||
///
|
||||
/// `quota_bytes = None` means unlimited (matches the wire and DB
|
||||
/// convention — `drives.quota_bytes` is nullable; a NULL row → the
|
||||
/// storage-usage service treats it as no cap).
|
||||
///
|
||||
/// **Personal drives are refused at the service layer** — their
|
||||
/// effective cap comes from the owner user's
|
||||
/// `users.storage_quota_bytes` envelope (see the memory
|
||||
/// `project_user_envelope_quota_model`). This method does not
|
||||
/// re-check the kind; the service does, and only calls the repo
|
||||
/// with a validated shared-drive id.
|
||||
///
|
||||
/// A newly-lowered quota can be **under** the drive's current
|
||||
/// `used_bytes` — that's a deliberate soft-quota semantic. The
|
||||
/// `storage_usage_service` gates NEW writes on
|
||||
/// `used + delta <= quota`, so a shared drive already over its
|
||||
/// freshly-reduced cap can only shrink (delete) until it comes back
|
||||
/// under the limit; no existing content is retroactively touched.
|
||||
///
|
||||
/// Cache invalidation mirrors `update_policies` — the user-keyed
|
||||
/// readable-drive-list caches carry the quota alongside the row so
|
||||
/// they'd serve stale numbers otherwise; the default-drive cache
|
||||
/// carries the DriveWithRootName which also includes the quota.
|
||||
///
|
||||
/// Returns the persisted post-mutation value so the caller can
|
||||
/// echo it back in the audit log and API response.
|
||||
async fn update_quota(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
quota_bytes: Option<i64>,
|
||||
) -> Result<Option<i64>, DriveRepositoryError>;
|
||||
}
|
||||
|
||||
/// Convenience: convert the canonical kind discriminator from its SQL
|
||||
|
||||
@@ -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,18 +87,43 @@ 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,
|
||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
|
||||
|
||||
/// Keyset-paged listing of `parent_id`'s direct sub-folders in name
|
||||
/// order — `name > $after_name ORDER BY name LIMIT $limit`, one bounded
|
||||
/// index-range read per page off the partial unique index
|
||||
/// `idx_folders_unique_name`. Streaming PROPFIND drains sub-folders
|
||||
/// with this instead of `COUNT(*) OVER() … LIMIT/OFFSET`, which
|
||||
/// window-aggregated and rescanned all N sub-folders on every page
|
||||
/// (4.5x on a 5k-dir parent, benches/FOLDER-KEYSET.md). `has_next`
|
||||
/// falls out of `rows.len() == limit` — no total needed.
|
||||
///
|
||||
/// The default implementation falls back to `list_folders` + in-memory
|
||||
/// slice so stubs and mocks compile without changes.
|
||||
async fn list_folders_batch(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
after_name: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let mut all = self.list_folders(parent_id).await?;
|
||||
all.sort_by(|a, b| a.name().cmp(b.name()));
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| after_name.is_none_or(|a| f.name() > a))
|
||||
.take(limit)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Renames a folder. `caller_id` is stamped into `updated_by`
|
||||
/// alongside the `updated_at = NOW()` bump (§14 provenance).
|
||||
async fn rename_folder(
|
||||
@@ -136,6 +179,21 @@ pub trait FolderRepository: Send + Sync + 'static {
|
||||
/// Permanently deletes a folder (used by the trash)
|
||||
async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// File ids in the subtree rooted at `folder_id` (inclusive).
|
||||
///
|
||||
/// Single GiST scan on `storage.folders.lpath`. Service-layer paths
|
||||
/// that delete a folder via bulk SQL (the PG cascade reaps descendant
|
||||
/// files transparently) call this BEFORE the delete so they can fire
|
||||
/// `on_file_deleted` per-file. Without it, file-id-keyed lifecycle
|
||||
/// data (e.g. `ext-{file_id}.jpg` video thumbnails) leaks past the
|
||||
/// cascade. See [[bug-folder-cascade-hooks-missing]].
|
||||
///
|
||||
/// Default: returns an empty vec (stubs / mocks).
|
||||
async fn list_file_ids_in_subtree(&self, folder_id: &str) -> Result<Vec<String>, DomainError> {
|
||||
let _ = folder_id;
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Lists every folder in a subtree rooted at `folder_id` (inclusive).
|
||||
///
|
||||
/// Uses ltree `<@` for a single GiST-indexed scan. The result is
|
||||
@@ -147,31 +205,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.
|
||||
@@ -179,13 +241,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)
|
||||
@@ -207,13 +269,21 @@ pub trait FolderRepository: Send + Sync + 'static {
|
||||
/// Results are ordered by relevance (exact > starts-with > contains) for
|
||||
/// autocomplete suggestions.
|
||||
///
|
||||
/// `caller_id` scopes results to folders whose owning drive the caller
|
||||
/// can Read (direct or group-mediated `role_grants`). Without it the
|
||||
/// endpoint leaked names + paths across every tenant on the instance —
|
||||
/// closed as AuthZ audit finding #1 (2026-07-12).
|
||||
///
|
||||
/// The default implementation falls back to `list_folders` + in-memory
|
||||
/// filter so that stubs and mocks compile without changes.
|
||||
/// filter so that stubs and mocks compile without changes. Stub-mode
|
||||
/// callers already operate against a single tenant's data, so ignoring
|
||||
/// `caller_id` here is safe; the PG impl enforces the real scope.
|
||||
async fn suggest_folders_by_name(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
_caller_id: uuid::Uuid,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let all = self.list_folders(parent_id).await?;
|
||||
let q = query.to_lowercase();
|
||||
|
||||
@@ -13,6 +13,11 @@ pub trait PlaylistRepository: Send + Sync + 'static {
|
||||
|
||||
async fn find_playlist_by_id(&self, id: &Uuid) -> PlaylistRepositoryResult<Playlist>;
|
||||
|
||||
/// Batch sibling of [`Self::find_playlist_by_id`]: one `= ANY($1)`
|
||||
/// round-trip for a page of grant-derived ids. Missing ids drop
|
||||
/// out; ordering is not guaranteed.
|
||||
async fn find_playlists_by_ids(&self, ids: &[Uuid]) -> PlaylistRepositoryResult<Vec<Playlist>>;
|
||||
|
||||
async fn list_playlists_by_owner(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
|
||||
@@ -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<()>;
|
||||
|
||||
@@ -111,6 +111,10 @@ pub trait UserRepository: Send + Sync + 'static {
|
||||
/// Lists users by role (admin or user)
|
||||
async fn list_users_by_role(&self, role: &str) -> UserRepositoryResult<Vec<User>>;
|
||||
|
||||
/// Counts users with a given role via a scalar `COUNT(*)` — no row
|
||||
/// hydration (benches/ROUND29.md §G).
|
||||
async fn count_users_by_role(&self, role: &str) -> UserRepositoryResult<i64>;
|
||||
|
||||
/// Deletes a user
|
||||
async fn delete_user(&self, user_id: Uuid) -> UserRepositoryResult<()>;
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! infrastructure/services/path_service.rs because it has file system dependencies.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
use unicode_normalization::{IsNormalized, UnicodeNormalization, is_nfc_quick};
|
||||
|
||||
/// NFC-normalize a single file or folder name component.
|
||||
///
|
||||
@@ -25,7 +25,34 @@ use unicode_normalization::UnicodeNormalization;
|
||||
/// (`migrate-nfc-filenames`) cleans up rows that pre-date this rule.
|
||||
///
|
||||
/// Pure function — no I/O, allocates one `String`.
|
||||
///
|
||||
/// Fast path: `is_nfc_quick` is a per-char table lookup that answers
|
||||
/// `Yes` for virtually every name already in NFC — which is every name
|
||||
/// loaded back from PostgreSQL (the DB invariant above) and every
|
||||
/// ASCII name. That skips the full decompose/recompose state machine
|
||||
/// this function otherwise runs once per row on every listing
|
||||
/// (PROPFIND, folder listing, photos timeline). `Maybe`/`No` fall
|
||||
/// through to the full pipeline.
|
||||
pub fn normalize_storage_name(name: &str) -> String {
|
||||
if is_nfc_quick(name.chars()) == IsNormalized::Yes {
|
||||
return name.to_string();
|
||||
}
|
||||
name.nfc().collect()
|
||||
}
|
||||
|
||||
/// Owned-input sibling of [`normalize_storage_name`].
|
||||
///
|
||||
/// The borrowing variant must always allocate a fresh `String` even when
|
||||
/// the input is already NFC — which is every name loaded back from
|
||||
/// PostgreSQL (DB invariant) and every ASCII name. Callers that own the
|
||||
/// `String` (entity constructors receive `name: String` by value) were
|
||||
/// paying that copy only to drop the original immediately. This variant
|
||||
/// returns the input unchanged on the fast path: zero allocations per
|
||||
/// row on every listing (PROPFIND, photos timeline, search).
|
||||
pub fn normalize_storage_name_owned(name: String) -> String {
|
||||
if is_nfc_quick(name.chars()) == IsNormalized::Yes {
|
||||
return name;
|
||||
}
|
||||
name.nfc().collect()
|
||||
}
|
||||
|
||||
@@ -49,10 +76,25 @@ pub fn validate_storage_name(name: &str) -> Result<(), &'static str> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Represents a storage path in the domain (Value Object)
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
/// Represents a storage path in the domain (Value Object).
|
||||
///
|
||||
/// Stored as the single **canonical joined form**: `"/"` for the root, or
|
||||
/// `/seg(/seg)*` with every segment safe (non-empty, not `.`/`..`, no
|
||||
/// `/`). Round 11 replaced the old `segments: Vec<String>` representation
|
||||
/// — one heap `String` per component built on EVERY hydrated listing row
|
||||
/// even though the DTO path only ever consumed the joined form — with this
|
||||
/// one-allocation shape; segment views are derived on demand
|
||||
/// (benches/ROUND11.md §20: 4 000 → 1 000 allocs on a 500-row page).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StoragePath {
|
||||
segments: Vec<String>,
|
||||
/// Canonical joined rendering (`Display`'s output).
|
||||
joined: String,
|
||||
}
|
||||
|
||||
impl Default for StoragePath {
|
||||
fn default() -> Self {
|
||||
Self::root()
|
||||
}
|
||||
}
|
||||
|
||||
impl StoragePath {
|
||||
@@ -61,20 +103,30 @@ impl StoragePath {
|
||||
!s.is_empty() && s != "." && s != ".." && !s.contains('/')
|
||||
}
|
||||
|
||||
/// Builds the canonical joined form from an iterator of raw segments,
|
||||
/// silently dropping unsafe ones. `cap` pre-sizes the buffer.
|
||||
fn build<'a>(segments: impl Iterator<Item = &'a str>, cap: usize) -> Self {
|
||||
let mut joined = String::with_capacity(cap);
|
||||
for seg in segments.filter(|s| Self::is_safe_segment(s)) {
|
||||
joined.push('/');
|
||||
joined.push_str(seg);
|
||||
}
|
||||
if joined.is_empty() {
|
||||
joined.push('/');
|
||||
}
|
||||
Self { joined }
|
||||
}
|
||||
|
||||
/// Creates a new storage path, silently dropping any traversal segments
|
||||
pub fn new(segments: Vec<String>) -> Self {
|
||||
Self {
|
||||
segments: segments
|
||||
.into_iter()
|
||||
.filter(|s| Self::is_safe_segment(s))
|
||||
.collect(),
|
||||
}
|
||||
let cap = segments.iter().map(|s| s.len() + 1).sum();
|
||||
Self::build(segments.iter().map(String::as_str), cap)
|
||||
}
|
||||
|
||||
/// Creates an empty path (root)
|
||||
pub fn root() -> Self {
|
||||
Self {
|
||||
segments: Vec::new(),
|
||||
joined: "/".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,83 +135,148 @@ impl StoragePath {
|
||||
/// Traversal segments (`.`, `..`) are silently stripped to prevent
|
||||
/// path-traversal attacks.
|
||||
pub fn from_string(path: &str) -> Self {
|
||||
let segments = path
|
||||
.split('/')
|
||||
.filter(|s| Self::is_safe_segment(s))
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
Self { segments }
|
||||
Self::build(path.split('/'), path.len() + 1)
|
||||
}
|
||||
|
||||
/// One-pass builder for PG listing rows: materialized folder path +
|
||||
/// file name → the canonical joined path.
|
||||
///
|
||||
/// Byte-equivalence with the historical segment chain holds because
|
||||
/// concatenating with a `/` separator distributes over `split('/')`:
|
||||
/// `(fp + "/" + name).split('/') == fp.split('/') ⧺ name.split('/')`,
|
||||
/// and the joined form is exactly `Display`'s `/`-prefixed rendering
|
||||
/// of the surviving segments (root renders as `"/"`).
|
||||
pub fn from_folder_and_name(folder_path: Option<&str>, file_name: &str) -> Self {
|
||||
let fp = folder_path.unwrap_or("");
|
||||
Self::build(
|
||||
fp.split('/').chain(file_name.split('/')),
|
||||
fp.len() + file_name.len() + 2,
|
||||
)
|
||||
}
|
||||
|
||||
/// Wrapper for a pre-joined materialized path (the
|
||||
/// `storage.folders.path` column).
|
||||
///
|
||||
/// When the input is already canonical (leading `/`, no empty/`.`/`..`
|
||||
/// segments, no trailing `/`) — which is every row the repository
|
||||
/// writes — the input `String` is adopted with zero copies.
|
||||
/// Non-canonical inputs fall back to the filtering rebuild and produce
|
||||
/// exactly what `from_string(&path)` yields.
|
||||
pub fn from_joined(path: String) -> Self {
|
||||
if Self::is_canonical_joined(&path) {
|
||||
return Self { joined: path };
|
||||
}
|
||||
Self::from_string(&path)
|
||||
}
|
||||
|
||||
/// `true` when `path` is exactly `Display`'s canonical rendering of
|
||||
/// its own segments: `"/"` alone, or `/seg(/seg)*` where every
|
||||
/// segment is safe. One scan, no allocations.
|
||||
fn is_canonical_joined(path: &str) -> bool {
|
||||
if path == "/" {
|
||||
return true;
|
||||
}
|
||||
if !path.starts_with('/') || path.ends_with('/') {
|
||||
return false;
|
||||
}
|
||||
path[1..].split('/').all(Self::is_safe_segment)
|
||||
}
|
||||
|
||||
/// Creates a path from a PathBuf
|
||||
pub fn from(path_buf: PathBuf) -> Self {
|
||||
let segments = path_buf
|
||||
.components()
|
||||
.filter_map(|c| match c {
|
||||
std::path::Component::Normal(os_str) => Some(os_str.to_string_lossy().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
Self { segments }
|
||||
let mut joined = String::new();
|
||||
for c in path_buf.components() {
|
||||
if let std::path::Component::Normal(os_str) = c {
|
||||
let seg = os_str.to_string_lossy();
|
||||
if Self::is_safe_segment(&seg) {
|
||||
joined.push('/');
|
||||
joined.push_str(&seg);
|
||||
}
|
||||
}
|
||||
}
|
||||
if joined.is_empty() {
|
||||
joined.push('/');
|
||||
}
|
||||
Self { joined }
|
||||
}
|
||||
|
||||
/// Appends a segment to the path, consuming `self` so the existing
|
||||
/// segment buffer is reused instead of deep-cloned.
|
||||
/// buffer is reused instead of deep-cloned.
|
||||
///
|
||||
/// Traversal segments (`.`, `..`) and segments containing `/` are
|
||||
/// silently ignored to prevent path-traversal attacks.
|
||||
pub fn join(mut self, segment: &str) -> Self {
|
||||
if Self::is_safe_segment(segment) {
|
||||
self.segments.push(segment.to_string());
|
||||
if self.joined == "/" {
|
||||
self.joined.clear();
|
||||
}
|
||||
self.joined.push('/');
|
||||
self.joined.push_str(segment);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the file name (last segment)
|
||||
pub fn file_name(&self) -> Option<String> {
|
||||
self.segments.last().cloned()
|
||||
if self.joined == "/" {
|
||||
None
|
||||
} else {
|
||||
self.joined.rsplit('/').next().map(str::to_string)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the parent directory path
|
||||
pub fn parent(&self) -> Option<Self> {
|
||||
if self.segments.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let parent_segments = self.segments[..self.segments.len() - 1].to_vec();
|
||||
Some(Self {
|
||||
segments: parent_segments,
|
||||
})
|
||||
if self.joined == "/" {
|
||||
return None;
|
||||
}
|
||||
let cut = self.joined.rfind('/').expect("canonical path has '/'");
|
||||
Some(if cut == 0 {
|
||||
Self::root()
|
||||
} else {
|
||||
Self {
|
||||
joined: self.joined[..cut].to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Checks if the path is empty (is the root)
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.segments.is_empty()
|
||||
self.joined == "/"
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StoragePath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if self.segments.is_empty() {
|
||||
write!(f, "/")
|
||||
} else {
|
||||
write!(f, "/{}", self.segments.join("/"))
|
||||
}
|
||||
f.write_str(&self.joined)
|
||||
}
|
||||
}
|
||||
|
||||
impl StoragePath {
|
||||
/// Returns the path representation as a string
|
||||
pub fn as_str(&self) -> &str {
|
||||
// Note: The implementation should really store the string,
|
||||
// but here we do a temporary implementation that always returns "/"
|
||||
// This is only used for the get_folder_path_str implementation
|
||||
"/"
|
||||
/// The canonical joined form as an owned `String` (one memcpy).
|
||||
pub fn to_path_string(&self) -> String {
|
||||
self.joined.clone()
|
||||
}
|
||||
|
||||
/// Gets the path segments
|
||||
pub fn segments(&self) -> &[String] {
|
||||
&self.segments
|
||||
/// Consume `self`, yielding the canonical joined `String` with zero
|
||||
/// copies. This is the row→entity→DTO hand-off path.
|
||||
pub fn into_joined(self) -> String {
|
||||
self.joined
|
||||
}
|
||||
|
||||
/// Returns the path representation as a string (canonical joined form).
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.joined
|
||||
}
|
||||
|
||||
/// Iterates the path segments (derived views over the joined form).
|
||||
pub fn segments(&self) -> impl Iterator<Item = &str> {
|
||||
let inner = if self.joined == "/" {
|
||||
""
|
||||
} else {
|
||||
&self.joined[1..]
|
||||
};
|
||||
inner.split('/').filter(|s| !s.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +287,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_storage_path_from_string() {
|
||||
let path = StoragePath::from_string("folder/subfolder/file.txt");
|
||||
assert_eq!(path.segments(), &["folder", "subfolder", "file.txt"]);
|
||||
assert_eq!(
|
||||
path.segments().collect::<Vec<_>>(),
|
||||
&["folder", "subfolder", "file.txt"]
|
||||
);
|
||||
assert_eq!(path.to_string(), "/folder/subfolder/file.txt");
|
||||
}
|
||||
|
||||
@@ -206,19 +326,19 @@ mod tests {
|
||||
#[test]
|
||||
fn test_from_string_strips_dot_dot() {
|
||||
let path = StoragePath::from_string("../../etc/passwd");
|
||||
assert_eq!(path.segments(), &["etc", "passwd"]);
|
||||
assert_eq!(path.segments().collect::<Vec<_>>(), &["etc", "passwd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_string_strips_single_dot() {
|
||||
let path = StoragePath::from_string("folder/./file.txt");
|
||||
assert_eq!(path.segments(), &["folder", "file.txt"]);
|
||||
assert_eq!(path.segments().collect::<Vec<_>>(), &["folder", "file.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_string_strips_mixed_traversal() {
|
||||
let path = StoragePath::from_string("a/../b/./c/../../d");
|
||||
assert_eq!(path.segments(), &["a", "b", "c", "d"]);
|
||||
assert_eq!(path.segments().collect::<Vec<_>>(), &["a", "b", "c", "d"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -231,13 +351,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_new_strips_traversal_segments() {
|
||||
let path = StoragePath::new(vec!["..".into(), "etc".into(), ".".into(), "passwd".into()]);
|
||||
assert_eq!(path.segments(), &["etc", "passwd"]);
|
||||
assert_eq!(path.segments().collect::<Vec<_>>(), &["etc", "passwd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_strips_empty_segments() {
|
||||
let path = StoragePath::new(vec!["a".into(), "".into(), "b".into()]);
|
||||
assert_eq!(path.segments(), &["a", "b"]);
|
||||
assert_eq!(path.segments().collect::<Vec<_>>(), &["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -245,14 +365,14 @@ mod tests {
|
||||
let base = StoragePath::from_string("folder");
|
||||
let joined = base.join("..");
|
||||
// ".." is silently ignored — path stays unchanged
|
||||
assert_eq!(joined.segments(), &["folder"]);
|
||||
assert_eq!(joined.segments().collect::<Vec<_>>(), &["folder"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_join_rejects_single_dot() {
|
||||
let base = StoragePath::from_string("folder");
|
||||
let joined = base.join(".");
|
||||
assert_eq!(joined.segments(), &["folder"]);
|
||||
assert_eq!(joined.segments().collect::<Vec<_>>(), &["folder"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -260,7 +380,7 @@ mod tests {
|
||||
let base = StoragePath::from_string("folder");
|
||||
let joined = base.join("sub/../../etc/passwd");
|
||||
// Segment contains '/' → silently ignored
|
||||
assert_eq!(joined.segments(), &["folder"]);
|
||||
assert_eq!(joined.segments().collect::<Vec<_>>(), &["folder"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -269,8 +389,8 @@ mod tests {
|
||||
// PathBuf Component::Normal only yields the normal parts
|
||||
// On most platforms this strips . and ..
|
||||
// but regardless, our from() only accepts Component::Normal
|
||||
assert!(!path.segments().contains(&"..".to_string()));
|
||||
assert!(!path.segments().contains(&".".to_string()));
|
||||
assert!(!path.segments().any(|s| s == ".."));
|
||||
assert!(!path.segments().any(|s| s == "."));
|
||||
}
|
||||
|
||||
// ── NFC normalization tests ─────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user