Merge pull request #484 from AtalayaLabs/claude/youthful-davinci-kvxbse
Batch resource resolution and live-role re-validation on every request
This commit is contained in:
@@ -250,6 +250,17 @@ impl FileRetrievalService {
|
|||||||
let stream = self.file_read.get_file_stream(id).await?;
|
let stream = self.file_read.get_file_stream(id).await?;
|
||||||
Ok((dto, OptimizedFileContent::Stream(Box::into_pin(stream))))
|
Ok((dto, OptimizedFileContent::Stream(Box::into_pin(stream))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Batch counterpart of [`FileRetrievalUseCase::get_file`]: resolve many
|
||||||
|
/// file ids in ONE query instead of one per id. Like `get_file` it
|
||||||
|
/// performs no per-file authorization — both current callers (ACL grant
|
||||||
|
/// listing, NextCloud favorites REPORT) resolve ids already vetted by the
|
||||||
|
/// authorization engine or the favorites table. Missing or trashed ids are
|
||||||
|
/// absent from the result; callers re-associate by `id`.
|
||||||
|
pub async fn get_files_by_ids(&self, ids: &[String]) -> Result<Vec<FileDto>, DomainError> {
|
||||||
|
let files = self.file_read.get_files_by_ids(ids).await?;
|
||||||
|
Ok(files.into_iter().map(FileDto::from).collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileRetrievalUseCase for FileRetrievalService {
|
impl FileRetrievalUseCase for FileRetrievalService {
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ impl FolderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Batch counterpart of `get_folder`: resolve many folder ids in ONE
|
||||||
|
/// query instead of one per id. Like `get_folder` it performs no
|
||||||
|
/// per-folder authorization — both current callers (ACL grant listing,
|
||||||
|
/// NextCloud favorites REPORT) resolve ids already vetted by the
|
||||||
|
/// authorization engine or the favorites table. Missing or trashed ids
|
||||||
|
/// are absent from the result; callers re-associate by `id`.
|
||||||
|
pub async fn get_folders_by_ids(&self, ids: &[String]) -> Result<Vec<FolderDto>, DomainError> {
|
||||||
|
let folders = self.folder_storage.get_folders_by_ids(ids).await?;
|
||||||
|
Ok(folders.into_iter().map(FolderDto::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper: parse a folder id string into a `Resource::Folder`. Returns
|
/// Helper: parse a folder id string into a `Resource::Folder`. Returns
|
||||||
/// `DomainError::not_found` on parse error (anti-enumeration — the same
|
/// `DomainError::not_found` on parse error (anti-enumeration — the same
|
||||||
/// error as "folder does not exist").
|
/// error as "folder does not exist").
|
||||||
|
|||||||
@@ -255,6 +255,52 @@ impl FileBlobReadRepository {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Batch-fetch files by id — the by-ids counterpart of [`get_file`],
|
||||||
|
/// used to resolve a page of ACL grants or favorites in ONE round-trip
|
||||||
|
/// instead of one query per id (the previous `join_all(ids.map(get_file))`
|
||||||
|
/// could fan out to ~200 concurrent pooled connections per page). Applies
|
||||||
|
/// the same `NOT is_trashed` filter and identical column mapping as
|
||||||
|
/// `get_file`. Ids that are missing or trashed simply drop out, so callers
|
||||||
|
/// must re-associate results by id; ordering is not guaranteed.
|
||||||
|
pub async fn get_files_by_ids(&self, ids: &[String]) -> Result<Vec<File>, DomainError> {
|
||||||
|
let uuid_ids: Vec<Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
|
||||||
|
if uuid_ids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, FileRow>(
|
||||||
|
"SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, \
|
||||||
|
fi.size, fi.mime_type, \
|
||||||
|
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
|
||||||
|
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
|
||||||
|
fi.blob_hash, \
|
||||||
|
fi.user_id \
|
||||||
|
FROM storage.files fi \
|
||||||
|
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
|
||||||
|
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
|
||||||
|
)
|
||||||
|
.bind(&uuid_ids)
|
||||||
|
.fetch_all(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error("FileBlobRead", format!("get_files_by_ids: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
rows.into_iter()
|
||||||
|
.map(
|
||||||
|
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
|
||||||
|
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error(
|
||||||
|
"FileBlobRead",
|
||||||
|
format!("get_files_by_ids mapping: {e}"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the user_id (owner) for a given file ID.
|
/// Returns the user_id (owner) for a given file ID.
|
||||||
/// Mirrors `FolderDbRepository::get_folder_user_id`.
|
/// Mirrors `FolderDbRepository::get_folder_user_id`.
|
||||||
/// Used by the AuthorizationEngine for owner short-circuit.
|
/// Used by the AuthorizationEngine for owner short-circuit.
|
||||||
|
|||||||
@@ -109,6 +109,37 @@ impl FolderDbRepository {
|
|||||||
)
|
)
|
||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}")))
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Batch-fetch folders by id — the by-ids counterpart of `get_folder`,
|
||||||
|
/// resolving a page of ACL grants or favorites in ONE query instead of
|
||||||
|
/// one per id. Same `NOT is_trashed` filter and column mapping as
|
||||||
|
/// `get_folder`; missing or trashed ids drop out and callers re-associate
|
||||||
|
/// by id; ordering is not guaranteed.
|
||||||
|
pub async fn get_folders_by_ids(&self, ids: &[String]) -> Result<Vec<Folder>, DomainError> {
|
||||||
|
let uuid_ids: Vec<Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
|
||||||
|
if uuid_ids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, FolderRow>(
|
||||||
|
r#"
|
||||||
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||||
|
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||||
|
FROM storage.folders
|
||||||
|
WHERE id = ANY($1) AND NOT is_trashed
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&uuid_ids)
|
||||||
|
.fetch_all(self.pool())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("get_folders_by_ids: {e}")))?;
|
||||||
|
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FolderRepository for FolderDbRepository {
|
impl FolderRepository for FolderDbRepository {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
use futures::future::join_all;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{error, warn};
|
use tracing::{error, warn};
|
||||||
use utoipa::IntoParams;
|
use utoipa::IntoParams;
|
||||||
@@ -26,13 +26,10 @@ use crate::application::dtos::grant_dto::{
|
|||||||
SubjectInputDto, UpdateRoleDto, role_from_permissions,
|
SubjectInputDto, UpdateRoleDto, role_from_permissions,
|
||||||
};
|
};
|
||||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
|
||||||
use crate::application::ports::folder_ports::FolderUseCase;
|
|
||||||
use crate::application::services::recipient_notification_service::NotifyTrigger;
|
use crate::application::services::recipient_notification_service::NotifyTrigger;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::domain::errors::ErrorKind;
|
|
||||||
use crate::domain::services::authorization::{
|
use crate::domain::services::authorization::{
|
||||||
GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind,
|
GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind,
|
||||||
Role, Subject,
|
Role, Subject,
|
||||||
@@ -664,83 +661,64 @@ pub async fn list_shared_with_me(
|
|||||||
.map(|s| s.resource_id.to_string())
|
.map(|s| s.resource_id.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Resolve resource details concurrently (files and folders in parallel).
|
// Resolve resource details in two batch queries (was one per id via
|
||||||
let (file_results, folder_results) = tokio::join!(
|
// join_all, which could fan out to ~limit concurrent pooled connections
|
||||||
join_all(file_ids.iter().map(|id| file_service.get_file(id))),
|
// and starve the primary pool). Missing ids — stale grants whose resource
|
||||||
join_all(folder_ids.iter().map(|id| folder_service.get_folder(id)))
|
// was deleted before the cascade trigger fired — drop out of the maps.
|
||||||
|
let (file_list, folder_list) = tokio::join!(
|
||||||
|
file_service.get_files_by_ids(&file_ids),
|
||||||
|
folder_service.get_folders_by_ids(&folder_ids)
|
||||||
);
|
);
|
||||||
|
let file_map: HashMap<String, _> = match file_list {
|
||||||
|
Ok(files) => files.into_iter().map(|f| (f.id.clone(), f)).collect(),
|
||||||
|
Err(e) => return AppError::from(e).into_response(),
|
||||||
|
};
|
||||||
|
let folder_map: HashMap<String, _> = match folder_list {
|
||||||
|
Ok(folders) => folders.into_iter().map(|f| (f.id.clone(), f)).collect(),
|
||||||
|
Err(e) => return AppError::from(e).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
// Build the unified item list in original grant order (newest first).
|
// Build the unified item list in original grant order (newest first),
|
||||||
// We iterate summaries in order and pick the resolved result from the
|
// looking each resolved resource up by id.
|
||||||
// appropriate typed bucket.
|
|
||||||
let mut file_idx = 0usize;
|
|
||||||
let mut folder_idx = 0usize;
|
|
||||||
|
|
||||||
let mut items: Vec<SharedWithMeItemDto> = Vec::with_capacity(summaries.len());
|
let mut items: Vec<SharedWithMeItemDto> = Vec::with_capacity(summaries.len());
|
||||||
|
|
||||||
for summary in &summaries {
|
for summary in &summaries {
|
||||||
|
let rid = summary.resource_id.to_string();
|
||||||
match summary.resource_type {
|
match summary.resource_type {
|
||||||
ResourceKind::File => {
|
ResourceKind::File => match file_map.get(&rid) {
|
||||||
let result = &file_results[file_idx];
|
Some(file_dto) => {
|
||||||
file_idx += 1;
|
items.push(SharedWithMeItemDto {
|
||||||
match result {
|
resource_type: ResourceTypeDto::File,
|
||||||
Ok(file_dto) => {
|
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
|
||||||
items.push(SharedWithMeItemDto {
|
granted_at: summary.granted_at,
|
||||||
resource_type: ResourceTypeDto::File,
|
granted_by: summary.granted_by,
|
||||||
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
|
resource: ResourceContentDto::File(
|
||||||
granted_at: summary.granted_at,
|
file_dto.clone().without_hierarchy_info(),
|
||||||
granted_by: summary.granted_by,
|
),
|
||||||
resource: ResourceContentDto::File(
|
});
|
||||||
file_dto.clone().without_hierarchy_info(),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
|
||||||
// Stale grant (file deleted, trigger not yet fired) — skip silently.
|
|
||||||
warn!(
|
|
||||||
"Skipping stale file grant for resource_id={}: not found",
|
|
||||||
summary.resource_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return AppError::internal_error(format!(
|
|
||||||
"Failed to fetch file {}: {e}",
|
|
||||||
summary.resource_id
|
|
||||||
))
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
None => warn!(
|
||||||
ResourceKind::Folder => {
|
"Skipping stale file grant for resource_id={}: not found",
|
||||||
let result = &folder_results[folder_idx];
|
summary.resource_id
|
||||||
folder_idx += 1;
|
),
|
||||||
match result {
|
},
|
||||||
Ok(folder_dto) => {
|
ResourceKind::Folder => match folder_map.get(&rid) {
|
||||||
items.push(SharedWithMeItemDto {
|
Some(folder_dto) => {
|
||||||
resource_type: ResourceTypeDto::Folder,
|
items.push(SharedWithMeItemDto {
|
||||||
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
|
resource_type: ResourceTypeDto::Folder,
|
||||||
granted_at: summary.granted_at,
|
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
|
||||||
granted_by: summary.granted_by,
|
granted_at: summary.granted_at,
|
||||||
resource: ResourceContentDto::Folder(
|
granted_by: summary.granted_by,
|
||||||
folder_dto.clone().without_hierarchy_info(),
|
resource: ResourceContentDto::Folder(
|
||||||
),
|
folder_dto.clone().without_hierarchy_info(),
|
||||||
});
|
),
|
||||||
}
|
});
|
||||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
|
||||||
warn!(
|
|
||||||
"Skipping stale folder grant for resource_id={}: not found",
|
|
||||||
summary.resource_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return AppError::internal_error(format!(
|
|
||||||
"Failed to fetch folder {}: {e}",
|
|
||||||
summary.resource_id
|
|
||||||
))
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
None => warn!(
|
||||||
|
"Skipping stale folder grant for resource_id={}: not found",
|
||||||
|
summary.resource_id
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,13 +887,20 @@ pub async fn list_my_shares(
|
|||||||
.map(|s| s.resource_id.to_string())
|
.map(|s| s.resource_id.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let (file_results, folder_results) = tokio::join!(
|
// Two batch queries instead of one get_* per id (see list_shared_with_me).
|
||||||
join_all(file_ids.iter().map(|id| file_service.get_file(id))),
|
let (file_list, folder_list) = tokio::join!(
|
||||||
join_all(folder_ids.iter().map(|id| folder_service.get_folder(id)))
|
file_service.get_files_by_ids(&file_ids),
|
||||||
|
folder_service.get_folders_by_ids(&folder_ids)
|
||||||
);
|
);
|
||||||
|
let file_map: HashMap<String, _> = match file_list {
|
||||||
|
Ok(files) => files.into_iter().map(|f| (f.id.clone(), f)).collect(),
|
||||||
|
Err(e) => return AppError::from(e).into_response(),
|
||||||
|
};
|
||||||
|
let folder_map: HashMap<String, _> = match folder_list {
|
||||||
|
Ok(folders) => folders.into_iter().map(|f| (f.id.clone(), f)).collect(),
|
||||||
|
Err(e) => return AppError::from(e).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
let mut file_idx = 0usize;
|
|
||||||
let mut folder_idx = 0usize;
|
|
||||||
let mut items: Vec<OutgoingResourceItemDto> = Vec::with_capacity(summaries.len());
|
let mut items: Vec<OutgoingResourceItemDto> = Vec::with_capacity(summaries.len());
|
||||||
|
|
||||||
for summary in &summaries {
|
for summary in &summaries {
|
||||||
@@ -935,64 +920,39 @@ pub async fn list_my_shares(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let rid = summary.resource_id.to_string();
|
||||||
match summary.resource_type {
|
match summary.resource_type {
|
||||||
ResourceKind::File => {
|
ResourceKind::File => match file_map.get(&rid) {
|
||||||
let result = &file_results[file_idx];
|
Some(file_dto) => {
|
||||||
file_idx += 1;
|
// Caller is the granter — they had share-access to the
|
||||||
match result {
|
// resource, so the containing hierarchy is already known
|
||||||
Ok(file_dto) => {
|
// to them. Keep `path` (unlike list_shared_with_me).
|
||||||
// Caller is the granter — they had share-access to the
|
items.push(OutgoingResourceItemDto {
|
||||||
// resource, so the containing hierarchy is already known
|
resource_type: ResourceTypeDto::File,
|
||||||
// to them. Keep `path` (unlike list_shared_with_me).
|
first_shared_at: summary.first_shared_at,
|
||||||
items.push(OutgoingResourceItemDto {
|
resource: ResourceContentDto::File(file_dto.clone()),
|
||||||
resource_type: ResourceTypeDto::File,
|
grants,
|
||||||
first_shared_at: summary.first_shared_at,
|
});
|
||||||
resource: ResourceContentDto::File(file_dto.clone()),
|
|
||||||
grants,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
|
||||||
warn!(
|
|
||||||
"Skipping stale outgoing file grant for resource_id={}: not found",
|
|
||||||
summary.resource_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return AppError::internal_error(format!(
|
|
||||||
"Failed to fetch file {}: {e}",
|
|
||||||
summary.resource_id
|
|
||||||
))
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
None => warn!(
|
||||||
ResourceKind::Folder => {
|
"Skipping stale outgoing file grant for resource_id={}: not found",
|
||||||
let result = &folder_results[folder_idx];
|
summary.resource_id
|
||||||
folder_idx += 1;
|
),
|
||||||
match result {
|
},
|
||||||
Ok(folder_dto) => {
|
ResourceKind::Folder => match folder_map.get(&rid) {
|
||||||
items.push(OutgoingResourceItemDto {
|
Some(folder_dto) => {
|
||||||
resource_type: ResourceTypeDto::Folder,
|
items.push(OutgoingResourceItemDto {
|
||||||
first_shared_at: summary.first_shared_at,
|
resource_type: ResourceTypeDto::Folder,
|
||||||
resource: ResourceContentDto::Folder(folder_dto.clone()),
|
first_shared_at: summary.first_shared_at,
|
||||||
grants,
|
resource: ResourceContentDto::Folder(folder_dto.clone()),
|
||||||
});
|
grants,
|
||||||
}
|
});
|
||||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
|
||||||
warn!(
|
|
||||||
"Skipping stale outgoing folder grant for resource_id={}: not found",
|
|
||||||
summary.resource_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return AppError::internal_error(format!(
|
|
||||||
"Failed to fetch folder {}: {e}",
|
|
||||||
summary.resource_id
|
|
||||||
))
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
None => warn!(
|
||||||
|
"Skipping stale outgoing folder grant for resource_id={}: not found",
|
||||||
|
summary.resource_id
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use crate::application::ports::auth_ports::TokenServicePort;
|
|||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::interfaces::api::cookie_auth::{ACCESS_COOKIE, extract_cookie_value};
|
use crate::interfaces::api::cookie_auth::{ACCESS_COOKIE, extract_cookie_value};
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
|
use crate::interfaces::middleware::user::{LiveRole, resolve_live_role};
|
||||||
|
|
||||||
/// Validate the request's JWT (from the `Authorization: Bearer …` header
|
/// Validate the request's JWT (from the `Authorization: Bearer …` header
|
||||||
/// or the access-token cookie) and require `claims.role == "admin"`.
|
/// or the access-token cookie) and require `claims.role == "admin"`.
|
||||||
@@ -43,19 +44,37 @@ pub async fn require_admin(
|
|||||||
.validate_token(&token)
|
.validate_token(&token)
|
||||||
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
|
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
|
||||||
|
|
||||||
if claims.role != "admin" {
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
return Err(AppError::new(
|
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?;
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
"Admin access required",
|
|
||||||
"Forbidden",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((
|
// Gate on the *live* role, not the JWT claim: a demotion or deactivation
|
||||||
Uuid::parse_str(&claims.sub)
|
// must take effect within the flags-cache TTL rather than surviving until
|
||||||
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?,
|
// the token expires.
|
||||||
claims.role.clone(),
|
match resolve_live_role(
|
||||||
))
|
auth.auth_application_service.as_ref(),
|
||||||
|
user_id,
|
||||||
|
&claims.role,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
LiveRole::Active(role) if role == "admin" => Ok((user_id, role)),
|
||||||
|
LiveRole::Active(role) => {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "authz.admin_denied",
|
||||||
|
reason = "not_admin",
|
||||||
|
caller_id = %user_id,
|
||||||
|
role = %role,
|
||||||
|
"👮🏻♂️ admin-only endpoint denied for non-admin caller"
|
||||||
|
);
|
||||||
|
Err(AppError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Admin access required",
|
||||||
|
"Forbidden",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
LiveRole::Revoked => Err(AppError::unauthorized("Account is no longer active")),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the request's JWT (any role) and return `(user_id, role)`.
|
/// Validate the request's JWT (any role) and return `(user_id, role)`.
|
||||||
@@ -84,9 +103,19 @@ pub async fn require_authenticated(
|
|||||||
.validate_token(&token)
|
.validate_token(&token)
|
||||||
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
|
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
|
||||||
|
|
||||||
Ok((
|
let user_id = Uuid::parse_str(&claims.sub)
|
||||||
Uuid::parse_str(&claims.sub)
|
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?;
|
||||||
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?,
|
|
||||||
claims.role.clone(),
|
// Reject tokens whose account was deactivated/deleted, and return the
|
||||||
))
|
// caller's live role rather than the (possibly stale) JWT claim.
|
||||||
|
match resolve_live_role(
|
||||||
|
auth.auth_application_service.as_ref(),
|
||||||
|
user_id,
|
||||||
|
&claims.role,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
LiveRole::Active(role) => Ok((user_id, role)),
|
||||||
|
LiveRole::Revoked => Err(AppError::unauthorized("Account is no longer active")),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use crate::common::di::AppState;
|
|||||||
// Re-export CurrentUser from application layer for use in handlers
|
// Re-export CurrentUser from application layer for use in handlers
|
||||||
pub use crate::application::dtos::user_dto::CurrentUser;
|
pub use crate::application::dtos::user_dto::CurrentUser;
|
||||||
use crate::application::ports::auth_ports::TokenServicePort;
|
use crate::application::ports::auth_ports::TokenServicePort;
|
||||||
|
use crate::interfaces::middleware::user::{LiveRole, resolve_live_role};
|
||||||
|
|
||||||
/// Marker inserted into request extensions when the user was authenticated
|
/// Marker inserted into request extensions when the user was authenticated
|
||||||
/// via the `oxicloud_access` HttpOnly cookie rather than a Bearer/Basic header.
|
/// via the `oxicloud_access` HttpOnly cookie rather than a Bearer/Basic header.
|
||||||
@@ -111,6 +112,9 @@ pub enum AuthError {
|
|||||||
#[error("User not found")]
|
#[error("User not found")]
|
||||||
UserNotFound,
|
UserNotFound,
|
||||||
|
|
||||||
|
#[error("Account is no longer active")]
|
||||||
|
AccountInactive,
|
||||||
|
|
||||||
#[error("Access denied: {0}")]
|
#[error("Access denied: {0}")]
|
||||||
AccessDenied(String),
|
AccessDenied(String),
|
||||||
|
|
||||||
@@ -127,6 +131,10 @@ impl IntoResponse for AuthError {
|
|||||||
AuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, msg),
|
AuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, msg),
|
||||||
AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token expired".to_string()),
|
AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token expired".to_string()),
|
||||||
AuthError::UserNotFound => (StatusCode::UNAUTHORIZED, "User not found".to_string()),
|
AuthError::UserNotFound => (StatusCode::UNAUTHORIZED, "User not found".to_string()),
|
||||||
|
AuthError::AccountInactive => (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Account is no longer active".to_string(),
|
||||||
|
),
|
||||||
AuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, msg),
|
AuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, msg),
|
||||||
AuthError::AuthServiceUnavailable => (
|
AuthError::AuthServiceUnavailable => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -181,11 +189,26 @@ pub async fn auth_middleware(
|
|||||||
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
|
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
|
||||||
AuthError::InvalidToken("Invalid user ID in token".to_string())
|
AuthError::InvalidToken("Invalid user ID in token".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
// A cryptographically valid token must not outlive the
|
||||||
|
// account: re-check the live record so deactivation,
|
||||||
|
// deletion and demotion take effect within the flags-cache
|
||||||
|
// TTL instead of waiting for token expiry. The returned
|
||||||
|
// role is authoritative — never the frozen JWT claim.
|
||||||
|
let role = match resolve_live_role(
|
||||||
|
auth_service.auth_application_service.as_ref(),
|
||||||
|
user_id,
|
||||||
|
&claims.role,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
LiveRole::Active(role) => role,
|
||||||
|
LiveRole::Revoked => return Err(AuthError::AccountInactive),
|
||||||
|
};
|
||||||
let current_user = Arc::new(CurrentUser {
|
let current_user = Arc::new(CurrentUser {
|
||||||
id: user_id,
|
id: user_id,
|
||||||
username: claims.username.clone(),
|
username: claims.username.clone(),
|
||||||
email: claims.email.clone(),
|
email: claims.email.clone(),
|
||||||
role: claims.role.clone(),
|
role,
|
||||||
});
|
});
|
||||||
request.extensions_mut().insert(current_user);
|
request.extensions_mut().insert(current_user);
|
||||||
tracing::Span::current().record("user_id", user_id.to_string());
|
tracing::Span::current().record("user_id", user_id.to_string());
|
||||||
@@ -280,16 +303,33 @@ pub async fn auth_middleware(
|
|||||||
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
|
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
|
||||||
AuthError::InvalidToken("Invalid user ID in token".to_string())
|
AuthError::InvalidToken("Invalid user ID in token".to_string())
|
||||||
})?;
|
})?;
|
||||||
let current_user = Arc::new(CurrentUser {
|
// Same live-account re-check as the Bearer path. On
|
||||||
id: user_id,
|
// revocation we fall through (rather than erroring) so the
|
||||||
username: claims.username.clone(),
|
// browser receives the standard 401 and redirects to
|
||||||
email: claims.email.clone(),
|
// /login, exactly like an invalid or expired cookie.
|
||||||
role: claims.role.clone(),
|
match resolve_live_role(
|
||||||
});
|
auth_service.auth_application_service.as_ref(),
|
||||||
request.extensions_mut().insert(current_user);
|
user_id,
|
||||||
request.extensions_mut().insert(CookieAuthenticated);
|
&claims.role,
|
||||||
tracing::Span::current().record("user_id", user_id.to_string());
|
)
|
||||||
return Ok(next.run(request).await);
|
.await
|
||||||
|
{
|
||||||
|
LiveRole::Active(role) => {
|
||||||
|
let current_user = Arc::new(CurrentUser {
|
||||||
|
id: user_id,
|
||||||
|
username: claims.username.clone(),
|
||||||
|
email: claims.email.clone(),
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
request.extensions_mut().insert(current_user);
|
||||||
|
request.extensions_mut().insert(CookieAuthenticated);
|
||||||
|
tracing::Span::current().record("user_id", user_id.to_string());
|
||||||
|
return Ok(next.run(request).await);
|
||||||
|
}
|
||||||
|
LiveRole::Revoked => {
|
||||||
|
// Fall through to the unauthenticated 401 / login redirect.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::debug!("Cookie token validation failed: {}", e);
|
tracing::debug!("Cookie token validation failed: {}", e);
|
||||||
@@ -344,8 +384,11 @@ fn dav_basic_auth_challenge(message: &'static str) -> Response {
|
|||||||
|
|
||||||
/// Middleware to verify that the authenticated user has an admin role.
|
/// Middleware to verify that the authenticated user has an admin role.
|
||||||
///
|
///
|
||||||
/// Must be applied AFTER auth_middleware, as it depends on
|
/// Must be applied AFTER auth_middleware, as it depends on `CurrentUser`
|
||||||
/// `CurrentUser` being present in the request extensions.
|
/// being present in the request extensions. The role carried by
|
||||||
|
/// `CurrentUser` is the *live* role resolved by `auth_middleware` (see
|
||||||
|
/// [`resolve_live_role`]), not the JWT claim, so a demotion is honoured
|
||||||
|
/// here within the flags-cache TTL.
|
||||||
pub async fn require_admin(request: Request, next: Next) -> Response {
|
pub async fn require_admin(request: Request, next: Next) -> Response {
|
||||||
// Get the CurrentUser inserted by auth_middleware
|
// Get the CurrentUser inserted by auth_middleware
|
||||||
if let Some(current_user) = request.extensions().get::<Arc<CurrentUser>>() {
|
if let Some(current_user) = request.extensions().get::<Arc<CurrentUser>>() {
|
||||||
@@ -353,13 +396,21 @@ pub async fn require_admin(request: Request, next: Next) -> Response {
|
|||||||
tracing::debug!("Admin access granted for user: {}", current_user.username);
|
tracing::debug!("Admin access granted for user: {}", current_user.username);
|
||||||
return next.run(request).await;
|
return next.run(request).await;
|
||||||
}
|
}
|
||||||
tracing::warn!(
|
tracing::info!(
|
||||||
"Admin access denied for user: {} (role: {})",
|
target: "audit",
|
||||||
current_user.username,
|
event = "authz.admin_denied",
|
||||||
current_user.role
|
reason = "not_admin",
|
||||||
|
caller_id = %current_user.id,
|
||||||
|
role = %current_user.role,
|
||||||
|
"👮🏻♂️ admin-only route denied for non-admin caller"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("Admin check failed: no authenticated user in request");
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "authz.admin_denied",
|
||||||
|
reason = "unauthenticated",
|
||||||
|
"👮🏻♂️ admin-only route reached with no authenticated user"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access denied
|
// Access denied
|
||||||
@@ -415,4 +466,13 @@ mod tests {
|
|||||||
Some(r#"Basic realm="OxiCloud""#),
|
Some(r#"Basic realm="OxiCloud""#),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn account_inactive_maps_to_401() {
|
||||||
|
// A token that is still cryptographically valid but whose account was
|
||||||
|
// deactivated/deleted must be rejected with 401 (credentials no longer
|
||||||
|
// valid), so browsers redirect to /login rather than seeing a 403.
|
||||||
|
let resp = AuthError::AccountInactive.into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::domain::entities::user::UserRole;
|
use crate::domain::entities::user::{UserFlags, UserRole};
|
||||||
|
use crate::domain::errors::{DomainError, ErrorKind};
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::CurrentUser;
|
use crate::interfaces::middleware::auth::CurrentUser;
|
||||||
|
|
||||||
@@ -102,6 +103,91 @@ pub async fn require_admin_user(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Outcome of re-checking a token-authenticated caller against the live
|
||||||
|
/// user record (see [`resolve_live_role`]).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum LiveRole {
|
||||||
|
/// The account exists and is active. Carries the caller's *current*
|
||||||
|
/// role string (`"admin"` / `"user"`), which is authoritative and
|
||||||
|
/// supersedes the — possibly stale — JWT `role` claim.
|
||||||
|
Active(String),
|
||||||
|
/// The account is deactivated or deleted: the request must be rejected
|
||||||
|
/// even though its token is still cryptographically valid.
|
||||||
|
Revoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-validate a caller carried by a still-valid token against the live
|
||||||
|
/// user record, so deactivation, deletion and role changes take effect
|
||||||
|
/// within [`USER_FLAGS_CACHE_TTL`](crate::application::services::auth_application_service)
|
||||||
|
/// instead of waiting for the token to expire (access 1 h / refresh 7 d by
|
||||||
|
/// default).
|
||||||
|
///
|
||||||
|
/// JWT claims — `role` included — are frozen at login. Without this check a
|
||||||
|
/// demoted admin keeps admin power, and a disabled or deleted account keeps
|
||||||
|
/// full access, until its token expires. Returning the *current* role lets
|
||||||
|
/// every caller stop trusting `claims.role`.
|
||||||
|
///
|
||||||
|
/// Cost: the short-TTL-cached `get_user_flags` (no `image` column), so ~one
|
||||||
|
/// tiny indexed query per user per cache-TTL window; admin role/active
|
||||||
|
/// changes invalidate the entry eagerly for immediate effect.
|
||||||
|
///
|
||||||
|
/// Availability stance mirrors [`require_internal_user`]: a *transient*
|
||||||
|
/// lookup failure fails OPEN with the claim role (a DB blip must not lock
|
||||||
|
/// every authenticated user out, and login/refresh already enforce `active`
|
||||||
|
/// at the canonical layer). A *missing* row (`NotFound`) is a definitive
|
||||||
|
/// revocation and fails CLOSED.
|
||||||
|
pub async fn resolve_live_role(
|
||||||
|
auth: &AuthApplicationService,
|
||||||
|
user_id: Uuid,
|
||||||
|
claim_role: &str,
|
||||||
|
) -> LiveRole {
|
||||||
|
decide_live_role(auth.get_user_flags(user_id).await, user_id, claim_role)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure decision core of [`resolve_live_role`], split out so the
|
||||||
|
/// allow/revoke/fail-open policy is unit-testable without a service or DB.
|
||||||
|
fn decide_live_role(
|
||||||
|
flags: Result<UserFlags, DomainError>,
|
||||||
|
user_id: Uuid,
|
||||||
|
claim_role: &str,
|
||||||
|
) -> LiveRole {
|
||||||
|
match flags {
|
||||||
|
Ok(flags) if flags.active => LiveRole::Active(flags.role.to_string()),
|
||||||
|
Ok(_) => {
|
||||||
|
audit_token_revoked(user_id, "deactivated");
|
||||||
|
LiveRole::Revoked
|
||||||
|
}
|
||||||
|
// The user row is gone — a definitive revocation; fail closed.
|
||||||
|
Err(e) if matches!(e.kind, ErrorKind::NotFound) => {
|
||||||
|
audit_token_revoked(user_id, "deleted");
|
||||||
|
LiveRole::Revoked
|
||||||
|
}
|
||||||
|
// Transient lookup failure (DB blip): fail open on the claim role so
|
||||||
|
// a momentary outage doesn't 401 every authenticated user at once.
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
user_id = %user_id,
|
||||||
|
error = %e,
|
||||||
|
"live-user re-check failed transiently; allowing request on the JWT claim role (fail-open)"
|
||||||
|
);
|
||||||
|
LiveRole::Active(claim_role.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audit a request rejected because the token outlived the account's access
|
||||||
|
/// (deactivation or deletion). Anti-enumeration is not a concern — the
|
||||||
|
/// subject is the caller's own account.
|
||||||
|
fn audit_token_revoked(user_id: Uuid, reason: &'static str) {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "auth.token_revoked",
|
||||||
|
reason = reason,
|
||||||
|
caller_id = %user_id,
|
||||||
|
"👮🏻♂️ valid token presented for an account that is no longer active — rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Axum middleware layer that blocks external users from a whole route
|
/// Axum middleware layer that blocks external users from a whole route
|
||||||
/// subtree. Apply via `.layer(from_fn_with_state(state, require_internal_user_layer))`
|
/// subtree. Apply via `.layer(from_fn_with_state(state, require_internal_user_layer))`
|
||||||
/// on the protocol nests (CalDAV / CardDAV / WebDAV) that have no
|
/// on the protocol nests (CalDAV / CardDAV / WebDAV) that have no
|
||||||
@@ -153,3 +239,52 @@ pub async fn require_internal_user_layer(
|
|||||||
|
|
||||||
next.run(request).await
|
next.run(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn flags(role: UserRole, active: bool) -> UserFlags {
|
||||||
|
UserFlags {
|
||||||
|
role,
|
||||||
|
is_external: false,
|
||||||
|
active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_admin_yields_current_admin_role() {
|
||||||
|
let live = decide_live_role(Ok(flags(UserRole::Admin, true)), Uuid::nil(), "user");
|
||||||
|
// The live record wins over the (stale) claim — a freshly promoted
|
||||||
|
// user is admin even though their token still says "user".
|
||||||
|
assert_eq!(live, LiveRole::Active("admin".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_user_yields_current_user_role() {
|
||||||
|
// A demoted admin: token claim still "admin", live record "user".
|
||||||
|
let live = decide_live_role(Ok(flags(UserRole::User, true)), Uuid::nil(), "admin");
|
||||||
|
assert_eq!(live, LiveRole::Active("user".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deactivated_account_is_revoked() {
|
||||||
|
let live = decide_live_role(Ok(flags(UserRole::Admin, false)), Uuid::nil(), "admin");
|
||||||
|
assert_eq!(live, LiveRole::Revoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleted_account_not_found_is_revoked() {
|
||||||
|
let err = DomainError::new(ErrorKind::NotFound, "User", "no such user");
|
||||||
|
let live = decide_live_role(Err(err), Uuid::nil(), "admin");
|
||||||
|
assert_eq!(live, LiveRole::Revoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transient_error_fails_open_on_claim_role() {
|
||||||
|
// A DB blip must not lock everyone out: allow on the claim role.
|
||||||
|
let err = DomainError::new(ErrorKind::InternalError, "User", "connection reset");
|
||||||
|
let live = decide_live_role(Err(err), Uuid::nil(), "admin");
|
||||||
|
assert_eq!(live, LiveRole::Active("admin".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use quick_xml::{
|
|||||||
Reader, Writer,
|
Reader, Writer,
|
||||||
events::{BytesEnd, BytesStart, Event},
|
events::{BytesEnd, BytesStart, Event},
|
||||||
};
|
};
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::application::dtos::display_helpers::{
|
use crate::application::dtos::display_helpers::{
|
||||||
@@ -17,7 +17,6 @@ use crate::application::dtos::file_dto::FileDto;
|
|||||||
use crate::application::dtos::folder_dto::FolderDto;
|
use crate::application::dtos::folder_dto::FolderDto;
|
||||||
use crate::application::dtos::search_dto::SearchCriteriaDto;
|
use crate::application::dtos::search_dto::SearchCriteriaDto;
|
||||||
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
||||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
|
||||||
use crate::application::ports::folder_ports::FolderUseCase;
|
use crate::application::ports::folder_ports::FolderUseCase;
|
||||||
use crate::application::ports::inbound::SearchUseCase;
|
use crate::application::ports::inbound::SearchUseCase;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
@@ -86,20 +85,47 @@ async fn handle_filter_files(
|
|||||||
|
|
||||||
let home_prefix = format!("My Folder - {}/", user.username);
|
let home_prefix = format!("My Folder - {}/", user.username);
|
||||||
|
|
||||||
// Pass 1: fetch the favorited DTOs (the per-item fetch is a separate
|
// Pass 1: resolve the favorited DTOs in two batch queries (was one
|
||||||
// concern from the oc:fileid resolution batched below).
|
// get_* per favorite — up to N serial round-trips on a sync client's
|
||||||
|
// REPORT). Results are looked up by id so the response keeps favorites
|
||||||
|
// order; missing/trashed favorites simply drop out (as before).
|
||||||
|
let mut file_ids: Vec<String> = Vec::new();
|
||||||
|
let mut folder_ids: Vec<String> = Vec::new();
|
||||||
|
for fav in &favorites {
|
||||||
|
match fav.item_type.as_str() {
|
||||||
|
"file" => file_ids.push(fav.item_id.clone()),
|
||||||
|
"folder" => folder_ids.push(fav.item_id.clone()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_map: HashMap<String, FileDto> = file_service
|
||||||
|
.get_files_by_ids(&file_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::internal_error(format!("Failed to resolve favorite files: {e}")))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|f| (f.id.clone(), f))
|
||||||
|
.collect();
|
||||||
|
let folder_map: HashMap<String, FolderDto> = folder_service
|
||||||
|
.get_folders_by_ids(&folder_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::internal_error(format!("Failed to resolve favorite folders: {e}")))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|f| (f.id.clone(), f))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let mut files: Vec<FileDto> = Vec::new();
|
let mut files: Vec<FileDto> = Vec::new();
|
||||||
let mut folders: Vec<FolderDto> = Vec::new();
|
let mut folders: Vec<FolderDto> = Vec::new();
|
||||||
for fav in &favorites {
|
for fav in &favorites {
|
||||||
match fav.item_type.as_str() {
|
match fav.item_type.as_str() {
|
||||||
"file" => {
|
"file" => {
|
||||||
if let Ok(f) = file_service.get_file(&fav.item_id).await {
|
if let Some(f) = file_map.get(&fav.item_id) {
|
||||||
files.push(f);
|
files.push(f.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"folder" => {
|
"folder" => {
|
||||||
if let Ok(f) = folder_service.get_folder(&fav.item_id).await {
|
if let Some(f) = folder_map.get(&fav.item_id) {
|
||||||
folders.push(f);
|
folders.push(f.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
Reference in New Issue
Block a user