Merge main (Photos/People/Places + ReBAC) into the SvelteKit rewrite

Bring the feature-rich main branch into the frontend Svelte rewrite
(PR #478, base bcn/frontend-svelte-rewrite). main moved well ahead of the
PR's branch point (b8a0018): it added the Places photo-map and People
(faces) backends, photos enhancements, the ReBAC→role-grants migration,
load tests, and more.

Conflicts resolved (4 files):
- Dockerfile: combine the explicit --bin allowlist (defence-in-depth from
  main) with the SPA copy from the frontend build stage (PR).
- .github/workflows/ci.yml: keep the PR's Svelte frontend job
  (svelte-check + eslint + stylelint + prettier + vitest); the legacy
  static/-targeted tsc/locale/icon advisory steps don't fit the new
  working-directory: frontend job and svelte-check supersedes them.
- justfile: keep both the new fe-* / dev recipes (PR) and the load-* k6
  recipes (main).
- static/locales: keep the PR's symlink (-> ../frontend/static/locales);
  main's new photos/people locale keys are folded into the Svelte locale
  files alongside the ported views.

Backend (people/places/faces handlers, routes, DI, migrations) merged
cleanly. `cargo check --bins` passes. The new Places/People UI is not yet
in the Svelte app; that is ported in follow-up commits.
This commit is contained in:
Claude
2026-06-19 12:45:22 +00:00
126 changed files with 11677 additions and 947 deletions
@@ -218,6 +218,9 @@ async fn handle_propfind(
.to_string();
let user = extract_user(&req)?;
// Caller UUID (string form) — gates the `<D:write/>` privilege on calendars
// the caller owns, so clients mount their own calendars read-write.
let caller_id = user.id.to_string();
let calendar_service = get_calendar_service(&state)?;
let body_bytes = body::to_bytes(req.into_body(), MAX_CALDAV_BODY)
@@ -256,6 +259,7 @@ async fn handle_propfind(
&propfind_request,
base_href,
&user.username,
&caller_id,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
@@ -333,6 +337,7 @@ async fn handle_propfind(
&propfind_request,
base_href,
&depth,
&caller_id,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
@@ -359,6 +364,7 @@ async fn handle_propfind(
&calendars,
&propfind_request,
base_href,
&caller_id,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
@@ -407,6 +413,7 @@ async fn handle_propfind(
&propfind_request,
base_href,
&depth,
&caller_id,
)
.map_err(|e| {
AppError::internal_error(format!("Failed to generate XML: {}", e))
+77 -7
View File
@@ -58,6 +58,24 @@ pub fn carddav_routes() -> Router<Arc<AppState>> {
.route("/carddav", axum::routing::any(handle_carddav_methods_root))
}
/// Creates the RFC 6764 well-known discovery route for CardDAV.
/// Public (no auth) — simply redirects to the CardDAV root so clients that
/// bootstrap from `/.well-known/carddav` can locate the service.
pub fn well_known_routes() -> Router<Arc<AppState>> {
Router::new().route(
"/.well-known/carddav",
axum::routing::any(handle_well_known_carddav),
)
}
async fn handle_well_known_carddav() -> Response<Body> {
Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header(header::LOCATION, "/carddav/")
.body(Body::empty())
.unwrap()
}
async fn handle_carddav_methods_root(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
req: Request<Body>,
@@ -226,10 +244,66 @@ async fn handle_propfind(
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPFIND: {}", e)))?
};
// Discovery: the true root `/carddav/` advertises current-user-principal and
// addressbook-home-set so clients (DAVx5, Apple Contacts) can locate the
// address books. Depth 0 → only the root entry; Depth 1+ → also the books.
if path.is_empty() {
let address_books = if depth == "0" {
vec![]
} else {
addressbook_service
.list_user_address_books(user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to list address books: {}", e))
})?
};
let mut response_body = Vec::new();
CardDavAdapter::generate_root_propfind_response(
&mut response_body,
&address_books,
&propfind_request,
"/carddav/",
&user.username,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(response_body))
.unwrap());
}
// Discovery: principal resource `/carddav/principals/{username}/` returns the
// addressbook-home-set the client should enumerate next.
if path == "principals" || path.starts_with("principals/") {
let username = path
.strip_prefix("principals/")
.map(|s| s.trim_end_matches('/'))
.filter(|s| !s.is_empty())
.unwrap_or(&user.username);
let mut response_body = Vec::new();
CardDavAdapter::generate_principal_propfind_response(
&mut response_body,
&propfind_request,
username,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(response_body))
.unwrap());
}
let effective_path = strip_username_prefix(path);
if effective_path.is_empty() {
// Root CardDAV path or user home — list user's address books
// User address-book home `/carddav/{username}/` — list the user's books.
let address_books = addressbook_service
.list_user_address_books(user.id)
.await
@@ -237,12 +311,8 @@ async fn handle_propfind(
AppError::internal_error(format!("Failed to list address books: {}", e))
})?;
let base_href = if path.is_empty() {
"/carddav/".to_string()
} else {
let user_part = path.split('/').next().unwrap_or(path);
format!("/carddav/{}/", user_part)
};
let user_part = path.split('/').next().unwrap_or(path);
let base_href = format!("/carddav/{}/", user_part);
let mut response_body = Vec::new();
CardDavAdapter::generate_addressbooks_propfind_response(
&mut response_body,
+183 -250
View File
@@ -11,31 +11,28 @@ use axum::{
http::StatusCode,
response::IntoResponse,
};
use futures::future::join_all;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{error, info, warn};
use tracing::{error, warn};
use utoipa::IntoParams;
use uuid::Uuid;
use crate::application::dtos::cursor::PageCursor;
use crate::application::dtos::grant_dto::{
CreateGrantDto, CreateGrantResponseDto, GrantDto, MySharesDto, NotifyOutcomeSetDto,
OutgoingResourceGrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto,
ResourceDto, ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery,
SubjectDto, SubjectInputDto, UpdateRoleDto, role_from_permissions,
OutgoingResourceGrantDto, OutgoingResourceItemDto, ResourceContentDto, ResourceDto,
ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto,
SubjectInputDto, UpdateRoleDto, role_from_permissions,
};
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::common::di::AppState;
#[allow(unused_imports)]
use crate::common::errors::DomainError;
use crate::domain::errors::ErrorKind;
use crate::domain::services::authorization::{
GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind,
Subject,
Role, Subject,
};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
@@ -66,28 +63,7 @@ pub async fn create_grant(
let authz = &state.authorization;
let caller_id = auth_user.id;
// Validate: exactly one of permissions/role
let permissions: Vec<Permission> = match (dto.permissions, dto.role) {
(Some(perms), None) if !perms.is_empty() => perms.into_iter().map(Into::into).collect(),
(None, Some(role)) => role.expand().to_vec(),
(Some(_), Some(_)) => {
return AppError::new(
StatusCode::BAD_REQUEST,
"Provide either 'permissions' or 'role', not both",
"InvalidInput",
)
.into_response();
}
_ => {
return AppError::new(
StatusCode::BAD_REQUEST,
"Either 'permissions' (non-empty) or 'role' is required",
"InvalidInput",
)
.into_response();
}
};
let role: Role = dto.role.into();
let resource: Resource = dto.resource.into();
let expires_at = dto.expires_at;
@@ -152,25 +128,32 @@ pub async fn create_grant(
}
};
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
for perm in permissions {
match authz
.grant(caller_id, subject, perm, resource, expires_at)
.await
{
Ok(grant) => results.push(grant.into()),
Err(err) => {
error!("grant insert failed for {perm:?}: {err}");
return AppError::from(err).into_response();
}
// Single role row in `storage.role_grants`. `ON CONFLICT UPDATE` in
// the engine makes repeated POSTs with the same (subject, resource)
// a role refresh, matching the PATCH-style semantics callers expect.
let grant = match authz
.set_role(caller_id, subject, role, resource, expires_at)
.await
{
Ok(g) => g,
Err(err) => {
error!("set_role write failed: {err}");
return AppError::from(err).into_response();
}
}
info!(
"Created {} grant(s) for subject={:?} on resource={:?} by user {}",
results.len(),
subject,
resource,
caller_id
};
let grants = vec![GrantDto::from(grant)];
tracing::info!(
target: "audit",
event = "role_grant.created",
caller_id = %caller_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
role = role.as_str(),
expires_at = ?expires_at,
"🤝 grant created with role '{}'", role.as_str(),
);
// PR N1 — route the post-grant notification through the unified
@@ -240,7 +223,7 @@ pub async fn create_grant(
(
StatusCode::CREATED,
Json(CreateGrantResponseDto {
grants: results,
grants,
notification,
}),
)
@@ -274,17 +257,20 @@ pub async fn revoke_grant(
Err(_) => return AppError::not_found(format!("Grant {id} not found")).into_response(),
};
// Look up the grant to find the underlying resource (and granter).
let on_resource = match authz.find_grant_by_id(grant_id).await {
Ok(Some((res, granter))) => (res, granter),
// Look up the grant to find the subject, resource, and granter.
// `find_grant_full_by_id` returns the subject too — needed for the
// `clear_role` dual-write below (role_grants is keyed by (subject,
// resource), not by access_grants id).
let (subject, resource, granter) = match authz.find_grant_full_by_id(grant_id).await {
Ok(Some(triple)) => triple,
Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent
Err(e) => return AppError::from(e).into_response(),
};
// Caller is authorized if they are the granter OR have Share on the resource.
if on_resource.1 != caller_id
if granter != caller_id
&& let Err(e) = authz
.require(Subject::User(caller_id), Permission::Share, on_resource.0)
.require(Subject::User(caller_id), Permission::Share, resource)
.await
{
return AppError::from(e).into_response();
@@ -293,7 +279,36 @@ pub async fn revoke_grant(
if let Err(e) = authz.revoke(grant_id).await {
return AppError::from(e).into_response();
}
info!("Revoked grant {grant_id} (caller {caller_id})");
// D-Prep dual-write: clear the role_grants row for this (subject,
// resource). Idempotent — succeeds whether or not the row existed.
//
// Today's API revokes one access_grants row by id; the role_grants
// row models the WHOLE (subject, resource) cluster. Calling clear_role
// here effectively revokes the WHOLE role assignment in role_grants,
// even if other per-permission access_grants rows remain. This is the
// correct semantics for the eventual cleanup-PR model (role_grants is
// role-keyed; once access_grants goes away, "revoke" means "drop the
// role"). During the dual-write window the two tables can drift
// briefly if a caller revokes only some permissions of a role, but
// the engine still reads access_grants so behaviour is unchanged.
if let Err(e) = authz.clear_role(subject, resource).await {
return AppError::from(e).into_response();
}
tracing::info!(
target: "audit",
event = "role_grant.revoked",
caller_id = %caller_id,
grant_id = %grant_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
granter_id = %granter,
self_revoke = (granter == caller_id),
"🗑️ grant revoked",
);
StatusCode::NO_CONTENT.into_response()
}
@@ -486,9 +501,8 @@ pub async fn set_role(
let caller_id = auth_user.id;
let subject: Subject = dto.subject.into();
let resource: Resource = dto.resource.into();
let role: Role = dto.role.into();
let expires_at = dto.expires_at;
let target_perms: std::collections::HashSet<Permission> =
dto.role.expand().iter().copied().collect();
// Caller must have Share on the resource.
if let Err(e) = authz
@@ -498,84 +512,41 @@ pub async fn set_role(
return AppError::from(e).into_response();
}
// Fetch current grants on the resource for this subject.
let current = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
Err(e) => return AppError::from(e).into_response(),
};
let current_perms: std::collections::HashSet<Permission> = current
.iter()
.filter(|g| g.subject == subject)
.map(|g| g.permission)
.collect();
// Diff and apply.
let to_add: Vec<Permission> = target_perms.difference(&current_perms).copied().collect();
let to_remove: Vec<Permission> = current_perms.difference(&target_perms).copied().collect();
for perm in &to_remove {
if let Some(g) = current
.iter()
.find(|g| g.subject == subject && g.permission == *perm)
&& let Err(e) = authz.revoke(g.id).await
{
return AppError::from(e).into_response();
}
}
for perm in &to_add {
if let Err(e) = authz
.grant(caller_id, subject, *perm, resource, expires_at)
.await
{
return AppError::from(e).into_response();
}
}
// Sync expiry on all remaining grants for this (subject, resource) pair —
// includes newly added ones and any that were already present (retained).
// Callers that omit expires_at will clear any existing expiry; this is
// intentional: it keeps all permission rows for the pair consistent.
if let Err(e) = authz
.set_expiry_on_resource(subject, resource, expires_at)
// Atomic role refresh. UNIQUE on (subject, resource) + ON CONFLICT
// UPDATE in `set_role` turns this into a single UPSERT — no diff,
// no race window. Returns the resulting role row.
let grant = match authz
.set_role(caller_id, subject, role, resource, expires_at)
.await
{
return AppError::from(e).into_response();
}
// Return the new full set.
let after = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
Err(e) => return AppError::from(e).into_response(),
};
let mine: Vec<GrantDto> = after
.into_iter()
.filter(|g| g.subject == subject)
.map(Into::into)
.collect();
info!(
"Role applied: caller={} subject={:?} resource={:?} added={:?} removed={:?}",
caller_id, subject, resource, to_add, to_remove
tracing::info!(
target: "audit",
event = "role_grant.role_set",
caller_id = %caller_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
role = role.as_str(),
expires_at = ?expires_at,
"🔁 role set to '{}'", role.as_str(),
);
(StatusCode::OK, Json(mine)).into_response()
(StatusCode::OK, Json(vec![GrantDto::from(grant)])).into_response()
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/incoming
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize, IntoParams)]
pub struct IncomingQuery {
#[serde(default)]
pub permission: Option<PermissionDto>,
}
#[utoipa::path(
get,
path = "/api/grants/incoming",
params(IncomingQuery),
responses(
(status = 200, description = "Direct grants targeting the caller", body = Vec<GrantDto>),
(status = 200, description = "Direct role grants targeting the caller", body = Vec<GrantDto>),
),
security(("bearerAuth" = [])),
tag = "grants"
@@ -583,12 +554,11 @@ pub struct IncomingQuery {
pub async fn list_incoming(
State(state): State<AppStateRef>,
auth_user: AuthUser,
Query(q): Query<IncomingQuery>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
match state
.authorization
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
.list_incoming_grants(Subject::User(caller_id))
.await
{
Ok(grants) => {
@@ -691,83 +661,64 @@ pub async fn list_shared_with_me(
.map(|s| s.resource_id.to_string())
.collect();
// Resolve resource details concurrently (files and folders in parallel).
let (file_results, folder_results) = tokio::join!(
join_all(file_ids.iter().map(|id| file_service.get_file(id))),
join_all(folder_ids.iter().map(|id| folder_service.get_folder(id)))
// Resolve resource details in two batch queries (was one per id via
// join_all, which could fan out to ~limit concurrent pooled connections
// and starve the primary pool). Missing ids — stale grants whose resource
// 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).
// We iterate summaries in order and pick the resolved result from the
// appropriate typed bucket.
let mut file_idx = 0usize;
let mut folder_idx = 0usize;
// Build the unified item list in original grant order (newest first),
// looking each resolved resource up by id.
let mut items: Vec<SharedWithMeItemDto> = Vec::with_capacity(summaries.len());
for summary in &summaries {
let rid = summary.resource_id.to_string();
match summary.resource_type {
ResourceKind::File => {
let result = &file_results[file_idx];
file_idx += 1;
match result {
Ok(file_dto) => {
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::File,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
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();
}
ResourceKind::File => match file_map.get(&rid) {
Some(file_dto) => {
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::File,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::File(
file_dto.clone().without_hierarchy_info(),
),
});
}
}
ResourceKind::Folder => {
let result = &folder_results[folder_idx];
folder_idx += 1;
match result {
Ok(folder_dto) => {
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::Folder,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
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 file grant for resource_id={}: not found",
summary.resource_id
),
},
ResourceKind::Folder => match folder_map.get(&rid) {
Some(folder_dto) => {
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::Folder,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::Folder(
folder_dto.clone().without_hierarchy_info(),
),
});
}
}
None => warn!(
"Skipping stale folder grant for resource_id={}: not found",
summary.resource_id
),
},
}
}
@@ -936,13 +887,20 @@ pub async fn list_my_shares(
.map(|s| s.resource_id.to_string())
.collect();
let (file_results, folder_results) = tokio::join!(
join_all(file_ids.iter().map(|id| file_service.get_file(id))),
join_all(folder_ids.iter().map(|id| folder_service.get_folder(id)))
// Two batch queries instead of one get_* per id (see list_shared_with_me).
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(),
};
let mut file_idx = 0usize;
let mut folder_idx = 0usize;
let mut items: Vec<OutgoingResourceItemDto> = Vec::with_capacity(summaries.len());
for summary in &summaries {
@@ -962,64 +920,39 @@ pub async fn list_my_shares(
})
.collect();
let rid = summary.resource_id.to_string();
match summary.resource_type {
ResourceKind::File => {
let result = &file_results[file_idx];
file_idx += 1;
match result {
Ok(file_dto) => {
// Caller is the granter — they had share-access to the
// resource, so the containing hierarchy is already known
// to them. Keep `path` (unlike list_shared_with_me).
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::File,
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();
}
ResourceKind::File => match file_map.get(&rid) {
Some(file_dto) => {
// Caller is the granter — they had share-access to the
// resource, so the containing hierarchy is already known
// to them. Keep `path` (unlike list_shared_with_me).
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::File,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::File(file_dto.clone()),
grants,
});
}
}
ResourceKind::Folder => {
let result = &folder_results[folder_idx];
folder_idx += 1;
match result {
Ok(folder_dto) => {
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::Folder,
first_shared_at: summary.first_shared_at,
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 file grant for resource_id={}: not found",
summary.resource_id
),
},
ResourceKind::Folder => match folder_map.get(&rid) {
Some(folder_dto) => {
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::Folder,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::Folder(folder_dto.clone()),
grants,
});
}
}
None => warn!(
"Skipping stale outgoing folder grant for resource_id={}: not found",
summary.resource_id
),
},
}
}
+1
View File
@@ -16,6 +16,7 @@ pub mod grant_handler;
pub mod i18n_handler;
pub mod magic_link_handler;
pub mod music_handler;
pub mod people_handler;
pub mod photos_handler;
pub mod recent_handler;
pub mod search_handler;
@@ -0,0 +1,177 @@
//! HTTP handlers for the People (faces) feature.
//!
//! Every route is mounted only when `OXICLOUD_ENABLE_FACES` is on (the service
//! is present in `AppState`); each handler is also defensive. All work is
//! strictly caller-scoped by `PeopleService` (the repository filters by user).
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use uuid::Uuid;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
fn disabled() -> Response {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "People feature is disabled" })),
)
.into_response()
}
fn bad_id() -> Response {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "invalid id" })),
)
.into_response()
}
/// GET /api/people — identity clusters for the caller.
pub async fn list_people(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
match svc.list_people(auth_user.id).await {
Ok(people) => Json(people).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// GET /api/people/{id}/photos — file ids of a person's photos.
pub async fn person_photos(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let Ok(person_id) = Uuid::parse_str(&id) else {
return bad_id();
};
match svc.person_photos(auth_user.id, person_id).await {
Ok(files) => Json(files).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
#[derive(Deserialize)]
pub struct RenameBody {
pub name: Option<String>,
}
/// PATCH /api/people/{id} — name (or clear the name of) a person.
pub async fn rename_person(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(body): Json<RenameBody>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let Ok(person_id) = Uuid::parse_str(&id) else {
return bad_id();
};
match svc.rename_person(auth_user.id, person_id, body.name).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
#[derive(Deserialize)]
pub struct HideBody {
pub hidden: bool,
}
/// POST /api/people/{id}/hide — hide/unhide a person from the grid.
pub async fn hide_person(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(body): Json<HideBody>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let Ok(person_id) = Uuid::parse_str(&id) else {
return bad_id();
};
match svc.set_hidden(auth_user.id, person_id, body.hidden).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
#[derive(Deserialize)]
pub struct MergeBody {
pub into: String,
pub from: String,
}
/// POST /api/people/merge — merge `from` into `into`.
pub async fn merge_people(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Json(body): Json<MergeBody>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let (Ok(into), Ok(from)) = (Uuid::parse_str(&body.into), Uuid::parse_str(&body.from)) else {
return bad_id();
};
match svc.merge(auth_user.id, into, from).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// POST /api/people/recluster — re-run identity clustering for the caller.
pub async fn recluster(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
match svc.recluster(auth_user.id).await {
Ok(n) => Json(serde_json::json!({ "persons_created": n })).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// DELETE /api/people/data — erase all of the caller's face data.
pub async fn delete_all(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
match svc.delete_all(auth_user.id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// GET /api/people/faces/{file_id} — face boxes within a photo (lightbox tags).
pub async fn faces_for_file(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(file_id): Path<String>,
) -> Response {
let Some(svc) = state.people_service.as_ref() else {
return disabled();
};
let Ok(fid) = Uuid::parse_str(&file_id) else {
return bad_id();
};
match svc.faces_for_file(auth_user.id, fid).await {
Ok(boxes) => Json(boxes).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
+99 -6
View File
@@ -4,11 +4,12 @@ use axum::{
http::StatusCode,
response::IntoResponse,
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::{error, info};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::geo_dto::GeoBounds;
use crate::common::di::AppState;
use crate::interfaces::middleware::auth::AuthUser;
@@ -21,6 +22,20 @@ pub struct PhotosQueryParams {
pub limit: Option<i64>,
}
/// Photos-timeline item: a `FileDto` plus the image's original pixel
/// dimensions (from EXIF/metadata), flattened into the same JSON shape so
/// the gallery can lay tiles out at their true aspect ratio without a
/// second per-file metadata round-trip.
#[derive(Serialize)]
struct PhotoDto {
#[serde(flatten)]
file: FileDto,
#[serde(skip_serializing_if = "Option::is_none")]
width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
height: Option<u32>,
}
/// Lists all image/video files for the authenticated user, sorted by
/// capture date (EXIF DateTimeOriginal) falling back to upload date.
///
@@ -55,17 +70,22 @@ pub async fn list_photos(
.list_media_files(user_id, params.before, limit)
.await
{
Ok((files, sort_dates)) => {
Ok((files, sort_dates, dims)) => {
info!("Photos: returned {} media files for user", files.len());
// Convert to DTOs with sort_date populated
let dtos: Vec<FileDto> = files
// Convert to DTOs with sort_date + pixel dimensions populated.
let dtos: Vec<PhotoDto> = files
.into_iter()
.zip(sort_dates.iter())
.map(|(file, &sd)| {
.zip(dims.iter())
.map(|((file, &sd), &(w, h))| {
let mut dto = FileDto::from(file);
dto.sort_date = Some(sd as u64);
dto
PhotoDto {
file: dto,
width: w.map(|v| v.max(0) as u32),
height: h.map(|v| v.max(0) as u32),
}
})
.collect();
@@ -91,3 +111,76 @@ pub async fn list_photos(
}
}
}
/// Query parameters for the photos map (clustered) endpoint.
#[derive(Deserialize)]
pub struct GeoQueryParams {
/// Bounding box as `west,south,east,north` (decimal degrees).
pub bbox: String,
/// Slippy-map zoom level (0–20); controls cluster granularity.
pub zoom: Option<u8>,
}
/// Lists the caller's geotagged photos aggregated into map clusters within a
/// bounding box. Gated on `OXICLOUD_ENABLE_PLACES` (the route is only mounted
/// when the Places service is present).
#[utoipa::path(
get,
path = "/api/photos/geo",
params(
("bbox" = String, Query, description = "Bounding box 'west,south,east,north' (decimal degrees)"),
("zoom" = Option<u8>, Query, description = "Map zoom level (0-20), controls cluster size")
),
responses(
(status = 200, description = "Geotagged photos aggregated into map clusters"),
(status = 400, description = "Invalid bounding box"),
(status = 401, description = "Unauthorized")
),
security(("bearerAuth" = [])),
tag = "photos"
)]
pub async fn list_photos_geo(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Query(params): Query<GeoQueryParams>,
) -> impl IntoResponse {
let Some(places) = state.places_service.as_ref() else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Places feature is disabled" })),
)
.into_response();
};
let coords: Vec<f64> = params
.bbox
.split(',')
.filter_map(|s| s.trim().parse::<f64>().ok())
.collect();
if coords.len() != 4 {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "bbox must be 'west,south,east,north'" })),
)
.into_response();
}
let bounds = GeoBounds {
west: coords[0],
south: coords[1],
east: coords[2],
north: coords[3],
};
let zoom = params.zoom.unwrap_or(3);
match places.clusters(auth_user.id, bounds, zoom).await {
Ok(clusters) => Json(clusters).into_response(),
Err(err) => {
error!("Error listing photo geo clusters: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("{}", err) })),
)
.into_response()
}
}
}
+3 -2
View File
@@ -23,7 +23,7 @@ use crate::application::dtos::folder_dto::{
use crate::application::dtos::folder_listing_dto::FolderListingDto;
use crate::application::dtos::grant_dto::{
CreateGrantDto, GrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto,
ResourceDto, ResourceTypeDto, Role, SharedWithMeDto, SharedWithMeItemDto, SubjectDto,
ResourceDto, ResourceTypeDto, RoleDto, SharedWithMeDto, SharedWithMeItemDto, SubjectDto,
SubjectTypeDto, UpdateRoleDto,
};
use crate::application::dtos::i18n_dto::{
@@ -164,6 +164,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::recent_handler::clear_recent_items,
// Photos handler (free function)
handlers::photos_handler::list_photos,
handlers::photos_handler::list_photos_geo,
// Batch handlers (free functions)
handlers::batch_handler::move_files_batch,
handlers::batch_handler::copy_files_batch,
@@ -351,7 +352,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
ResourceTypeDto,
ResourceDto,
PermissionDto,
Role,
RoleDto,
CreateGrantDto,
UpdateRoleDto,
GrantDto,
+24 -4
View File
@@ -6,7 +6,7 @@ use axum::{
extract::{DefaultBodyLimit, State},
http::StatusCode,
response::{IntoResponse, Json as AxumJson, Response},
routing::{any, delete, get, post, put},
routing::{any, delete, get, patch, post, put},
};
use serde_json::json;
use std::sync::Arc;
@@ -431,13 +431,33 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
{
use crate::interfaces::api::handlers::photos_handler;
let photos_router = Router::new()
.route("/", get(photos_handler::list_photos))
.with_state(app_state.clone());
let mut photos_router = Router::new().route("/", get(photos_handler::list_photos));
if app_state.places_service.is_some() {
photos_router = photos_router.route("/geo", get(photos_handler::list_photos_geo));
}
let photos_router = photos_router.with_state(app_state.clone());
router = router.nest("/photos", photos_router);
}
// People (faces) routes — mounted only when OXICLOUD_ENABLE_FACES is on.
if app_state.people_service.is_some() {
use crate::interfaces::api::handlers::people_handler;
let people_router = Router::new()
.route("/", get(people_handler::list_people))
.route("/merge", post(people_handler::merge_people))
.route("/recluster", post(people_handler::recluster))
.route("/data", delete(people_handler::delete_all))
.route("/faces/{file_id}", get(people_handler::faces_for_file))
.route("/{id}", patch(people_handler::rename_person))
.route("/{id}/photos", get(people_handler::person_photos))
.route("/{id}/hide", post(people_handler::hide_person))
.with_state(app_state.clone());
router = router.nest("/people", people_router);
}
// Re-enable trash routes to make the trash view work
if let Some(_trash_service_ref) = trash_service.clone() {
tracing::info!("Setting up trash routes for trash view");
+46 -17
View File
@@ -14,6 +14,7 @@ use crate::application::ports::auth_ports::TokenServicePort;
use crate::common::di::AppState;
use crate::interfaces::api::cookie_auth::{ACCESS_COOKIE, extract_cookie_value};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::user::{LiveRole, resolve_live_role};
/// Validate the request's JWT (from the `Authorization: Bearer …` header
/// or the access-token cookie) and require `claims.role == "admin"`.
@@ -43,19 +44,37 @@ pub async fn require_admin(
.validate_token(&token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
if claims.role != "admin" {
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Admin access required",
"Forbidden",
));
}
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?;
Ok((
Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?,
claims.role.clone(),
))
// Gate on the *live* role, not the JWT claim: a demotion or deactivation
// must take effect within the flags-cache TTL rather than surviving until
// the token expires.
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)`.
@@ -84,9 +103,19 @@ pub async fn require_authenticated(
.validate_token(&token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
Ok((
Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?,
claims.role.clone(),
))
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::internal_error("Invalid user ID in token"))?;
// 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")),
}
}
+164 -41
View File
@@ -13,6 +13,7 @@ use crate::common::di::AppState;
// Re-export CurrentUser from application layer for use in handlers
pub use crate::application::dtos::user_dto::CurrentUser;
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
/// via the `oxicloud_access` HttpOnly cookie rather than a Bearer/Basic header.
@@ -111,6 +112,9 @@ pub enum AuthError {
#[error("User not found")]
UserNotFound,
#[error("Account is no longer active")]
AccountInactive,
#[error("Access denied: {0}")]
AccessDenied(String),
@@ -127,6 +131,10 @@ impl IntoResponse for AuthError {
AuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, msg),
AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token expired".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::AuthServiceUnavailable => (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -181,11 +189,26 @@ pub async fn auth_middleware(
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
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 {
id: user_id,
username: claims.username.clone(),
email: claims.email.clone(),
role: claims.role.clone(),
role,
});
request.extensions_mut().insert(current_user);
tracing::Span::current().record("user_id", user_id.to_string());
@@ -240,17 +263,12 @@ pub async fn auth_middleware(
}
Err(e) => {
tracing::warn!("App password verification failed: {}", e);
// For WebDAV: include WWW-Authenticate so the client
// knows to re-prompt rather than silently failing.
if request.uri().path().starts_with("/webdav") {
return Ok(Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::WWW_AUTHENTICATE, r#"Basic realm="OxiCloud""#)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(axum::body::Body::from(
"Invalid username or app password",
))
.unwrap());
// For DAV clients: include WWW-Authenticate so the client
// re-prompts for credentials rather than failing silently.
if is_dav_path(request.uri().path()) {
return Ok(dav_basic_auth_challenge(
"Invalid username or app password",
));
}
return Err(AuthError::InvalidToken(
"Invalid username or app password".to_string(),
@@ -285,16 +303,33 @@ pub async fn auth_middleware(
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
AuthError::InvalidToken("Invalid user ID in token".to_string())
})?;
let current_user = Arc::new(CurrentUser {
id: user_id,
username: claims.username.clone(),
email: claims.email.clone(),
role: claims.role.clone(),
});
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);
// Same live-account re-check as the Bearer path. On
// revocation we fall through (rather than erroring) so the
// browser receives the standard 401 and redirects to
// /login, exactly like an invalid or expired cookie.
match resolve_live_role(
auth_service.auth_application_service.as_ref(),
user_id,
&claims.role,
)
.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) => {
tracing::debug!("Cookie token validation failed: {}", e);
@@ -312,27 +347,48 @@ pub async fn auth_middleware(
return Err(AuthError::AuthServiceUnavailable);
}
// For WebDAV requests with no credentials at all: return 401 with
// WWW-Authenticate so that spec-compliant clients (Nautilus, Cyberduck,
// Windows Explorer, macOS Finder) know to prompt for a username/password.
// Non-WebDAV routes return the standard AuthError which renders without
// this header — keeping browser sessions redirecting to /login as before.
if request.uri().path().starts_with("/webdav") {
return Ok(Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::WWW_AUTHENTICATE, r#"Basic realm="OxiCloud""#)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(axum::body::Body::from("Authentication required"))
.unwrap());
// For DAV requests with no credentials at all: return 401 with
// WWW-Authenticate so that spec-compliant clients (Thunderbird, DAVx5,
// Apple Calendar/Contacts, Nautilus, Cyberduck, Windows Explorer, macOS
// Finder) know to prompt for credentials and retry. Unlike `curl -u`, these
// clients do NOT send Basic credentials preemptively — without the
// challenge they never authenticate and fail with "discovery failed" / 401.
// Non-DAV routes return the standard AuthError which renders without this
// header — keeping browser sessions redirecting to /login as before.
if is_dav_path(request.uri().path()) {
return Ok(dav_basic_auth_challenge("Authentication required"));
}
Err(AuthError::TokenNotProvided)
}
/// DAV protocol surfaces (WebDAV, CalDAV, CardDAV) authenticate over HTTP Basic.
/// Spec-compliant clients (Thunderbird, DAVx5, Apple Calendar/Contacts, file
/// managers) only send credentials after receiving a `401` carrying a
/// `WWW-Authenticate: Basic` challenge, so these paths must emit it. Browser and
/// JSON-API routes deliberately do not, so they keep redirecting to `/login`.
fn is_dav_path(path: &str) -> bool {
path.starts_with("/webdav") || path.starts_with("/caldav") || path.starts_with("/carddav")
}
/// Build the `401 Unauthorized` Basic-auth challenge shared by every DAV
/// surface, so clients re-prompt for credentials instead of failing silently.
fn dav_basic_auth_challenge(message: &'static str) -> Response {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::WWW_AUTHENTICATE, r#"Basic realm="OxiCloud""#)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(axum::body::Body::from(message))
.unwrap()
}
/// Middleware to verify that the authenticated user has an admin role.
///
/// Must be applied AFTER auth_middleware, as it depends on
/// `CurrentUser` being present in the request extensions.
/// Must be applied AFTER auth_middleware, as it depends on `CurrentUser`
/// 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 {
// Get the CurrentUser inserted by auth_middleware
if let Some(current_user) = request.extensions().get::<Arc<CurrentUser>>() {
@@ -340,16 +396,83 @@ pub async fn require_admin(request: Request, next: Next) -> Response {
tracing::debug!("Admin access granted for user: {}", current_user.username);
return next.run(request).await;
}
tracing::warn!(
"Admin access denied for user: {} (role: {})",
current_user.username,
current_user.role
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason = "not_admin",
caller_id = %current_user.id,
role = %current_user.role,
"👮🏻‍♂️ admin-only route denied for non-admin caller"
);
} 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
let error = AuthError::AccessDenied("Admin role required".to_string());
error.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dav_paths_receive_basic_auth_challenge() {
// Regression for #480: CalDAV/CardDAV clients (Thunderbird, DAVx5) only
// send credentials after a 401 carrying WWW-Authenticate. All three DAV
// surfaces must qualify so the challenge is emitted.
for path in [
"/webdav/",
"/webdav/admin/file.txt",
"/caldav/",
"/caldav/admin/cal/",
"/carddav/",
"/carddav/principals/admin/",
] {
assert!(is_dav_path(path), "{path} should be treated as a DAV path");
}
}
#[test]
fn non_dav_paths_do_not_receive_basic_auth_challenge() {
for path in [
"/",
"/api/files",
"/login",
"/index.html",
"/.well-known/caldav",
] {
assert!(
!is_dav_path(path),
"{path} must not get a Basic-auth challenge (browser/API surface)"
);
}
}
#[test]
fn challenge_sets_www_authenticate_header() {
let resp = dav_basic_auth_challenge("Authentication required");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
resp.headers()
.get(header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok()),
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);
}
}
+136 -1
View File
@@ -32,7 +32,8 @@ use uuid::Uuid;
use crate::application::services::auth_application_service::AuthApplicationService;
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::middleware::auth::CurrentUser;
@@ -102,6 +103,91 @@ pub async fn require_admin_user(
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
/// subtree. Apply via `.layer(from_fn_with_state(state, require_internal_user_layer))`
/// 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
}
#[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()));
}
}
+34 -8
View File
@@ -7,7 +7,7 @@ use quick_xml::{
Reader, Writer,
events::{BytesEnd, BytesStart, Event},
};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
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::search_dto::SearchCriteriaDto;
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::inbound::SearchUseCase;
use crate::common::di::AppState;
@@ -86,20 +85,47 @@ async fn handle_filter_files(
let home_prefix = format!("My Folder - {}/", user.username);
// Pass 1: fetch the favorited DTOs (the per-item fetch is a separate
// concern from the oc:fileid resolution batched below).
// Pass 1: resolve the favorited DTOs in two batch queries (was one
// get_* per favorite — up to N serial round-trips on a sync client's
// 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 folders: Vec<FolderDto> = Vec::new();
for fav in &favorites {
match fav.item_type.as_str() {
"file" => {
if let Ok(f) = file_service.get_file(&fav.item_id).await {
files.push(f);
if let Some(f) = file_map.get(&fav.item_id) {
files.push(f.clone());
}
}
"folder" => {
if let Ok(f) = folder_service.get_folder(&fav.item_id).await {
folders.push(f);
if let Some(f) = folder_map.get(&fav.item_id) {
folders.push(f.clone());
}
}
_ => {}