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