2025-04-02 05:08:30 +02:00
|
|
|
use axum::{
|
2026-02-14 01:29:34 +01:00
|
|
|
Json,
|
|
|
|
|
extract::{Path, Query, State},
|
2025-04-02 05:08:30 +02:00
|
|
|
http::StatusCode,
|
|
|
|
|
response::IntoResponse,
|
|
|
|
|
};
|
2026-02-14 01:29:34 +01:00
|
|
|
use std::sync::Arc;
|
2026-07-04 23:31:10 +02:00
|
|
|
use tracing::info;
|
2025-04-02 05:08:30 +02:00
|
|
|
|
2026-05-28 11:50:32 +02:00
|
|
|
use crate::application::dtos::display_helpers::{
|
|
|
|
|
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
|
|
|
|
};
|
|
|
|
|
use crate::application::dtos::file_dto::FileDto;
|
|
|
|
|
use crate::application::dtos::folder_dto::FolderDto;
|
|
|
|
|
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
|
|
|
|
use crate::application::dtos::recent_dto::{
|
|
|
|
|
RecentResourceItemDto, RecentResourcesDto, RecentResourcesQuery,
|
|
|
|
|
};
|
2025-04-02 05:08:30 +02:00
|
|
|
use crate::application::ports::recent_ports::RecentItemsUseCase;
|
2026-03-03 15:36:42 +00:00
|
|
|
use crate::application::services::recent_service::RecentService;
|
2026-06-06 19:51:51 +02:00
|
|
|
use crate::domain::entities::file::File;
|
2026-05-28 11:50:32 +02:00
|
|
|
use crate::interfaces::errors::AppError;
|
2026-03-04 23:55:08 +01:00
|
|
|
use crate::interfaces::middleware::auth::AuthUser;
|
2026-05-29 12:12:29 +02:00
|
|
|
use uuid::Uuid;
|
2025-04-02 05:08:30 +02:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Record access to an item
|
2026-03-29 18:49:10 +02:00
|
|
|
#[utoipa::path(
|
|
|
|
|
post,
|
|
|
|
|
path = "/api/recent/{item_type}/{item_id}",
|
|
|
|
|
params(
|
|
|
|
|
("item_type" = String, Path, description = "Item type (file or folder)"),
|
|
|
|
|
("item_id" = String, Path, description = "Item ID")
|
|
|
|
|
),
|
|
|
|
|
responses(
|
|
|
|
|
(status = 200, description = "Access recorded"),
|
|
|
|
|
(status = 400, description = "Invalid item type")
|
|
|
|
|
),
|
2026-05-27 12:43:07 +02:00
|
|
|
security(("bearerAuth" = [])),
|
2026-03-29 18:49:10 +02:00
|
|
|
tag = "recent"
|
|
|
|
|
)]
|
2025-04-02 05:08:30 +02:00
|
|
|
pub async fn record_item_access(
|
2026-03-03 15:36:42 +00:00
|
|
|
State(recent_service): State<Arc<RecentService>>,
|
2026-02-07 04:02:38 +01:00
|
|
|
auth_user: AuthUser,
|
2026-05-29 12:12:29 +02:00
|
|
|
Path((item_type, item_id)): Path<(String, Uuid)>,
|
2025-04-02 05:08:30 +02:00
|
|
|
) -> impl IntoResponse {
|
2026-03-07 14:59:32 +01:00
|
|
|
let user_id = auth_user.id;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Validate item type
|
2025-04-02 05:08:30 +02:00
|
|
|
if item_type != "file" && item_type != "folder" {
|
|
|
|
|
return (
|
2026-02-14 01:29:34 +01:00
|
|
|
StatusCode::BAD_REQUEST,
|
2025-04-02 05:08:30 +02:00
|
|
|
Json(serde_json::json!({
|
2026-02-12 09:41:25 +01:00
|
|
|
"error": "Item type must be 'file' or 'folder'"
|
2026-02-14 01:29:34 +01:00
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
.into_response();
|
2025-04-02 05:08:30 +02:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
|
|
|
match recent_service
|
2026-05-29 12:12:29 +02:00
|
|
|
.record_item_access(user_id, &item_id.to_string(), &item_type)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
|
|
|
|
{
|
2025-04-02 05:08:30 +02:00
|
|
|
Ok(_) => {
|
2026-02-12 09:41:25 +01:00
|
|
|
info!("Recorded access to {} '{}' in recents", item_type, item_id);
|
2025-04-02 05:08:30 +02:00
|
|
|
(
|
2026-02-14 01:29:34 +01:00
|
|
|
StatusCode::OK,
|
2025-04-02 05:08:30 +02:00
|
|
|
Json(serde_json::json!({
|
2026-02-12 09:41:25 +01:00
|
|
|
"message": "Access recorded successfully"
|
2026-02-14 01:29:34 +01:00
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
2026-07-04 23:31:10 +02:00
|
|
|
// Preserve DomainError→HTTP status mapping — the Round 1
|
|
|
|
|
// AuthZ fix relies on the NotFound from `authz.require`
|
|
|
|
|
// propagating as 404 (anti-enum), not being masked as 500.
|
|
|
|
|
Err(err) => AppError::from(err).into_response(),
|
2025-04-02 05:08:30 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Remove an item from recents
|
2026-03-29 18:49:10 +02:00
|
|
|
#[utoipa::path(
|
|
|
|
|
delete,
|
|
|
|
|
path = "/api/recent/{item_type}/{item_id}",
|
|
|
|
|
params(
|
|
|
|
|
("item_type" = String, Path, description = "Item type (file or folder)"),
|
|
|
|
|
("item_id" = String, Path, description = "Item ID")
|
|
|
|
|
),
|
|
|
|
|
responses(
|
|
|
|
|
(status = 200, description = "Item removed from recents"),
|
|
|
|
|
(status = 404, description = "Item not in recents")
|
|
|
|
|
),
|
2026-05-27 12:43:07 +02:00
|
|
|
security(("bearerAuth" = [])),
|
2026-03-29 18:49:10 +02:00
|
|
|
tag = "recent"
|
|
|
|
|
)]
|
2025-04-02 05:08:30 +02:00
|
|
|
pub async fn remove_from_recent(
|
2026-03-03 15:36:42 +00:00
|
|
|
State(recent_service): State<Arc<RecentService>>,
|
2026-02-07 04:02:38 +01:00
|
|
|
auth_user: AuthUser,
|
2026-05-29 12:12:29 +02:00
|
|
|
Path((item_type, item_id)): Path<(String, Uuid)>,
|
2025-04-02 05:08:30 +02:00
|
|
|
) -> impl IntoResponse {
|
2026-03-07 14:59:32 +01:00
|
|
|
let user_id = auth_user.id;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
|
|
|
|
match recent_service
|
2026-05-29 12:12:29 +02:00
|
|
|
.remove_from_recent(user_id, &item_id.to_string(), &item_type)
|
2026-02-14 01:29:34 +01:00
|
|
|
.await
|
|
|
|
|
{
|
2025-04-02 05:08:30 +02:00
|
|
|
Ok(removed) => {
|
|
|
|
|
if removed {
|
2026-02-12 09:41:25 +01:00
|
|
|
info!("Removed {} '{}' from recents", item_type, item_id);
|
2025-04-02 05:08:30 +02:00
|
|
|
(
|
2026-02-14 01:29:34 +01:00
|
|
|
StatusCode::OK,
|
2025-04-02 05:08:30 +02:00
|
|
|
Json(serde_json::json!({
|
2026-02-12 09:41:25 +01:00
|
|
|
"message": "Item removed from recents"
|
2026-02-14 01:29:34 +01:00
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
.into_response()
|
2025-04-02 05:08:30 +02:00
|
|
|
} else {
|
2026-02-12 09:41:25 +01:00
|
|
|
info!("Item {} '{}' was not in recents", item_type, item_id);
|
2025-04-02 05:08:30 +02:00
|
|
|
(
|
2026-02-14 01:29:34 +01:00
|
|
|
StatusCode::NOT_FOUND,
|
2025-04-02 05:08:30 +02:00
|
|
|
Json(serde_json::json!({
|
2026-02-12 09:41:25 +01:00
|
|
|
"message": "Item was not in recents"
|
2026-02-14 01:29:34 +01:00
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
.into_response()
|
2025-04-02 05:08:30 +02:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|
2026-07-04 23:31:10 +02:00
|
|
|
// Same rationale as `record_item_access` — preserve the
|
|
|
|
|
// DomainError→HTTP mapping instead of collapsing to 500.
|
|
|
|
|
Err(err) => AppError::from(err).into_response(),
|
2025-04-02 05:08:30 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
/// Clear all recent items
|
2026-03-29 18:49:10 +02:00
|
|
|
#[utoipa::path(
|
|
|
|
|
delete,
|
|
|
|
|
path = "/api/recent/clear",
|
|
|
|
|
responses(
|
|
|
|
|
(status = 200, description = "Recent items cleared")
|
|
|
|
|
),
|
2026-05-27 12:43:07 +02:00
|
|
|
security(("bearerAuth" = [])),
|
2026-03-29 18:49:10 +02:00
|
|
|
tag = "recent"
|
|
|
|
|
)]
|
2025-04-02 05:08:30 +02:00
|
|
|
pub async fn clear_recent_items(
|
2026-03-03 15:36:42 +00:00
|
|
|
State(recent_service): State<Arc<RecentService>>,
|
2026-02-07 04:02:38 +01:00
|
|
|
auth_user: AuthUser,
|
2025-04-02 05:08:30 +02:00
|
|
|
) -> impl IntoResponse {
|
2026-03-07 14:59:32 +01:00
|
|
|
let user_id = auth_user.id;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-04-02 05:08:30 +02:00
|
|
|
match recent_service.clear_recent_items(user_id).await {
|
|
|
|
|
Ok(_) => {
|
2026-02-12 09:41:25 +01:00
|
|
|
info!("Cleared all recent items for user");
|
2025-04-02 05:08:30 +02:00
|
|
|
(
|
2026-02-14 01:29:34 +01:00
|
|
|
StatusCode::OK,
|
2025-04-02 05:08:30 +02:00
|
|
|
Json(serde_json::json!({
|
2026-02-12 09:41:25 +01:00
|
|
|
"message": "Recent items cleared successfully"
|
2026-02-14 01:29:34 +01:00
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
2026-07-04 23:31:10 +02:00
|
|
|
// Same rationale as `record_item_access` — preserve the
|
|
|
|
|
// DomainError→HTTP mapping instead of collapsing to 500.
|
|
|
|
|
Err(err) => AppError::from(err).into_response(),
|
2025-04-02 05:08:30 +02:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|
2026-05-28 11:50:32 +02:00
|
|
|
|
|
|
|
|
/// List recently accessed resources with cursor pagination.
|
|
|
|
|
///
|
|
|
|
|
/// Sorted by `accessed_at` DESC by default (most recently accessed first).
|
|
|
|
|
/// `path` is cleared when the resource is not owned by the requesting user.
|
|
|
|
|
#[utoipa::path(
|
|
|
|
|
get,
|
|
|
|
|
path = "/api/recent/resources",
|
|
|
|
|
params(RecentResourcesQuery),
|
|
|
|
|
responses(
|
|
|
|
|
(status = 200, description = "Paginated list of recently accessed resources",
|
|
|
|
|
body = RecentResourcesDto),
|
|
|
|
|
(status = 400, description = "Invalid cursor or query parameters"),
|
|
|
|
|
),
|
|
|
|
|
security(("bearerAuth" = [])),
|
|
|
|
|
tag = "recent"
|
|
|
|
|
)]
|
|
|
|
|
pub async fn list_recent_resources(
|
|
|
|
|
State(recent_service): State<Arc<RecentService>>,
|
|
|
|
|
auth_user: AuthUser,
|
|
|
|
|
Query(q): Query<RecentResourcesQuery>,
|
|
|
|
|
) -> impl IntoResponse {
|
|
|
|
|
let user_id = auth_user.id;
|
|
|
|
|
|
|
|
|
|
let order_by = q.order_by.as_deref().unwrap_or("accessed_at").to_owned();
|
|
|
|
|
|
|
|
|
|
// If a cursor exists, validate that it matches the requested sort/direction.
|
|
|
|
|
let cursor = q
|
|
|
|
|
.decode_cursor()
|
|
|
|
|
.filter(|c| c.order_by == order_by && c.reverse == q.reverse);
|
|
|
|
|
|
|
|
|
|
let kinds = q.resource_kinds();
|
|
|
|
|
|
|
|
|
|
match recent_service
|
|
|
|
|
.list_resources_paged(
|
|
|
|
|
user_id,
|
|
|
|
|
q.limit_clamped(),
|
|
|
|
|
cursor,
|
|
|
|
|
&order_by,
|
|
|
|
|
kinds.as_deref(),
|
|
|
|
|
q.reverse,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok((rows, next_cursor)) => {
|
|
|
|
|
let items: Vec<RecentResourceItemDto> = rows
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|row| {
|
|
|
|
|
// Path is only shown to the owner; non-owners see ""
|
|
|
|
|
// to avoid leaking another user's folder hierarchy.
|
|
|
|
|
let path = if row.is_owner {
|
|
|
|
|
row.path.clone().unwrap_or_default()
|
|
|
|
|
} else {
|
|
|
|
|
String::new()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if row.resource_type == "folder" {
|
2026-06-06 15:37:27 +02:00
|
|
|
let resource_id = row.resource_id.to_string();
|
2026-05-28 11:50:32 +02:00
|
|
|
let dto = FolderDto {
|
2026-06-06 15:37:27 +02:00
|
|
|
etag: resource_id.clone(),
|
|
|
|
|
id: resource_id,
|
2026-05-28 11:50:32 +02:00
|
|
|
name: row.name.clone(),
|
|
|
|
|
path,
|
|
|
|
|
parent_id: row.parent_id.map(|u| u.to_string()),
|
2026-06-29 21:55:47 +02:00
|
|
|
drive_id: row.drive_id,
|
2026-05-28 11:50:32 +02:00
|
|
|
created_at: row.resource_created_at.timestamp() as u64,
|
|
|
|
|
modified_at: row.modified_at.timestamp() as u64,
|
|
|
|
|
is_root: false,
|
|
|
|
|
icon_class: std::sync::Arc::from("fas fa-folder"),
|
|
|
|
|
icon_special_class: std::sync::Arc::from("folder-icon"),
|
|
|
|
|
category: std::sync::Arc::from("Folder"),
|
2026-06-19 10:51:13 +02:00
|
|
|
// §14 provenance not selected by the recents query.
|
|
|
|
|
created_by: None,
|
|
|
|
|
updated_by: None,
|
2026-05-28 11:50:32 +02:00
|
|
|
};
|
|
|
|
|
RecentResourceItemDto {
|
|
|
|
|
resource_type: ResourceTypeDto::Folder,
|
|
|
|
|
accessed_at: row.accessed_at,
|
|
|
|
|
resource: ResourceContentDto::Folder(dto),
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
let mime = row
|
|
|
|
|
.mime_type
|
|
|
|
|
.as_deref()
|
|
|
|
|
.unwrap_or("application/octet-stream");
|
|
|
|
|
let size_bytes = row.size.max(0) as u64;
|
2026-06-06 19:51:51 +02:00
|
|
|
// Route ETag through `File::compute_etag` so this
|
|
|
|
|
// listing matches GET/HEAD/PROPFIND byte-for-byte
|
|
|
|
|
// for the same file.
|
|
|
|
|
let modified_at_u = row.modified_at.timestamp() as u64;
|
|
|
|
|
let content_hash = row.blob_hash.clone().unwrap_or_default();
|
|
|
|
|
let etag = if content_hash.is_empty() {
|
|
|
|
|
String::new()
|
|
|
|
|
} else {
|
|
|
|
|
File::compute_etag(&content_hash, modified_at_u)
|
|
|
|
|
};
|
2026-05-28 11:50:32 +02:00
|
|
|
let dto = FileDto {
|
|
|
|
|
id: row.resource_id.to_string(),
|
|
|
|
|
name: row.name.clone(),
|
|
|
|
|
path,
|
|
|
|
|
size: size_bytes,
|
|
|
|
|
mime_type: std::sync::Arc::from(mime),
|
|
|
|
|
folder_id: row.parent_id.map(|u| u.to_string()),
|
|
|
|
|
created_at: row.resource_created_at.timestamp() as u64,
|
2026-06-06 19:51:51 +02:00
|
|
|
modified_at: modified_at_u,
|
2026-05-28 11:50:32 +02:00
|
|
|
icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
|
|
|
|
|
icon_special_class: std::sync::Arc::from(icon_special_class_for(
|
|
|
|
|
&row.name, mime,
|
|
|
|
|
)),
|
|
|
|
|
category: std::sync::Arc::from(category_for(&row.name, mime)),
|
|
|
|
|
size_formatted: format_file_size(size_bytes),
|
|
|
|
|
sort_date: None,
|
2026-06-06 19:51:51 +02:00
|
|
|
content_hash,
|
|
|
|
|
etag,
|
2026-06-19 10:51:13 +02:00
|
|
|
// §14 provenance not selected by the recents query.
|
|
|
|
|
created_by: None,
|
|
|
|
|
updated_by: None,
|
2026-05-28 11:50:32 +02:00
|
|
|
};
|
|
|
|
|
RecentResourceItemDto {
|
|
|
|
|
resource_type: ResourceTypeDto::File,
|
|
|
|
|
accessed_at: row.accessed_at,
|
|
|
|
|
resource: ResourceContentDto::File(dto),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
(
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
Json(RecentResourcesDto::with_cursor(items, next_cursor)),
|
|
|
|
|
)
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
Err(e) => AppError::from(e).into_response(),
|
|
|
|
|
}
|
|
|
|
|
}
|