style: apply rustfmt formatting to fix CI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -80,7 +80,11 @@ pub trait ChunkedUploadPort: Send + Sync + 'static {
|
|||||||
) -> Result<ChunkUploadResponseDto, DomainError>;
|
) -> Result<ChunkUploadResponseDto, DomainError>;
|
||||||
|
|
||||||
/// Get the current status of an upload session.
|
/// Get the current status of an upload session.
|
||||||
async fn get_status(&self, upload_id: &str, user_id: &str) -> Result<UploadStatusResponseDto, DomainError>;
|
async fn get_status(
|
||||||
|
&self,
|
||||||
|
upload_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<UploadStatusResponseDto, DomainError>;
|
||||||
|
|
||||||
/// Assemble all chunks into the final file.
|
/// Assemble all chunks into the final file.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -16,11 +16,7 @@ pub trait ShareUseCase: Send + Sync + 'static {
|
|||||||
) -> Result<ShareDto, DomainError>;
|
) -> Result<ShareDto, DomainError>;
|
||||||
|
|
||||||
/// Get a shared link by its ID (ownership-verified)
|
/// Get a shared link by its ID (ownership-verified)
|
||||||
async fn get_shared_link(
|
async fn get_shared_link(&self, id: &str, requester_id: &str) -> Result<ShareDto, DomainError>;
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
requester_id: &str,
|
|
||||||
) -> Result<ShareDto, DomainError>;
|
|
||||||
|
|
||||||
/// Get a shared link by its token (for access by non-users)
|
/// Get a shared link by its token (for access by non-users)
|
||||||
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError>;
|
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError>;
|
||||||
|
|||||||
@@ -384,10 +384,7 @@ impl AdminSettingsService {
|
|||||||
/// initialized (the caller "won" the race), or `Ok(false)` if another
|
/// initialized (the caller "won" the race), or `Ok(false)` if another
|
||||||
/// request already did it. This eliminates the race-condition window
|
/// request already did it. This eliminates the race-condition window
|
||||||
/// between `is_system_initialized()` and `mark_system_initialized()`.
|
/// between `is_system_initialized()` and `mark_system_initialized()`.
|
||||||
pub async fn try_claim_initialization(
|
pub async fn try_claim_initialization(&self, admin_user_id: &str) -> Result<bool, DomainError> {
|
||||||
&self,
|
|
||||||
admin_user_id: &str,
|
|
||||||
) -> Result<bool, DomainError> {
|
|
||||||
self.settings_repo
|
self.settings_repo
|
||||||
.try_claim_initialization(admin_user_id)
|
.try_claim_initialization(admin_user_id)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -682,7 +682,11 @@ impl BatchOperationService {
|
|||||||
|
|
||||||
// ── Add folders as sub-trees (bulk subtree queries, not N+1) ─────
|
// ── Add folders as sub-trees (bulk subtree queries, not N+1) ─────
|
||||||
for folder_id in &folder_ids {
|
for folder_id in &folder_ids {
|
||||||
match self.folder_service.get_folder_owned(folder_id, caller_id).await {
|
match self
|
||||||
|
.folder_service
|
||||||
|
.get_folder_owned(folder_id, caller_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(root_folder) => {
|
Ok(root_folder) => {
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.add_folder_subtree_to_zip(&mut zip, folder_id, &root_folder, caller_id)
|
.add_folder_subtree_to_zip(&mut zip, folder_id, &root_folder, caller_id)
|
||||||
@@ -928,7 +932,9 @@ impl BatchOperationService {
|
|||||||
|
|
||||||
async move {
|
async move {
|
||||||
// If a parent is specified, verify the caller owns it
|
// If a parent is specified, verify the caller owns it
|
||||||
if let Some(ref pid) = parent_id && let Err(e) = folder_service.get_folder_owned(pid, &caller).await {
|
if let Some(ref pid) = parent_id
|
||||||
|
&& let Err(e) = folder_service.get_folder_owned(pid, &caller).await
|
||||||
|
{
|
||||||
let id = format!("{}:{}", name, pid);
|
let id = format!("{}:{}", name, pid);
|
||||||
return (id, Err(e));
|
return (id, Err(e));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,7 +228,10 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
|||||||
folder_id: Option<&str>,
|
folder_id: Option<&str>,
|
||||||
owner_id: &str,
|
owner_id: &str,
|
||||||
) -> Result<Vec<FileDto>, DomainError> {
|
) -> Result<Vec<FileDto>, DomainError> {
|
||||||
let files = self.file_read.list_files_for_owner(folder_id, owner_id).await?;
|
let files = self
|
||||||
|
.file_read
|
||||||
|
.list_files_for_owner(folder_id, owner_id)
|
||||||
|
.await?;
|
||||||
Ok(files.into_iter().map(FileDto::from).collect())
|
Ok(files.into_iter().map(FileDto::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ impl FolderService {
|
|||||||
Ok(FolderDto::empty())
|
Ok(FolderDto::empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_folder_owned(&self, _id: &str, _caller_id: &str) -> Result<FolderDto, DomainError> {
|
async fn get_folder_owned(
|
||||||
|
&self,
|
||||||
|
_id: &str,
|
||||||
|
_caller_id: &str,
|
||||||
|
) -> Result<FolderDto, DomainError> {
|
||||||
Ok(FolderDto::empty())
|
Ok(FolderDto::empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ use crate::{
|
|||||||
storage_ports::FileReadPort,
|
storage_ports::FileReadPort,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
common::{config::AppConfig, errors::{DomainError, ErrorKind}},
|
common::{
|
||||||
|
config::AppConfig,
|
||||||
|
errors::{DomainError, ErrorKind},
|
||||||
|
},
|
||||||
domain::entities::share::{Share, ShareItemType, SharePermissions},
|
domain::entities::share::{Share, ShareItemType, SharePermissions},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,11 +150,7 @@ impl ShareService {
|
|||||||
/// but belongs to a different user — this prevents share-ID enumeration
|
/// but belongs to a different user — this prevents share-ID enumeration
|
||||||
/// attacks where an attacker probes IDs and uses 403-vs-404 to learn
|
/// attacks where an attacker probes IDs and uses 403-vs-404 to learn
|
||||||
/// which ones are valid.
|
/// which ones are valid.
|
||||||
async fn fetch_owned_share(
|
async fn fetch_owned_share(&self, id: &str, requester_id: &str) -> Result<Share, DomainError> {
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
requester_id: &str,
|
|
||||||
) -> Result<Share, DomainError> {
|
|
||||||
let share = self
|
let share = self
|
||||||
.share_repository
|
.share_repository
|
||||||
.find_share_by_id_for_user(id, requester_id)
|
.find_share_by_id_for_user(id, requester_id)
|
||||||
@@ -206,11 +205,7 @@ impl ShareUseCase for ShareService {
|
|||||||
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
|
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_shared_link(
|
async fn get_shared_link(&self, id: &str, requester_id: &str) -> Result<ShareDto, DomainError> {
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
requester_id: &str,
|
|
||||||
) -> Result<ShareDto, DomainError> {
|
|
||||||
// SECURITY: ownership-verified lookup — returns 404 if the share
|
// SECURITY: ownership-verified lookup — returns 404 if the share
|
||||||
// doesn't exist OR belongs to another user.
|
// doesn't exist OR belongs to another user.
|
||||||
let share = self.fetch_owned_share(id, requester_id).await?;
|
let share = self.fetch_owned_share(id, requester_id).await?;
|
||||||
@@ -553,7 +548,11 @@ mod tests {
|
|||||||
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
|
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_shared_link(&self, id: &str, requester_id: &str) -> Result<ShareDto, DomainError> {
|
async fn get_shared_link(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
requester_id: &str,
|
||||||
|
) -> Result<ShareDto, DomainError> {
|
||||||
let share = self
|
let share = self
|
||||||
.share_repository
|
.share_repository
|
||||||
.find_share_by_id_for_user(id, requester_id)
|
.find_share_by_id_for_user(id, requester_id)
|
||||||
@@ -634,7 +633,11 @@ mod tests {
|
|||||||
Ok(ShareDto::from_entity(&updated, &self.config.base_url()))
|
Ok(ShareDto::from_entity(&updated, &self.config.base_url()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_shared_link(&self, id: &str, requester_id: &str) -> Result<(), DomainError> {
|
async fn delete_shared_link(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
requester_id: &str,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
self.share_repository
|
self.share_repository
|
||||||
.delete_share_for_user(id, requester_id)
|
.delete_share_for_user(id, requester_id)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -186,7 +186,11 @@ impl TrashUseCase for TrashService {
|
|||||||
// Returns NotFound if the file does not exist OR belongs to
|
// Returns NotFound if the file does not exist OR belongs to
|
||||||
// another user, preventing cross-user trash operations.
|
// another user, preventing cross-user trash operations.
|
||||||
debug!("Getting file data (owner-scoped): {}", item_id);
|
debug!("Getting file data (owner-scoped): {}", item_id);
|
||||||
let file = match self.file_read_port.get_file_for_owner(item_id, user_id).await {
|
let file = match self
|
||||||
|
.file_read_port
|
||||||
|
.get_file_for_owner(item_id, user_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(file) => {
|
Ok(file) => {
|
||||||
debug!("File found: {} ({})", file.name(), item_id);
|
debug!("File found: {} ({})", file.name(), item_id);
|
||||||
file
|
file
|
||||||
|
|||||||
+5
-1
@@ -359,7 +359,11 @@ impl FolderUseCase for StubFolderUseCase {
|
|||||||
Ok(FolderDto::default())
|
Ok(FolderDto::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_folder_owned(&self, _id: &str, _caller_id: &str) -> Result<FolderDto, DomainError> {
|
async fn get_folder_owned(
|
||||||
|
&self,
|
||||||
|
_id: &str,
|
||||||
|
_caller_id: &str,
|
||||||
|
) -> Result<FolderDto, DomainError> {
|
||||||
Ok(FolderDto::default())
|
Ok(FolderDto::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -292,8 +292,10 @@ impl User {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Disallow leading/trailing dots or hyphens
|
// Disallow leading/trailing dots or hyphens
|
||||||
if username.starts_with('.') || username.starts_with('-')
|
if username.starts_with('.')
|
||||||
|| username.ends_with('.') || username.ends_with('-')
|
|| username.starts_with('-')
|
||||||
|
|| username.ends_with('.')
|
||||||
|
|| username.ends_with('-')
|
||||||
{
|
{
|
||||||
return Err(UserError::InvalidUsername(
|
return Err(UserError::InvalidUsername(
|
||||||
"Username must not start or end with a dot or hyphen".to_string(),
|
"Username must not start or end with a dot or hyphen".to_string(),
|
||||||
@@ -310,7 +312,9 @@ impl User {
|
|||||||
fn validate_email(email: &str) -> UserResult<()> {
|
fn validate_email(email: &str) -> UserResult<()> {
|
||||||
let parts: Vec<&str> = email.splitn(2, '@').collect();
|
let parts: Vec<&str> = email.splitn(2, '@').collect();
|
||||||
if parts.len() != 2 {
|
if parts.len() != 2 {
|
||||||
return Err(UserError::ValidationError("Invalid email: missing @".to_string()));
|
return Err(UserError::ValidationError(
|
||||||
|
"Invalid email: missing @".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let (local, domain) = (parts[0], parts[1]);
|
let (local, domain) = (parts[0], parts[1]);
|
||||||
if local.is_empty() || domain.is_empty() {
|
if local.is_empty() || domain.is_empty() {
|
||||||
@@ -324,7 +328,9 @@ impl User {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Reject characters commonly used in XSS / header injection
|
// Reject characters commonly used in XSS / header injection
|
||||||
let forbidden = ['<', '>', '"', '\'', '\\', ' ', '\t', '\n', '\r', '(', ')', ',', ';'];
|
let forbidden = [
|
||||||
|
'<', '>', '"', '\'', '\\', ' ', '\t', '\n', '\r', '(', ')', ',', ';',
|
||||||
|
];
|
||||||
if email.chars().any(|c| forbidden.contains(&c)) {
|
if email.chars().any(|c| forbidden.contains(&c)) {
|
||||||
return Err(UserError::ValidationError(
|
return Err(UserError::ValidationError(
|
||||||
"Invalid email: contains forbidden characters".to_string(),
|
"Invalid email: contains forbidden characters".to_string(),
|
||||||
|
|||||||
@@ -34,10 +34,7 @@ pub trait SettingsRepository: Send + Sync + 'static {
|
|||||||
/// The default implementation falls back to the non-atomic
|
/// The default implementation falls back to the non-atomic
|
||||||
/// get-then-set pattern for repositories that don't support a native
|
/// get-then-set pattern for repositories that don't support a native
|
||||||
/// atomic upsert.
|
/// atomic upsert.
|
||||||
async fn try_claim_initialization(
|
async fn try_claim_initialization(&self, admin_user_id: &str) -> Result<bool, DomainError> {
|
||||||
&self,
|
|
||||||
admin_user_id: &str,
|
|
||||||
) -> Result<bool, DomainError> {
|
|
||||||
// Default: non-atomic fallback (overridden by PG implementation)
|
// Default: non-atomic fallback (overridden by PG implementation)
|
||||||
match self.get("system_initialized").await? {
|
match self.get("system_initialized").await? {
|
||||||
Some(v) if v == "true" => Ok(false),
|
Some(v) if v == "true" => Ok(false),
|
||||||
|
|||||||
@@ -325,9 +325,7 @@ impl FileReadPort for FileBlobReadRepository {
|
|||||||
.fetch_all(self.pool.as_ref())
|
.fetch_all(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
.map_err(|e| {
|
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_for_owner: {e}")))?;
|
||||||
DomainError::internal_error("FileBlobRead", format!("list_for_owner: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| {
|
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| {
|
||||||
|
|||||||
@@ -868,7 +868,9 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
user_id: &str,
|
user_id: &str,
|
||||||
) -> Result<Vec<Folder>, DomainError> {
|
) -> Result<Vec<Folder>, DomainError> {
|
||||||
let (where_extra, name_pattern) = match name_contains {
|
let (where_extra, name_pattern) = match name_contains {
|
||||||
Some(name) if name.len() >= 3 => (" AND fo.name ILIKE $3", Some(super::like_escape(name))),
|
Some(name) if name.len() >= 3 => {
|
||||||
|
(" AND fo.name ILIKE $3", Some(super::like_escape(name)))
|
||||||
|
}
|
||||||
_ => ("", None),
|
_ => ("", None),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ pub use user_pg_repository::UserPgRepository;
|
|||||||
/// `%` is a wildcard in LIKE patterns.
|
/// `%` is a wildcard in LIKE patterns.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn like_escape(raw: &str) -> String {
|
pub fn like_escape(raw: &str) -> String {
|
||||||
let escaped = raw.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
let escaped = raw
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace('%', "\\%")
|
||||||
|
.replace('_', "\\_");
|
||||||
format!("%{escaped}%")
|
format!("%{escaped}%")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,10 +102,7 @@ impl SettingsRepository for SettingsPgRepository {
|
|||||||
///
|
///
|
||||||
/// Only the first caller that inserts the row gets `rows_affected == 1`;
|
/// Only the first caller that inserts the row gets `rows_affected == 1`;
|
||||||
/// concurrent callers see 0 rows affected and receive `false`.
|
/// concurrent callers see 0 rows affected and receive `false`.
|
||||||
async fn try_claim_initialization(
|
async fn try_claim_initialization(&self, admin_user_id: &str) -> Result<bool, DomainError> {
|
||||||
&self,
|
|
||||||
admin_user_id: &str,
|
|
||||||
) -> Result<bool, DomainError> {
|
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"INSERT INTO auth.admin_settings (key, value, category, is_secret, updated_by, updated_at)
|
"INSERT INTO auth.admin_settings (key, value, category, is_secret, updated_by, updated_at)
|
||||||
VALUES ('system_initialized', 'true', 'system', false, $1, NOW())
|
VALUES ('system_initialized', 'true', 'system', false, $1, NOW())
|
||||||
|
|||||||
@@ -202,10 +202,7 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Database error deleting share for user: {}", e);
|
tracing::error!("Database error deleting share for user: {}", e);
|
||||||
DomainError::internal_error(
|
DomainError::internal_error("Share", format!("Failed to delete share: {e}"))
|
||||||
"Share",
|
|
||||||
format!("Failed to delete share: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
@@ -242,10 +239,7 @@ impl ShareStoragePort for SharePgRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Database error finding shares by item for user: {}", e);
|
tracing::error!("Database error finding shares by item for user: {}", e);
|
||||||
DomainError::internal_error(
|
DomainError::internal_error("Share", format!("Failed to find shares by item: {e}"))
|
||||||
"Share",
|
|
||||||
format!("Failed to find shares by item: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
rows.iter().map(Self::row_to_entity).collect()
|
rows.iter().map(Self::row_to_entity).collect()
|
||||||
|
|||||||
@@ -371,11 +371,7 @@ impl ChunkedUploadService {
|
|||||||
|
|
||||||
/// Verify that the given session belongs to the given user.
|
/// Verify that the given session belongs to the given user.
|
||||||
/// Returns 404 (not 403) to avoid revealing the existence of other users' sessions.
|
/// Returns 404 (not 403) to avoid revealing the existence of other users' sessions.
|
||||||
fn verify_session_owner(
|
fn verify_session_owner(&self, upload_id: &str, user_id: &str) -> Result<(), String> {
|
||||||
&self,
|
|
||||||
upload_id: &str,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let session = self
|
let session = self
|
||||||
.sessions
|
.sessions
|
||||||
.get(upload_id)
|
.get(upload_id)
|
||||||
@@ -592,7 +588,11 @@ impl ChunkedUploadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get upload status
|
/// Get upload status
|
||||||
async fn get_status_inner(&self, upload_id: &str, user_id: &str) -> Result<UploadStatusResponseDto, String> {
|
async fn get_status_inner(
|
||||||
|
&self,
|
||||||
|
upload_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<UploadStatusResponseDto, String> {
|
||||||
self.verify_session_owner(upload_id, user_id)?;
|
self.verify_session_owner(upload_id, user_id)?;
|
||||||
|
|
||||||
let session = self
|
let session = self
|
||||||
@@ -808,9 +808,16 @@ impl ChunkedUploadPort for ChunkedUploadService {
|
|||||||
total_size: u64,
|
total_size: u64,
|
||||||
chunk_size: Option<usize>,
|
chunk_size: Option<usize>,
|
||||||
) -> Result<CreateUploadResponseDto, DomainError> {
|
) -> Result<CreateUploadResponseDto, DomainError> {
|
||||||
self.create_session_inner(user_id.to_owned(), filename, folder_id, content_type, total_size, chunk_size)
|
self.create_session_inner(
|
||||||
.await
|
user_id.to_owned(),
|
||||||
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))
|
filename,
|
||||||
|
folder_id,
|
||||||
|
content_type,
|
||||||
|
total_size,
|
||||||
|
chunk_size,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upload_chunk(
|
async fn upload_chunk(
|
||||||
@@ -826,7 +833,11 @@ impl ChunkedUploadPort for ChunkedUploadService {
|
|||||||
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))
|
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_status(&self, upload_id: &str, user_id: &str) -> Result<UploadStatusResponseDto, DomainError> {
|
async fn get_status(
|
||||||
|
&self,
|
||||||
|
upload_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<UploadStatusResponseDto, DomainError> {
|
||||||
self.get_status_inner(upload_id, user_id)
|
self.get_status_inner(upload_id, user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::new(ErrorKind::NotFound, "ChunkedUpload", e))
|
.map_err(|e| DomainError::new(ErrorKind::NotFound, "ChunkedUpload", e))
|
||||||
@@ -1097,14 +1108,19 @@ mod tests {
|
|||||||
assert_eq!(r1.bytes_received, 1024);
|
assert_eq!(r1.bytes_received, 1024);
|
||||||
|
|
||||||
// 3. Status check
|
// 3. Status check
|
||||||
let status = service.get_status_inner(&id, "test-user").await.expect("status");
|
let status = service
|
||||||
|
.get_status_inner(&id, "test-user")
|
||||||
|
.await
|
||||||
|
.expect("status");
|
||||||
assert!(status.is_complete);
|
assert!(status.is_complete);
|
||||||
assert_eq!(status.completed_chunks, 2);
|
assert_eq!(status.completed_chunks, 2);
|
||||||
assert!(status.pending_chunks.is_empty());
|
assert!(status.pending_chunks.is_empty());
|
||||||
|
|
||||||
// 4. Complete (assemble)
|
// 4. Complete (assemble)
|
||||||
let (path, filename, _folder, _ct, size, hash) =
|
let (path, filename, _folder, _ct, size, hash) = service
|
||||||
service.complete_upload_inner(&id, "test-user").await.expect("complete");
|
.complete_upload_inner(&id, "test-user")
|
||||||
|
.await
|
||||||
|
.expect("complete");
|
||||||
assert_eq!(filename, "test.txt");
|
assert_eq!(filename, "test.txt");
|
||||||
assert_eq!(size, 1024);
|
assert_eq!(size, 1024);
|
||||||
assert!(!hash.is_empty());
|
assert!(!hash.is_empty());
|
||||||
@@ -1116,7 +1132,10 @@ mod tests {
|
|||||||
assert_eq!(&content[512..], &[b'B'; 512]);
|
assert_eq!(&content[512..], &[b'B'; 512]);
|
||||||
|
|
||||||
// 6. Finalize
|
// 6. Finalize
|
||||||
service.finalize_upload_inner(&id, "test-user").await.expect("finalize");
|
service
|
||||||
|
.finalize_upload_inner(&id, "test-user")
|
||||||
|
.await
|
||||||
|
.expect("finalize");
|
||||||
assert_eq!(service.active_sessions().await, 0);
|
assert_eq!(service.active_sessions().await, 0);
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(&base).await;
|
let _ = fs::remove_dir_all(&base).await;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tokio::sync::RwLock;
|
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::domain::services::i18n_service::{I18nError, I18nResult, I18nService, Locale};
|
use crate::domain::services::i18n_service::{I18nError, I18nResult, I18nService, Locale};
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
//! Compatible with Authentik, Keycloak, and any standard OIDC provider.
|
//! Compatible with Authentik, Keycloak, and any standard OIDC provider.
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::sync::RwLock;
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::application::ports::auth_ports::{OidcIdClaims, OidcServicePort, OidcTokenSet};
|
use crate::application::ports::auth_ports::{OidcIdClaims, OidcServicePort, OidcTokenSet};
|
||||||
use crate::common::config::OidcConfig;
|
use crate::common::config::OidcConfig;
|
||||||
|
|||||||
@@ -119,10 +119,10 @@ impl PathResolverService {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(path) // $1
|
.bind(path) // $1
|
||||||
.bind(filename) // $2
|
.bind(filename) // $2
|
||||||
.bind(&folder_path) // $3
|
.bind(&folder_path) // $3
|
||||||
.bind(user_id) // $4
|
.bind(user_id) // $4
|
||||||
.fetch_optional(self.pool.as_ref())
|
.fetch_optional(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_for_user: {e}")))?
|
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_for_user: {e}")))?
|
||||||
@@ -215,7 +215,9 @@ impl PathResolverService {
|
|||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_one(self.pool.as_ref())
|
.fetch_one(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("PathResolver", format!("exists_for_user: {e}")))?;
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error("PathResolver", format!("exists_for_user: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(exists)
|
Ok(exists)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ fn tiny_png() -> Vec<u8> {
|
|||||||
let mut img = image::RgbaImage::new(1, 1);
|
let mut img = image::RgbaImage::new(1, 1);
|
||||||
img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
|
img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
img.write_to(
|
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
|
||||||
&mut std::io::Cursor::new(&mut buf),
|
.expect("encode test PNG");
|
||||||
image::ImageFormat::Png,
|
|
||||||
)
|
|
||||||
.expect("encode test PNG");
|
|
||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -342,7 +342,9 @@ async fn logout(
|
|||||||
.ok()
|
.ok()
|
||||||
.map(|dto| dto.refresh_token)
|
.map(|dto| dto.refresh_token)
|
||||||
.or_else(|| cookie_auth::extract_cookie_value(&headers, cookie_auth::REFRESH_COOKIE))
|
.or_else(|| cookie_auth::extract_cookie_value(&headers, cookie_auth::REFRESH_COOKIE))
|
||||||
.ok_or_else(|| AppError::unauthorized("Refresh token required for logout (JSON body or cookie)"))?;
|
.ok_or_else(|| {
|
||||||
|
AppError::unauthorized("Refresh token required for logout (JSON body or cookie)")
|
||||||
|
})?;
|
||||||
|
|
||||||
auth_service
|
auth_service
|
||||||
.auth_application_service
|
.auth_application_service
|
||||||
|
|||||||
@@ -164,7 +164,10 @@ pub async fn move_files_batch(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Batch move_files failed: {}", e);
|
tracing::error!("Batch move_files failed: {}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Batch operation failed".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Convert result to DTO
|
// Convert result to DTO
|
||||||
@@ -217,7 +220,10 @@ pub async fn copy_files_batch(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Batch copy_files failed: {}", e);
|
tracing::error!("Batch copy_files failed: {}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Batch operation failed".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Convert result to DTO
|
// Convert result to DTO
|
||||||
@@ -270,7 +276,10 @@ pub async fn delete_files_batch(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Batch delete_files failed: {}", e);
|
tracing::error!("Batch delete_files failed: {}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Batch operation failed".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Create custom response for string IDs
|
// Create custom response for string IDs
|
||||||
@@ -331,7 +340,10 @@ pub async fn delete_folders_batch(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Batch delete_folders failed: {}", e);
|
tracing::error!("Batch delete_folders failed: {}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Batch operation failed".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Create custom response for string IDs
|
// Create custom response for string IDs
|
||||||
@@ -399,7 +411,10 @@ pub async fn create_folders_batch(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Batch create_folders failed: {}", e);
|
tracing::error!("Batch create_folders failed: {}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Batch operation failed".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Convert result to DTO
|
// Convert result to DTO
|
||||||
@@ -452,7 +467,10 @@ pub async fn get_files_batch(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Batch get_files failed: {}", e);
|
tracing::error!("Batch get_files failed: {}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Batch operation failed".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Convert result to DTO
|
// Convert result to DTO
|
||||||
@@ -505,7 +523,10 @@ pub async fn get_folders_batch(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Batch get_folders failed: {}", e);
|
tracing::error!("Batch get_folders failed: {}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Batch operation failed".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Convert result to DTO
|
// Convert result to DTO
|
||||||
@@ -690,7 +711,10 @@ pub async fn move_folders_batch(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Batch move_folders failed: {}", e);
|
tracing::error!("Batch move_folders failed: {}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Batch operation failed".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let response: BatchOperationResponse<FolderDto> = result.into();
|
let response: BatchOperationResponse<FolderDto> = result.into();
|
||||||
@@ -727,7 +751,10 @@ pub async fn download_batch(
|
|||||||
if combined_size > MAX_BATCH_SIZE {
|
if combined_size > MAX_BATCH_SIZE {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
format!("Batch size {} exceeds maximum of {}", combined_size, MAX_BATCH_SIZE),
|
format!(
|
||||||
|
"Batch size {} exceeds maximum of {}",
|
||||||
|
combined_size, MAX_BATCH_SIZE
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,7 +764,10 @@ pub async fn download_batch(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Batch download ZIP failed: {}", e);
|
tracing::error!("Batch download ZIP failed: {}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch download failed".to_string())
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Batch download failed".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Read file size for Content-Length before splitting ownership
|
// Read file size for Content-Length before splitting ownership
|
||||||
|
|||||||
@@ -190,7 +190,13 @@ impl ChunkedUploadHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
match chunked_service
|
match chunked_service
|
||||||
.upload_chunk(&upload_id, &auth_user.id, params.chunk_index, body, checksum)
|
.upload_chunk(
|
||||||
|
&upload_id,
|
||||||
|
&auth_user.id,
|
||||||
|
params.chunk_index,
|
||||||
|
body,
|
||||||
|
checksum,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
@@ -213,7 +219,7 @@ impl ChunkedUploadHandler {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
Err(e) => AppError::from(e).into_response()
|
Err(e) => AppError::from(e).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +267,10 @@ impl ChunkedUploadHandler {
|
|||||||
|
|
||||||
// Assemble chunks (hash-on-write: SHA-256 computed during assembly)
|
// Assemble chunks (hash-on-write: SHA-256 computed during assembly)
|
||||||
let (assembled_path, filename, folder_id, content_type, total_size, hash) =
|
let (assembled_path, filename, folder_id, content_type, total_size, hash) =
|
||||||
match chunked_service.complete_upload(&upload_id, &auth_user.id).await {
|
match chunked_service
|
||||||
|
.complete_upload(&upload_id, &auth_user.id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return AppError::from(e).into_response();
|
return AppError::from(e).into_response();
|
||||||
@@ -289,7 +298,9 @@ impl ChunkedUploadHandler {
|
|||||||
{
|
{
|
||||||
Ok(file) => {
|
Ok(file) => {
|
||||||
// Cleanup session
|
// Cleanup session
|
||||||
let _ = chunked_service.finalize_upload(&upload_id, &auth_user.id).await;
|
let _ = chunked_service
|
||||||
|
.finalize_upload(&upload_id, &auth_user.id)
|
||||||
|
.await;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"✅ CHUNKED UPLOAD COMPLETE: {} (ID: {}, {} bytes)",
|
"✅ CHUNKED UPLOAD COMPLETE: {} (ID: {}, {} bytes)",
|
||||||
@@ -311,8 +322,7 @@ impl ChunkedUploadHandler {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to create file from assembled upload: {:?}", e);
|
tracing::error!("Failed to create file from assembled upload: {:?}", e);
|
||||||
AppError::internal_error(format!("Failed to create file: {}", e))
|
AppError::internal_error(format!("Failed to create file: {}", e)).into_response()
|
||||||
.into_response()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,10 +337,14 @@ impl ChunkedUploadHandler {
|
|||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let chunked_service = &state.core.chunked_upload_service;
|
let chunked_service = &state.core.chunked_upload_service;
|
||||||
|
|
||||||
match chunked_service.cancel_upload(&upload_id, &auth_user.id).await {
|
match chunked_service
|
||||||
|
.cancel_upload(&upload_id, &auth_user.id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(e) => AppError::internal_error(format!("Failed to cancel upload: {}", e))
|
Err(e) => {
|
||||||
.into_response(),
|
AppError::internal_error(format!("Failed to cancel upload: {}", e)).into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,9 +231,7 @@ impl DedupHandler {
|
|||||||
return Response::builder()
|
return Response::builder()
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
.header(header::CONTENT_TYPE, "application/json")
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
.body(Body::from(
|
.body(Body::from(r#"{"error": "Upload failed"}"#))
|
||||||
r#"{"error": "Upload failed"}"#,
|
|
||||||
))
|
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
@@ -408,9 +406,7 @@ impl DedupHandler {
|
|||||||
return Response::builder()
|
return Response::builder()
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
.header(header::CONTENT_TYPE, "application/json")
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
.body(Body::from(
|
.body(Body::from(r#"{"error": "Verification failed"}"#))
|
||||||
r#"{"error": "Verification failed"}"#,
|
|
||||||
))
|
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,11 @@ impl FileHandler {
|
|||||||
if let Some(ref fid) = folder_id {
|
if let Some(ref fid) = folder_id {
|
||||||
use crate::application::ports::inbound::FolderUseCase;
|
use crate::application::ports::inbound::FolderUseCase;
|
||||||
let folder_service = &state.applications.folder_service;
|
let folder_service = &state.applications.folder_service;
|
||||||
if folder_service.get_folder_owned(fid, &auth_user.id).await.is_err() {
|
if folder_service
|
||||||
|
.get_folder_owned(fid, &auth_user.id)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"⛔ UPLOAD REJECTED (IDOR): user='{}' attempted upload to folder '{}' owned by another user",
|
"⛔ UPLOAD REJECTED (IDOR): user='{}' attempted upload to folder '{}' owned by another user",
|
||||||
auth_user.username,
|
auth_user.username,
|
||||||
@@ -323,7 +327,12 @@ impl FileHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the actual blob path on disk (not the logical file path).
|
// Resolve the actual blob path on disk (not the logical file path).
|
||||||
let blob_hash = match state.repositories.file_read_repository.get_blob_hash(&id).await {
|
let blob_hash = match state
|
||||||
|
.repositories
|
||||||
|
.file_read_repository
|
||||||
|
.get_blob_hash(&id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(h) => h,
|
Ok(h) => h,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return (
|
return (
|
||||||
@@ -353,10 +362,8 @@ impl FileHandler {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => AppError::internal_error(format!("Thumbnail generation failed: {}", err))
|
||||||
AppError::internal_error(format!("Thumbnail generation failed: {}", err))
|
.into_response(),
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,9 +536,7 @@ impl FileHandler {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response(),
|
.into_response(),
|
||||||
},
|
},
|
||||||
Err(err) => {
|
Err(err) => AppError::from(err).into_response(),
|
||||||
AppError::from(err).into_response()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,9 +586,7 @@ impl FileHandler {
|
|||||||
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
|
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => AppError::from(err).into_response(),
|
||||||
AppError::from(err).into_response()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,7 +667,7 @@ impl FileHandler {
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,7 +699,7 @@ impl FileHandler {
|
|||||||
let mgmt = &state.applications.file_management_service;
|
let mgmt = &state.applications.file_management_service;
|
||||||
match mgmt.rename_file_owned(&id, &auth_user.id, &new_name).await {
|
match mgmt.rename_file_owned(&id, &auth_user.id, &new_name).await {
|
||||||
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
|
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,7 +719,7 @@ impl FileHandler {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(file) => (StatusCode::OK, Json(file)).into_response(),
|
Ok(file) => (StatusCode::OK, Json(file)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,7 +738,7 @@ impl FileHandler {
|
|||||||
let mgmt = &state.applications.file_management_service;
|
let mgmt = &state.applications.file_management_service;
|
||||||
match mgmt.move_file_owned(&id, &auth_user.id, folder_id).await {
|
match mgmt.move_file_owned(&id, &auth_user.id, folder_id).await {
|
||||||
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
|
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,9 +797,7 @@ impl FileHandler {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
format!(
|
format!("{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}")
|
||||||
"{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a 201 Created JSON response.
|
/// Build a 201 Created JSON response.
|
||||||
|
|||||||
@@ -70,19 +70,24 @@ impl FolderHandler {
|
|||||||
// ── SECURITY: Verify parent folder ownership (IDOR V-04 fix) ──
|
// ── SECURITY: Verify parent folder ownership (IDOR V-04 fix) ──
|
||||||
if let Some(ref parent_id) = dto.parent_id {
|
if let Some(ref parent_id) = dto.parent_id {
|
||||||
use crate::application::ports::inbound::FolderUseCase;
|
use crate::application::ports::inbound::FolderUseCase;
|
||||||
if service.get_folder_owned(parent_id, &auth_user.id).await.is_err() {
|
if service
|
||||||
|
.get_folder_owned(parent_id, &auth_user.id)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"create_folder: user '{}' attempted to create folder in parent '{}' owned by another user",
|
"create_folder: user '{}' attempted to create folder in parent '{}' owned by another user",
|
||||||
auth_user.username,
|
auth_user.username,
|
||||||
parent_id,
|
parent_id,
|
||||||
);
|
);
|
||||||
return AppError::not_found(format!("Parent folder not found: {}", parent_id)).into_response();
|
return AppError::not_found(format!("Parent folder not found: {}", parent_id))
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match service.create_folder(dto).await {
|
match service.create_folder(dto).await {
|
||||||
Ok(folder) => (StatusCode::CREATED, Json(folder)).into_response(),
|
Ok(folder) => (StatusCode::CREATED, Json(folder)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +114,7 @@ impl FolderHandler {
|
|||||||
}
|
}
|
||||||
(StatusCode::OK, Json(folder)).into_response()
|
(StatusCode::OK, Json(folder)).into_response()
|
||||||
}
|
}
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +159,7 @@ impl FolderHandler {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(),
|
Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +176,7 @@ impl FolderHandler {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(folders) => (StatusCode::OK, Json(folders)).into_response(),
|
Ok(folders) => (StatusCode::OK, Json(folders)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +242,7 @@ impl FolderHandler {
|
|||||||
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
|
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
(Err(err), _) | (_, Err(err)) => AppError::from(err).into_response()
|
(Err(err), _) | (_, Err(err)) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +255,7 @@ impl FolderHandler {
|
|||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match service.rename_folder(&id, dto, &auth_user.id).await {
|
match service.rename_folder(&id, dto, &auth_user.id).await {
|
||||||
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +268,7 @@ impl FolderHandler {
|
|||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match service.move_folder(&id, dto, &auth_user.id).await {
|
match service.move_folder(&id, dto, &auth_user.id).await {
|
||||||
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +280,7 @@ impl FolderHandler {
|
|||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match service.delete_folder(&id, &auth_user.id).await {
|
match service.delete_folder(&id, &auth_user.id).await {
|
||||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,9 +318,7 @@ impl FolderHandler {
|
|||||||
tracing::info!("Folder permanently deleted: {}", id);
|
tracing::info!("Folder permanently deleted: {}", id);
|
||||||
StatusCode::NO_CONTENT.into_response()
|
StatusCode::NO_CONTENT.into_response()
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => AppError::from(err).into_response(),
|
||||||
AppError::from(err).into_response()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,12 +40,9 @@ pub async fn create_shared_link(
|
|||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Json(dto): Json<CreateShareDto>,
|
Json(dto): Json<CreateShareDto>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match share_use_case
|
match share_use_case.create_shared_link(&auth_user.id, dto).await {
|
||||||
.create_shared_link(&auth_user.id, dto)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(share) => (StatusCode::CREATED, Json(share)).into_response(),
|
Ok(share) => (StatusCode::CREATED, Json(share)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +54,7 @@ pub async fn get_shared_link(
|
|||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match share_use_case.get_shared_link(&id, &auth_user.id).await {
|
match share_use_case.get_shared_link(&id, &auth_user.id).await {
|
||||||
Ok(share) => (StatusCode::OK, Json(share)).into_response(),
|
Ok(share) => (StatusCode::OK, Json(share)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +113,7 @@ pub async fn update_shared_link(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(share) => (StatusCode::OK, Json(share)).into_response(),
|
Ok(share) => (StatusCode::OK, Json(share)).into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,12 +123,9 @@ pub async fn delete_shared_link(
|
|||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match share_use_case
|
match share_use_case.delete_shared_link(&id, &auth_user.id).await {
|
||||||
.delete_shared_link(&id, &auth_user.id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(err) => AppError::from(err).into_response()
|
Err(err) => AppError::from(err).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +154,7 @@ pub async fn access_shared_item(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
if err.message.contains("expired") {
|
if err.message.contains("expired") {
|
||||||
return AppError::new(StatusCode::GONE, err.message, "Expired")
|
return AppError::new(StatusCode::GONE, err.message, "Expired").into_response();
|
||||||
.into_response();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppError::from(err).into_response()
|
AppError::from(err).into_response()
|
||||||
@@ -183,8 +176,7 @@ pub async fn verify_shared_item_password(
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
if err.kind == ErrorKind::AccessDenied {
|
if err.kind == ErrorKind::AccessDenied {
|
||||||
if err.message.contains("expired") {
|
if err.message.contains("expired") {
|
||||||
return AppError::new(StatusCode::GONE, err.message, "Expired")
|
return AppError::new(StatusCode::GONE, err.message, "Expired").into_response();
|
||||||
.into_response();
|
|
||||||
}
|
}
|
||||||
if err.message.contains("password") {
|
if err.message.contains("password") {
|
||||||
return AppError::unauthorized("Invalid password").into_response();
|
return AppError::unauthorized("Invalid password").into_response();
|
||||||
|
|||||||
@@ -1161,10 +1161,7 @@ async fn handle_move(
|
|||||||
|
|
||||||
// Resolve source: single-query when PathResolver is available (user-scoped)
|
// Resolve source: single-query when PathResolver is available (user-scoped)
|
||||||
if let Some(resolver) = &state.path_resolver {
|
if let Some(resolver) = &state.path_resolver {
|
||||||
match resolver
|
match resolver.resolve_path_for_user(&source_path, &user.id).await {
|
||||||
.resolve_path_for_user(&source_path, &user.id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(ResolvedResource::Folder(folder)) => {
|
Ok(ResolvedResource::Folder(folder)) => {
|
||||||
let dest_folder_name = destination_path
|
let dest_folder_name = destination_path
|
||||||
.split('/')
|
.split('/')
|
||||||
@@ -1183,7 +1180,11 @@ async fn handle_move(
|
|||||||
match folder_service.get_folder_by_path(dest_parent_path).await {
|
match folder_service.get_folder_by_path(dest_parent_path).await {
|
||||||
Ok(parent) => {
|
Ok(parent) => {
|
||||||
// SECURITY: verify destination parent belongs to caller (V-08)
|
// SECURITY: verify destination parent belongs to caller (V-08)
|
||||||
assert_owner(parent.owner_id.as_deref(), &user.id, dest_parent_path)?;
|
assert_owner(
|
||||||
|
parent.owner_id.as_deref(),
|
||||||
|
&user.id,
|
||||||
|
dest_parent_path,
|
||||||
|
)?;
|
||||||
Some(parent.id)
|
Some(parent.id)
|
||||||
}
|
}
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
@@ -1228,7 +1229,10 @@ async fn handle_move(
|
|||||||
|
|
||||||
if source_parent_path != dest_parent_path {
|
if source_parent_path != dest_parent_path {
|
||||||
// SECURITY: verify destination parent belongs to caller (V-08)
|
// SECURITY: verify destination parent belongs to caller (V-08)
|
||||||
if !dest_parent_path.is_empty() && let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await {
|
if !dest_parent_path.is_empty()
|
||||||
|
&& let Ok(parent) =
|
||||||
|
folder_service.get_folder_by_path(dest_parent_path).await
|
||||||
|
{
|
||||||
assert_owner(parent.owner_id.as_deref(), &user.id, dest_parent_path)?;
|
assert_owner(parent.owner_id.as_deref(), &user.id, dest_parent_path)?;
|
||||||
}
|
}
|
||||||
file_management_service
|
file_management_service
|
||||||
@@ -1327,7 +1331,9 @@ async fn handle_move(
|
|||||||
|
|
||||||
if source_parent_path != dest_parent_path {
|
if source_parent_path != dest_parent_path {
|
||||||
// SECURITY: verify destination parent belongs to caller (V-08)
|
// SECURITY: verify destination parent belongs to caller (V-08)
|
||||||
if !dest_parent_path.is_empty() && let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await {
|
if !dest_parent_path.is_empty()
|
||||||
|
&& let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await
|
||||||
|
{
|
||||||
assert_owner(parent.owner_id.as_deref(), &user.id, dest_parent_path)?;
|
assert_owner(parent.owner_id.as_deref(), &user.id, dest_parent_path)?;
|
||||||
}
|
}
|
||||||
file_management_service
|
file_management_service
|
||||||
@@ -1436,10 +1442,7 @@ async fn handle_copy(
|
|||||||
|
|
||||||
// Resolve source: single-query when PathResolver is available (user-scoped)
|
// Resolve source: single-query when PathResolver is available (user-scoped)
|
||||||
if let Some(resolver) = &state.path_resolver {
|
if let Some(resolver) = &state.path_resolver {
|
||||||
match resolver
|
match resolver.resolve_path_for_user(&source_path, &user.id).await {
|
||||||
.resolve_path_for_user(&source_path, &user.id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(ResolvedResource::Folder(folder)) => {
|
Ok(ResolvedResource::Folder(folder)) => {
|
||||||
let recursive = depth != "0";
|
let recursive = depth != "0";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user