feat(drive): improve Drive model

now Drive is purely a metadata
    each drive has always a root folder
    this model minimize Oxicloud changes, and simplify
    the Drive name is simply the folder's root's name
    note: owner of Drive has more permission that an owner of the root folder
This commit is contained in:
Edouard Vanbelle
2026-06-18 23:02:17 +02:00
parent eab7a609b9
commit 16ea08b093
26 changed files with 1067 additions and 460 deletions
+25 -13
View File
@@ -8,7 +8,8 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::domain::entities::drive::{Drive, DriveKind};
use crate::domain::entities::drive::DriveKind;
use crate::domain::repositories::drive_repository::DriveWithRootName;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
@@ -34,12 +35,22 @@ impl From<DriveKind> for DriveKindDto {
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct DriveDto {
pub id: Uuid,
/// Display name. Sourced from `storage.folders.name` of the row
/// pointed at by `root_folder_id` (drives have no `name` column —
/// see docs/plan/drive.md §3). The wire shape is unchanged from
/// the client's perspective.
pub name: String,
pub kind: DriveKindDto,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_for_user: Option<Uuid>,
/// The drive's mount-point folder. Folder API calls
/// (`POST /api/folders { parent_id: <root_folder_id> }`,
/// `PATCH /api/folders/<root_folder_id>` to rename) use this id —
/// no polymorphic "create at drive root" surface needed.
pub root_folder_id: Uuid,
/// Storage cap in bytes. `None` means "no quota" (admin override /
/// future system drives).
/// future system drives). Mutation is OxiCloud-admin only — drive
/// owners cannot self-grant capacity.
#[serde(skip_serializing_if = "Option::is_none")]
pub quota_bytes: Option<i64>,
/// Running total of bytes consumed. Maintained incrementally in D4;
@@ -53,18 +64,19 @@ pub struct DriveDto {
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<Drive> for DriveDto {
fn from(d: Drive) -> Self {
impl From<DriveWithRootName> for DriveDto {
fn from(d: DriveWithRootName) -> Self {
Self {
id: d.id,
name: d.name,
kind: d.kind.into(),
default_for_user: d.default_for_user,
quota_bytes: d.quota_bytes,
used_bytes: d.used_bytes,
policies: d.policies,
created_at: d.created_at,
updated_at: d.updated_at,
id: d.drive.id,
name: d.root_folder_name,
kind: d.drive.kind.into(),
default_for_user: d.drive.default_for_user,
root_folder_id: d.drive.root_folder_id,
quota_bytes: d.drive.quota_bytes,
used_bytes: d.drive.used_bytes,
policies: d.drive.policies,
created_at: d.drive.created_at,
updated_at: d.drive.updated_at,
}
}
}
+13 -2
View File
@@ -36,8 +36,19 @@ pub trait FolderUseCase: Send + Sync + 'static {
caller_id: Uuid,
) -> Result<FolderDto, DomainError>;
/// Gets a folder by its path
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError>;
/// Gets a folder by its path within the caller's tree.
///
/// Scoped by `user_id` because `storage.folders.path` is unique
/// only within a single user's drive after D0 — multiple users
/// share names like `"Personal"` for their default-drive root
/// folder (docs/plan/drive.md §10). Pre-D0 the wrapper name
/// embedded the username and made the path globally unique;
/// post-D0 the caller_id filter is required.
async fn get_folder_by_path(
&self,
path: &str,
user_id: Uuid,
) -> Result<FolderDto, DomainError>;
/// Lists folders within a parent folder
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError>;
+36 -54
View File
@@ -82,7 +82,11 @@ impl FolderService {
Ok(FolderDto::empty())
}
async fn get_folder_by_path(&self, _path: &str) -> Result<FolderDto, DomainError> {
async fn get_folder_by_path(
&self,
_path: &str,
_user_id: Uuid,
) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty())
}
@@ -293,14 +297,17 @@ impl FolderUseCase for FolderService {
self.get_folder(id).await
}
/// Gets a folder by its path
async fn get_folder_by_path(&self, path: &str) -> Result<FolderDto, DomainError> {
// Convert the string path to StoragePath
/// Gets a folder by its path, scoped to the caller's tree.
async fn get_folder_by_path(
&self,
path: &str,
user_id: Uuid,
) -> Result<FolderDto, DomainError> {
let storage_path = StoragePath::from_string(path);
let folder = self
.folder_storage
.get_folder_by_path(&storage_path)
.get_folder_by_path(&storage_path, user_id)
.await
.map_err(|e| {
DomainError::internal_error(
@@ -766,23 +773,22 @@ use crate::domain::entities::user::User;
/// when the Owner row is already present.
pub struct PersonalDriveLifecycleHook {
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
folder_service: Arc<FolderService>,
// The `AuthorizationEngine` trait isn't `dyn`-compatible (native
// async-fn-in-trait methods are not object-safe), so we hold the
// concrete engine. This matches the convention already used by
// `AppState.authorization`.
// `AppState.authorization`. Only the idempotent-rerun path uses it
// now; the create path goes through the repo's atomic CTE which
// writes the role_grant inline.
authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
}
impl PersonalDriveLifecycleHook {
pub fn new(
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
folder_service: Arc<FolderService>,
authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
) -> Self {
Self {
drive_repo,
folder_service,
authorization,
}
}
@@ -792,9 +798,7 @@ impl PersonalDriveLifecycleHook {
/// trait docstring — they have no resources of their own, only
/// grants on other users' resources.
async fn provision_if_needed(&self, user: &User) -> Result<(), DomainError> {
use crate::domain::repositories::drive_repository::{
CreatePersonalDriveInput, DriveRepositoryError,
};
use crate::domain::repositories::drive_repository::DriveRepositoryError;
use crate::domain::services::authorization::{Resource, Role, Subject};
if user.is_external() {
@@ -802,20 +806,19 @@ impl PersonalDriveLifecycleHook {
}
// Idempotent shortcut: if the user already has a default drive,
// nothing to do. Covers re-runs from `on_user_login` plus the
// case where `on_user_created` ran successfully but logged in
// before reaching the role_grant step (next-login retry lands
// here and finds the drive, completing the role_grant if missing).
// the atomic CTE already ran on a prior turn. The CTE writes
// the Owner role_grant inline, so there's nothing to repair —
// but we still re-emit the grant via `set_role` (UPSERT-safe)
// to cover the historical case where a pre-CTE provisioning
// path partially completed (drive created, grant missing).
match self.drive_repo.find_default_for_user(user.id()).await {
Ok(drive) => {
// Drive exists; ensure the Owner role_grant is in
// place too. `set_role` is an UPSERT — safe to re-run.
Ok(drive_with_name) => {
self.authorization
.set_role(
user.id(),
Subject::User(user.id()),
Role::Owner,
Resource::Drive(drive.id),
Resource::Drive(drive_with_name.drive.id),
None,
)
.await
@@ -831,49 +834,28 @@ impl PersonalDriveLifecycleHook {
}
}
// Create the drive.
let drive = self
// One atomic CTE — drive row + root folder ("Personal",
// parent_id=NULL, drive_id pinned) + drives.root_folder_id
// wire-up + Owner role_grant. Single SQL statement, atomic
// against server crash mid-sequence (docs/plan/drive.md §3).
let drive_with_name = self
.drive_repo
.create_personal(CreatePersonalDriveInput {
name: "Personal".to_owned(),
owner_id: user.id(),
is_default: true,
quota_bytes: Some(user.storage_quota_bytes()),
})
.create_personal_drive_atomic(user.id(), Some(user.storage_quota_bytes()))
.await
.map_err(|e| {
DomainError::internal_error("PersonalDriveHook", format!("create_personal: {e}"))
DomainError::internal_error(
"PersonalDriveHook",
format!("create_personal_drive_atomic: {e}"),
)
})?;
// Stamp the Owner role_grant.
self.authorization
.set_role(
user.id(),
Subject::User(user.id()),
Role::Owner,
Resource::Drive(drive.id),
None,
)
.await
.map(|_grant| ())?;
// Provision the wrapper `My Folder - <username>` folder under
// the new drive. The wrapper is retained through the D0 dual-
// write window (M2b retires it later); without it, existing API
// surfaces that assume `GET /api/folders` returns a root folder
// (the UI listing, the WebDAV resolver, the Hurl baselines) all
// break for newly-provisioned users.
self.folder_service
.ensure_home_folder(user.id(), drive.id, user.username())
.await
.map(|_created| ())?;
tracing::info!(
target: "user_lifecycle",
hook = "personal_drive",
user_id = %user.id(),
drive_id = %drive.id,
"Default personal drive + wrapper folder provisioned"
drive_id = %drive_with_name.drive.id,
root_folder_id = %drive_with_name.drive.root_folder_id,
"Default personal drive + root folder + owner grant provisioned (atomic CTE)"
);
Ok(())
}
+1 -1
View File
@@ -306,7 +306,7 @@ impl SearchService {
.list_for_subjects(&subject_types, &subject_ids)
.await
{
Ok(drives) => drives.into_iter().map(|d| d.id).collect(),
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
Err(e) => {
tracing::warn!("Content-index: drive lookup failed — degrading to empty: {e}");
return Vec::new();
@@ -925,6 +925,7 @@ mod tests {
async fn get_folder_by_path(
&self,
_storage_path: &crate::domain::services::path_service::StoragePath,
_user_id: uuid::Uuid,
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
@@ -709,6 +709,7 @@ impl FolderRepository for MockFolderRepository {
async fn get_folder_by_path(
&self,
_storage_path: &StoragePath,
_user_id: Uuid,
) -> std::result::Result<Folder, DomainError> {
unimplemented!()
}
-1
View File
@@ -1202,7 +1202,6 @@ impl AppServiceFactory {
.with_hook(Arc::new(
crate::application::services::folder_service::PersonalDriveLifecycleHook::new(
drive_repo.clone(),
apps.folder_service_concrete.clone(),
authorization.clone(),
),
))
+10 -2
View File
@@ -242,7 +242,11 @@ impl FolderRepository for StubFolderStoragePort {
Ok(Folder::default())
}
async fn get_folder_by_path(&self, _storage_path: &StoragePath) -> Result<Folder, DomainError> {
async fn get_folder_by_path(
&self,
_storage_path: &StoragePath,
_user_id: Uuid,
) -> Result<Folder, DomainError> {
Ok(Folder::default())
}
@@ -398,7 +402,11 @@ impl FolderUseCase for StubFolderUseCase {
Ok(FolderDto::default())
}
async fn get_folder_by_path(&self, _path: &str) -> Result<FolderDto, DomainError> {
async fn get_folder_by_path(
&self,
_path: &str,
_user_id: Uuid,
) -> Result<FolderDto, DomainError> {
Ok(FolderDto::default())
}
+17 -7
View File
@@ -76,29 +76,39 @@ impl DriveKind {
/// Domain entity for a row in `storage.drives`.
///
/// Drives are pure metadata under the D0 design (docs/plan/drive.md §3):
/// no `name` column — the display name lives on the root folder pointed
/// at by `root_folder_id`. Code that needs the name pairs this struct
/// with a JOIN through `storage.folders`; see the repository's
/// `DriveWithRootName` view-model.
///
/// Field-level constraints are enforced at the SQL layer (CHECK on
/// `kind`, partial UNIQUE on `default_for_user`). The struct mirrors
/// the column set 1:1; behaviour beyond field access lives in
/// `DriveRepository` (D0-5) and `DriveService` (post-D0).
/// `DriveRepository` and `DriveService` (post-D0).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Drive {
/// Stable identifier. Generated server-side at creation.
pub id: Uuid,
/// Display name. Renameable by owners; defaults to "Personal" for
/// the user's default personal drive, or the original sibling-root
/// folder name for secondaries promoted by the D0 backfill.
pub name: String,
/// Discriminant — see [`DriveKind`].
pub kind: DriveKind,
/// Set iff this is the user's default personal drive (UNIQUE in SQL
/// via a partial index `WHERE default_for_user IS NOT NULL`). NULL
/// on shared drives and on secondary personal drives.
pub default_for_user: Option<Uuid>,
/// The drive's mount-point folder. The column is NULLable in SQL
/// only because the atomic creation CTE writes it mid-statement
/// (a column-level `NOT NULL` would refuse the initial drive INSERT
/// — see docs/plan/drive.md §3). After any successful creation path,
/// this is populated; code reading `Drive` may treat it as `Uuid`,
/// not `Option<Uuid>`. A NULL at read time is a data-invariant bug.
pub root_folder_id: Uuid,
/// Soft cap on this drive's storage usage, in bytes. `None` means
/// "no quota" (rare; reserved for admin overrides). The default
/// initial quota for a fresh personal drive is taken from the
/// owner's `auth.users.storage_quota_bytes` at creation time (see
/// Open Question 2 in `docs/plan/drive.md`).
/// owner's `auth.users.storage_quota_bytes` at creation time.
/// **Mutation is OxiCloud-admin only** (docs/plan/drive.md §7) —
/// not in the drive `owner` role bundle.
pub quota_bytes: Option<i64>,
/// Running total of bytes consumed. Maintained incrementally by
/// upload/delete paths in D4; on D0 still reflects the pre-Drive
+44 -40
View File
@@ -37,52 +37,56 @@ pub enum DriveRepositoryError {
StorageError(String),
}
/// Input parameters for creating a new personal drive.
/// A drive paired with the display name from its root folder.
///
/// Shared drives land in D3 with their own creation surface
/// (`create_shared_drive`). For now D0 only mints personal drives —
/// either as the default for a fresh user (via the lifecycle hook) or
/// as a secondary promoted by the M2 backfill.
#[derive(Debug, Clone)]
pub struct CreatePersonalDriveInput {
/// Display name. The lifecycle hook passes `"Personal"`; the M2
/// backfill carries over the original sibling-root folder name for
/// secondaries.
pub name: String,
/// The owner. For personal drives the owner is exactly one user.
pub owner_id: Uuid,
/// `true` when this is the user's default drive (sets the partial-
/// unique `default_for_user` column). `false` for secondaries.
pub is_default: bool,
/// Initial storage quota in bytes. `None` defers to admin policy
/// (typically copied from `auth.users.storage_quota_bytes` at the
/// call site).
pub quota_bytes: Option<i64>,
/// `storage.drives` has no `name` column under the D0 design
/// (docs/plan/drive.md §3) — the display name lives on
/// `storage.folders.name` of the row pointed at by `drive.root_folder_id`.
/// Read paths join the two tables and hand callers this view-model so the
/// API surface can continue to expose a single "drive with name" shape
/// without a follow-up query per drive.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DriveWithRootName {
pub drive: Drive,
/// The drive's display name. Sourced from `storage.folders.name`
/// of the root folder via JOIN at read time.
pub root_folder_name: String,
}
#[async_trait::async_trait]
pub trait DriveRepository: Send + Sync + 'static {
/// Insert a personal drive row. The caller is responsible for
/// inserting the matching owner row in `storage.role_grants` in the
/// same transaction (the lifecycle hook handles this; M2's backfill
/// did it directly in SQL).
/// Atomically create a personal drive together with its root folder
/// and the owner role_grant — all four DB writes in a single SQL
/// statement (docs/plan/drive.md §3 "Atomic creation"). The
/// statement runs as its own implicit transaction in autocommit mode
/// so a server crash mid-statement leaves no half-row state.
///
/// Returns `DefaultDriveAlreadyExists` when `is_default=true` and the
/// owner already has a default drive — relies on the partial UNIQUE
/// index on `default_for_user`.
async fn create_personal(
/// The root folder is created with name `"Personal"` (the canonical
/// default) and `parent_id IS NULL`. The drive's `root_folder_id`
/// is wired to point at it before the statement commits.
///
/// Returns `DefaultDriveAlreadyExists` when the owner already has a
/// default drive — relies on the partial UNIQUE index on
/// `default_for_user`.
async fn create_personal_drive_atomic(
&self,
input: CreatePersonalDriveInput,
) -> Result<Drive, DriveRepositoryError>;
owner_id: Uuid,
quota_bytes: Option<i64>,
) -> Result<DriveWithRootName, DriveRepositoryError>;
/// Fetch a drive by id. `NotFound` when no row matches.
async fn get_by_id(&self, id: Uuid) -> Result<Drive, DriveRepositoryError>;
/// Fetch a drive by id together with its display name. `NotFound`
/// when no row matches.
async fn get_by_id(&self, id: Uuid) -> Result<DriveWithRootName, DriveRepositoryError>;
/// Return the caller's default personal drive, or `NotFound` if they
/// don't have one (e.g. external users; users created before the
/// lifecycle hook fired). Drives the Photos timeline scope, the
/// `/api/recent/*` scope, and D1's redirect-from-`/`.
async fn find_default_for_user(&self, user_id: Uuid) -> Result<Drive, DriveRepositoryError>;
/// Return the caller's default personal drive paired with its
/// display name, or `NotFound` if they don't have one (e.g.
/// external users; users created before the lifecycle hook fired).
/// Drives the Photos timeline scope, the `/api/recent/*` scope, and
/// D1's redirect-from-`/`.
async fn find_default_for_user(
&self,
user_id: Uuid,
) -> Result<DriveWithRootName, DriveRepositoryError>;
/// List drives the caller can read, resolved via `role_grants` for
/// `resource_type='drive'`. The caller's group memberships are
@@ -90,13 +94,13 @@ pub trait DriveRepository: Send + Sync + 'static {
/// is what this method's `subject_ids` argument carries.
///
/// Returns rows in a stable order: default drive first (if any),
/// then by name. The `/api/drives` handler relies on that order for
/// the picker UI without a follow-up sort.
/// 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(
&self,
subject_types: &[&str],
subject_ids: &[Uuid],
) -> Result<Vec<Drive>, DriveRepositoryError>;
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
}
/// Convenience: convert the canonical kind discriminator from its SQL
+12 -2
View File
@@ -28,8 +28,18 @@ pub trait FolderRepository: Send + Sync + 'static {
/// Gets a folder by its ID
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
/// Gets a folder by its storage path
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError>;
/// Gets a folder by its storage path within the caller's tree.
///
/// Post-D0, `storage.folders.path` is no longer globally unique —
/// multiple users share root-folder names like `"Personal"`. The
/// `user_id` filter scopes the lookup to the caller's own folders
/// (the equivalent of the pre-D0 implicit user-namespacing that
/// came from `My Folder - <username>` paths).
async fn get_folder_by_path(
&self,
storage_path: &StoragePath,
user_id: Uuid,
) -> Result<Folder, DomainError>;
/// Lists folders within a parent folder
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError>;
@@ -15,7 +15,7 @@ use sqlx::{PgPool, Row, types::Uuid};
use crate::domain::entities::drive::{Drive, DriveKind};
use crate::domain::repositories::drive_repository::{
CreatePersonalDriveInput, DriveRepository, DriveRepositoryError,
DriveRepository, DriveRepositoryError, DriveWithRootName,
};
pub struct DrivePgRepository {
@@ -35,67 +35,159 @@ impl DrivePgRepository {
// unique_violation. With drives, the only relevant unique is
// the partial index `idx_drives_default_for_user_unique` —
// surface the typed variant so the lifecycle hook can detect
// idempotent re-runs (D0-9 calls create_personal during
// user provisioning).
// idempotent re-runs (D0-9 calls create_personal_drive_atomic
// during user provisioning).
return DriveRepositoryError::DefaultDriveAlreadyExists(dberr.to_string());
}
DriveRepositoryError::StorageError(format!("{context}: {e}"))
}
fn row_to_drive(row: &sqlx::postgres::PgRow) -> Result<Drive, DriveRepositoryError> {
/// Map a row carrying both the drive's columns AND a `root_folder_name`
/// column (sourced via JOIN with `storage.folders`) into the view-model.
fn row_to_drive_with_name(
row: &sqlx::postgres::PgRow,
) -> Result<DriveWithRootName, DriveRepositoryError> {
let kind_str: String = row.get("kind");
let kind = DriveKind::from_sql(&kind_str)?;
Ok(Drive {
let drive = Drive {
id: row.get("id"),
name: row.get("name"),
kind,
default_for_user: row.get("default_for_user"),
root_folder_id: row.get("root_folder_id"),
quota_bytes: row.get("quota_bytes"),
used_bytes: row.get("used_bytes"),
policies: row.get("policies"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
};
Ok(DriveWithRootName {
drive,
root_folder_name: row.get("root_folder_name"),
})
}
}
#[async_trait::async_trait]
impl DriveRepository for DrivePgRepository {
async fn create_personal(
async fn create_personal_drive_atomic(
&self,
input: CreatePersonalDriveInput,
) -> Result<Drive, DriveRepositoryError> {
let default_for_user = if input.is_default {
Some(input.owner_id)
} else {
None
};
let row = sqlx::query(
owner_id: Uuid,
quota_bytes: Option<i64>,
) -> Result<DriveWithRootName, DriveRepositoryError> {
// Four writes wrapped in a single transaction so either all
// commit or none does (docs/plan/drive.md §3). A single CTE
// statement would be cleaner on paper but doesn't work in
// PostgreSQL: CTE sub-statements share an MVCC snapshot, so
// `UPDATE storage.drives WHERE id = …` cannot match a row
// inserted by an earlier CTE branch. We use plain sequential
// statements inside `pool.begin()` instead — each statement
// sees the prior ones' writes (transaction-local visibility),
// and FK constraints are satisfied at insert time because the
// referenced rows already exist.
//
// Rollback semantics: any error before `tx.commit()` (FK
// violation, unique_violation on `default_for_user`, server
// crash) discards every partial write. No orphan drive, no
// folder without a drive, no drive without an owner.
let mut tx = self
.pool
.begin()
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.begin", e))?;
// 1. Drive row (root_folder_id NULL — populated in step 3).
let drive_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.drives
(name, kind, default_for_user, quota_bytes, policies)
VALUES ($1, 'personal', $2, $3, '{}'::jsonb)
RETURNING id, name, kind, default_for_user, quota_bytes,
used_bytes, policies, created_at, updated_at
(kind, default_for_user, quota_bytes, policies)
VALUES ('personal', $1, $2, '{}'::jsonb)
RETURNING id
"#,
)
.bind(&input.name)
.bind(default_for_user)
.bind(input.quota_bytes)
.fetch_one(self.pool.as_ref())
.bind(owner_id)
.bind(quota_bytes)
.fetch_one(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_personal", e))?;
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.drive", e))?;
Self::row_to_drive(&row)
}
// 2. Root folder. `parent_id IS NULL` makes it a root in the
// drive; `drive_id` closes the FK in this direction.
let folder_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
VALUES ('Personal', NULL, $1, $2, $1, $1)
RETURNING id
"#,
)
.bind(owner_id)
.bind(drive_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.folder", e))?;
async fn get_by_id(&self, id: Uuid) -> Result<Drive, DriveRepositoryError> {
// 3. Close the other side of the circular reference.
sqlx::query(
r#"UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2"#,
)
.bind(folder_id)
.bind(drive_id)
.execute(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.wire", e))?;
// 4. Owner role_grant — the caller becomes the drive's sole
// owner (single-user invariant on personal drives, §2).
sqlx::query(
r#"
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id,
role, granted_by)
VALUES ('user', $1, 'drive', $2, 'owner', $1)
"#,
)
.bind(owner_id)
.bind(drive_id)
.execute(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.grant", e))?;
// Fetch the row in its final state so the caller gets a
// consistent view (including DB-computed defaults like
// `created_at`, `used_bytes`).
let row = sqlx::query(
r#"
SELECT id, name, kind, default_for_user, quota_bytes,
used_bytes, policies, created_at, updated_at
FROM storage.drives
WHERE id = $1
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at,
f.name AS root_folder_name
FROM storage.drives d
JOIN storage.folders f ON f.id = d.root_folder_id
WHERE d.id = $1
"#,
)
.bind(drive_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.read", e))?;
tx.commit()
.await
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.commit", e))?;
Self::row_to_drive_with_name(&row)
}
async fn get_by_id(&self, id: Uuid) -> Result<DriveWithRootName, DriveRepositoryError> {
let row = sqlx::query(
r#"
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at,
f.name AS root_folder_name
FROM storage.drives d
JOIN storage.folders f ON f.id = d.root_folder_id
WHERE d.id = $1
"#,
)
.bind(id)
@@ -104,16 +196,22 @@ impl DriveRepository for DrivePgRepository {
.map_err(|e| Self::map_sqlx_err("get_by_id", e))?
.ok_or_else(|| DriveRepositoryError::NotFound(id.to_string()))?;
Self::row_to_drive(&row)
Self::row_to_drive_with_name(&row)
}
async fn find_default_for_user(&self, user_id: Uuid) -> Result<Drive, DriveRepositoryError> {
async fn find_default_for_user(
&self,
user_id: Uuid,
) -> Result<DriveWithRootName, DriveRepositoryError> {
let row = sqlx::query(
r#"
SELECT id, name, kind, default_for_user, quota_bytes,
used_bytes, policies, created_at, updated_at
FROM storage.drives
WHERE default_for_user = $1
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at,
f.name AS root_folder_name
FROM storage.drives d
JOIN storage.folders f ON f.id = d.root_folder_id
WHERE d.default_for_user = $1
"#,
)
.bind(user_id)
@@ -122,39 +220,41 @@ impl DriveRepository for DrivePgRepository {
.map_err(|e| Self::map_sqlx_err("find_default_for_user", e))?
.ok_or_else(|| DriveRepositoryError::NotFound(user_id.to_string()))?;
Self::row_to_drive(&row)
Self::row_to_drive_with_name(&row)
}
async fn list_for_subjects(
&self,
subject_types: &[&str],
subject_ids: &[Uuid],
) -> Result<Vec<Drive>, DriveRepositoryError> {
// Joining `role_grants` → `storage.drives` returns every drive
// the expanded subject set can read. ORDER BY puts default
// drives first (so the picker UI doesn't need a follow-up
// sort), then alphabetical by name. DISTINCT collapses the
// case where a caller has multiple role_grants on the same
// drive (e.g. direct + group-mediated); a GROUP BY on the
// drive id sidesteps PostgreSQL's "ORDER BY expression must
// appear in select list" rule that `SELECT DISTINCT` imposes.
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
// Joining role_grants → drives → folders returns every drive the
// expanded subject set can read, paired with its display name.
// ORDER BY puts default drives first (so the picker UI doesn't
// need a follow-up sort), then alphabetical by name. GROUP BY
// collapses duplicate role_grants on the same drive (direct +
// group-mediated) and sidesteps PostgreSQL's "ORDER BY
// expression must appear in select list" rule that SELECT
// DISTINCT imposes.
let rows = sqlx::query(
r#"
SELECT d.id, d.name, d.kind, d.default_for_user,
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at
d.created_at, d.updated_at,
f.name AS root_folder_name
FROM storage.drives d
JOIN storage.folders f ON f.id = d.root_folder_id
JOIN storage.role_grants g
ON g.resource_type = 'drive'
AND g.resource_id = d.id
WHERE g.subject_type = ANY($1)
AND g.subject_id = ANY($2)
AND (g.expires_at IS NULL OR g.expires_at > NOW())
GROUP BY d.id, d.name, d.kind, d.default_for_user,
GROUP BY d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at
d.created_at, d.updated_at, f.name
ORDER BY (d.default_for_user IS NULL) ASC,
LOWER(d.name) ASC
LOWER(f.name) ASC
"#,
)
.bind(
@@ -168,6 +268,6 @@ impl DriveRepository for DrivePgRepository {
.await
.map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?;
rows.iter().map(Self::row_to_drive).collect()
rows.iter().map(Self::row_to_drive_with_name).collect()
}
}
@@ -236,7 +236,11 @@ impl FolderRepository for FolderDbRepository {
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
}
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
async fn get_folder_by_path(
&self,
storage_path: &StoragePath,
user_id: Uuid,
) -> Result<Folder, DomainError> {
let path_str = storage_path.to_string();
// Strip leading '/' if present — DB stores "Home - user/Docs", not "/Home - user/Docs"
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
@@ -245,6 +249,13 @@ impl FolderRepository for FolderDbRepository {
return Err(DomainError::not_found("Folder", "empty path"));
}
// Scoped by user_id: post-D0 the wrapper folder is named
// "Personal" for every user, so `path = 'Personal'` matches
// every user's root folder. Without the user_id filter, this
// returns a non-deterministic row (whichever the planner emits
// first) — which broke owner-short-circuit checks for the
// caller whose folder wasn't returned. See bug-fix on rewind
// commit.
let row = sqlx::query_as::<_, FolderRow>(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
@@ -252,10 +263,11 @@ impl FolderRepository for FolderDbRepository {
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE path = $1 AND NOT is_trashed
WHERE path = $1 AND user_id = $2 AND NOT is_trashed
"#,
)
.bind(lookup)
.bind(user_id)
.fetch_optional(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))?
+12 -12
View File
@@ -469,7 +469,7 @@ async fn handle_propfind(
}
} else {
// Fallback: legacy double-query path when PathResolver is unavailable
if let Ok(folder) = folder_service.get_folder_by_path(&path).await {
if let Ok(folder) = folder_service.get_folder_by_path(&path, user.id).await {
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
let folder_id = folder.id.clone();
return build_streaming_propfind_response(
@@ -656,7 +656,7 @@ async fn handle_proppatch(
req: Request<Body>,
path: String,
) -> Result<Response<Body>, AppError> {
let _user = extract_user(&req)?;
let user = extract_user(&req)?;
// Active-lock guard (RFC 4918 §9.10.4): PROPPATCH writes properties,
// so a lock on the target must release them via `If:`. Captured
@@ -691,7 +691,7 @@ async fn handle_proppatch(
state
.applications
.folder_service
.get_folder_by_path(&path)
.get_folder_by_path(&path, user.id)
.await
.is_ok()
};
@@ -877,7 +877,7 @@ async fn handle_head(
}
// Fallback: legacy double-query path (with ownership check)
if let Ok(folder) = folder_service.get_folder_by_path(&path).await {
if let Ok(folder) = folder_service.get_folder_by_path(&path, user.id).await {
assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?;
return Ok(Response::builder()
.status(StatusCode::OK)
@@ -941,7 +941,7 @@ async fn resolve_or_legacy(
let user_id_str = user_id.to_string();
let folder_service = &state.applications.folder_service;
if let Ok(folder) = folder_service.get_folder_by_path(path).await
if let Ok(folder) = folder_service.get_folder_by_path(path, user_id).await
&& folder.owner_id.as_deref() == Some(&user_id_str)
{
return Some(ResolvedResource::Folder(folder));
@@ -1208,7 +1208,7 @@ async fn handle_mkcol(
}
accumulated_path.push_str(segment);
match folder_service.get_folder_by_path(&accumulated_path).await {
match folder_service.get_folder_by_path(&accumulated_path, user.id).await {
Ok(existing) => {
parent_id = Some(existing.id);
}
@@ -1401,7 +1401,7 @@ async fn handle_move(
.unwrap_or(false)
} else {
folder_service
.get_folder_by_path(&destination_path)
.get_folder_by_path(&destination_path, user.id)
.await
.is_ok()
|| file_retrieval_service
@@ -1443,7 +1443,7 @@ async fn handle_move(
let move_dto = crate::application::dtos::folder_dto::MoveFolderDto {
parent_id: if dest_parent_path.is_empty() {
None
} else if let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await
} else if let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path, user.id).await
{
assert_owner(
parent.owner_id.as_deref(),
@@ -1483,7 +1483,7 @@ async fn handle_move(
None
} else {
let parent = folder_service
.get_folder_by_path(dest_parent_path)
.get_folder_by_path(dest_parent_path, user.id)
.await
.map_err(|_| {
AppError::not_found(format!(
@@ -1610,7 +1610,7 @@ async fn handle_copy(
.unwrap_or(false)
} else {
folder_service
.get_folder_by_path(&destination_path)
.get_folder_by_path(&destination_path, user.id)
.await
.is_ok()
|| file_retrieval_service
@@ -1644,7 +1644,7 @@ async fn handle_copy(
let target_parent_id = if dest_parent_path.is_empty() {
None
} else if let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await {
} else if let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path, user.id).await {
assert_owner(
parent.owner_id.as_deref(),
&user.id.to_string(),
@@ -1743,7 +1743,7 @@ async fn handle_lock(
state
.applications
.folder_service
.get_folder_by_path(&path)
.get_folder_by_path(&path, user.id)
.await
.is_ok()
};
+7 -3
View File
@@ -399,10 +399,14 @@ pub async fn handle_search(
let mut entries: Vec<serde_json::Value> = Vec::new();
// Map file results
// TODO(D1): drop the hardcoded "Personal/" prefix and read the
// caller's default-drive root folder name from `drives.root_folder_id`
// instead. Correct for D0-provisioned default drives; secondary
// drives keep their original root name.
for file in &results.files {
let display_path = file
.path
.strip_prefix(&format!("My Folder - {}/", user.username))
.strip_prefix("Personal/")
.unwrap_or(&file.path);
let display_path = format!("/{}", display_path);
@@ -427,11 +431,11 @@ pub async fn handle_search(
}));
}
// Map folder results
// Map folder results — same TODO(D1) as above.
for folder in &results.folders {
let display_path = folder
.path
.strip_prefix(&format!("My Folder - {}/", user.username))
.strip_prefix("Personal/")
.unwrap_or(&folder.path);
let display_path = format!("/{}", display_path);
+21 -9
View File
@@ -83,7 +83,11 @@ async fn handle_filter_files(
// All items in this response are favorites.
let favorite_ids: HashSet<String> = favorites.iter().map(|f| f.item_id.clone()).collect();
let home_prefix = format!("My Folder - {}/", user.username);
// TODO(D1): replace the hardcoded "Personal/" prefix with the
// caller's default-drive root folder name read from
// `drives.root_folder_id`. Correct for D0-provisioned default
// drives; secondary drives keep their original root name.
let home_prefix = "Personal/";
// Pass 1: resolve the favorited DTOs in two batch queries (was one
// get_* per favorite — up to N serial round-trips on a sync client's
@@ -146,7 +150,7 @@ async fn handle_filter_files(
write_multistatus_start(&mut xml)?;
for file in &files {
let subpath = strip_home_prefix(&file.path, &home_prefix);
let subpath = strip_home_prefix(&file.path, home_prefix);
let href = nc_href(&user.username, subpath);
let fid = file_id_map.get(&file.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
@@ -163,7 +167,7 @@ async fn handle_filter_files(
}
for folder in &folders {
let subpath = strip_home_prefix(&folder.path, &home_prefix);
let subpath = strip_home_prefix(&folder.path, home_prefix);
let href = format!("{}/", nc_href(&user.username, subpath));
let fid = folder_id_map.get(&folder.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
@@ -210,7 +214,7 @@ async fn handle_search(
let nresults = parse_nresults(body).unwrap_or(100);
// Resolve folder scope from <d:href> inside <d:scope>.
let folder_id = resolve_scope_folder(&state, body, &user.username).await;
let folder_id = resolve_scope_folder(&state, body, &user.username, user.id).await;
let criteria = SearchCriteriaDto {
name_contains: Some(term),
@@ -227,7 +231,10 @@ async fn handle_search(
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
let home_prefix = format!("My Folder - {}/", user.username);
// TODO(D1): same as the favorites pass above — replace the
// hardcoded "Personal/" with the caller's actual default-drive
// root folder name from `drives.root_folder_id`.
let home_prefix = "Personal/";
// No favorite checking for search results -- pass an empty set.
let favorite_ids: HashSet<String> = HashSet::new();
@@ -249,7 +256,7 @@ async fn handle_search(
// Files.
for file in &files {
let subpath = strip_home_prefix(&file.path, &home_prefix);
let subpath = strip_home_prefix(&file.path, home_prefix);
let href = nc_href(&user.username, subpath);
let fid = file_id_map.get(&file.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
@@ -267,7 +274,7 @@ async fn handle_search(
// Folders.
for folder in &folders {
let subpath = strip_home_prefix(&folder.path, &home_prefix);
let subpath = strip_home_prefix(&folder.path, home_prefix);
let href = format!("{}/", nc_href(&user.username, subpath));
let fid = folder_id_map.get(&folder.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
@@ -461,7 +468,12 @@ fn xml_extract_text(body: &str, local_name: &[u8]) -> Option<String> {
}
/// Resolve a scope href (e.g. `/files/username/Documents`) to a folder ID.
async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> Option<String> {
async fn resolve_scope_folder(
state: &AppState,
body: &str,
username: &str,
user_id: uuid::Uuid,
) -> Option<String> {
let href = parse_scope_href(body)?;
// The href is typically `/files/{user}/subpath` or `/remote.php/dav/files/{user}/subpath`.
@@ -477,7 +489,7 @@ async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> O
let folder_service = &state.applications.folder_service;
folder_service
.get_folder_by_path(&internal_path)
.get_folder_by_path(&internal_path, user_id)
.await
.ok()
.map(|f| f.id)
+13 -6
View File
@@ -133,7 +133,7 @@ async fn handle_restore(
let file_service = &state.applications.file_retrieval_service;
let dest_taken = file_service.get_file_by_path(&dest_internal).await.is_ok()
|| folder_service
.get_folder_by_path(&dest_internal)
.get_folder_by_path(&dest_internal, user.id)
.await
.is_ok();
if dest_taken {
@@ -248,11 +248,18 @@ fn mime_from_name(name: &str) -> String {
.to_string()
}
/// Strip the "My Folder - {username}/" prefix from an original path to produce
/// the Nextcloud-relative original location.
fn strip_home_prefix<'a>(original_path: &'a str, username: &str) -> &'a str {
let prefix = format!("My Folder - {}/", username);
original_path.strip_prefix(&prefix).unwrap_or(original_path)
/// Strip the home-folder prefix from an original path to produce the
/// Nextcloud-relative original location.
///
/// TODO(D1): replace the hardcoded "Personal/" with the caller's actual
/// default-drive root folder name read from `drives.root_folder_id`.
/// Correct for D0-provisioned default drives; secondary drives keep
/// their original root name. The `_username` arg stays for now so the
/// upcoming dynamic lookup has a way to identify the caller.
fn strip_home_prefix<'a>(original_path: &'a str, _username: &str) -> &'a str {
original_path
.strip_prefix("Personal/")
.unwrap_or(original_path)
}
// ────────────── Trashbin PROPFIND XML Generation ──────────────
+8 -11
View File
@@ -256,11 +256,12 @@ async fn handle_assemble(
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
let internal_path = format!(
"My Folder - {}/{}",
user.username,
dest_subpath.trim_matches('/')
);
// TODO(D1): read the caller's default-drive root folder name from
// `drives.root_folder_id` instead of hardcoding "Personal". The
// constant is correct for every default personal drive provisioned
// by the D0 lifecycle hook, but secondary drives (M2 backfill from
// SQL-created sibling root folders) keep their original name.
let internal_path = format!("Personal/{}", dest_subpath.trim_matches('/'));
let filename = filename_from_path(&dest_subpath).to_string();
let ingested = ingest_stream_to_cas(
@@ -291,15 +292,11 @@ async fn handle_assemble(
Some((p, n)) => (p, n),
None => ("", dest_subpath.as_str()),
};
let parent_internal = format!(
"My Folder - {}/{}",
user.username,
parent_sub.trim_matches('/')
);
let parent_internal = format!("Personal/{}", parent_sub.trim_matches('/'));
let parent_internal = parent_internal.trim_end_matches('/');
use crate::application::ports::folder_ports::FolderUseCase;
let parent_folder = match folder_service.get_folder_by_path(parent_internal).await {
let parent_folder = match folder_service.get_folder_by_path(parent_internal, user.id).await {
Ok(folder) => folder,
Err(e) => {
discard_ingested(&state.core.dedup_service, &ingested).await;
+21 -14
View File
@@ -53,8 +53,15 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
/// Internal: My Folder - {username}/{subpath}
///
/// An empty subpath maps to the user's home folder root.
pub fn nc_to_internal_path(username: &str, subpath: &str) -> Result<String, AppError> {
let home = format!("My Folder - {}", username);
pub fn nc_to_internal_path(_username: &str, subpath: &str) -> Result<String, AppError> {
// D0: every default personal drive's root folder is named "Personal"
// (docs/plan/drive.md §3 — the canonical post-D0 default). The NC
// dispatcher chroots into the caller's default drive, so the leading
// segment of the internal path is always the drive's root folder
// name. Hardcoded for now; a follow-up will read it from
// `drives.root_folder_id`'s name to support secondary drives with
// custom root-folder names.
let home = "Personal".to_string();
let subpath = subpath.trim_matches('/');
if subpath.is_empty() {
return Ok(home);
@@ -203,7 +210,7 @@ async fn handle_propfind(
let file_service = &state.applications.file_retrieval_service;
// Try to resolve as folder first.
let folder_result = folder_service.get_folder_by_path(&internal_path).await;
let folder_result = folder_service.get_folder_by_path(&internal_path, user.id).await;
if let Ok(folder) = folder_result {
// It's a folder — stream the multistatus: children are fetched in
@@ -281,7 +288,7 @@ async fn handle_get(
// Check if path is a folder first (NC clients use GET as existence check)
if folder_service
.get_folder_by_path(&internal_path)
.get_folder_by_path(&internal_path, user.id)
.await
.is_ok()
{
@@ -358,7 +365,7 @@ async fn handle_head(
// Check if path is a folder (NC clients use HEAD as existence check)
if folder_service
.get_folder_by_path(&internal_path)
.get_folder_by_path(&internal_path, user.id)
.await
.is_ok()
{
@@ -433,7 +440,7 @@ async fn handle_proppatch(
let folder_service = &state.applications.folder_service;
let resource = if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
Some((file.id, "file"))
} else if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
} else if let Ok(folder) = folder_service.get_folder_by_path(&internal_path, user.id).await {
Some((folder.id, "folder"))
} else {
None
@@ -733,7 +740,7 @@ async fn handle_mkcol(
// auto-create doesn't break real clients.
if folder_service
.get_folder_by_path(&internal_path)
.get_folder_by_path(&internal_path, user.id)
.await
.is_ok()
{
@@ -758,7 +765,7 @@ async fn handle_mkcol(
format!("{}/{}", user_root, parent_segments.join("/"))
};
let parent_folder = match folder_service.get_folder_by_path(&parent_path).await {
let parent_folder = match folder_service.get_folder_by_path(&parent_path, user.id).await {
Ok(folder) => folder,
Err(_) => {
return Ok(Response::builder()
@@ -797,7 +804,7 @@ async fn handle_delete(
// Prefer soft-delete (move to trash) when trash service is available.
// This is what Nextcloud clients expect — items appear in the trashbin.
if let Some(trash_svc) = state.trash_service.as_ref() {
if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
if let Ok(folder) = folder_service.get_folder_by_path(&internal_path, user.id).await {
trash_svc
.move_to_trash(&folder.id, "folder", user.id)
.await
@@ -823,7 +830,7 @@ async fn handle_delete(
// Fallback: hard delete when trash service is not available.
let file_mgmt = &state.applications.file_management_service;
if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
if let Ok(folder) = folder_service.get_folder_by_path(&internal_path, user.id).await {
folder_service
.delete_folder_with_perms(&folder.id, user.id)
.await
@@ -897,7 +904,7 @@ async fn handle_move(
.await
.ok();
let dest_existing_folder = folder_service
.get_folder_by_path(&dest_internal_precheck)
.get_folder_by_path(&dest_internal_precheck, user.id)
.await
.ok();
let dest_existed_before = dest_existing_file.is_some() || dest_existing_folder.is_some();
@@ -962,7 +969,7 @@ async fn handle_move(
} else {
// Different parent → move.
let dest_parent = folder_service
.get_folder_by_path(&dest_parent_internal)
.get_folder_by_path(&dest_parent_internal, user.id)
.await
.map_err(|_| AppError::not_found("Destination folder not found"))?;
@@ -997,7 +1004,7 @@ async fn handle_move(
}
// Try as folder.
if let Ok(folder) = folder_service.get_folder_by_path(&src_internal).await {
if let Ok(folder) = folder_service.get_folder_by_path(&src_internal, user.id).await {
let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') {
Some((parent, name)) => (parent, name),
None => ("", dest_subpath.as_str()),
@@ -1025,7 +1032,7 @@ async fn handle_move(
} else {
// Different parent → move.
let dest_parent = folder_service
.get_folder_by_path(&dest_parent_internal)
.get_folder_by_path(&dest_parent_internal, user.id)
.await
.map_err(|_| AppError::not_found("Destination parent not found"))?;