Merge upstream/main into feat/external-file-mounts

Resolve conflicts between the external-file-mounts feature and upstream's
D5/D7 refactor (per-file provenance, keyset pagination, cross-drive move
gates, resource-access hook, folder-cascade lifecycle hook).

Key resolutions:
- FolderService::new now takes (repo, authz, file_lifecycle, mount_router);
  all callers + DI updated.
- FileRetrievalService / FileManagementService keep both the mount_router
  and the new resource_access_hook / drive_repo / storage_usage wiring.
- list_files_batch_with_perms: adapt the mount branch from offset- to
  keyset (after_name) pagination, mirroring paginate_mount_entries.
- download_file_impl: keep upstream's &HeaderMap + `impl IntoResponse + use<>`
  signature, retain the mount-download branch.
- Mount DTOs: the retired `owner_id` field maps onto created_by/updated_by
  (the mount owner) — the fields the frontend now uses for owner display.
- admin/+page.svelte: keep upstream's user-delete modal + the 'mounts' tab.
- Bump memmap2 0.9.10 -> 0.9.11 (RUSTSEC critical advisory fix) and
  regenerate Cargo.lock against the merged Cargo.toml.
This commit is contained in:
Bradley Nelson
2026-07-21 17:09:36 -06:00
600 changed files with 105575 additions and 14440 deletions
+20 -3
View File
@@ -44,7 +44,17 @@ pub fn is_cookie_secure() -> bool {
cookie_secure()
}
/// Memoised [`resolve_cookie_secure`]. The flag is a pure function of two
/// process-invariant env vars, yet a single login used to re-resolve it
/// ~4× (two auth cookies + the CSRF cookie + the handler's own probe) —
/// each call paying the env-lock syscalls and re-emitting the same
/// "⚠️ SECURITY" log line. Resolve once, log once.
fn cookie_secure() -> bool {
static COOKIE_SECURE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*COOKIE_SECURE.get_or_init(resolve_cookie_secure)
}
fn resolve_cookie_secure() -> bool {
if let Ok(v) = std::env::var("OXICLOUD_COOKIE_SECURE") {
let secure = v == "true" || v == "1";
if !secure {
@@ -131,8 +141,10 @@ pub fn append_clear_cookies(headers: &mut HeaderMap) {
}
}
/// Extract a named cookie value from the `Cookie` request header.
pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
/// Extract a named cookie value from the `Cookie` request header,
/// borrowing from the header map. Callers that only compare or parse the
/// value (CSRF check) avoid the per-request copy.
pub fn extract_cookie_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
let cookie_header = headers.get(axum::http::header::COOKIE)?;
let cookie_str = cookie_header.to_str().ok()?;
@@ -141,13 +153,18 @@ pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
if let Some(val) = pair.strip_prefix(name) {
let val = val.strip_prefix('=')?;
if !val.is_empty() {
return Some(val.to_string());
return Some(val);
}
}
}
None
}
/// Extract a named cookie value from the `Cookie` request header.
pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
extract_cookie_str(headers, name).map(str::to_string)
}
// ────────────────────────────────────────────────────────────
// CSRF double-submit cookie helpers
// ────────────────────────────────────────────────────────────
+408 -119
View File
@@ -1,7 +1,7 @@
use axum::{
Router,
extract::{DefaultBodyLimit, Json, Multipart, Path, Query, State},
http::{HeaderMap, StatusCode},
http::StatusCode,
response::{
IntoResponse,
sse::{Event, KeepAlive, Sse},
@@ -21,13 +21,17 @@ use crate::application::dtos::settings_dto::{
SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto,
UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, VerifyMigrationDto,
};
use crate::application::dtos::user_dto::UserDto;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError};
use crate::application::ports::storage_ports::StorageUsagePort;
use crate::common::di::AppState;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::services::authorization::{Resource, Subject};
use crate::interfaces::api::handlers::dedup_handler::{get_stats, recalculate_stats};
use crate::interfaces::api::handlers::search_handler::clear_search_cache;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::admin::require_admin;
use crate::interfaces::middleware::auth::AuthUser;
use std::sync::Arc;
use uuid::Uuid;
@@ -75,6 +79,10 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
.route("/users/{id}/active", put(update_user_active))
.route("/users/{id}/quota", put(update_user_quota))
.route("/users/{id}/password", put(reset_user_password))
.route(
"/users/{id}/promote-to-internal",
post(admin_promote_external_to_internal),
)
// Registration control
.route("/settings/registration", put(set_registration_setting))
// Audio metadata
@@ -98,6 +106,22 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
.route("/plugins/{id}/logs/stream", get(stream_plugin_logs))
.route("/plugins/{id}/retention", get(get_plugin_retention))
.route("/plugins/{id}/retention", put(set_plugin_retention))
// Search — operator flush of the shared moka results cache
// (AuthZ audit #14, 2026-07-16). `invalidate_all()` semantics
// touch every tenant, so this is admin-only. Lived at
// `/api/search/cache` pre-2026-07-17; the URL now declares
// its admin intent up front.
.route("/search/cache", delete(clear_search_cache))
// Dedup — global storage stats + integrity recalculation
// (AuthZ audit #24 + #25, 2026-07-17). Both are operator-only
// observability / maintenance surfaces (blob-count-level data
// + verify_integrity sweep). Moved here from `/api/dedup/*`
// so the URL declares admin intent and the middleware layer
// enforces it — same pattern as `search/cache` above. The
// any-authenticated sibling routes (`/check`, `/check-batch`,
// `/blob/{hash}`) stay at `/api/dedup/*`.
.route("/dedup/stats", get(get_stats))
.route("/dedup/recalculate", post(recalculate_stats))
// SMTP diagnostics
.route("/smtp/info", get(get_smtp_info))
.route("/smtp/test", post(send_smtp_test))
@@ -105,9 +129,21 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
// when `OXICLOUD_SMTP_MOCK` is off, so production deployments
// can route the path freely without leaking inboxes.
.route("/smtp/test/captured", get(get_captured_email))
// Test-only sweep triggers. Routes are always registered; the
// handlers themselves short-circuit to 404 when
// `features.enable_admin_internal_endpoints` is off — matches
// the `/smtp/test/captured` convention so production
// deployments don't need a different route table.
.route("/internal/trigger-sweep", post(internal_trigger_sweep))
.route("/internal/trigger-gc", post(internal_trigger_gc))
.route(
"/internal/trigger-grant-cleanup",
post(internal_trigger_grant_cleanup),
)
// Drives — admin-wide view (distinct from `/api/drives` which
// is filtered to the caller's role grants).
.route("/drives", get(list_all_drives))
.route("/drives/{id}", delete(delete_drive_admin))
.route(
"/drives/{id}/members",
get(list_drive_members_admin).post(add_drive_member_admin),
@@ -118,14 +154,13 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
)
}
/// Validate JWT and require admin role. Returns (user_id, role).
///
/// Thin wrapper over the shared `require_admin` middleware helper so this
/// handler keeps a stable signature while the implementation lives next to
/// the new `subject_group_handler` that also needs it.
async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, String), AppError> {
require_admin(state, headers).await
}
// Every route under `/api/admin/*` is gated by the
// `require_admin` middleware layer wired at the router nest point
// (`routes.rs::admin_router`). Handlers no longer need an inline
// guard call — the caller is guaranteed to be admin by construction.
// Callers that need the caller's id read it from the `AuthUser`
// extractor (`middleware::auth::AuthUser`), populated by the outer
// `auth_middleware`.
/// GET /api/admin/settings/oidc — get OIDC settings for the admin panel
#[utoipa::path(
@@ -141,10 +176,7 @@ async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, Str
)]
pub async fn get_oidc_settings(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let svc = state
.admin_settings_service
.as_ref()
@@ -172,10 +204,10 @@ pub async fn get_oidc_settings(
)]
pub async fn save_oidc_settings(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Json(dto): Json<SaveOidcSettingsDto>,
) -> Result<impl IntoResponse, AppError> {
let (user_id, _) = admin_guard(&state, &headers).await?;
let user_id = auth_user.id;
let svc = state
.admin_settings_service
@@ -197,11 +229,8 @@ pub async fn save_oidc_settings(
/// POST /api/admin/settings/oidc/test — test OIDC discovery
async fn test_oidc_connection(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<TestOidcConnectionDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let svc = state
.admin_settings_service
.as_ref()
@@ -233,10 +262,7 @@ async fn test_oidc_connection(
)]
pub async fn get_storage_settings(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let svc = state
.storage_settings_service
.as_ref()
@@ -264,10 +290,10 @@ pub async fn get_storage_settings(
)]
pub async fn save_storage_settings(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Json(dto): Json<SaveStorageSettingsDto>,
) -> Result<impl IntoResponse, AppError> {
let (user_id, _) = admin_guard(&state, &headers).await?;
let user_id = auth_user.id;
let svc = state
.storage_settings_service
@@ -289,11 +315,8 @@ pub async fn save_storage_settings(
/// POST /api/admin/settings/storage/test — test storage backend connection
async fn test_storage_connection(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<TestStorageConnectionDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let svc = state
.storage_settings_service
.as_ref()
@@ -325,9 +348,7 @@ async fn test_storage_connection(
)]
pub async fn get_migration_status(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let s = state.migration_state.read().await;
Ok(Json(migration_state_to_dto(&s)))
}
@@ -347,13 +368,10 @@ pub async fn get_migration_status(
)]
pub async fn start_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<StartMigrationDto>,
) -> Result<impl IntoResponse, AppError> {
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
admin_guard(&state, &headers).await?;
// Check not already running.
{
let s = state.migration_state.read().await;
@@ -425,10 +443,8 @@ pub async fn start_migration(
)]
pub async fn pause_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
admin_guard(&state, &headers).await?;
let mut s = state.migration_state.write().await;
if s.status != MigrationStatus::Running {
@@ -456,10 +472,8 @@ pub async fn pause_migration(
)]
pub async fn resume_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
admin_guard(&state, &headers).await?;
// Set status back to Running — the background task checks on each blob.
let mut s = state.migration_state.write().await;
@@ -488,10 +502,8 @@ pub async fn resume_migration(
)]
pub async fn complete_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
admin_guard(&state, &headers).await?;
let s = state.migration_state.read().await;
if s.status != MigrationStatus::Completed {
@@ -528,11 +540,8 @@ pub async fn complete_migration(
)]
pub async fn verify_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<VerifyMigrationDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let pool = state
.db_pool
.clone()
@@ -604,12 +613,7 @@ fn migration_state_to_dto(
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn generate_encryption_key(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
pub async fn generate_encryption_key() -> Result<impl IntoResponse, AppError> {
let key =
crate::infrastructure::services::encrypted_blob_backend::EncryptedBlobBackend::generate_key(
);
@@ -667,10 +671,7 @@ fn build_backend_from_config(
)]
pub async fn get_dashboard_stats(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let auth = state
.auth_service
.as_ref()
@@ -684,7 +685,16 @@ pub async fn get_dashboard_stats(
.as_ref()
.ok_or_else(|| AppError::internal_error("Database not available"))?;
// Use direct SQL for aggregated stats — more efficient than loading all users
// Use direct SQL for aggregated stats — more efficient than loading all users.
//
// Scope: internal users only (`is_external = false`). External
// accounts (grant-only magic-link / OCM recipients) have no
// storage envelope by construction (DB CHECK
// `users_external_no_storage`) and cannot be admin
// (`users_external_not_admin`), so they'd inflate `total_users`
// and `active_users` with rows that don't represent operational
// seats. The audit list (`/api/admin/users`) still shows every
// account; only the dashboard totals filter externals out.
let stats_row = sqlx::query(
r#"
SELECT
@@ -696,6 +706,7 @@ pub async fn get_dashboard_stats(
COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes * 0.8)::INT8 as users_over_80,
COUNT(*) FILTER (WHERE storage_quota_bytes > 0 AND storage_used_bytes > storage_quota_bytes)::INT8 as users_over_quota
FROM auth.users
WHERE is_external = false
"#
)
.fetch_one(db_pool.as_ref())
@@ -758,11 +769,8 @@ pub async fn get_dashboard_stats(
)]
pub async fn list_users(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<ListUsersQueryDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let auth = state
.auth_service
.as_ref()
@@ -771,9 +779,14 @@ pub async fn list_users(
let limit = query.limit.unwrap_or(100).min(500);
let offset = query.offset.unwrap_or(0);
// Admin surface must show *every* account for audit — grant-only
// magic-link / OCM recipients (is_external = true) included. The
// internal-only variant is used by system address book / sharee
// search, where surfacing externals would leak identities. See
// `auth_application_service::list_users` doc for the split.
let users = auth
.auth_application_service
.list_users(limit, offset)
.list_users_including_external(limit, offset)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list users: {}", e)))?;
@@ -807,11 +820,8 @@ pub async fn list_users(
)]
pub async fn get_user(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
let auth = state
@@ -844,10 +854,10 @@ pub async fn get_user(
)]
pub async fn delete_user(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
@@ -894,11 +904,11 @@ pub async fn delete_user(
)]
pub async fn update_user_role(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<UpdateUserRoleDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
@@ -945,11 +955,11 @@ pub async fn update_user_role(
)]
pub async fn update_user_active(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<UpdateUserActiveDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
@@ -1000,12 +1010,9 @@ pub async fn update_user_active(
)]
pub async fn update_user_quota(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<UpdateUserQuotaDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
let auth = state
@@ -1046,11 +1053,8 @@ pub async fn update_user_quota(
)]
pub async fn create_user(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<AdminCreateUserDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let auth = state
.auth_service
.as_ref()
@@ -1087,12 +1091,9 @@ pub async fn create_user(
)]
pub async fn reset_user_password(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<AdminResetPasswordDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
let auth = state
@@ -1119,6 +1120,48 @@ pub async fn reset_user_password(
))
}
/// POST /api/admin/users/{id}/promote-to-internal — flip an external
/// (grant-only) account into a normal internal account, provisioning
/// its personal drive on the way. The deployment MUST have magic-link
/// login enabled (the admin doesn't set the user's password on their
/// behalf, so the promoted user needs some way to log in). Refuses
/// OIDC-linked users and users who are already internal.
#[utoipa::path(
post,
path = "/api/admin/users/{id}/promote-to-internal",
params(("id" = String, Path, description = "Target user id")),
responses(
(status = 200, description = "User promoted", body = UserDto),
(status = 400, description = "Magic-link login is disabled on this deployment"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required (or target is OIDC-linked)"),
(status = 404, description = "User not found"),
(status = 409, description = "User is already internal"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn admin_promote_external_to_internal(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let target_id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
let auth = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let dto = auth
.auth_application_service
.admin_promote_external_to_internal(auth_user.id, target_id)
.await
.map_err(AppError::from)?;
Ok((StatusCode::OK, Json(dto)))
}
// ============================================================================
// Registration Control
// ============================================================================
@@ -1138,10 +1181,10 @@ pub async fn reset_user_password(
)]
pub async fn set_registration_setting(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Json(body): Json<serde_json::Value>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let enabled = body
.get("registration_enabled")
@@ -1174,10 +1217,7 @@ pub async fn set_registration_setting(
async fn reextract_audio_metadata(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let audio_service = state
.applications
.audio_metadata_service
@@ -1204,10 +1244,7 @@ async fn reextract_audio_metadata(
/// Photos timeline by real capture date. Safe to re-run (idempotent upsert).
async fn reextract_image_metadata(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let result = state
.applications
.media_metadata_service
@@ -1250,12 +1287,7 @@ async fn reextract_image_metadata(
security(("bearerAuth" = [])),
tag = "admin"
)]
async fn get_smtp_info(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
async fn get_smtp_info(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> {
let smtp = &state.core.config.smtp;
let info = SmtpInfoDto {
enabled: smtp.is_enabled() && state.email_sender.is_some(),
@@ -1284,11 +1316,8 @@ async fn get_smtp_info(
/// returns 404 to keep the endpoint inert.
async fn get_captured_email(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(params): Query<CapturedEmailQuery>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
if !std::env::var("OXICLOUD_SMTP_MOCK")
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
@@ -1344,10 +1373,10 @@ struct CapturedEmailQuery {
)]
async fn send_smtp_test(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Json(dto): Json<SendSmtpTestDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let recipient = dto.to.trim().to_string();
if recipient.is_empty() {
@@ -1459,9 +1488,7 @@ fn map_mgmt_err(err: &PluginMgmtError) -> AppError {
/// GET /api/admin/plugins — list installed plugins.
pub async fn list_plugins(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let plugins: Vec<PluginInfoDto> = mgmt.list().into_iter().map(PluginInfoDto::from).collect();
// `enabled` reports that the plugin *subsystem* is active (reaching here
@@ -1476,11 +1503,11 @@ pub async fn list_plugins(
/// PUT /api/admin/plugins/{id}/enabled — enable or disable a plugin.
pub async fn set_plugin_enabled(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<SetEnabledDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let mgmt = plugin_mgmt(&state)?;
mgmt.set_enabled(&id, dto.enabled)
.map_err(|e| map_mgmt_err(&e))?;
@@ -1517,10 +1544,10 @@ pub async fn set_plugin_enabled(
/// single `bundle` part: a `.zip` containing `plugin.toml` and its `.wasm`.
pub async fn install_plugin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
mut multipart: Multipart,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let mgmt = plugin_mgmt(&state)?;
let mut bundle: Option<Vec<u8>> = None;
@@ -1581,10 +1608,10 @@ pub async fn install_plugin(
/// DELETE /api/admin/plugins/{id} — uninstall a plugin and delete its files.
pub async fn delete_plugin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let mgmt = plugin_mgmt(&state)?;
mgmt.remove(&id).map_err(|e| map_mgmt_err(&e))?;
@@ -1606,11 +1633,9 @@ pub async fn delete_plugin(
/// structured log entries (newest first).
pub async fn get_plugin_logs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<PluginLogQueryDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let limit = q.limit.unwrap_or(50).clamp(1, 500);
@@ -1634,10 +1659,10 @@ pub async fn get_plugin_logs(
/// DELETE /api/admin/plugins/{id}/logs — wipe a plugin's persisted logs.
pub async fn clear_plugin_logs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let mgmt = plugin_mgmt(&state)?;
mgmt.clear_logs(&id).await.map_err(|e| map_mgmt_err(&e))?;
@@ -1661,13 +1686,11 @@ pub async fn clear_plugin_logs(
/// so `EventSource` works without setting headers.
pub async fn stream_plugin_logs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
use tokio_stream::StreamExt;
use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError};
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
if !mgmt.list().iter().any(|p| p.id == id) {
return Err(AppError::not_found("Plugin not found"));
@@ -1695,10 +1718,8 @@ pub async fn stream_plugin_logs(
/// GET /api/admin/plugins/{id}/retention — the plugin's effective retention.
pub async fn get_plugin_retention(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let settings = mgmt
.get_retention(&id)
@@ -1710,11 +1731,11 @@ pub async fn get_plugin_retention(
/// PUT /api/admin/plugins/{id}/retention — set the plugin's retention policy.
pub async fn set_plugin_retention(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<PluginRetentionDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let mgmt = plugin_mgmt(&state)?;
mgmt.set_retention(&id, dto.into())
.await
@@ -1758,9 +1779,7 @@ pub async fn set_plugin_retention(
)]
pub async fn list_all_drives(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let drives = state
.drive_repo
.list_all()
@@ -1796,10 +1815,8 @@ pub async fn list_all_drives(
)]
pub async fn list_drive_members_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let grants = state
.authorization
.list_grants_on_resource(Resource::Drive(drive_id))
@@ -1859,11 +1876,11 @@ fn admin_parse_subject(kind: SubjectTypeDto, id: Uuid) -> Subject {
)]
pub async fn add_drive_member_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
Json(dto): Json<AdminAddDriveMemberDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let subject = admin_parse_subject(dto.subject.kind, dto.subject.id);
let grant = state
.drive_management_service
@@ -1904,7 +1921,7 @@ pub async fn add_drive_member_admin(
)]
pub async fn update_drive_member_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<(
Uuid,
SubjectTypeDto,
@@ -1912,7 +1929,7 @@ pub async fn update_drive_member_admin(
)>,
Json(dto): Json<AdminUpdateDriveMemberDto>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let subject = admin_parse_subject(kind, subject_id);
let grant = state
.drive_management_service
@@ -1951,14 +1968,14 @@ pub async fn update_drive_member_admin(
)]
pub async fn remove_drive_member_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<(
Uuid,
SubjectTypeDto,
Uuid,
)>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let subject = admin_parse_subject(kind, subject_id);
state
.drive_management_service
@@ -1967,3 +1984,275 @@ pub async fn remove_drive_member_admin(
.map_err(AppError::from)?;
Ok(StatusCode::NO_CONTENT)
}
/// `DELETE /api/admin/drives/{id}` — admin-only drive delete (D3b).
///
/// Same shape as the user-facing `DELETE /api/drives/{id}`, but
/// bypasses the per-drive `Manage` check (the admin guard at the
/// route edge is the access control). The remaining invariants —
/// default Personal drive is undeletable, drive must be empty — still
/// apply: an admin can't accidentally wipe a populated drive or the
/// default home folder of any user. Audit emits
/// `drive.deleted_via_admin` on success.
#[utoipa::path(
delete,
path = "/api/admin/drives/{id}",
params(("id" = Uuid, Path, description = "Drive UUID")),
responses(
(status = 204, description = "Drive deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 405, description = "Default Personal drive — undeletable"),
(status = 409, description = "Drive is not empty"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn delete_drive_admin(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let admin_id = auth_user.id;
state
.drive_management_service
.delete_drive(admin_id, true, drive_id)
.await
.map_err(AppError::from)?;
Ok(StatusCode::NO_CONTENT)
}
// ════════════════════════════════════════════════════════════════════════════
// Test-only sweep triggers (`/api/admin/internal/*`)
//
// Wraps the periodic background jobs (storage-usage reconciliation,
// blob garbage collection) behind admin-gated synchronous endpoints
// so Hurl / integration tests can wait for them deterministically
// rather than polling the cached value. Disabled at the handler edge
// when `features.enable_admin_internal_endpoints == false` — match
// the `/smtp/test/captured` convention so production deployments
// don't need a different route table.
// ════════════════════════════════════════════════════════════════════════════
/// Refusal when the test-only endpoints are disabled. Returns 404
/// rather than 403 to avoid leaking the route's existence (and the
/// corresponding config flag) to an unauthenticated probe — the
/// legitimate test runner sets the env explicitly.
fn internal_endpoints_disabled() -> axum::response::Response {
use axum::response::IntoResponse;
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "endpoint not available" })),
)
.into_response()
}
/// `POST /api/admin/internal/trigger-sweep` — run the storage-usage
/// reconciliation sweep synchronously.
///
/// Test-only. Recomputes `users.storage_used_bytes` and
/// `drives.used_bytes` from `SUM(size) WHERE NOT is_trashed`, in the
/// same set-based UPDATEs the periodic ticker runs. Used by Hurl
/// suites that need to assert post-delete quota convergence without
/// waiting out the sweep interval (default 600 s).
#[utoipa::path(
post,
path = "/api/admin/internal/trigger-sweep",
responses(
(status = 200, description = "Sweep ran"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 404, description = "Endpoint disabled (set OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true)"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn internal_trigger_sweep(
State(state): State<Arc<AppState>>,
) -> axum::response::Response {
use axum::response::IntoResponse;
if !state.core.config.features.enable_admin_internal_endpoints {
return internal_endpoints_disabled();
}
let svc = match state.storage_usage_service.as_ref() {
Some(s) => s,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "storage_usage_service not available",
})),
)
.into_response();
}
};
// Order matches the periodic ticker (`start_reconciliation_job`):
// drive sweep first because the user sweep reads `drives.used_bytes`
// (sum-of-personal-drives — `docs/plan/drive.md` §7). Running them
// in the other order makes the user counter freeze on the previous
// tick's drive numbers — invisible in steady state but breaks any
// Hurl that trashes + sweeps within one call.
if let Err(e) = svc.update_all_drives_storage_usage().await {
return AppError::internal_error(format!("drive sweep failed: {e}")).into_response();
}
if let Err(e) = svc.update_all_users_storage_usage().await {
return AppError::internal_error(format!("user sweep failed: {e}")).into_response();
}
(
StatusCode::OK,
Json(serde_json::json!({ "ok": true, "ran": ["drives", "users"] })),
)
.into_response()
}
/// Query parameters for `POST /api/admin/internal/trigger-gc`.
///
/// `force=true` bypasses the orphan-grace window so the sweep reaps
/// just-orphaned blobs in the same call. Without this, a blob orphaned
/// less than `GC_ORPHAN_GRACE_SECS` (1 h) ago survives the sweep — the
/// grace exists so a concurrent uploader pinning a just-orphaned chunk
/// can't race the row-delete → file-unlink gap. Integration tests
/// don't have concurrent uploaders, so the test runner sets
/// `force=true` to make the sweep deterministic within a test's
/// runtime.
#[derive(Debug, serde::Deserialize, Default)]
pub struct InternalTriggerGcQuery {
#[serde(default)]
pub force: bool,
}
/// `POST /api/admin/internal/trigger-gc` — run the blob garbage
/// collector synchronously.
///
/// Test-only. Drops `file_blobs` rows with `ref_count = 0` (subject
/// to the orphan-grace window) and their on-disk content. Same call
/// as the inline post-purge GC and the periodic blob-GC sweep — just
/// exposed under an admin route so Hurl can wait for it
/// deterministically. Add `?force=true` to bypass the grace window —
/// see [`InternalTriggerGcQuery`].
#[utoipa::path(
post,
path = "/api/admin/internal/trigger-gc",
params(("force" = Option<bool>, Query, description = "Bypass the orphan-grace window (test-only)")),
responses(
(status = 200, description = "GC ran"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 404, description = "Endpoint disabled (set OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true)"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn internal_trigger_gc(
State(state): State<Arc<AppState>>,
Query(query): Query<InternalTriggerGcQuery>,
) -> axum::response::Response {
use axum::response::IntoResponse;
if !state.core.config.features.enable_admin_internal_endpoints {
return internal_endpoints_disabled();
}
let result = if query.force {
state.core.dedup_service.garbage_collect_force().await
} else {
state.core.dedup_service.garbage_collect().await
};
match result {
Ok((blobs_deleted, bytes_freed)) => (
StatusCode::OK,
Json(serde_json::json!({
"ok": true,
"blobs_deleted": blobs_deleted,
"bytes_freed": bytes_freed,
"forced": query.force,
})),
)
.into_response(),
Err(e) => AppError::internal_error(format!("gc failed: {e}")).into_response(),
}
}
/// Query parameters for `POST /api/admin/internal/trigger-grant-cleanup`.
///
/// `force=true` sets the grace window to `0` for this call — deletes
/// every row whose `expires_at` is in the past, right now. Enables
/// Hurl regressions to plant a past-dated grant and immediately
/// observe it purged, without waiting the configured
/// `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` out.
///
/// Without `force`, the daemon's configured grace applies — the same
/// SQL the daily loop runs.
#[derive(Debug, serde::Deserialize, Default)]
pub struct InternalTriggerGrantCleanupQuery {
#[serde(default)]
pub force: bool,
}
/// `POST /api/admin/internal/trigger-grant-cleanup` — run the expired-
/// grant purge synchronously.
///
/// Test-only. Deletes rows from `storage.role_grants` whose
/// `expires_at` is more than `grace_days` in the past (or immediately,
/// with `?force=true`). Same SQL as the periodic `GrantCleanupService`
/// daemon — exposed under an admin route so Hurl can wait for it
/// deterministically.
///
/// Response fields:
/// `grants_deleted` — count of rows removed by this invocation
/// `grace_days` — the grace window that was applied (0 when
/// `?force=true`, otherwise the config value)
/// `forced` — echoes the query param
#[utoipa::path(
post,
path = "/api/admin/internal/trigger-grant-cleanup",
params(("force" = Option<bool>, Query, description = "Force grace = 0 for this run (test-only)")),
responses(
(status = 200, description = "Purge ran"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 404, description = "Endpoint disabled (set OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true)"),
(status = 503, description = "Grant-cleanup daemon disabled (OXICLOUD_GRANT_CLEANUP_ENABLED=false)"),
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn internal_trigger_grant_cleanup(
State(state): State<Arc<AppState>>,
Query(query): Query<InternalTriggerGrantCleanupQuery>,
) -> axum::response::Response {
use axum::response::IntoResponse;
if !state.core.config.features.enable_admin_internal_endpoints {
return internal_endpoints_disabled();
}
// Daemon may be disabled by config even when the internal-endpoint
// gate is on. Return 503 (rather than 404 or 500) so integration
// tests can distinguish "surface not exposed" from "surface
// exposed but backing service off".
let svc = match state.grant_cleanup_service.as_ref() {
Some(s) => s,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "grant_cleanup_service not available (disabled by OXICLOUD_GRANT_CLEANUP_ENABLED=false)",
})),
)
.into_response();
}
};
// `force=true` collapses the grace window to zero for this run
// only — the daemon's configured grace is untouched. Mirrors the
// `trigger-gc?force=true` shape.
let grace_override = if query.force { Some(0) } else { None };
let grants_deleted = svc.purge(grace_override).await;
let grace_days = grace_override.unwrap_or_else(|| svc.grace_days());
(
StatusCode::OK,
Json(serde_json::json!({
"ok": true,
"grants_deleted": grants_deleted,
"grace_days": grace_days,
"forced": query.force,
})),
)
.into_response()
}
+331 -61
View File
@@ -12,7 +12,8 @@ use uuid::Uuid;
use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto,
OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto,
OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UpgradeToInternalDto,
UserDto,
};
use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult};
use crate::common::di::AppState;
@@ -45,6 +46,7 @@ pub fn auth_protected_routes() -> Router<Arc<AppState>> {
.route("/me/image", put(update_user_image))
.route("/me/profile", patch(update_profile))
.route("/change-password", put(change_password))
.route("/upgrade-to-internal", post(upgrade_to_internal))
.route("/logout", post(logout))
}
@@ -127,21 +129,37 @@ pub async fn register(
}
};
// Block password registration when OIDC-only mode is active.
// Email-only signup still works in OIDC-only mode (no password
// stored; the user authenticates via magic-link).
// Block password registration when the policy forbids password
// logins (OIDC-only mode OR `OXICLOUD_AUTH_METHODS` allowlist
// without `password`). Email-only signup still works — the user
// authenticates via magic-link or SSO on their first visit.
if dto.password.is_some()
&& auth_service
&& !auth_service
.auth_application_service
.password_login_disabled()
.is_password_login_allowed()
{
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Password registration is disabled. Please use SSO/OIDC to sign in.",
"Password registration is disabled by policy.",
"PasswordRegistrationDisabled",
));
}
// Symmetric guard: when magic-link is off, an email-only signup has
// no path to a session (there's no token to click). Refuse rather
// than silently succeed and leave the user with an unusable account.
if dto.password.is_none()
&& !auth_service
.auth_application_service
.is_magic_link_login_allowed()
{
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Email-only registration requires magic-link login, which is disabled.",
"MagicLinkLoginDisabled",
));
}
// Admin disabled public registration globally — surface 403.
if let Some(admin_svc) = state.admin_settings_service.as_ref()
&& !admin_svc.get_registration_enabled().await
@@ -153,6 +171,47 @@ pub async fn register(
));
}
// Operator-configured allowlist of email domains that can
// self-register. Empty list = no restriction (any domain accepted).
// Distinct from `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`, which gates
// magic-link / grant invitations — an operator can leave that
// permissive while locking self-registration down, or vice versa.
//
// Matching mirrors the magic-link list:
// * post-`@` part of the address is extracted and lowercased
// * case-insensitive exact match against the allowlist
// * no wildcard / subdomain expansion (list every domain
// explicitly, per the config docstring)
//
// Audit-log denials at the `audit` target so operators can spot
// enumeration / probe attempts — mirrors the shape used by the
// magic-link domain rejection at
// `magic_link_invite_service.rs`.
let allow_list = &state.core.config.auth.registration_allowed_email_domains;
if !allow_list.is_empty() {
let domain = dto
.email
.split('@')
.nth(1)
.map(|d| d.trim().to_ascii_lowercase())
.unwrap_or_default();
if domain.is_empty() || !allow_list.iter().any(|d| d == &domain) {
tracing::info!(
target: "audit",
event = "auth.register_rejected",
reason = "domain_not_allowed",
domain = %domain,
"👮🏻‍♂️ Public registration refused: email domain not in \
OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS"
);
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Registration is not open to this email domain.",
"RegistrationDomainNotAllowed",
));
}
}
// Email-only signup requires SMTP. Without it the welcome mail
// can't be dispatched and the user is stranded with no way to log
// in. 503 is the right response: instance-wide policy, no per-user
@@ -310,13 +369,19 @@ pub async fn login(
));
}
// Check if password login is disabled (OIDC-only mode)
if auth_service
// Check if password login is allowed (composes the legacy OIDC-only
// flag with the newer `OXICLOUD_AUTH_METHODS` allowlist). When
// disabled, return `PasswordLoginDisabled` so the SPA can hide the
// password field and surface the available fallback (magic-link or
// SSO) instead of showing a generic "invalid credentials".
if !auth_service
.auth_application_service
.password_login_disabled()
.is_password_login_allowed()
{
return Err(AppError::unauthorized(
"Password login is disabled. Please use SSO/OIDC to sign in.",
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Password login is disabled by policy.",
"PasswordLoginDisabled",
));
}
@@ -384,6 +449,55 @@ pub async fn login(
.login_lockout
.record_failure(&dto.username, &client_ip);
tracing::error!("Login failed for user {}: {}", dto.username, err);
// Remap the `require_verified_email` refusal (message
// string comes from AuthApplicationService::login) into a
// distinguished error_type and, critically, PIGGYBACK a
// verification link on the successful-password proof: the
// caller just showed they know the password, so we can
// safely mint a verification magic-link for their address
// without going through the anti-enum-fronted
// `magic-link/send` (which would refuse `has_password`).
//
// This branch is reached ONLY when the password validated
// successfully — the service checks `require_verified_email`
// AFTER the password check specifically so an attacker
// without the password can't discover an account's
// verification state from the response shape.
if err.message == "Email not verified" {
// Best-effort auto-send. We swallow any error and still
// return the same EmailNotVerified response — the
// frontend hint ("check your inbox") doubles as the
// resend affordance if delivery didn't land.
if let Some(invite_svc) = state.magic_link_invite_service.as_ref() {
// Re-look up the user by identifier (mirrors the
// service's login dispatch) to get the User entity
// that the verification helper needs. On any
// lookup failure we skip the send — attacker never
// sees the difference.
let lookup = if dto.username.contains('@') {
auth_service
.auth_application_service
.find_user_by_email(&dto.username)
.await
} else {
auth_service
.auth_application_service
.find_user_by_username(&dto.username)
.await
};
if let Ok(user) = lookup {
let challenge = cookie_auth::generate_magic_request_challenge();
let _ = invite_svc
.send_verification_link_authenticated(&user, &challenge)
.await;
}
}
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Your email is not verified. We sent a verification link to your inbox.",
"EmailNotVerified",
));
}
Err(err.into())
}
}
@@ -471,11 +585,16 @@ pub async fn get_current_user(
// Storage usage is served from the cached `storage_used_bytes` column —
// it is NOT recomputed here. Recomputing on this hot endpoint meant an
// O(N) `SUM(size)` over all the user's files plus an `UPDATE` of
// `auth.users` on every single call (one of the most frequent endpoints).
// The cached value is kept current by the per-upload update and a periodic
// background reconciliation sweep
// O(N) `SUM(size)` plus an `UPDATE` of `auth.users` on every single call
// (one of the most frequent endpoints). The cached value is kept current
// by the per-upload update and a periodic background reconciliation sweep
// (see `StorageUsageService::start_reconciliation_job`).
//
// Semantics (`docs/plan/drive.md` §7): `storage_used_bytes` is the SUM
// of `used_bytes` across the user's personal drives only. Shared drives
// never count against this envelope — collaborating in a team drive
// costs no personal bytes. The matching cap is
// `storage_quota_bytes` (admin-only mutation).
let user = auth_service
.auth_application_service
.get_user_by_id(user_id)
@@ -522,6 +641,118 @@ pub async fn change_password(
Ok(StatusCode::OK)
}
/// Convert the authenticated external user into a full internal
/// account. The caller must currently be `is_external = true`; on
/// success, `is_external` is flipped to `false`, a personal drive is
/// provisioned (atomic CTE via `PersonalDriveLifecycleHook`), and the
/// user's flags cache is invalidated so subsequent per-request guards
/// see the new state within cache-round-trip time.
///
/// Password policy:
/// * If the deployment offers magic-link login
/// (`OXICLOUD_AUTH_METHODS` includes `magic_link` AND OIDC is not
/// enabled AND SMTP is wired), the body's `password` field is
/// optional — an upgraded user without a password stays magic-
/// link-only for login.
/// * Otherwise, `password` is required — refused with 400
/// `error_type = "PasswordRequired"`.
///
/// Domain gate: the caller's email domain MUST be in
/// `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` (when non-empty).
/// Otherwise invitations would become a bypass of the operator's
/// self-registration policy. Refused with 403
/// `error_type = "RegistrationDomainNotAllowed"`.
///
/// Response: the updated `UserDto` (post-upgrade view — `is_external`
/// is false, `storage_quota_bytes` is set).
#[utoipa::path(
post,
path = "/api/auth/upgrade-to-internal",
request_body = UpgradeToInternalDto,
responses(
(status = 200, description = "Upgrade succeeded", body = UserDto),
(status = 400, description = "Password missing / too short"),
(status = 401, description = "Not authenticated"),
(status = 403, description = "OIDC user, or domain not in allowlist"),
(status = 409, description = "Already internal"),
),
security(("bearerAuth" = [])),
tag = "auth"
)]
pub async fn upgrade_to_internal(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
Json(dto): Json<UpgradeToInternalDto>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
// Domain gate. Mirrors the register handler
// (`OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS`). Rationale: an
// internal-user invitation must NOT become a way around the
// operator's self-registration policy. If a domain isn't
// allowlisted for register, it shouldn't be allowed for upgrade
// either. External users on non-allowlisted domains remain
// external — they can still act on shared resources but never own
// a drive of their own on this deployment.
let allow_list = &state.core.config.auth.registration_allowed_email_domains;
if !allow_list.is_empty() {
// The service re-fetches the user inside `upgrade_to_internal`;
// one extra id-lookup here just to extract the email is cheap
// and keeps the domain check at the same layer as the register
// handler for consistency.
let email = auth_service
.auth_application_service
.get_user_by_id(user_id)
.await
.map(|dto| dto.email)?;
let domain = email
.split('@')
.nth(1)
.map(|d| d.trim().to_ascii_lowercase())
.unwrap_or_default();
if domain.is_empty() || !allow_list.iter().any(|d| d == &domain) {
tracing::info!(
target: "audit",
event = "user.upgrade_rejected",
reason = "domain_not_allowed",
user_id = %user_id,
domain = %domain,
"👮🏻‍♂️ upgrade refused: email domain not in \
OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS"
);
return Err(AppError::new(
StatusCode::FORBIDDEN,
"This deployment does not accept new accounts from your email domain.",
"RegistrationDomainNotAllowed",
));
}
}
let updated = auth_service
.auth_application_service
.upgrade_to_internal(user_id, dto)
.await
.map_err(|err| match err.message.as_str() {
"Account is already internal" => {
AppError::new(StatusCode::CONFLICT, err.message.clone(), "AlreadyInternal")
}
"SSO/OIDC accounts are managed by your identity provider" => {
AppError::new(StatusCode::FORBIDDEN, err.message.clone(), "ManagedByIdP")
}
m if m.starts_with("Password is required") => AppError::new(
StatusCode::BAD_REQUEST,
err.message.clone(),
"PasswordRequired",
),
_ => AppError::from(err),
})?;
Ok((StatusCode::OK, Json(updated)))
}
/// Update the caller's profile (PR 24).
///
/// Fields are individually optional — absent = no change. Username is
@@ -824,12 +1055,22 @@ pub async fn oidc_providers(
let auth_app = &auth_service.auth_application_service;
// Policy questions the SPA needs to decide which forms to render.
// `is_magic_link_login_allowed()` composes SMTP wiring + allowlist +
// the "OIDC master → no magic-link login" hard rule; the login page
// shows the magic-link tab iff this is true.
let password_login_enabled = auth_app.is_password_login_allowed();
let magic_link_login_enabled = auth_app.is_magic_link_login_allowed();
let require_verified_email = auth_app.require_verified_email();
if !auth_app.oidc_enabled() {
return Ok(Json(OidcProviderInfoDto {
enabled: false,
provider_name: String::new(),
authorize_endpoint: String::new(),
password_login_enabled: true,
password_login_enabled,
magic_link_login_enabled,
require_verified_email,
}));
}
@@ -839,7 +1080,9 @@ pub async fn oidc_providers(
enabled: true,
provider_name: config.provider_name.clone(),
authorize_endpoint: "/api/auth/oidc/authorize".to_string(),
password_login_enabled: !config.disable_password_login,
password_login_enabled,
magic_link_login_enabled,
require_verified_email,
}))
}
@@ -939,53 +1182,38 @@ pub async fn oidc_callback(
let frontend_url = config.frontend_url.trim_end_matches('/');
let redirect_url = format!("{}/login?oidc_code={}", frontend_url, exchange_code);
tracing::info!("OIDC login successful, redirecting with exchange code");
Ok(Redirect::temporary(&redirect_url))
Ok(Redirect::temporary(&redirect_url).into_response())
}
OidcCallbackResult::NextcloudLogin {
nc_flow_token,
user_id,
username,
} => {
// Nextcloud Login Flow v2, create app password and complete flow
let nextcloud = state
.nextcloud
.as_ref()
.ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?;
let (_id, app_password) = nextcloud
.app_passwords
.create_nc(user_id, "Nextcloud (OIDC)")
.await
.map_err(|e| {
tracing::error!(error = %e, user = %username, "OIDC+NC: failed to create app password");
AppError::from(e)
})?;
let base_url = state.core.config.base_url();
let completed =
nextcloud
.login_flow
.complete(&nc_flow_token, &username, &base_url, &app_password);
if completed {
tracing::info!(
user = %username,
"OIDC login completed Nextcloud Login Flow v2 successfully"
);
let nc_url = format!(
"nc://login/server:{}&user:{}&password:{}",
base_url, username, app_password
);
Ok(Redirect::temporary(&nc_url))
} else {
tracing::error!(
user = %username,
"OIDC+NC: login flow token expired or not found"
);
Ok(Redirect::temporary(
"/nextcloud-error.html?type=session-expired",
))
}
// Hand the browser off to the shared LFv2 completion path.
// That path lists the user's drives, renders the picker
// when there are ≥ 2, and only completes the flow (via the
// poll backchannel) when the user has picked. Prior to
// this refactor the OIDC arm minted the app password
// inline and completed with the bare username — customers
// with multiple drives had no way to pick a non-home
// drive under SSO, and the deprecated `nc://` redirect
// caused the "Impossible de valider la requête" dialog on
// NC clients that had already picked up credentials via
// the poll endpoint. Routing through the shared helper
// fixes both.
tracing::info!(
user = %username,
"OIDC callback → NC Login Flow v2: handing off to picker/completion path"
);
Ok(
crate::interfaces::nextcloud::login_v2_handler::handle_oidc_login_completion(
&state,
&nc_flow_token,
user_id,
&username,
)
.await,
)
}
}
}
@@ -1096,6 +1324,21 @@ pub async fn send_magic_link(
));
};
// Policy: `OXICLOUD_AUTH_METHODS` may forbid magic-link login even
// when SMTP is wired (an operator might want the invite path — used
// by admins to seed accounts — without offering it as a login
// fallback). Refuse with the same anti-enum shape as any other
// policy-gated endpoint.
if let Some(auth) = state.auth_service.as_ref()
&& !auth.auth_application_service.is_magic_link_login_allowed()
{
return Err(AppError::new(
StatusCode::FORBIDDEN,
"Magic-link login is disabled by policy.",
"MagicLinkLoginDisabled",
));
}
// Authentication signal — presence (not validity) of Bearer header
// OR access cookie. We deliberately don't decode the JWT here: a
// stale-cookie holder gets a 401 from any other endpoint they
@@ -1133,6 +1376,26 @@ pub async fn send_magic_link(
)
})?;
// Login-identifier resolution. The DTO field is named `email` for
// backwards-compat, but the value may be either an email address or
// a username — dispatch matches the `POST /api/auth/login`
// convention (`@` present → email, else → username). Username
// lookups happen BEFORE rate-limiting so `alice` and
// `alice@example.com` bucket on the same key; without this,
// alternating shapes would double the effective per-email budget.
//
// Anti-enum: username misses fall through to `body.email` unchanged
// and land in the malformed_email / no_account branches downstream,
// both of which return the uniform 200 with an audit line.
let resolved_email = if let Some(auth) = state.auth_service.as_ref() {
auth.auth_application_service
.resolve_login_identifier_to_email(&body.email)
.await
.unwrap_or_else(|| body.email.clone())
} else {
body.email.clone()
};
// Per-request browser-binding challenge (PR 22). Generated for
// every request and set as a cookie on every 200 response —
// including the silent-rate-limit paths — so the cookie's
@@ -1180,8 +1443,11 @@ pub async fn send_magic_link(
// casing/IDN-host tricks don't multiply the budget. Malformed
// addresses skip this check and fall through to the service,
// which records its own audit entry under reason="malformed_email".
// Buckets on the RESOLVED email (post-username lookup) so
// username and email inputs for the same account share one
// budget — see resolve_login_identifier_to_email() above.
if let Ok(normalised) =
crate::domain::services::email_normalize::normalize_email(&body.email)
crate::domain::services::email_normalize::normalize_email(&resolved_email)
&& state
.magic_link_send_per_email_rate_limiter
.check_and_increment(&normalised)
@@ -1201,8 +1467,12 @@ pub async fn send_magic_link(
// The service swallows every operational outcome and logs the truth
// via the audit channel; we surface only an internal error (DB down,
// etc.). Anti-enumeration means we always return the same body.
// We pass the resolved email — if the caller sent a username, the
// service sees the corresponding address; if the caller sent a
// bare unknown identifier, the service still audits it as
// malformed_email / no_account.
invite_svc
.send_login_link(&body.email, &challenge)
.send_login_link(&resolved_email, &challenge)
.await
.map_err(AppError::from)?;
+450 -196
View File
@@ -21,16 +21,20 @@ use axum::{
http::{HeaderName, Request, StatusCode, header},
response::Response,
};
use bytes::Buf;
use bytes::{Buf, Bytes};
use percent_encoding::percent_decode_str;
use quick_xml::Writer;
use std::fmt::Write;
use std::sync::Arc;
use crate::application::adapters::caldav_adapter::{CalDavAdapter, CalDavReportType};
use crate::application::adapters::caldav_adapter::{
CalDavAdapter, CalDavReportType, bundle_to_calendar_body, extract_vevent_chunk,
group_events_by_uid,
};
use crate::application::adapters::uid_from_multiget_href;
use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType};
use crate::application::dtos::calendar_dto::{
CreateCalendarDto, CreateEventICalDto, UpdateCalendarDto,
CalendarEventDto, CreateCalendarDto, CreateEventICalDto, UpdateCalendarDto,
};
use crate::application::ports::calendar_ports::CalendarUseCase;
use crate::application::services::calendar_service::CalendarService;
@@ -44,6 +48,249 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
/// Prevents OOM/DoS via unbounded body buffering.
const MAX_CALDAV_BODY: usize = 1_048_576;
/// Minimum rows per emitted page for the streaming CalDAV emitters.
/// Pages only cut at UID boundaries (the cursor delivers same-UID rows
/// adjacent), so a master + its exception overrides always land in one
/// chunk and peak memory is one page of DTOs + its XML instead of the
/// whole calendar twice.
const CALDAV_STREAM_PAGE_EVENTS: usize = 500;
/// Streamed multistatus REPORT: header chunk, one chunk per hydrated
/// UID page, footer chunk. Byte-compatible with the buffered
/// `generate_calendar_events_response` output (same bundle order:
/// `(MIN(start_time), uid)` = first appearance in the start_time
/// listing). TTFB becomes the first page instead of the full
/// generation; the whole-calendar DTO Vec is never materialised.
fn build_streaming_report_response(
calendar_service: Arc<CalendarService>,
calendar_id: String,
report: CalDavReportType,
base_href: String,
user_id: uuid::Uuid,
) -> Response<Body> {
let stream = async_stream::try_stream! {
let mut buf = Vec::with_capacity(256);
{
let mut w = Writer::new(&mut buf);
CalDavAdapter::write_caldav_multistatus_start(&mut w)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
yield Bytes::from(buf);
// ONE server-side scan+sort in bundle order streamed through a
// cursor — the same aggregate work the buffered path paid, but
// only a page of rows resident. Pages cut at UID boundaries.
{
use futures::TryStreamExt;
let mut rows = calendar_service
.stream_events_uid_order(&calendar_id, user_id)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let mut page: Vec<CalendarEventDto> =
Vec::with_capacity(CALDAV_STREAM_PAGE_EVENTS + 32);
loop {
let next = rows
.try_next()
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let flush = match &next {
Some(ev) => {
page.len() >= CALDAV_STREAM_PAGE_EVENTS
&& page.last().is_some_and(|p| p.ical_uid != ev.ical_uid)
}
None => !page.is_empty(),
};
if flush {
let mut chunk = Vec::with_capacity(page.len() * 1024 + 128);
{
let mut w = Writer::new(&mut chunk);
CalDavAdapter::write_report_page(&mut w, &page, &report, &base_href)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
page.clear();
yield Bytes::from(chunk);
}
match next {
Some(ev) => page.push(ev),
None => break,
}
}
}
let mut buf = Vec::with_capacity(32);
{
let mut w = Writer::new(&mut buf);
CalDavAdapter::write_caldav_multistatus_end(&mut w)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
yield Bytes::from(buf);
};
use futures::TryStreamExt;
let stream = stream
.map_err(|e: std::io::Error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) });
Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from_stream(stream))
.unwrap()
}
/// Streamed depth-1 collection PROPFIND: head (multistatus + the
/// calendar's own response), one chunk per hydrated UID page, footer.
#[allow(clippy::too_many_arguments)]
fn build_streaming_collection_propfind(
calendar_service: Arc<CalendarService>,
calendar: crate::application::dtos::calendar_dto::CalendarDto,
propfind_request: PropFindRequest,
calendar_id: String,
base_href: String,
caller_id: String,
user_id: uuid::Uuid,
) -> Response<Body> {
let stream = async_stream::try_stream! {
let mut buf = Vec::with_capacity(2048);
{
let mut w = Writer::new(&mut buf);
CalDavAdapter::write_collection_head(&mut w, &calendar, &propfind_request, &base_href, &caller_id)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
yield Bytes::from(buf);
{
use futures::TryStreamExt;
let mut rows = calendar_service
.stream_events_uid_order(&calendar_id, user_id)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let mut page: Vec<CalendarEventDto> =
Vec::with_capacity(CALDAV_STREAM_PAGE_EVENTS + 32);
loop {
let next = rows
.try_next()
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let flush = match &next {
Some(ev) => {
page.len() >= CALDAV_STREAM_PAGE_EVENTS
&& page.last().is_some_and(|p| p.ical_uid != ev.ical_uid)
}
None => !page.is_empty(),
};
if flush {
let mut chunk = Vec::with_capacity(page.len() * 512 + 128);
{
let mut w = Writer::new(&mut chunk);
CalDavAdapter::write_collection_event_page(&mut w, &page, &base_href)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
page.clear();
yield Bytes::from(chunk);
}
match next {
Some(ev) => page.push(ev),
None => break,
}
}
}
let mut buf = Vec::with_capacity(32);
{
let mut w = Writer::new(&mut buf);
CalDavAdapter::write_caldav_multistatus_end(&mut w)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
yield Bytes::from(buf);
};
use futures::TryStreamExt;
let stream = stream
.map_err(|e: std::io::Error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) });
Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from_stream(stream))
.unwrap()
}
/// Streamed whole-calendar `.ics` GET: VCALENDAR header, one chunk per
/// hydrated UID page (each row's stored VEVENT chunk served verbatim),
/// `END:VCALENDAR` footer.
fn build_streaming_calendar_ics(
calendar_service: Arc<CalendarService>,
calendar_id: String,
calendar_name: String,
calendar_etag: String,
user_id: uuid::Uuid,
) -> Response<Body> {
let stream = async_stream::try_stream! {
let mut head = String::with_capacity(128);
let _ = write!(
head,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\nX-WR-CALNAME:{}\r\n",
calendar_name
);
yield Bytes::from(head);
{
use futures::TryStreamExt;
let mut rows = calendar_service
.stream_events_uid_order(&calendar_id, user_id)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let mut page: Vec<CalendarEventDto> =
Vec::with_capacity(CALDAV_STREAM_PAGE_EVENTS + 32);
loop {
let next = rows
.try_next()
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let flush = match &next {
Some(ev) => {
page.len() >= CALDAV_STREAM_PAGE_EVENTS
&& page.last().is_some_and(|p| p.ical_uid != ev.ical_uid)
}
None => !page.is_empty(),
};
if flush {
let mut chunk = String::with_capacity(page.len() * 384);
for group in group_events_by_uid(&page) {
for event in group {
if let Some(vevent) = extract_vevent_chunk(&event.ical_data) {
chunk.push_str(vevent);
if !chunk.ends_with('\n') {
chunk.push_str("\r\n");
}
}
}
}
page.clear();
yield Bytes::from(chunk);
}
match next {
Some(ev) => page.push(ev),
None => break,
}
}
}
yield Bytes::from_static(b"END:VCALENDAR\r\n");
};
use futures::TryStreamExt;
let stream = stream
.map_err(|e: std::io::Error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) });
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/calendar; charset=utf-8")
.header(header::ETAG, format!("\"{}\"", calendar_etag))
.body(Body::from_stream(stream))
.unwrap()
}
/// Creates CalDAV routes with full path prefixes.
///
/// Uses `merge()` instead of `nest()` to avoid Axum's trailing-slash routing gap.
@@ -248,7 +495,7 @@ async fn handle_propfind(
calendar_service
.list_my_calendars(user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list calendars: {}", e)))?
.map_err(AppError::from)?
};
let base_href = "/caldav/";
@@ -317,15 +564,23 @@ async fn handle_propfind(
};
if let Ok(calendar) = calendar_result {
// Valid calendar ID — return calendar collection
let events = if depth != "0" {
calendar_service
.list_events(first_segment, None, None, user.id)
.await
.unwrap_or_default()
} else {
vec![]
};
// Valid calendar ID — return calendar collection.
// Depth-1 streams the event listing page by page
// (whole-calendar responses used to materialise every
// DTO + the full multistatus in RAM); depth-0 has no
// event section and keeps the tiny buffered path.
if depth != "0" {
let base_href = format!("/caldav/{}/", first_segment);
return Ok(build_streaming_collection_propfind(
calendar_service.clone(),
calendar,
propfind_request,
first_segment.to_string(),
base_href,
caller_id.clone(),
user.id,
));
}
let base_href = &format!("/caldav/{}/", first_segment);
let mut response_body = Vec::new();
@@ -333,7 +588,7 @@ async fn handle_propfind(
CalDavAdapter::generate_calendar_collection_propfind(
&mut response_body,
&calendar,
&events,
&[],
&propfind_request,
base_href,
&depth,
@@ -346,15 +601,25 @@ async fn handle_propfind(
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(response_body))
.unwrap())
} else if first_is_uuid {
// Path segment IS a UUID but the calendar isn't
// accessible to the caller — could be another
// owner's calendar or genuinely missing. Return
// 404 (anti-enum, matches every other OxiCloud
// surface post-D7). The pre-Round-3 fall-through
// silently listed the caller's OWN calendars,
// which was misleading (the URL claimed one calendar,
// response returned unrelated ones) and violated
// the anti-enumeration contract audited in
// `docs/plan/authz_audit/caldav_carddav_wopi.md`.
Err(AppError::not_found("Calendar not found"))
} else {
// Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/)
// List all calendars for this user
let calendars = calendar_service
.list_my_calendars(user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to list calendars: {}", e))
})?;
.map_err(AppError::from)?;
let base_href = &format!("/caldav/{}/", first_segment);
let mut response_body = Vec::new();
@@ -394,14 +659,20 @@ async fn handle_propfind(
.await
.map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?;
let events = if depth != "0" {
calendar_service
.list_events(sub_parts[0], None, None, user.id)
.await
.unwrap_or_default()
} else {
vec![]
};
// Same streaming/buffered split as the
// single-segment collection branch above.
if depth != "0" {
let base_href = format!("/caldav/{}/{}/", first_segment, sub_parts[0]);
return Ok(build_streaming_collection_propfind(
calendar_service.clone(),
cal,
propfind_request,
sub_parts[0].to_string(),
base_href,
caller_id.clone(),
user.id,
));
}
let base_href = &format!("/caldav/{}/{}/", first_segment, sub_parts[0]);
let mut response_body = Vec::new();
@@ -409,7 +680,7 @@ async fn handle_propfind(
CalDavAdapter::generate_calendar_collection_propfind(
&mut response_body,
&cal,
&events,
&[],
&propfind_request,
base_href,
&depth,
@@ -436,7 +707,7 @@ async fn handle_propfind(
let event = calendar_service
.get_event_by_ical_uid(calendar_id, ical_uid, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to look up event: {}", e)))?
.map_err(AppError::from)?
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
let base_href = &format!("/caldav/{}/", calendar_id);
@@ -487,22 +758,42 @@ async fn handle_report(
return Err(AppError::bad_request("Calendar ID required in path"));
}
// Whole-calendar shapes (no-range calendar-query, sync-collection)
// stream: header + one chunk per hydrated UID page + footer, instead
// of materialising every DTO AND the full multistatus in RAM with
// TTFB = complete generation. Bounded shapes (time-range query,
// multiget) keep the buffered path.
if matches!(
&report,
CalDavReportType::CalendarQuery {
time_range: None,
..
} | CalDavReportType::SyncCollection { .. }
) {
// Surface not-found / authz before committing to a 207 stream.
calendar_service
.get_calendar(calendar_id, user.id)
.await
.map_err(AppError::from)?;
let base_href = format!("/caldav/{}/", calendar_id);
return Ok(build_streaming_report_response(
calendar_service.clone(),
calendar_id.to_string(),
report,
base_href,
user.id,
));
}
let events = match &report {
CalDavReportType::CalendarQuery { time_range, .. } => {
if let Some((start, end)) = time_range {
calendar_service
.get_events_in_range(calendar_id, *start, *end, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to query events: {}", e))
})?
.map_err(AppError::from)?
} else {
calendar_service
.list_events(calendar_id, None, None, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to list events: {}", e))
})?
unreachable!("no-range calendar-query streams above")
}
}
CalDavReportType::CalendarMultiget { hrefs, .. } => {
@@ -517,12 +808,11 @@ async fn handle_report(
calendar_service
.get_events_by_ical_uids(calendar_id, &uids, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to fetch events: {}", e)))?
.map_err(AppError::from)?
}
CalDavReportType::SyncCollection { .. } => {
unreachable!("sync-collection streams above")
}
CalDavReportType::SyncCollection { .. } => calendar_service
.list_events(calendar_id, None, None, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?,
};
let base_href = &format!("/caldav/{}/", calendar_id);
@@ -575,10 +865,12 @@ async fn handle_mkcalendar(
is_public: Some(false),
};
// See the comment above create_event_from_ical for why this uses
// `AppError::from` (kind-aware mapping) instead of `internal_error`.
calendar_service
.create_calendar(create_dto, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create calendar: {}", e)))?;
.map_err(AppError::from)?;
Ok(Response::builder()
.status(StatusCode::CREATED)
@@ -613,68 +905,51 @@ async fn handle_put(
let ical_data = String::from_utf8(body_bytes.to_vec())
.map_err(|e| AppError::bad_request(format!("Invalid UTF-8 in iCalendar data: {}", e)))?;
let ical_uid = extract_uid_from_ical(&ical_data);
// Indexed single-row lookup — listing the whole calendar (every row
// with its ical_data) to find one UID made imports O(N²).
let existing = if let Some(ref uid) = ical_uid {
calendar_service
.get_event_by_ical_uid(calendar_id, uid, user.id)
.await
.unwrap_or_default()
} else {
None
// Route the PUT through `upsert_ical_events` so a body carrying a
// master + N per-instance overrides (RFC 5545 §3.8.4.4 — the
// Thunderbird / Apple Calendar / DAVx⁵ "modify one occurrence"
// shape) persists each VEVENT to its own row instead of the last
// one clobbering the master. See AtalayaLabs/OxiCloud#528.
//
// Kind-aware error mapping (`AppError::from(DomainError)`):
// * `InvalidInput` → 400 (malformed iCal / missing DTSTART)
// * `NotFound` → 404 (calendar doesn't exist / no perm)
// * `AccessDenied` → 403 (caller lacks Write on the calendar)
// * anything else → 500 (genuine server bug)
let create_dto = CreateEventICalDto {
calendar_id: calendar_id.to_string(),
ical_data,
};
if let Some(existing_event) = existing {
// Update existing event — re-create from iCal for full fidelity
calendar_service
.delete_event(&existing_event.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update event: {}", e)))?;
let result = calendar_service
.upsert_ical_events(create_dto, user.id)
.await
.map_err(AppError::from)?;
let create_dto = CreateEventICalDto {
calendar_id: calendar_id.to_string(),
ical_data,
};
let event = calendar_service
.create_event_from_ical(create_dto, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to recreate event: {}", e)))?;
// The event surface still exposes a single object resource per
// UID, so we return an ETag anchored on the master row when
// present, otherwise the first exception's id. This matches the
// pre-#528 header contract for clients that only understand a
// single ETag per PUT.
let etag_source = result
.events
.iter()
.find(|e| e.recurrence_id.is_none())
.or_else(|| result.events.first())
.map(|e| e.id.to_string())
.unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::ETAG, format!("\"{}\"", event.id))
.body(Body::empty())
.unwrap())
let status = if result.any_inserted {
StatusCode::CREATED
} else {
let create_dto = CreateEventICalDto {
calendar_id: calendar_id.to_string(),
ical_data,
};
StatusCode::NO_CONTENT
};
let event = calendar_service
.create_event_from_ical(create_dto, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create event: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.header(header::ETAG, format!("\"{}\"", event.id))
.body(Body::empty())
.unwrap())
}
}
/// Extract UID from iCalendar data
fn extract_uid_from_ical(ical_data: &str) -> Option<String> {
for line in ical_data.lines() {
let trimmed = line.trim();
if let Some(stripped) = trimmed.strip_prefix("UID:") {
return Some(stripped.trim().to_string());
}
}
None
Ok(Response::builder()
.status(status)
.header(header::ETAG, format!("\"{}\"", etag_source))
.body(Body::empty())
.unwrap())
}
// ─── GET (.ics) ──────────────────────────────────────────────────────
@@ -692,103 +967,77 @@ async fn handle_get(
let calendar_id = parts[0];
if parts.len() < 2 {
// GET on calendar collection
let events = calendar_service
.list_events(calendar_id, None, None, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
// GET on calendar collection — stream all events, folded
// per UID so master + exception overrides live in ONE
// VCALENDAR body per resource (RFC 4791 §4.1 + RFC 5545
// §3.6.1). Each row's stored `ical_data` VEVENT chunk is
// served verbatim; VTIMEZONE / VALARM / ATTENDEE /
// CATEGORIES / X-* survive because the body is never
// regenerated from DTO fields. Streaming (header + one
// chunk per hydrated UID page + footer) replaces the old
// whole-calendar String build.
let calendar = calendar_service
.get_calendar(calendar_id, user.id)
.await
.map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?;
.map_err(AppError::from)?;
let ical = generate_full_calendar_ical(&calendar.name, &events);
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/calendar; charset=utf-8")
.header(header::ETAG, format!("\"{}\"", calendar.id))
.body(Body::from(ical))
.unwrap())
Ok(build_streaming_calendar_ics(
calendar_service.clone(),
calendar_id.to_string(),
calendar.name,
calendar.id,
user.id,
))
} else {
// GET on individual event — indexed lookup by iCalendar UID.
// GET on individual event resource — fetch ALL rows for
// this UID (master + any exception overrides) and emit
// ONE calendar-object-resource containing every VEVENT.
// This is the phase-4 fix: `get_event_by_ical_uid` is
// master-only; using it here made exceptions invisible
// to clients and their next-PUT would silently drop the
// stored exception rows.
let event_file = parts[1];
let ical_uid = event_file.trim_end_matches(".ics");
let event = calendar_service
.get_event_by_ical_uid(calendar_id, ical_uid, user.id)
let bundle = calendar_service
.get_events_by_ical_uids(calendar_id, &[ical_uid.to_string()], user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to look up event: {}", e)))?
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
.map_err(AppError::from)?;
let ical = generate_event_ical(&event);
if bundle.is_empty() {
return Err(AppError::not_found(format!(
"Event not found: {}",
ical_uid
)));
}
// Group so the master (recurrence_id None) sits first,
// then flatten for the bundle emitter. ETag anchors on
// the first row of the first group — that's the master
// for a recurring event, or the sole row for a
// non-recurring one. Stable across bundle contents so
// If-Match on subsequent PUTs keys off the master's id.
let grouped = group_events_by_uid(&bundle);
let flat: Vec<&_> = grouped.into_iter().flatten().collect();
let etag_source = flat.first().map(|e| e.id.clone()).unwrap_or_default();
let ical = bundle_to_calendar_body(&flat);
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/calendar; charset=utf-8")
.header(header::ETAG, format!("\"{}\"", event.id))
.header(header::ETAG, format!("\"{}\"", etag_source))
.body(Body::from(ical))
.unwrap())
}
}
fn generate_full_calendar_ical(
calendar_name: &str,
events: &[crate::application::dtos::calendar_dto::CalendarEventDto],
) -> String {
// Pre-estimate: ~200 bytes header + ~320 bytes per event
let mut buf = String::with_capacity(256 + events.len() * 320);
let _ = write!(
buf,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\nX-WR-CALNAME:{}\r\n",
calendar_name
);
for event in events {
write_vevent(&mut buf, event);
}
buf.push_str("END:VCALENDAR\r\n");
buf
}
fn generate_event_ical(event: &crate::application::dtos::calendar_dto::CalendarEventDto) -> String {
let mut buf = String::with_capacity(512);
buf.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n");
write_vevent(&mut buf, event);
buf.push_str("END:VCALENDAR\r\n");
buf
}
/// Writes a VEVENT block directly into `buf` — zero intermediate allocations.
fn write_vevent(
buf: &mut String,
event: &crate::application::dtos::calendar_dto::CalendarEventDto,
) {
let _ = write!(
buf,
"BEGIN:VEVENT\r\nUID:{}\r\nSUMMARY:{}\r\nDTSTART:{}\r\nDTEND:{}\r\n",
event.ical_uid,
event.summary.replace('\n', "\\n"),
event.start_time.format("%Y%m%dT%H%M%SZ"),
event.end_time.format("%Y%m%dT%H%M%SZ"),
);
if let Some(ref desc) = event.description {
let _ = write!(buf, "DESCRIPTION:{}\r\n", desc.replace('\n', "\\n"));
}
if let Some(ref loc) = event.location {
let _ = write!(buf, "LOCATION:{}\r\n", loc);
}
if let Some(ref rrule) = event.rrule {
let _ = write!(buf, "RRULE:{}\r\n", rrule);
}
let _ = write!(
buf,
"DTSTAMP:{}\r\nCREATED:{}\r\nLAST-MODIFIED:{}\r\nEND:VEVENT\r\n",
event.updated_at.format("%Y%m%dT%H%M%SZ"),
event.created_at.format("%Y%m%dT%H%M%SZ"),
event.updated_at.format("%Y%m%dT%H%M%SZ"),
);
}
// NOTE: the pre-phase-4 `generate_event_ical` + `write_vevent`
// helpers were removed. They regenerated the response body from
// DTO fields, which (a) silently dropped every property outside
// the DTO surface (ATTENDEE, VALARM, CATEGORIES, STATUS, X-*)
// and (b) never emitted RECURRENCE-ID on exception rows. The
// `bundle_to_calendar_body` path replaces both by serving each
// row's stored `ical_data` verbatim.
// ─── DELETE ──────────────────────────────────────────────────────────
@@ -812,7 +1061,7 @@ async fn handle_delete(
calendar_service
.delete_calendar(calendar_id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete calendar: {}", e)))?;
.map_err(AppError::from)?;
} else {
let event_file = parts[1];
let ical_uid = event_file.trim_end_matches(".ics");
@@ -821,13 +1070,13 @@ async fn handle_delete(
let event = calendar_service
.get_event_by_ical_uid(calendar_id, ical_uid, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to look up event: {}", e)))?
.map_err(AppError::from)?
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
calendar_service
.delete_event(&event.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete event: {}", e)))?;
.map_err(AppError::from)?;
}
Ok(Response::builder()
@@ -850,11 +1099,10 @@ async fn handle_proppatch(
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let (props_to_set, props_to_remove) =
crate::application::adapters::webdav_adapter::WebDavAdapter::parse_proppatch(
body_bytes.reader(),
)
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?;
let ops = crate::application::adapters::webdav_adapter::WebDavAdapter::parse_proppatch(
body_bytes.reader(),
)
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?;
let effective_path = strip_username_prefix(path);
let calendar_id = effective_path.split('/').next().unwrap_or(effective_path);
@@ -870,12 +1118,14 @@ async fn handle_proppatch(
is_public: None,
};
for prop in &props_to_set {
match prop.name.name.as_str() {
"displayname" => update.name = Some(prop.value.clone().unwrap_or_default()),
"calendar-description" => update.description = prop.value.clone(),
"calendar-color" => update.color = prop.value.clone(),
_ => {}
for op in &ops {
if let crate::application::adapters::webdav_adapter::PropPatchOp::Set(prop) = op {
match prop.name.name.as_str() {
"displayname" => update.name = Some(prop.value.clone().unwrap_or_default()),
"calendar-description" => update.description = prop.value.clone(),
"calendar-color" => update.color = prop.value.clone(),
_ => {}
}
}
}
@@ -883,15 +1133,19 @@ async fn handle_proppatch(
calendar_service
.update_calendar(calendar_id, update, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update calendar: {}", e)))?;
.map_err(AppError::from)?;
}
let mut results = Vec::new();
for prop in &props_to_set {
results.push((&prop.name, true));
}
for prop in &props_to_remove {
results.push((prop, true));
for op in &ops {
match op {
crate::application::adapters::webdav_adapter::PropPatchOp::Set(prop) => {
results.push((&prop.name, true));
}
crate::application::adapters::webdav_adapter::PropPatchOp::Remove(name) => {
results.push((name, true));
}
}
}
let href = format!("/caldav/{}", path);
+248 -76
View File
@@ -22,7 +22,8 @@ use axum::{
http::{HeaderName, Request, StatusCode, header},
response::Response,
};
use bytes::Buf;
use bytes::{Buf, Bytes};
use quick_xml::Writer;
use std::sync::Arc;
use crate::application::adapters::carddav_adapter::{
@@ -31,10 +32,10 @@ use crate::application::adapters::carddav_adapter::{
use crate::application::adapters::uid_from_multiget_href;
use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType};
use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto};
use crate::application::dtos::contact_dto::CreateContactVCardDto;
use crate::application::dtos::contact_dto::{ContactDto, CreateContactVCardDto};
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
use crate::application::services::contact_service::ContactService;
use crate::common::di::AppState;
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
@@ -177,7 +178,7 @@ fn extract_user(req: &Request<Body>) -> Result<AuthUser, AppError> {
.ok_or_else(|| AppError::unauthorized("Authentication required"))
}
fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactStorageAdapter>, AppError> {
fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactService>, AppError> {
state.addressbook_use_case.as_ref().ok_or_else(|| {
AppError::new(
StatusCode::NOT_IMPLEMENTED,
@@ -187,7 +188,165 @@ fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactStorageAdapte
})
}
fn get_contact_service(state: &AppState) -> Result<&Arc<ContactStorageAdapter>, AppError> {
/// Rows per emitted page for the streaming CardDAV emitters — contacts
/// carry no master/exception bundling, so pages cut anywhere.
const CARDDAV_STREAM_PAGE_CONTACTS: usize = 500;
/// Streamed multistatus REPORT: header, one chunk per cursor page,
/// footer. Byte-compatible with the buffered
/// `generate_contacts_response` output; TTFB becomes the first page and
/// the whole-book DTO Vec is never materialised.
fn build_streaming_contacts_report(
contact_svc: Arc<ContactService>,
address_book_id: String,
report: CardDavReportType,
base_href: String,
user_id: uuid::Uuid,
) -> Response<Body> {
let stream = async_stream::try_stream! {
let mut buf = Vec::with_capacity(160);
{
let mut w = Writer::new(&mut buf);
CardDavAdapter::write_report_multistatus_start(&mut w)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
yield Bytes::from(buf);
{
use futures::TryStreamExt;
let mut rows = contact_svc
.stream_contacts_by_book(&address_book_id, user_id)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let mut page: Vec<ContactDto> =
Vec::with_capacity(CARDDAV_STREAM_PAGE_CONTACTS);
loop {
let next = rows
.try_next()
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let flush = match &next {
Some(_) => page.len() >= CARDDAV_STREAM_PAGE_CONTACTS,
None => !page.is_empty(),
};
if flush {
let mut chunk = Vec::with_capacity(page.len() * 256 + 64);
{
let mut w = Writer::new(&mut chunk);
CardDavAdapter::write_contacts_report_page(
&mut w, &page, &report, &base_href,
)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
page.clear();
yield Bytes::from(chunk);
}
match next {
Some(c) => page.push(c),
None => break,
}
}
}
let mut buf = Vec::with_capacity(32);
{
let mut w = Writer::new(&mut buf);
CardDavAdapter::write_carddav_multistatus_end(&mut w)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
yield Bytes::from(buf);
};
use futures::TryStreamExt;
let stream = stream
.map_err(|e: std::io::Error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) });
Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from_stream(stream))
.unwrap()
}
/// Streamed depth-1 address-book PROPFIND: head (multistatus + the
/// book's own response), one chunk per cursor page, footer.
fn build_streaming_book_propfind(
contact_svc: Arc<ContactService>,
address_book: crate::application::dtos::address_book_dto::AddressBookDto,
propfind_request: PropFindRequest,
address_book_id: String,
base_href: String,
user_id: uuid::Uuid,
) -> Response<Body> {
let stream = async_stream::try_stream! {
let mut buf = Vec::with_capacity(2048);
{
let mut w = Writer::new(&mut buf);
CardDavAdapter::write_collection_head(
&mut w,
&address_book,
&propfind_request,
&base_href,
)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
yield Bytes::from(buf);
{
use futures::TryStreamExt;
let mut rows = contact_svc
.stream_contacts_by_book(&address_book_id, user_id)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let mut page: Vec<ContactDto> =
Vec::with_capacity(CARDDAV_STREAM_PAGE_CONTACTS);
loop {
let next = rows
.try_next()
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let flush = match &next {
Some(_) => page.len() >= CARDDAV_STREAM_PAGE_CONTACTS,
None => !page.is_empty(),
};
if flush {
let mut chunk = Vec::with_capacity(page.len() * 512 + 64);
{
let mut w = Writer::new(&mut chunk);
CardDavAdapter::write_collection_contact_page(&mut w, &page, &base_href)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
page.clear();
yield Bytes::from(chunk);
}
match next {
Some(c) => page.push(c),
None => break,
}
}
}
let mut buf = Vec::with_capacity(32);
{
let mut w = Writer::new(&mut buf);
CardDavAdapter::write_carddav_multistatus_end(&mut w)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
yield Bytes::from(buf);
};
use futures::TryStreamExt;
let stream = stream
.map_err(|e: std::io::Error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) });
Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from_stream(stream))
.unwrap()
}
fn get_contact_service(state: &AppState) -> Result<&Arc<ContactService>, AppError> {
state.contact_use_case.as_ref().ok_or_else(|| {
AppError::new(
StatusCode::NOT_IMPLEMENTED,
@@ -254,9 +413,7 @@ async fn handle_propfind(
addressbook_service
.list_user_address_books(user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to list address books: {}", e))
})?
.map_err(AppError::from)?
};
let mut response_body = Vec::new();
@@ -307,9 +464,7 @@ async fn handle_propfind(
let address_books = addressbook_service
.list_user_address_books(user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to list address books: {}", e))
})?;
.map_err(AppError::from)?;
let user_part = path.split('/').next().unwrap_or(path);
let base_href = format!("/carddav/{}/", user_part);
@@ -338,14 +493,19 @@ async fn handle_propfind(
.await
.map_err(|e| AppError::not_found(format!("Address book not found: {}", e)))?;
let contacts = if depth != "0" {
contact_svc
.list_contacts(address_book_id, None, None, user.id)
.await
.unwrap_or_default()
} else {
vec![]
};
// Depth-1 streams the contact listing page by page; depth-0
// has no contact section and keeps the tiny buffered path.
if depth != "0" {
let base_href = format!("/carddav/{}/", address_book_id);
return Ok(build_streaming_book_propfind(
contact_svc.clone(),
address_book,
propfind_request,
address_book_id.to_string(),
base_href,
user.id,
));
}
let base_href = &format!("/carddav/{}/", address_book_id);
let mut response_body = Vec::new();
@@ -353,7 +513,7 @@ async fn handle_propfind(
CardDavAdapter::generate_addressbook_collection_propfind(
&mut response_body,
&address_book,
&contacts,
&[],
&propfind_request,
base_href,
&depth,
@@ -373,7 +533,7 @@ async fn handle_propfind(
let contact = contact_svc
.get_contact_by_uid(address_book_id, contact_uid, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to look up contact: {}", e)))?
.map_err(AppError::from)?
.ok_or_else(|| {
AppError::not_found(format!("Contact not found: {}", contact_uid))
})?;
@@ -389,7 +549,6 @@ async fn handle_propfind(
CardDavAdapter::generate_contacts_response(
&mut response_body,
std::slice::from_ref(&contact),
&[(contact.uid.clone(), contact_to_vcard(&contact))],
&report,
base_href,
)
@@ -428,11 +587,25 @@ async fn handle_report(
return Err(AppError::bad_request("Address book ID required in path"));
}
// Whole-book shapes stream; bounded multiget keeps the buffered path.
if matches!(
&report,
CardDavReportType::AddressbookQuery { .. } | CardDavReportType::SyncCollection { .. }
) {
let base_href = format!("/carddav/{}/", address_book_id);
return Ok(build_streaming_contacts_report(
contact_svc.clone(),
address_book_id.to_string(),
report,
base_href,
user.id,
));
}
let contacts = match &report {
CardDavReportType::AddressbookQuery { .. } => contact_svc
.list_contacts(address_book_id, None, None, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?,
CardDavReportType::AddressbookQuery { .. } => {
unreachable!("addressbook-query streams above")
}
CardDavReportType::AddressbookMultiget { hrefs, .. } => {
// Indexed batch lookup (`uid = ANY(...)`) — a multiget for a
// handful of contacts must not pay for listing the whole
@@ -445,30 +618,17 @@ async fn handle_report(
contact_svc
.get_contacts_by_uids(address_book_id, &uids, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to fetch contacts: {}", e)))?
.map_err(AppError::from)?
}
CardDavReportType::SyncCollection { .. } => {
unreachable!("sync-collection streams above")
}
CardDavReportType::SyncCollection { .. } => contact_svc
.list_contacts(address_book_id, None, None, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?,
};
// Generate vCards
let vcards: Vec<(String, String)> = contacts
.iter()
.map(|c| (c.uid.clone(), contact_to_vcard(c)))
.collect();
let base_href = &format!("/carddav/{}/", address_book_id);
let mut response_body = Vec::new();
CardDavAdapter::generate_contacts_response(
&mut response_body,
&contacts,
&vcards,
&report,
base_href,
)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
CardDavAdapter::generate_contacts_response(&mut response_body, &contacts, &report, base_href)
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
@@ -511,10 +671,13 @@ async fn handle_mkcol(
is_public: Some(false),
};
// See the comment on the vCard PUT path — kind-aware error mapping
// so a client MKCOL body with a bad name / duplicate returns
// 400 / 409 instead of an opaque 500.
addressbook_service
.create_address_book(create_dto)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create address book: {}", e)))?;
.map_err(AppError::from)?;
Ok(Response::builder()
.status(StatusCode::CREATED)
@@ -564,11 +727,17 @@ async fn handle_put(
};
if let Some(existing_contact) = existing {
// Update: delete + recreate from vCard
// Update: delete + recreate from vCard. `AppError::from` maps
// the domain-error ErrorKind onto the right status code:
// NotFound → 404 (contact/address-book gone), AccessDenied →
// 403, InvalidInput → 400 (malformed vCard PUT from the
// client). Naive `internal_error(...)` wrapping used to hide
// all client-input bugs as 500 — same class of bug as the
// CalDAV `create_event_from_ical` path (see #545).
contact_svc
.delete_contact(&existing_contact.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update contact: {}", e)))?;
.map_err(AppError::from)?;
let create_dto = CreateContactVCardDto {
address_book_id: address_book_id.to_string(),
@@ -578,7 +747,7 @@ async fn handle_put(
let contact = contact_svc
.create_contact_from_vcard(create_dto)
.await
.map_err(|e| AppError::internal_error(format!("Failed to recreate contact: {}", e)))?;
.map_err(AppError::from)?;
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
@@ -592,10 +761,12 @@ async fn handle_put(
user_id: user.id.to_string(),
};
// See the comment above the update branch — same rationale for
// preferring `AppError::from` over blanket 500.
let contact = contact_svc
.create_contact_from_vcard(create_dto)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create contact: {}", e)))?;
.map_err(AppError::from)?;
Ok(Response::builder()
.status(StatusCode::CREATED)
@@ -635,7 +806,7 @@ async fn handle_get(
let contacts = contact_svc
.list_contacts(address_book_id, None, None, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list contacts: {}", e)))?;
.map_err(AppError::from)?;
let mut vcf_data = String::new();
for contact in &contacts {
@@ -655,7 +826,7 @@ async fn handle_get(
let contact = contact_svc
.get_contact_by_uid(address_book_id, contact_uid, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to look up contact: {}", e)))?
.map_err(AppError::from)?
.ok_or_else(|| AppError::not_found(format!("Contact not found: {}", contact_uid)))?;
let vcard = contact_to_vcard(&contact);
@@ -693,9 +864,7 @@ async fn handle_delete(
addressbook_service
.delete_address_book(address_book_id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to delete address book: {}", e))
})?;
.map_err(AppError::from)?;
} else {
// Delete contact — indexed lookup by vCard UID.
let contact_file = parts[1];
@@ -704,13 +873,13 @@ async fn handle_delete(
let contact = contact_svc
.get_contact_by_uid(address_book_id, contact_uid, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to look up contact: {}", e)))?
.map_err(AppError::from)?
.ok_or_else(|| AppError::not_found(format!("Contact not found: {}", contact_uid)))?;
contact_svc
.delete_contact(&contact.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete contact: {}", e)))?;
.map_err(AppError::from)?;
}
Ok(Response::builder()
@@ -733,11 +902,10 @@ async fn handle_proppatch(
.await
.map_err(|e| AppError::bad_request(format!("Failed to read request body: {}", e)))?;
let (props_to_set, props_to_remove) =
crate::application::adapters::webdav_adapter::WebDavAdapter::parse_proppatch(
body_bytes.reader(),
)
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?;
let ops = crate::application::adapters::webdav_adapter::WebDavAdapter::parse_proppatch(
body_bytes.reader(),
)
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?;
let effective_path = strip_username_prefix(path);
let address_book_id = effective_path.split('/').next().unwrap_or(effective_path);
@@ -754,12 +922,14 @@ async fn handle_proppatch(
user_id: user.id.to_string(),
};
for prop in &props_to_set {
match prop.name.name.as_str() {
"displayname" => update.name = Some(prop.value.clone().unwrap_or_default()),
"addressbook-description" => update.description = prop.value.clone(),
"calendar-color" | "addressbook-color" => update.color = prop.value.clone(),
_ => {}
for op in &ops {
if let crate::application::adapters::webdav_adapter::PropPatchOp::Set(prop) = op {
match prop.name.name.as_str() {
"displayname" => update.name = Some(prop.value.clone().unwrap_or_default()),
"addressbook-description" => update.description = prop.value.clone(),
"calendar-color" | "addressbook-color" => update.color = prop.value.clone(),
_ => {}
}
}
}
@@ -767,17 +937,19 @@ async fn handle_proppatch(
addressbook_service
.update_address_book(address_book_id, update)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to update address book: {}", e))
})?;
.map_err(AppError::from)?;
}
let mut results = Vec::new();
for prop in &props_to_set {
results.push((&prop.name, true));
}
for prop in &props_to_remove {
results.push((prop, true));
for op in &ops {
match op {
crate::application::adapters::webdav_adapter::PropPatchOp::Set(prop) => {
results.push((&prop.name, true));
}
crate::application::adapters::webdav_adapter::PropPatchOp::Remove(name) => {
results.push((name, true));
}
}
}
let href = format!("/carddav/{}", path);
@@ -188,9 +188,14 @@ impl ChunkedUploadHandler {
// ── Permission pre-check: caller must have Create on the target
// folder BEFORE we allocate a session and accept chunks. The
// upload service re-checks at finalize time, but failing here
// avoids wasting client+server resources on chunks that will be
// rejected. None = caller's root namespace, no check needed.
// upload service re-checks at finalize via
// `upload_file_streaming_with_perms` (AuthZ audit #17 fix,
// 2026-07-16) so a grant revoked mid-session is caught. This
// pre-check is the fail-fast: it avoids wasting client+server
// resources on chunks that will be rejected anyway. `None`
// means the write lands at drive-root — that path is currently
// unchecked (session doesn't carry `drive_id`; tracked with the
// folder-id-walking follow-up).
if let Some(ref fid) = request.folder_id
&& let Err(err) = state
.applications
@@ -214,7 +219,7 @@ impl ChunkedUploadHandler {
.await
{
tracing::warn!(
"⛔ CHUNKED UPLOAD REJECTED (quota): user={}, file={}, size={} — {}",
"⛔ CHUNKED UPLOAD REJECTED (user quota): user={}, file={}, size={} — {}",
auth_user.username,
request.filename,
request.total_size,
@@ -230,6 +235,37 @@ impl ChunkedUploadHandler {
.into_response();
}
// ── Per-drive quota (D4) ─────────────────────────────────
// Native-chunked declares `total_size` at session creation,
// so we can refuse here before any chunk is accepted — same
// wasted-bandwidth optimisation the multipart path has via
// the post-ingest check. No folder_id means root-level which
// the folder-permission check above already rejects.
if let Some(storage_svc) = state.storage_usage_service.as_ref()
&& let Some(fid_str) = request.folder_id.as_deref()
&& let Ok(fid) = uuid::Uuid::parse_str(fid_str)
&& let Err(err) = storage_svc
.check_drive_quota_by_folder(fid, request.total_size)
.await
{
tracing::warn!(
"⛔ CHUNKED UPLOAD REJECTED (drive quota): user={}, folder={}, file={}, size={} — {}",
auth_user.username,
fid,
request.filename,
request.total_size,
err.message
);
return (
StatusCode::INSUFFICIENT_STORAGE,
Json(serde_json::json!({
"error": err.message,
"error_type": "QuotaExceeded"
})),
)
.into_response();
}
// Validate chunk size if provided
let chunk_size = request.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
if chunk_size < 1024 * 1024 {
@@ -410,9 +446,17 @@ impl ChunkedUploadHandler {
}
// Register the file row against the ingested blob.
//
// AuthZ audit #17 (2026-07-12): swapped `upload_file_streaming` →
// `upload_file_streaming_with_perms` so `Create` on the target
// folder is re-verified at finalize. Session creation already
// pre-checked (line ~198), but that was potentially hours or
// days ago; app-passwords keep sessions valid indefinitely.
// Without the finalize re-check, a grant revoked mid-session
// stayed effective until the last chunk landed.
let size = ingested.size;
match upload_service
.upload_file_streaming(
.upload_file_streaming_with_perms(
parts.filename.clone(),
parts.folder_id.clone(),
ingested.content_type.clone(),
@@ -447,7 +491,12 @@ impl ChunkedUploadHandler {
}
Err(e) => {
tracing::error!("Failed to create file from chunked upload: {:?}", e);
AppError::internal_error(format!("Failed to create file: {}", e)).into_response()
// AuthZ audit #2 (2026-07-12) — route DomainError through
// `AppError::from` so graduated denial from
// `upload_file_streaming_with_perms` keeps the 403/404
// shape instead of collapsing into a 500. Sibling
// `cancel_upload_impl` at :514 already uses this pattern.
AppError::from(e).into_response()
}
}
}
@@ -488,9 +537,15 @@ impl ChunkedUploadHandler {
// routes.rs calls these free functions directly.
// TODO: collapse back into the impl block after a utoipa upgrade resolves the issue.
/// **Deprecated.** Prefer `/api/files/delta/*` — hash-first negotiation,
/// resumable, chunked. The `/api/uploads/*` family stays for backward
/// compatibility with existing clients but receives no new features.
#[utoipa::path(
post,
path = "/api/uploads",
description = "**Deprecated.** Prefer the delta-upload surface at `/api/files/delta/*` \
(hash-first negotiation, resumable, chunked). The `/api/uploads/*` family is kept for \
backward compatibility with existing clients but is no longer receiving new features.",
request_body(content = CreateUploadRequest, content_type = "application/json", description = "Upload session parameters"),
responses(
(status = 201, description = "Upload session created", body = crate::application::ports::chunked_upload_ports::CreateUploadResponseDto),
@@ -500,6 +555,7 @@ impl ChunkedUploadHandler {
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn create_upload(
state: State<Arc<AppState>>,
auth_user: AuthUser,
@@ -508,9 +564,11 @@ pub async fn create_upload(
ChunkedUploadHandler::create_upload_impl(state, auth_user, request).await
}
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
#[utoipa::path(
patch,
path = "/api/uploads/{upload_id}",
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
params(
("upload_id" = String, Path, description = "Upload session ID"),
("chunk_index" = usize, Query, description = "Zero-based chunk index"),
@@ -539,6 +597,7 @@ pub async fn create_upload(
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn upload_chunk(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
@@ -652,9 +711,11 @@ pub async fn upload_chunk(
.into_response()
}
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
#[utoipa::path(
head,
path = "/api/uploads/{upload_id}",
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
params(
("upload_id" = String, Path, description = "Upload session ID"),
),
@@ -665,6 +726,7 @@ pub async fn upload_chunk(
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn get_upload_status(
state: State<Arc<AppState>>,
auth_user: AuthUser,
@@ -673,9 +735,11 @@ pub async fn get_upload_status(
ChunkedUploadHandler::get_upload_status_impl(state, auth_user, path).await
}
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
#[utoipa::path(
post,
path = "/api/uploads/{upload_id}/complete",
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
params(
("upload_id" = String, Path, description = "Upload session ID"),
),
@@ -700,6 +764,7 @@ pub async fn get_upload_status(
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn complete_upload(
state: State<Arc<AppState>>,
auth_user: AuthUser,
@@ -713,9 +778,11 @@ pub async fn complete_upload(
ChunkedUploadHandler::complete_upload_impl(state, auth_user, path, req).await
}
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
#[utoipa::path(
delete,
path = "/api/uploads/{upload_id}",
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
params(
("upload_id" = String, Path, description = "Upload session ID"),
),
@@ -726,6 +793,7 @@ pub async fn complete_upload(
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn cancel_upload(
state: State<Arc<AppState>>,
auth_user: AuthUser,
@@ -19,8 +19,8 @@ use crate::application::dtos::contact_dto::{
use crate::application::dtos::user_dto::UserDto;
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
use crate::application::services::auth_application_service::AuthApplicationService;
use crate::application::services::contact_service::ContactService;
use crate::domain::errors::ErrorKind;
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
use crate::interfaces::middleware::auth::AuthUser;
const SYSTEM_BOOK_ID: &str = "system";
@@ -28,7 +28,7 @@ const SYSTEM_BOOK_ID: &str = "system";
/// Combined state for the contacts REST API.
#[derive(Clone)]
pub struct ContactsApiState {
pub contact_service: Arc<ContactStorageAdapter>,
pub contact_service: Arc<ContactService>,
pub auth_service: Option<Arc<AuthApplicationService>>,
/// When false, the virtual "system" address book (OxiCloud users) is hidden.
pub expose_system_users: bool,
+36 -27
View File
@@ -218,18 +218,16 @@ impl DedupHandler {
/// - Deduplication ratio
pub(super) async fn get_stats_impl(
State(state): State<GlobalState>,
auth_user: AuthUser,
_auth_user: AuthUser,
) -> impl IntoResponse {
// Admin-only — global dedup statistics are sensitive infrastructure data
if auth_user.role != "admin" {
return Response::builder()
.status(StatusCode::FORBIDDEN)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Admin role required"}"#))
.unwrap()
.into_response();
}
// AuthZ audit #24 (2026-07-17): admin check moved to the
// `/api/admin/*` middleware layer. Reaching this handler means
// the caller is admin by construction — the bespoke role
// string comparison here (`auth_user.role != "admin"` → 403
// with a hand-rolled JSON body, no audit line) is gone. The
// route is registered at `admin_handler::admin_routes()`;
// moving the URL to `/api/admin/dedup/stats` also declares
// the admin intent up front.
let dedup = &state.core.dedup_service;
let stats = dedup.get_stats().await;
@@ -343,16 +341,10 @@ impl DedupHandler {
State(state): State<GlobalState>,
auth_user: AuthUser,
) -> impl IntoResponse {
// Admin-only — integrity verification is a privileged operation
if auth_user.role != "admin" {
return Response::builder()
.status(StatusCode::FORBIDDEN)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error": "Admin role required"}"#))
.unwrap()
.into_response();
}
// AuthZ audit #25 (2026-07-17): admin check moved to the
// `/api/admin/*` middleware layer — see the sibling
// `get_stats_impl` comment. `auth_user` is kept so the
// success-side audit line carries the caller id.
let dedup = &state.core.dedup_service;
// Verify integrity first
@@ -392,6 +384,21 @@ impl DedupHandler {
savings_percentage: savings_pct,
};
// AuthZ audit #25 (2026-07-17): integrity recalculation is a
// low-frequency privileged operation — landing an audit event
// so security reviews can see who ran verify + integrity
// sweeps and when. The pre-fix path emitted no audit line at
// all (the accepted 200 was silent from the security POV).
tracing::info!(
target: "audit",
event = "dedup.integrity_recalculated",
caller_id = %auth_user.id,
unique_blobs = response.unique_blobs,
total_references = response.total_references,
bytes_saved = response.bytes_saved,
"🧮 dedup integrity verified and stats recomputed by admin",
);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
@@ -453,12 +460,13 @@ pub async fn check_hashes_batch(
#[utoipa::path(
get,
path = "/api/dedup/stats",
path = "/api/admin/dedup/stats",
responses(
(status = 200, description = "Deduplication statistics", body = StatsResponse),
(status = 403, description = "Admin role required"),
(status = 401, description = "Missing or invalid token"),
(status = 403, description = "Caller is not an admin"),
),
tag = "dedup",
tag = "admin",
security(("bearerAuth" = []))
)]
pub async fn get_stats(state: State<GlobalState>, auth_user: AuthUser) -> impl IntoResponse {
@@ -489,13 +497,14 @@ pub async fn get_blob(
#[utoipa::path(
post,
path = "/api/dedup/recalculate",
path = "/api/admin/dedup/recalculate",
responses(
(status = 200, description = "Statistics after integrity verification", body = StatsResponse),
(status = 403, description = "Admin role required"),
(status = 401, description = "Missing or invalid token"),
(status = 403, description = "Caller is not an admin"),
(status = 500, description = "Integrity verification failed"),
),
tag = "dedup",
tag = "admin",
security(("bearerAuth" = []))
)]
pub async fn recalculate_stats(
@@ -19,7 +19,7 @@ use axum::{
response::{IntoResponse, Response},
};
use bytes::{Buf, Bytes, BytesMut};
use futures::Stream;
use futures::{Stream, TryStreamExt};
use std::sync::Arc;
use tokio_stream::StreamExt;
@@ -343,17 +343,36 @@ pub async fn delta_download_chunks(
// Stream the frames: 4-byte length headers come from the (entitled)
// index sizes; bytes stream straight from the blob backend. Peak RAM
// is one backend read frame, independent of batch size.
// is bounded by `read_prefetch` open streams (their first frame),
// independent of batch size.
//
// `buffered(read_prefetch)` overlaps the NEXT chunk's open with the
// current chunk's drain — the same combinator/tuning as the main CDC
// download path (benches/BLOB-PREFETCH.md). The old per-chunk await
// paid every open's full round-trip serially: on an object-store
// backend a 64-chunk batch at ~30 ms first-byte cost ~1.9 s of pure
// latency. Frames still arrive strictly in request order.
let prefetch = service.read_prefetch().max(1);
let svc = service.clone();
// `futures::StreamExt` spelled out — this handler imports
// `tokio_stream::StreamExt`, whose `map` adapter lacks `buffered`.
let opened = futures::StreamExt::map(futures::stream::iter(ordered), move |(hash, size)| {
let svc = svc.clone();
async move {
let chunk = svc
.chunk_stream(&hash)
.await
.map_err(std::io::Error::other)?;
let header = futures::stream::once(async move {
Ok::<Bytes, std::io::Error>(Bytes::copy_from_slice(&(size as u32).to_be_bytes()))
});
Ok::<_, std::io::Error>(futures::StreamExt::chain(header, chunk))
}
});
let body_stream: std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> =
Box::pin(async_stream::try_stream! {
for (hash, size) in ordered {
yield Bytes::copy_from_slice(&(size as u32).to_be_bytes());
let mut chunk = service.chunk_stream(&hash).await.map_err(std::io::Error::other)?;
while let Some(part) = chunk.next().await {
yield part?;
}
}
});
Box::pin(TryStreamExt::try_flatten(futures::StreamExt::buffered(
opened, prefetch,
)));
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
+270 -18
View File
@@ -48,25 +48,9 @@ pub async fn list_drives(
) -> impl IntoResponse {
let caller_id = auth_user.id;
let (subject_types, subject_ids) = match state
.authorization
.expand_subject_for_listing(Subject::User(caller_id))
.await
{
Ok(pair) => pair,
Err(e) => {
error!("list_drives: subject expansion failed: {e}");
return AppError::from(e).into_response();
}
};
match state
.drive_repo
.list_for_subjects(&subject_types, &subject_ids)
.await
{
match state.drive_repo.list_readable_by(caller_id).await {
Ok(drives) => {
let dtos: Vec<DriveDto> = drives.into_iter().map(DriveDto::from).collect();
let dtos: Vec<DriveDto> = drives.iter().cloned().map(DriveDto::from).collect();
(StatusCode::OK, Json(dtos)).into_response()
}
Err(e) => {
@@ -356,3 +340,271 @@ pub async fn remove_drive_member(
Err(e) => AppError::from(e).into_response(),
}
}
/// `DELETE /api/drives/{id}` — Owner-only deletion (D3b).
///
/// Refuses (per `DriveManagementService::delete_drive`):
/// - `404` when the caller lacks Manage on the drive (anti-enum).
/// - `405` when the drive is the user's default Personal drive.
/// - `409` when the drive still holds live folders/files; the caller
/// must trash or move them first.
///
/// On success the drive row, its root folder, and every role grant
/// scoped to the drive are removed in one transaction; cached drive
/// roles are invalidated.
#[utoipa::path(
delete,
path = "/api/drives/{id}",
params(("id" = Uuid, Path, description = "Drive UUID")),
responses(
(status = 204, description = "Drive deleted"),
(status = 404, description = "Drive not found or caller lacks Manage"),
(status = 405, description = "Default Personal drive — undeletable"),
(status = 409, description = "Drive is not empty — move/trash contents first"),
),
security(("bearerAuth" = [])),
tag = "drives"
)]
pub async fn delete_drive(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(drive_id): Path<Uuid>,
) -> impl IntoResponse {
match state
.drive_management_service
.delete_drive(auth_user.id, false, drive_id)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// Body for `PATCH /api/drives/{id}/policies` (D5).
///
/// Partial merge: any field left out of the JSON keeps its current
/// JSONB value (the repo uses `policies || $partial`). Each field
/// defaults to `false` in `DrivePolicies`, but the merge is keyed on
/// presence — so omitting a field means "leave it alone", not "set
/// it to false". Clients flip a single key at a time without
/// round-tripping the whole bag.
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateDrivePoliciesDto {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forbid_sharing: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forbid_external_sharing: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forbid_public_links: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forbid_cross_drive_move: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forbid_owner_role_change: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_in_photo_index: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_in_music_index: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_only: Option<bool>,
}
/// `PATCH /api/drives/{id}/policies` — **OxiCloud-admin only** policy
/// update (D5).
///
/// Policies were originally owner-mutable, but that made them
/// self-policing soft caps — an owner could disable
/// `forbid_external_sharing`, create the grant, and re-enable. For
/// compliance-grade enforcement, mutation is restricted to the
/// tenant operator (admin role), mirroring the same carve-out that
/// guards `drives.quota_bytes` and `users.storage_quota_bytes` (§7).
///
/// Non-admin callers receive `404` (anti-enumeration — same response
/// as "drive does not exist", so a probe can't tell apart "no such
/// drive" from "policies are admin-managed").
///
/// Partial merge into the JSONB `policies` column; the post-merge
/// typed view is returned.
///
/// Audit: emits `drive.policy_changed` with `by = <admin_user_id>`
/// and every key's post-merge value (steady-state observability).
#[utoipa::path(
patch,
path = "/api/drives/{id}/policies",
params(("id" = Uuid, Path, description = "Drive UUID")),
request_body = UpdateDrivePoliciesDto,
responses(
(status = 200, description = "Policies merged"),
(status = 404, description = "Drive not found OR caller is not OxiCloud admin"),
),
security(("bearerAuth" = [])),
tag = "drives"
)]
pub async fn update_drive_policies(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(drive_id): Path<Uuid>,
axum::Json(dto): axum::Json<UpdateDrivePoliciesDto>,
) -> impl IntoResponse {
// OxiCloud-admin only. Anti-enumeration: return the same 404 a
// non-existent drive would carry, never 403, so the policy
// existence isn't probable by error shape.
if auth_user.role != "admin" {
tracing::info!(
target: "audit",
event = "drive.policy_change_rejected",
reason = "not_admin",
caller_id = %auth_user.id,
drive_id = %drive_id,
"👮🏻‍♂️ policy mutation refused: caller is not OxiCloud admin",
);
return AppError::not_found(format!("Drive {drive_id} not found")).into_response();
}
// Translate the Option-per-field DTO into a serde_json partial that
// only carries the supplied keys, so the JSONB merge in
// `update_policies` skips fields the caller didn't touch. Building a
// `DrivePolicies` and serialising would lose the partial-update
// semantics (every field defaults to false → omitted vs. "set to
// false" become indistinguishable on the wire).
let mut partial_obj = serde_json::Map::new();
if let Some(v) = dto.forbid_sharing {
partial_obj.insert("forbid_sharing".into(), serde_json::Value::Bool(v));
}
if let Some(v) = dto.forbid_external_sharing {
partial_obj.insert("forbid_external_sharing".into(), serde_json::Value::Bool(v));
}
if let Some(v) = dto.forbid_public_links {
partial_obj.insert("forbid_public_links".into(), serde_json::Value::Bool(v));
}
if let Some(v) = dto.forbid_cross_drive_move {
partial_obj.insert("forbid_cross_drive_move".into(), serde_json::Value::Bool(v));
}
if let Some(v) = dto.forbid_owner_role_change {
partial_obj.insert(
"forbid_owner_role_change".into(),
serde_json::Value::Bool(v),
);
}
if let Some(v) = dto.include_in_photo_index {
partial_obj.insert("include_in_photo_index".into(), serde_json::Value::Bool(v));
}
if let Some(v) = dto.include_in_music_index {
partial_obj.insert("include_in_music_index".into(), serde_json::Value::Bool(v));
}
if let Some(v) = dto.read_only {
partial_obj.insert("read_only".into(), serde_json::Value::Bool(v));
}
// Pass the raw JSON straight through so the JSONB `||` merge in
// the repo only touches keys the caller supplied. Round-tripping
// via `DrivePolicies` (which has `#[serde(default)]`) would
// silently fill every omitted field with `false` — the merge
// would then clobber every unmentioned policy on the row.
let partial_value = serde_json::Value::Object(partial_obj);
match state
.drive_management_service
.update_policies(auth_user.id, drive_id, partial_value)
.await
{
Ok(merged) => (StatusCode::OK, axum::Json(merged)).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
/// Body for `PATCH /api/drives/{id}/quota` (D4).
///
/// `quota_bytes = null` (or ≤ 0) means unlimited — matches the DB
/// convention where NULL on the row is treated as "no cap" by
/// `storage_usage_service::check_drive_quota`. The service
/// normalises 0/negative to None before writing.
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateDriveQuotaDto {
/// New quota in bytes. `null` (or omitted) or ≤ 0 → unlimited.
/// A value below the drive's current `used_bytes` is accepted
/// intentionally (soft-quota semantic — new writes gated,
/// existing content untouched; owners recover by deleting
/// until the drive comes back under the cap).
#[serde(default)]
pub quota_bytes: Option<i64>,
}
/// `PATCH /api/drives/{id}/quota` — **OxiCloud-admin only** storage-cap
/// mutation for **shared** drives (D4).
///
/// Personal drives are refused with `400 InvalidInput` — their
/// effective cap comes from the owner user's
/// `users.storage_quota_bytes` envelope (memory
/// `project_user_envelope_quota_model`); use
/// `PUT /api/admin/users/{id}/quota` instead. Allowing a per-personal-
/// drive quota here would fork the model into two competing paths.
///
/// Non-admin callers receive `404` (anti-enumeration — same shape as
/// "no such drive", so a probe can't distinguish "drive doesn't
/// exist" from "quota edit is admin-only"). Matches the pattern
/// established by `update_drive_policies` above.
///
/// **Soft-quota semantic on reduction.** A newly-lowered quota may
/// land BELOW the drive's current `used_bytes`. The write succeeds;
/// `storage_usage_service` then blocks new writes on
/// `used + delta > quota`, so owners of a shared drive that's now
/// over its freshly-reduced cap can only shrink (delete) until they
/// come back under. Existing content is never retroactively touched
/// — matches xfs `xfs_quota` / ext4 `edquota` behaviour on quota
/// shrink.
///
/// Cache invalidation: the repo drops `readable_cache` +
/// `default_drive_cache` (both embed the whole drive row incl.
/// `quota_bytes`), matching the `update_policies` pattern.
///
/// Audit: emits `drive.quota_changed` with `new_quota_bytes`,
/// `used_bytes`, and `over_quota` — so an operator grepping
/// `audit drive.quota_changed` can spot a shrink that landed the
/// drive in the over-quota delete-only state.
#[utoipa::path(
patch,
path = "/api/drives/{id}/quota",
params(("id" = Uuid, Path, description = "Drive UUID")),
request_body = UpdateDriveQuotaDto,
responses(
(status = 200, description = "Quota updated"),
(status = 400, description = "Personal drive — quota is envelope-managed via the owner user"),
(status = 404, description = "Drive not found OR caller is not OxiCloud admin"),
),
security(("bearerAuth" = [])),
tag = "drives"
)]
pub async fn update_drive_quota(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(drive_id): Path<Uuid>,
axum::Json(dto): axum::Json<UpdateDriveQuotaDto>,
) -> impl IntoResponse {
// Same admin gate + anti-enum shape as `update_drive_policies`.
// Refusing with 404 (rather than 403) means an unauthorised
// caller can't distinguish "no such drive" from "you're not
// admin" — the endpoint's existence isn't probable by error
// shape.
if auth_user.role != "admin" {
tracing::info!(
target: "audit",
event = "drive.quota_change_rejected",
reason = "not_admin",
caller_id = %auth_user.id,
drive_id = %drive_id,
"👮🏻‍♂️ quota mutation refused: caller is not OxiCloud admin",
);
return AppError::not_found(format!("Drive {drive_id} not found")).into_response();
}
match state
.drive_management_service
.update_quota(auth_user.id, drive_id, dto.quota_bytes)
.await
{
Ok(persisted) => (
StatusCode::OK,
axum::Json(serde_json::json!({ "quota_bytes": persisted })),
)
.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
@@ -6,11 +6,11 @@ use axum::{
};
use serde::Deserialize;
use std::sync::Arc;
use tracing::{error, info};
use tracing::info;
use utoipa::ToSchema;
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
classify_display, format_file_size, intern_display, intern_mime,
};
use crate::application::dtos::favorites_dto::{
FavoritesResourceItemDto, FavoritesResourcesDto, FavoritesResourcesQuery,
@@ -66,7 +66,8 @@ pub async fn add_favorite(
Json(serde_json::json!({
"error": "Item type must be 'file' or 'folder'"
})),
);
)
.into_response();
}
match favorites_service
@@ -81,16 +82,14 @@ pub async fn add_favorite(
"message": "Item added to favorites"
})),
)
.into_response()
}
Err(err) => {
error!("Error adding to favorites: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to add to favorites"
})),
)
}
// Route through AppError so the `DomainError::kind` maps to the
// right status code (NotFound → 404 anti-enum for the pre-write
// authz gate, InvalidInput → 400 for a malformed UUID, etc.).
// A hardcoded 500 here would mask the 404 the Round 1 AuthZ
// fix relies on.
Err(err) => AppError::from(err).into_response(),
}
}
@@ -129,6 +128,7 @@ pub async fn remove_favorite(
"message": "Item removed from favorites"
})),
)
.into_response()
} else {
info!("Item {} '{}' was not in favorites", item_type, item_id);
(
@@ -137,17 +137,12 @@ pub async fn remove_favorite(
"message": "Item was not in favorites"
})),
)
.into_response()
}
}
Err(err) => {
error!("Error removing from favorites: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to remove from favorites"
})),
)
}
// Same rationale as `add_favorite` — preserve DomainError→HTTP
// status mapping instead of collapsing every error to 500.
Err(err) => AppError::from(err).into_response(),
}
}
@@ -202,7 +197,7 @@ pub async fn list_favorites_resources(
// 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()
row.path.unwrap_or_default()
} else {
String::new()
};
@@ -212,24 +207,18 @@ pub async fn list_favorites_resources(
let dto = FolderDto {
etag: resource_id.clone(),
id: resource_id,
name: row.name.clone(),
name: row.name,
path,
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
// Listing handler — drive_id is informational
// and the favorites row doesn't currently
// SELECT it. Path-based lookups never enter
// this code path.
drive_id: uuid::Uuid::nil(),
drive_id: row.drive_id,
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"),
// §14 provenance not selected by the favorites query.
created_by: None,
updated_by: None,
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
created_by: row.created_by,
updated_by: row.updated_by,
};
FavoritesResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -248,34 +237,36 @@ pub async fn list_favorites_resources(
// file. `blob_hash` is `None` only for
// folder rows, which take the other branch.
let modified_at_u = row.modified_at.timestamp() as u64;
let content_hash = row.blob_hash.clone().unwrap_or_default();
let content_hash = row.blob_hash.unwrap_or_default();
let etag = if content_hash.is_empty() {
String::new()
} else {
File::compute_etag(&content_hash, modified_at_u)
};
// Name-derived display classes borrow `row.name`;
// compute them before the name moves into the DTO.
let classes = classify_display(&row.name, mime);
let icon_class = intern_display(classes.icon_class);
let icon_special_class = intern_display(classes.icon_special_class);
let category = intern_display(classes.category);
let dto = FileDto {
id: row.resource_id.to_string(),
name: row.name.clone(),
name: row.name,
path,
size: size_bytes,
mime_type: std::sync::Arc::from(mime),
mime_type: intern_mime(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: modified_at_u,
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)),
icon_class,
icon_special_class,
category,
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
content_hash,
etag,
// §14 provenance not selected by the favorites query.
created_by: None,
updated_by: None,
created_by: row.created_by,
updated_by: row.updated_by,
};
FavoritesResourceItemDto {
resource_type: ResourceTypeDto::File,
@@ -353,15 +344,10 @@ pub async fn batch_add_favorites(
);
(StatusCode::OK, Json(serde_json::json!(result))).into_response()
}
Err(err) => {
error!("Error in batch add favorites: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to batch add favorites"
})),
)
.into_response()
}
// Preserve DomainError→HTTP status mapping — the Round 1
// AuthZ fix relies on a per-item NotFound propagating out
// of the batch. A hardcoded 500 would mask the 404 that
// signals a cross-tenant probe.
Err(err) => AppError::from(err).into_response(),
}
}
+124 -34
View File
@@ -13,7 +13,7 @@ use utoipa::ToSchema;
use crate::application::ports::external_mount_ports::MountStat;
use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, RangeContent,
};
use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort};
use crate::application::ports::thumbnail_ports::ThumbnailPort;
@@ -301,7 +301,7 @@ impl FileHandler {
{
upload_ingest::discard_ingested(dedup, &ingested).await;
tracing::warn!(
"⛔ UPLOAD REJECTED (quota): user={}, file={}, size={}",
"⛔ UPLOAD REJECTED (user quota): user={}, file={}, size={}",
auth_user.username,
filename,
ingested.size
@@ -309,6 +309,31 @@ impl FileHandler {
return Err(Self::quota_error_response(err));
}
// ── Per-drive quota enforcement (D4) ─────────────────
// Sibling to the per-user check above: same read-only
// SELECT shape, same discard-then-507 outcome. Skipped
// when there's no folder_id (root-level upload — no
// drive to charge; folder service refuses these
// independently). Unlimited-quota drives (`NULL`)
// short-circuit inside the service.
if let Some(storage_svc) = state.storage_usage_service.as_ref()
&& let Some(fid_str) = folder_id.as_deref()
&& let Ok(fid) = uuid::Uuid::parse_str(fid_str)
&& let Err(err) = storage_svc
.check_drive_quota_by_folder(fid, ingested.size)
.await
{
upload_ingest::discard_ingested(dedup, &ingested).await;
tracing::warn!(
"⛔ UPLOAD REJECTED (drive quota): user={}, folder={}, file={}, size={}",
auth_user.username,
fid,
filename,
ingested.size
);
return Err(Self::quota_error_response(err));
}
// ── Register the file row against the ingested blob ──
let hash = ingested.hash.clone();
let size = ingested.size;
@@ -370,9 +395,9 @@ impl FileHandler {
pub(super) async fn get_thumbnail_impl(
State(state): State<GlobalState>,
auth_user: AuthUser,
headers: HeaderMap,
headers: &HeaderMap,
Path((id, size)): Path<(String, String)>,
) -> impl IntoResponse {
) -> impl IntoResponse + use<> {
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailSize};
// check first that user can access this resource
@@ -413,7 +438,18 @@ impl FileHandler {
// (file_id, size, format) triple. If the browser already has it, return
// 304 with zero I/O or DB work. Format is in the ETag so a client that
// switched codecs doesn't get a stale 304.
let etag = format!("\"thumb-{}-{:?}-{:?}\"", id, thumb_size, format);
let etag = {
let (s, f) = (thumb_size.as_str(), format.as_str());
let mut e = String::with_capacity(9 + id.len() + s.len() + f.len());
e.push_str("\"thumb-");
e.push_str(&id);
e.push('-');
e.push_str(s);
e.push('-');
e.push_str(f);
e.push('"');
e
};
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH)
&& let Ok(val) = if_none_match.to_str()
&& (val == etag || val == "*")
@@ -663,8 +699,8 @@ impl FileHandler {
auth_user: AuthUser,
Path(id): Path<String>,
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> impl IntoResponse {
headers: &HeaderMap,
) -> impl IntoResponse + use<> {
// External mount: download a file living on the provider's backend.
// (A mount-root UUID is a folder and is not downloadable — it falls
// through and 404s as a non-file.)
@@ -676,7 +712,7 @@ impl FileHandler {
&id,
auth_user.id,
&params,
&headers,
headers,
)
.await;
}
@@ -719,7 +755,7 @@ impl FileHandler {
let etag = format!("\"{}\"", file_dto.etag);
// ── ETag (304 Not Modified) ──────────────────────────────────
if let Some(resp) = not_modified_response(&headers, &etag) {
if let Some(resp) = not_modified_response(headers, &etag) {
return resp.into_response();
}
@@ -737,11 +773,22 @@ impl FileHandler {
let disposition =
Self::content_disposition(&file_dto.name, &file_dto.mime_type, &params);
// `file_dto` was already Read-authorized (and the access
// recorded) by `get_file_with_perms` above — every seek in
// a media/PDF scrub is a separate Range request, so
// re-authorizing + re-notifying per seek doubled that work
// for nothing. Use the non-perms range read, matching the
// share-landing and WebDAV range paths which authorize once
// then stream (benches/ROUND7.md).
match retrieval
.get_file_range_stream_with_perms(&id, auth_user.id, start, Some(end + 1))
.get_file_range_preloaded(&file_dto, start, Some(end + 1))
.await
{
Ok(stream) => {
Ok(content) => {
let body = match content {
RangeContent::Bytes(b) => Body::from(b),
RangeContent::Stream(s) => Body::from_stream(Box::into_pin(s)),
};
return Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, &*file_dto.mime_type)
@@ -757,7 +804,7 @@ impl FileHandler {
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
.body(Body::from_stream(Box::into_pin(stream)))
.body(body)
.unwrap()
.into_response();
}
@@ -790,9 +837,15 @@ impl FileHandler {
// Use the ownership-scoped optimized download.
// Ownership was already verified by get_file_owned above,
// so we can safely use the preloaded variant.
// so we can safely use the preloaded variant. Capture the two
// fields the stream arm needs (one Arc bump + a u64 copy) and MOVE
// the DTO in — the old `file_dto.clone()` deep-copied all 7 owned
// Strings on every download, purely to read mime/size afterwards
// (benches/ROUND11.md §1).
let dto_mime = file_dto.mime_type.clone();
let dto_size = file_dto.size;
match retrieval
.get_file_optimized_preloaded(&id, file_dto.clone(), accept_webp, prefer_original)
.get_file_optimized_preloaded(&id, file_dto, accept_webp, prefer_original)
.await
{
Ok((_file, content)) => match content {
@@ -802,9 +855,9 @@ impl FileHandler {
.into_response(),
OptimizedFileContent::Stream(pinned_stream) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &*file_dto.mime_type)
.header(header::CONTENT_TYPE, &*dto_mime)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, file_dto.size)
.header(header::CONTENT_LENGTH, dto_size)
.header(header::ETAG, &etag)
.header(
header::CACHE_CONTROL,
@@ -955,9 +1008,9 @@ impl FileHandler {
pub(super) async fn list_files_query_impl(
State(state): State<GlobalState>,
auth_user: AuthUser,
headers: HeaderMap,
headers: &HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
) -> impl IntoResponse + use<> {
let folder_id = params.get("folder_id").map(|id| id.as_str());
tracing::info!("API: Listing files with folder_id: {:?}", folder_id);
@@ -989,7 +1042,13 @@ impl FileHandler {
}
tracing::info!("Found {} files", files.len());
let mut resp = (StatusCode::OK, Json(files)).into_response();
// Pre-sized serialization — this listing is unbounded (no
// page cap), the axum Json 128-byte seed reallocs ~11 times
// on a big folder (benches/ROUND12.md §M1).
let mut resp = crate::interfaces::api::sized_json::sized_json(
64 + files.len() * crate::interfaces::api::sized_json::EST_ROW_BYTES,
&files,
);
resp.headers_mut()
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
resp
@@ -1272,18 +1331,39 @@ pub(super) fn build_content_disposition(name: &str, mime: &str, force_inline: bo
.remove(b'`')
.remove(b'|')
.remove(b'~');
let encoded = utf8_percent_encode(name, RFC5987_SET).to_string();
// Fast path: a name whose every byte is an RFC 5987 attr-char needs neither
// percent-encoding nor ASCII-fallback filtering ('"' and '\\' are not
// attr-chars, so none is substituted), so `filename` and `filename*` are the
// name verbatim — one allocation (the header) instead of three.
let all_attr_char = name.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#' | b'$' | b'&' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~'
)
});
if all_attr_char {
return format!("{disposition}; filename=\"{name}\"; filename*=UTF-8''{name}");
}
let ascii_safe: String = name
.chars()
.filter(|c| c.is_ascii_graphic() || *c == ' ')
.map(|c| match c {
// Slow path: assemble the header in one pre-sized buffer, writing the ASCII
// fallback and the percent-encoded form in place — no throwaway `ascii_safe`
// / `encoded` Strings. Sized for the worst case (every byte → %XX) so it
// never grows.
let mut out = String::with_capacity(disposition.len() + name.len() * 4 + 32);
out.push_str(disposition);
out.push_str("; filename=\"");
for c in name.chars().filter(|c| c.is_ascii_graphic() || *c == ' ') {
out.push(match c {
'"' | '\\' => '_',
_ => c,
})
.collect();
format!("{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}")
});
}
out.push_str("\"; filename*=UTF-8''");
for chunk in utf8_percent_encode(name, RFC5987_SET) {
out.push_str(chunk);
}
out
}
// ── Route handlers (free functions) ──────────────────────────────────────────
@@ -1315,10 +1395,14 @@ pub(super) fn build_content_disposition(name: &str, mime: &str, force_inline: bo
pub async fn list_files_query(
state: State<GlobalState>,
auth_user: AuthUser,
headers: HeaderMap,
query: Query<HashMap<String, String>>,
req: axum::extract::Request,
) -> impl IntoResponse {
FileHandler::list_files_query_impl(state, auth_user, headers, query).await
// Read headers by borrow (`req.headers()`) instead of the `HeaderMap`
// extractor, which clones the whole request header table (~2 allocs) just to
// read one If-None-Match — the ROUND14 §A4 middleware pattern applied to the
// hot listing handler (benches/ROUND22.md §H1).
FileHandler::list_files_query_impl(state, auth_user, req.headers(), query).await
}
#[utoipa::path(
@@ -1397,9 +1481,12 @@ pub async fn download_file(
auth_user: AuthUser,
path: Path<String>,
query: Query<HashMap<String, String>>,
headers: HeaderMap,
req: axum::extract::Request,
) -> impl IntoResponse {
FileHandler::download_file_impl(state, auth_user, path, query, headers).await
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
// full clone — every download AND every media Range seek hit this path
// (benches/ROUND22.md §H1).
FileHandler::download_file_impl(state, auth_user, path, query, req.headers()).await
}
#[utoipa::path(
@@ -1421,10 +1508,13 @@ pub async fn download_file(
pub async fn get_thumbnail(
state: State<GlobalState>,
auth_user: AuthUser,
headers: HeaderMap,
path: Path<(String, String)>,
req: axum::extract::Request,
) -> impl IntoResponse {
FileHandler::get_thumbnail_impl(state, auth_user, headers, path).await
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
// full clone — thumbnails are the highest-frequency GET (one per grid tile),
// and this handler reads only Accept + If-None-Match (benches/ROUND22.md §H1).
FileHandler::get_thumbnail_impl(state, auth_user, req.headers(), path).await
}
#[utoipa::path(
+63 -79
View File
@@ -4,12 +4,11 @@ use axum::{
http::{Response, StatusCode, header},
response::IntoResponse,
};
use std::collections::HashMap;
use std::sync::Arc;
use tokio_util::io::ReaderStream;
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
category_for, classify_display, format_file_size, icon_class_for, icon_special_class_for,
intern_display, intern_mime,
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::{
@@ -112,9 +111,12 @@ impl FolderHandler {
Self::list_folders_scoped(service, None, &auth_user).await
}
/// Internal helper: lists folders scoped to the authenticated user.
/// Uses `list_folders_for_owner` — the DB query filters by `user_id`,
/// so no data from other users ever leaves the database.
/// Internal helper: lists folders the authenticated caller can Read.
/// Post-PR-B, `list_root_folders_for_caller` scopes via
/// drive-membership grants (`role_grants` + group cascade via
/// `storage.caller_group_ids`) instead of the legacy `folders.user_id`
/// filter, so folders in shared drives the caller belongs to
/// surface here too.
async fn list_folders_scoped(
service: AppState,
parent_id: Option<&str>,
@@ -230,7 +232,6 @@ impl FolderHandler {
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
Query(_params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
tracing::info!("Downloading folder as ZIP: {}", id);
@@ -257,53 +258,28 @@ impl FolderHandler {
}
};
// Create the ZIP archive (written to a temp file, O(1) RAM)
match zip_service.create_folder_zip(&id, &folder.name).await {
Ok(temp_file) => {
// Get the file size for Content-Length
let file_size = match temp_file.as_file().metadata() {
Ok(m) => m.len(),
Err(e) => {
tracing::error!("Error reading temp file metadata: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Error creating ZIP file"
})),
)
.into_response();
}
};
tracing::info!("ZIP file created successfully, size: {} bytes", file_size);
// Split the NamedTempFile into the already-open std File
// and the TempPath (auto-deletes on drop). This reuses
// the existing fd instead of opening a second one.
let (std_file, temp_path) = temp_file.into_parts();
let tokio_file = tokio::fs::File::from_std(std_file);
// Stream the file to the client in chunks
let stream = ReaderStream::new(tokio_file);
// Stream the archive as it is built — the first byte reaches
// the client after the first entry, not after the whole ZIP
// exists on disk (benches/ZIP-STREAM.md). No Content-Length:
// the final size isn't known up front (chunked encoding).
match zip_service
.create_folder_zip_stream(&id, &folder.name)
.await
{
Ok(stream) => {
let body = axum::body::Body::from_stream(stream);
// Setup headers for download
let filename = format!("{}.zip", folder.name);
let content_disposition = format!("attachment; filename=\"{}\"", filename);
let mut response = Response::builder()
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/zip")
.header(header::CONTENT_DISPOSITION, content_disposition)
.header(header::CONTENT_LENGTH, file_size)
.body(body)
.unwrap();
// Keep TempPath alive in the response extensions so the
// file is only deleted AFTER the body stream finishes.
response.extensions_mut().insert(Arc::new(temp_path));
response.into_response()
.unwrap()
.into_response()
}
Err(err) => {
tracing::error!("Error creating ZIP file: {}", err);
@@ -466,9 +442,12 @@ pub async fn download_folder_zip(
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
query: Query<HashMap<String, String>>,
) -> impl IntoResponse {
FolderHandler::download_folder_zip_impl(state, auth_user, path, query).await
// No `Query` extractor: the handler reads only the path `id`. axum ignores
// any query string when no extractor is present, so the response is
// byte-identical while a per-request HashMap + owned key/value Strings are
// no longer parsed and dropped (benches/ROUND25.md §M3).
FolderHandler::download_folder_zip_impl(state, auth_user, path).await
}
// ── GET /api/folders/{id}/resources ─────────────────────────────────────────
@@ -542,23 +521,20 @@ pub async fn list_folder_resources(
let dto = FolderDto {
etag: resource_id.clone(),
id: resource_id,
name: row.name.clone(),
// Folders use fixed icon classes (below), so `name`
// is never borrowed again — move it instead of cloning.
name: row.name,
path: String::new(), // cleared — share recipients must not see hierarchy
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
// Resources listing — drive_id is informational
// here; not selected by the underlying query.
// Path-based lookups never enter this code path.
drive_id: uuid::Uuid::nil(),
drive_id: row.drive_id,
created_at: row.created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
// §14 provenance not selected by the resources query.
created_by: None,
updated_by: None,
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
created_by: row.created_by,
updated_by: row.updated_by,
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -578,32 +554,38 @@ pub async fn list_folder_resources(
// listing's `etag` byte-equals what a
// conditional request would compare against.
let modified_at_u = row.modified_at.timestamp() as u64;
let content_hash = row.blob_hash.clone().unwrap_or_default();
let content_hash = row.blob_hash.unwrap_or_default();
let etag = if content_hash.is_empty() {
String::new()
} else {
File::compute_etag(&content_hash, modified_at_u)
};
// Compute the name-derived icon/category classes first
// (they borrow `&row.name`), so `name` can be moved into
// the DTO below instead of cloned — one fewer String
// alloc per file row (benches/ROUND7.md).
let classes = classify_display(&row.name, mime);
let icon_class = intern_display(classes.icon_class);
let icon_special_class = intern_display(classes.icon_special_class);
let category = intern_display(classes.category);
let dto = FileDto {
id: row.id.to_string(),
name: row.name.clone(),
name: row.name,
path: String::new(),
size: size_bytes,
mime_type: Arc::from(mime),
mime_type: intern_mime(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
icon_class: Arc::from(icon_class_for(&row.name, mime)),
icon_special_class: Arc::from(icon_special_class_for(&row.name, mime)),
category: Arc::from(category_for(&row.name, mime)),
icon_class,
icon_special_class,
category,
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
content_hash,
etag,
// §14 provenance not selected by the resources query.
created_by: None,
updated_by: None,
created_by: row.created_by,
updated_by: row.updated_by,
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::File,
@@ -613,11 +595,15 @@ pub async fn list_folder_resources(
})
.collect();
(
StatusCode::OK,
Json(FolderResourcesDto::with_cursor(items, next_cursor)),
)
.into_response()
{
// Pre-sized serialization (benches/ROUND12.md §M1).
let body = FolderResourcesDto::with_cursor(items, next_cursor);
crate::interfaces::api::sized_json::sized_json(
128 + body.items.len()
* crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES,
&body,
)
}
}
Err(e) => AppError::from(e).into_response(),
}
@@ -669,7 +655,6 @@ fn mount_entry_to_item(
name: entry.name.clone(),
path: String::new(),
parent_id: Some(parent_id.to_owned()),
owner_id: Some(cfg.owner_id.to_string()),
drive_id: cfg.drive_id,
created_at: entry.created_at,
modified_at: entry.modified_at,
@@ -677,8 +662,8 @@ fn mount_entry_to_item(
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
created_by: None,
updated_by: None,
created_by: Some(cfg.owner_id),
updated_by: Some(cfg.owner_id),
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -701,12 +686,11 @@ fn mount_entry_to_item(
icon_special_class: Arc::from(icon_special_class_for(&entry.name, &mime)),
category: Arc::from(category_for(&entry.name, &mime)),
size_formatted: format_file_size(entry.size),
owner_id: Some(cfg.owner_id.to_string()),
sort_date: None,
content_hash: String::new(),
etag: virtual_file_etag(entry.size, entry.modified_at),
created_by: None,
updated_by: None,
created_by: Some(cfg.owner_id),
updated_by: Some(cfg.owner_id),
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::File,
@@ -100,6 +100,92 @@ pub async fn create_grant(
return AppError::from(e).into_response();
}
// D5: load the resource's owning drive policies in one round-trip
// and gate `forbid_external_sharing` (early refusal for email
// subjects below + late refusal for resolved external users further
// down). `forbid_sharing` (the next D5 policy) will read the same
// fetched bag — see `docs/plan/drive.md` §8.
let drive_policies = match resource {
Resource::File(id) => state.drive_repo.get_policies_for_file(id).await,
Resource::Folder(id) => state.drive_repo.get_policies_for_folder(id).await,
Resource::Drive(id) => state
.drive_repo
.get_by_id(id)
.await
.map(|d| d.drive.typed_policies()),
// Calendars, address books and playlists live outside the
// drive hierarchy (top-level per user), so no drive-level
// policy gates apply. If per-resource policies ever ship for
// these kinds, they'll live on the resource itself, not on a
// drive; the default-empty bag is the right no-op here.
Resource::Calendar(_) | Resource::AddressBook(_) | Resource::Playlist(_) => {
Ok(crate::domain::entities::drive::DrivePolicies::default())
}
};
let drive_policies = match drive_policies {
Ok(p) => p,
Err(e) => {
return AppError::internal_error(format!("drive policy lookup: {e:?}")).into_response();
}
};
// D5 — `forbid_sharing`: refuses per-resource grants on
// File / Folder when the drive's policy is on. Drive-resource
// grants intentionally bypass this gate — they're drive
// membership, not per-resource sharing (§8 semantic carve-out).
if !matches!(resource, Resource::Drive(_))
&& let Err(e) =
drive_policies.refuse_sharing(crate::domain::entities::drive::SharingGateContext {
caller_id,
resource_type: resource.type_str(),
resource_id: resource.id(),
})
{
return AppError::from(e).into_response();
}
// D5 — `forbid_public_links`: Token subjects on `POST /api/grants`
// create exactly the anonymous-link grant that this policy is meant
// to block — the canonical surface is `share_service::create_shared_link`
// but the same kind of grant can be minted here by passing
// `subject.type=token`. Use the same shared gate so the refusal
// shape stays in lockstep with the share-handler path.
if matches!(&dto.subject, SubjectInputDto::Token { .. })
&& let Err(e) = drive_policies.refuse_public_links(
crate::domain::entities::drive::PublicLinkGateContext {
caller_id,
item_type: resource.type_str(),
item_id: resource.id(),
},
)
{
return AppError::from(e).into_response();
}
// D5 — `forbid_external_sharing` (early): when the caller is sharing
// by email, refuse BEFORE `resolve_or_create_recipient` runs so the
// policy never side-effects a fresh external-user row. Existing
// external users are caught by the late check below.
if drive_policies.forbid_external_sharing
&& matches!(&dto.subject, SubjectInputDto::Email { .. })
{
tracing::info!(
target: "audit",
event = "grant.rejected",
reason = "forbid_external_sharing",
stage = "early_email",
caller_id = %caller_id,
resource_type = resource.type_str(),
resource_id = %resource.id(),
"👮🏻‍♂️ email-grant refused: drive policy forbid_external_sharing",
);
return AppError::from(DomainError::operation_not_supported(
"Grant",
"This drive does not allow external sharing.",
))
.into_response();
}
// Resolve the subject. For the email variant this lazily provisions
// an external user (or reuses an existing match) and remembers the
// resolved User so the invitation email can be sent after the grant
@@ -153,6 +239,53 @@ pub async fn create_grant(
}
};
// D5 — `forbid_external_sharing` (late) for File/Folder ONLY:
// catches the case where the subject resolved to a pre-existing
// external user. The early check above only fires for email-input;
// this one closes the user-by-id loophole.
//
// Drive resources are deliberately skipped here — they route through
// `set_member_role` below, which runs the SAME gate
// (`DrivePolicies::refuse_external_sharing`) at the service layer.
// That one service-layer check also covers `POST /api/drives/{id}/members`
// and its PATCH sibling, where no grant_handler runs. Checking
// again here for Drive would duplicate the user-flags lookup.
//
// `invite_recipient` carries the User entity when we just came from
// the email path — read its `is_external` flag instead of a
// redundant lookup; otherwise probe via `get_user_flags`.
if drive_policies.forbid_external_sharing
&& !matches!(resource, Resource::Drive(_))
&& let Subject::User(uid) = subject
{
let is_external = if let Some(user) = invite_recipient.as_ref() {
user.is_external()
} else if let Some(auth_svc) = state.auth_service.as_ref() {
match auth_svc.auth_application_service.get_user_flags(uid).await {
Ok(flags) => flags.is_external,
Err(e) => {
return AppError::internal_error(format!("user flags lookup: {e:?}"))
.into_response();
}
}
} else {
false
};
if let Err(e) = drive_policies.refuse_external_sharing(
subject,
is_external,
crate::domain::entities::drive::ExternalSharingGateContext {
caller_id,
stage: "late_user",
drive_id: None,
resource_type: Some(resource.type_str()),
resource_id: Some(resource.id()),
},
) {
return AppError::from(e).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.
@@ -8,10 +8,10 @@
//! 2. Issues access + refresh JWT for the token's owning user.
//! 3. Sets the standard `oxicloud_access` / `oxicloud_refresh` /
//! `oxicloud_csrf` cookies (same as `POST /api/auth/login`).
//! 4. 302-redirects to a frontend hash-route based on the token's
//! resource target:
//! - Folder → `/#/files/folder/{id}`
//! - File or NULL → `/#/sharedwithme`
//! 4. 302-redirects to a SPA route based on the token's resource
//! target:
//! - Folder → `/files/{id}`
//! - File or NULL → `/shared-with-me`
//!
//! Files don't have a deep-link route today; v1 lands file invitations
//! on Shared With Me where the file shows up.
@@ -140,7 +140,7 @@ struct RedeemQuery {
params(("token" = String, Path, description = "Opaque magic-link token")),
responses(
(status = 200, description = "Cross-browser confirmation prompt (HTML page)"),
(status = 302, description = "Redemption succeeded — redirects to the resource or to /#/sharedwithme"),
(status = 302, description = "Redemption succeeded — redirects to the resource or to /shared-with-me"),
(status = 410, description = "Token is unknown, expired, or already used"),
(status = 503, description = "Magic-link feature is not configured on this server"),
),
@@ -574,24 +574,32 @@ fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption
response
}
/// Build the SPA hash-route the redemption should land on. Mirrors the
/// front-end's `deserializeHash()` parser at `static/js/app/main.js`.
/// Build the SPA route the redemption should land on.
///
/// - **Resource token** (folder invitation): deep-link to the resource.
/// - **NULL-resource token + external user**: land on `/#/sharedwithme`
/// - **Resource token** (folder invitation): deep-link into the folder
/// view. SvelteKit `files/[...path]` accepts folder IDs as path
/// segments (see `frontend/src/routes/files/[...path]/+page.svelte`
/// — `goto(resolve(`/files/${folder.id}`))`).
/// - **NULL-resource token + external user**: land on `/shared-with-me`
/// (their entry point — they own no folders themselves).
/// - **NULL-resource token + internal user**: land on `/#/files` (the
/// - **NULL-resource token + internal user**: land on `/files` (the
/// user has a home folder; the "shared with me" view would be empty
/// on first signup, so home is the better welcome). Internal users
/// on NULL-resource tokens come from the email-only-signup welcome
/// path (PR 18) or from a magic-link they requested themselves
/// while password-eligible-and-lenient-mode (PR 19).
///
/// Historical: pre-SvelteKit these were hash routes
/// (`/#/files`, `/#/sharedwithme`, `/#/files/folder/{id}`) served by the
/// legacy vanilla frontend. Landing on those now serves the legacy
/// shell (with old meta-CSP + inline scripts) instead of the SPA and
/// triggers a CSP violation on modern deployments.
fn redirect_target(redemption: &MagicLinkRedemption) -> String {
match (redemption.resource_kind, redemption.resource_id) {
(Some(MagicLinkResourceKind::Folder), Some(folder_id)) => {
format!("/#/files/folder/{}", folder_id)
format!("/files/{}", folder_id)
}
_ if redemption.auth.user.is_external => "/#/sharedwithme".to_string(),
_ => "/#/files".to_string(),
_ if redemption.auth.user.is_external => "/shared-with-me".to_string(),
_ => "/files".to_string(),
}
}
+13 -6
View File
@@ -2,7 +2,7 @@ use axum::{
Json,
body::Body,
extract::{Query, State},
http::{HeaderMap, Response, StatusCode, header},
http::{Response, StatusCode, header},
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
@@ -60,16 +60,19 @@ struct PhotoDto {
pub async fn list_photos(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
headers: HeaderMap,
Query(params): Query<PhotosQueryParams>,
req: axum::extract::Request,
) -> impl IntoResponse {
let user_id = auth_user.id;
// Borrow headers (`req.headers()`) instead of cloning the whole request
// header table via the `HeaderMap` extractor to read one If-None-Match — the
// gallery open + every pagination page hit this (benches/ROUND22.md §H1).
let caller_id = auth_user.id;
let limit = params.limit.unwrap_or(200).clamp(1, 500);
let file_read = &state.repositories.file_read_repository;
match file_read
.list_media_files(user_id, params.before, limit)
.list_media_files(caller_id, params.before, limit)
.await
{
Ok((files, sort_dates, dims)) => {
@@ -88,7 +91,7 @@ pub async fn list_photos(
std::hash::Hash::hash(&count, &mut hasher);
let etag = format!("\"{:x}\"", std::hash::Hasher::finish(&hasher));
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
if let Some(inm) = req.headers().get(header::IF_NONE_MATCH)
&& let Ok(client_etag) = inm.to_str()
&& client_etag == etag
{
@@ -119,7 +122,11 @@ pub async fn list_photos(
})
.collect();
let mut response = Json(&dtos).into_response();
// Pre-sized serialization (benches/ROUND12.md §M1).
let mut response = crate::interfaces::api::sized_json::sized_json(
64 + dtos.len() * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES,
&dtos,
);
{
let h = response.headers_mut();
h.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
+40 -60
View File
@@ -5,10 +5,10 @@ use axum::{
response::IntoResponse,
};
use std::sync::Arc;
use tracing::{error, info};
use tracing::info;
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
classify_display, format_file_size, intern_display, intern_mime,
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
@@ -56,8 +56,10 @@ pub async fn record_item_access(
.into_response();
}
let mut id_buf = [0u8; 36];
let item_id_str: &str = item_id.as_hyphenated().encode_lower(&mut id_buf);
match recent_service
.record_item_access(user_id, &item_id.to_string(), &item_type)
.record_item_access(user_id, item_id_str, &item_type)
.await
{
Ok(_) => {
@@ -70,16 +72,10 @@ pub async fn record_item_access(
)
.into_response()
}
Err(err) => {
error!("Error recording access in recents: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to record access"
})),
)
.into_response()
}
// 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(),
}
}
@@ -105,8 +101,10 @@ pub async fn remove_from_recent(
) -> impl IntoResponse {
let user_id = auth_user.id;
let mut id_buf = [0u8; 36];
let item_id_str: &str = item_id.as_hyphenated().encode_lower(&mut id_buf);
match recent_service
.remove_from_recent(user_id, &item_id.to_string(), &item_type)
.remove_from_recent(user_id, item_id_str, &item_type)
.await
{
Ok(removed) => {
@@ -130,16 +128,9 @@ pub async fn remove_from_recent(
.into_response()
}
}
Err(err) => {
error!("Error removing from recents: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to remove from recents"
})),
)
.into_response()
}
// Same rationale as `record_item_access` — preserve the
// DomainError→HTTP mapping instead of collapsing to 500.
Err(err) => AppError::from(err).into_response(),
}
}
@@ -170,16 +161,9 @@ pub async fn clear_recent_items(
)
.into_response()
}
Err(err) => {
error!("Error clearing recent items: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to clear recent items"
})),
)
.into_response()
}
// Same rationale as `record_item_access` — preserve the
// DomainError→HTTP mapping instead of collapsing to 500.
Err(err) => AppError::from(err).into_response(),
}
}
@@ -233,7 +217,7 @@ pub async fn list_recent_resources(
// 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()
row.path.unwrap_or_default()
} else {
String::new()
};
@@ -243,24 +227,18 @@ pub async fn list_recent_resources(
let dto = FolderDto {
etag: resource_id.clone(),
id: resource_id,
name: row.name.clone(),
name: row.name,
path,
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
// Listing handler — drive_id is informational
// and the recents row doesn't currently SELECT
// it. Path-based lookups never enter this code
// path.
drive_id: uuid::Uuid::nil(),
drive_id: row.drive_id,
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"),
// §14 provenance not selected by the recents query.
created_by: None,
updated_by: None,
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
created_by: row.created_by,
updated_by: row.updated_by,
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -277,34 +255,36 @@ pub async fn list_recent_resources(
// 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 content_hash = row.blob_hash.unwrap_or_default();
let etag = if content_hash.is_empty() {
String::new()
} else {
File::compute_etag(&content_hash, modified_at_u)
};
// Name-derived display classes borrow `row.name`;
// compute them before the name moves into the DTO.
let classes = classify_display(&row.name, mime);
let icon_class = intern_display(classes.icon_class);
let icon_special_class = intern_display(classes.icon_special_class);
let category = intern_display(classes.category);
let dto = FileDto {
id: row.resource_id.to_string(),
name: row.name.clone(),
name: row.name,
path,
size: size_bytes,
mime_type: std::sync::Arc::from(mime),
mime_type: intern_mime(mime),
folder_id: row.parent_id.map(|u| u.to_string()),
created_at: row.resource_created_at.timestamp() as u64,
modified_at: modified_at_u,
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)),
icon_class,
icon_special_class,
category,
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
content_hash,
etag,
// §14 provenance not selected by the recents query.
created_by: None,
updated_by: None,
created_by: row.created_by,
updated_by: row.updated_by,
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::File,
+70 -26
View File
@@ -1,7 +1,7 @@
use axum::{
extract::{Json, Query, State},
http::StatusCode,
response::IntoResponse,
response::{IntoResponse, Response},
};
use serde_json::json;
use tracing::{error, info};
@@ -11,6 +11,7 @@ use crate::application::dtos::search_dto::{
};
use crate::application::ports::inbound::SearchUseCase;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
use std::sync::Arc;
@@ -83,7 +84,14 @@ impl SearchHandler {
results.files.len(),
results.folders.len()
);
(StatusCode::OK, Json(&*results)).into_response()
{
// Pre-sized serialization (benches/ROUND12.md §M1).
let rows = results.files.len() + results.folders.len();
crate::interfaces::api::sized_json::sized_json(
256 + rows * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES,
&*results,
)
}
}
Err(err) => {
error!("Search error: {}", err);
@@ -124,7 +132,14 @@ impl SearchHandler {
results.files.len(),
results.folders.len()
);
(StatusCode::OK, Json(&*results)).into_response()
{
// Pre-sized serialization (benches/ROUND12.md §M1).
let rows = results.files.len() + results.folders.len();
crate::interfaces::api::sized_json::sized_json(
256 + rows * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES,
&*results,
)
}
}
Err(err) => {
error!("Search error: {}", err);
@@ -140,6 +155,7 @@ impl SearchHandler {
/// Autocomplete suggestions for search.
pub(super) async fn suggest_files_impl(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Query(params): Query<SuggestParams>,
) -> impl IntoResponse {
info!("API: Search suggestions for {:?}", params.query);
@@ -159,7 +175,12 @@ impl SearchHandler {
let limit = params.limit.unwrap_or(10).min(20);
match search_service
.suggest(&params.query, params.folder_id.as_deref(), limit)
.suggest_with_perms(
&params.query,
params.folder_id.as_deref(),
limit,
auth_user.id,
)
.await
{
Ok(suggestions) => {
@@ -181,40 +202,57 @@ impl SearchHandler {
}
}
/// DELETE /search/cache — clears the search results cache.
/// `DELETE /admin/search/cache` — flush the shared moka search
/// results cache. Admin-only.
///
/// AuthZ audit #14 (2026-07-12): pre-fix this endpoint lived at
/// `/api/search/cache` and required only a valid JWT — any
/// authenticated user (external / magic-link included) could
/// DELETE it in a loop and keep the results cache cold indefinitely
/// (sustained DoS on every subsequent `/api/search` query). Now
/// mounted at `/api/admin/search/cache`, gated by the
/// `require_admin` middleware layer on the `/api/admin` nest point.
/// The handler no longer needs an inline authz call — reaching
/// this code implies `AuthUser` is admin by construction. Audit
/// line on success so operator-driven flushes are traceable in
/// security reviews.
pub(super) async fn clear_search_cache_impl(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
auth_user: AuthUser,
) -> Result<Response, AppError> {
let caller_id = auth_user.id;
info!("API: Clearing search cache");
let search_service = match &state.applications.search_service {
Some(service) => service,
None => {
error!("Search service not available");
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "Search service is not available" })),
)
.into_response();
}
let Some(search_service) = &state.applications.search_service else {
error!("Search service not available");
return Ok((
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "Search service is not available" })),
)
.into_response());
};
match search_service.clear_search_cache().await {
Ok(_) => {
info!("Search cache cleared successfully");
(
tracing::info!(
target: "audit",
event = "search.cache_cleared",
caller_id = %caller_id,
"🧹 search results cache flushed by admin",
);
Ok((
StatusCode::OK,
Json(json!({ "message": "Search cache cleared successfully" })),
)
.into_response()
.into_response())
}
Err(err) => {
error!("Error clearing search cache: {}", err);
(
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "Error clearing search cache" })),
)
.into_response()
.into_response())
}
}
}
@@ -354,21 +392,27 @@ pub async fn search_files_post(
)]
pub async fn suggest_files(
state: State<Arc<AppState>>,
auth_user: AuthUser,
query: Query<SuggestParams>,
) -> impl IntoResponse {
SearchHandler::suggest_files_impl(state, query).await
SearchHandler::suggest_files_impl(state, auth_user, query).await
}
#[utoipa::path(
delete,
path = "/api/search/cache",
path = "/api/admin/search/cache",
responses(
(status = 200, description = "Cache cleared"),
(status = 401, description = "Missing or invalid token"),
(status = 403, description = "Caller is not an admin"),
(status = 503, description = "Search service unavailable"),
),
security(("bearerAuth" = [])),
tag = "search"
tag = "admin"
)]
pub async fn clear_search_cache(state: State<Arc<AppState>>) -> impl IntoResponse {
SearchHandler::clear_search_cache_impl(state).await
pub async fn clear_search_cache(
state: State<Arc<AppState>>,
auth_user: AuthUser,
) -> Result<Response, AppError> {
SearchHandler::clear_search_cache_impl(state, auth_user).await
}
+43 -42
View File
@@ -13,6 +13,7 @@ use serde::Deserialize;
use serde_json::json;
use utoipa::ToSchema;
use crate::application::ports::file_ports::RangeContent;
use crate::application::services::share_browse_service::ZipTarget;
use crate::application::services::share_service::ShareService;
use crate::infrastructure::services::share_unlock_cookie;
@@ -30,7 +31,6 @@ use crate::{
interfaces::errors::AppError,
interfaces::middleware::auth::AuthUser,
};
use tokio_util::io::ReaderStream;
fn unlock_jwt_from_headers(headers: &HeaderMap, share_token: &str) -> Option<String> {
headers
@@ -230,19 +230,22 @@ pub async fn delete_shared_link(
pub async fn access_shared_item(
State(share_use_case): State<Arc<ShareService>>,
Path(token): Path<String>,
headers: HeaderMap,
req: axum::extract::Request,
) -> impl IntoResponse {
// Register the access
let _ = share_use_case.register_shared_link_access(&token).await;
// Honour an unlock cookie if one was issued by a prior `/verify` call.
let unlock_jwt = unlock_jwt_from_headers(&headers, &token);
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
// full clone to read the unlock cookie (benches/ROUND22.md §H1).
let unlock_jwt = unlock_jwt_from_headers(req.headers(), &token);
// Get the shared link
match share_use_case
.get_shared_link_with_unlock(&token, unlock_jwt.as_deref())
.await
{
// The access-count increment doesn't gate the fetch — run both
// round-trips concurrently instead of serially (one RTT saved on
// every public share landing).
let (_, item) = tokio::join!(
share_use_case.register_shared_link_access(&token),
share_use_case.get_shared_link_with_unlock(&token, unlock_jwt.as_deref()),
);
match item {
Ok(item) => (StatusCode::OK, Json(item)).into_response(),
Err(err) => {
// Special handling for share access errors
@@ -332,8 +335,11 @@ pub async fn verify_shared_item_password(
pub async fn download_shared_file(
State(state): State<Arc<AppState>>,
Path(token): Path<String>,
headers: HeaderMap,
req: axum::extract::Request,
) -> impl IntoResponse {
// Borrow the headers (`req.headers()`) instead of the `HeaderMap` extractor's
// full clone — the public-share download + Range path (benches/ROUND22.md §H1).
let headers = req.headers();
// 1. Resolve share service
let share_service = match &state.share_service {
Some(s) => s.clone(),
@@ -348,7 +354,7 @@ pub async fn download_shared_file(
};
// 2. Validate the share token (handles expiry + password checks)
let unlock_jwt = unlock_jwt_from_headers(&headers, &token);
let unlock_jwt = unlock_jwt_from_headers(headers, &token);
let share_dto = match share_service
.get_shared_link_with_unlock(&token, unlock_jwt.as_deref())
.await
@@ -384,7 +390,7 @@ pub async fn download_shared_file(
&state,
&share_dto.item_id,
share_dto.item_name.as_deref(),
&headers,
headers,
)
.await
}
@@ -438,10 +444,14 @@ async fn serve_share_file(
let length = end - start + 1;
match retrieval
.get_file_range_stream(file_id, start, Some(end + 1))
.get_file_range_preloaded(&file_dto, start, Some(end + 1))
.await
{
Ok(stream) => {
Ok(content) => {
let body = match content {
RangeContent::Bytes(b) => Body::from(b),
RangeContent::Stream(s) => Body::from_stream(Box::into_pin(s)),
};
return Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, &*mime)
@@ -458,7 +468,7 @@ async fn serve_share_file(
"private, max-age=3600, must-revalidate",
)
.header(header::VARY, "Cookie, Range")
.body(Body::from_stream(Box::into_pin(stream)))
.body(body)
.unwrap()
.into_response();
}
@@ -484,7 +494,14 @@ async fn serve_share_file(
}
}
match retrieval.get_file_optimized(file_id, false, true).await {
// The metadata was already fetched at the top of this fn — hand the DTO
// to the `_preloaded` variant (as the authenticated download path does)
// instead of letting `get_file_optimized` re-run the same metadata query.
let file_size = file_dto.size;
match retrieval
.get_file_optimized_preloaded(file_id, file_dto, false, true)
.await
{
Ok((_, content)) => match content {
OptimizedFileContent::Bytes { data, .. } => Response::builder()
.status(StatusCode::OK)
@@ -505,7 +522,7 @@ async fn serve_share_file(
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &*mime)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, file_dto.size)
.header(header::CONTENT_LENGTH, file_size)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::ETAG, &etag)
.header(
@@ -730,30 +747,19 @@ async fn serve_share_zip(
Err(err) => return share_browse_error_response(err),
};
let temp_file = match zip_service
.create_folder_zip(&target.folder_id, &target.display_name)
// Streamed archive: first byte after the first entry, not after the
// whole ZIP is built (benches/ZIP-STREAM.md). No Content-Length.
let stream = match zip_service
.create_folder_zip_stream(&target.folder_id, &target.display_name)
.await
{
Ok(f) => f,
Ok(s) => s,
Err(err) => {
tracing::error!("share zip: create_folder_zip failed: {}", err);
return AppError::internal_error(format!("ZIP creation failed: {}", err))
.into_response();
}
};
let file_size = match temp_file.as_file().metadata() {
Ok(m) => m.len(),
Err(e) => {
tracing::error!("share zip: temp metadata failed: {}", e);
return AppError::internal_error("ZIP creation failed").into_response();
}
};
// Reuse the existing fd: split off the std::File and the TempPath.
let (std_file, temp_path) = temp_file.into_parts();
let tokio_file = tokio::fs::File::from_std(std_file);
let stream = ReaderStream::new(tokio_file);
let body = Body::from_stream(stream);
let disposition = build_content_disposition(
@@ -762,17 +768,12 @@ async fn serve_share_zip(
false,
);
let mut response = Response::builder()
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/zip")
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, file_size)
.header(header::CACHE_CONTROL, "private, no-store")
.header(header::VARY, "Cookie")
.body(body)
.unwrap();
// Keep TempPath alive until the body finishes streaming.
response.extensions_mut().insert(Arc::new(temp_path));
response
.unwrap()
}
+82 -68
View File
@@ -97,7 +97,7 @@ pub async fn move_file_to_trash(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(item_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
) -> axum::response::Response {
let user_id = auth_user.id;
debug!(
"Request to move file to trash: id={}, user={}",
@@ -112,7 +112,8 @@ pub async fn move_file_to_trash(
Json(json!({
"error": "Trash feature is not enabled"
})),
);
)
.into_response();
}
};
@@ -129,15 +130,11 @@ pub async fn move_file_to_trash(
"message": "File moved to trash successfully"
})),
)
.into_response()
}
Err(e) => {
error!("Error moving file to trash: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "Error moving file to trash"
})),
)
warn!("move_file_to_trash failed: {:?}", e);
AppError::from(e).into_response()
}
}
}
@@ -159,7 +156,7 @@ pub async fn move_folder_to_trash(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(item_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
) -> axum::response::Response {
let user_id = auth_user.id;
debug!(
"Request to move folder to trash: id={}, user={}",
@@ -174,7 +171,8 @@ pub async fn move_folder_to_trash(
Json(json!({
"error": "Trash feature is not enabled"
})),
);
)
.into_response();
}
};
@@ -193,15 +191,11 @@ pub async fn move_folder_to_trash(
"message": "Folder moved to trash successfully"
})),
)
.into_response()
}
Err(e) => {
error!("Error moving folder to trash: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "Error moving folder to trash"
})),
)
warn!("move_folder_to_trash failed: {:?}", e);
AppError::from(e).into_response()
}
}
}
@@ -223,7 +217,7 @@ pub async fn restore_from_trash(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(trash_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
) -> axum::response::Response {
debug!("Request to restore item {} from trash", trash_id);
let trash_service = match state.trash_service.as_ref() {
@@ -234,7 +228,8 @@ pub async fn restore_from_trash(
Json(json!({
"error": "Trash feature is not enabled"
})),
);
)
.into_response();
}
};
let result = trash_service.restore_item(&trash_id, auth_user.id).await;
@@ -249,31 +244,11 @@ pub async fn restore_from_trash(
"message": "Item restored successfully"
})),
)
.into_response()
}
Err(e) => {
let err_str = format!("{}", e);
// If item not found, report success (it was already restored or removed)
if err_str.contains("not found") || err_str.contains("NotFound") {
warn!(
"Item not found in trash, but reporting success: {}",
trash_id
);
return (
StatusCode::OK,
Json(json!({
"success": true,
"message": "Item restored (or was already removed from trash)"
})),
);
}
error!("Error restoring item from trash: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "Error restoring item from trash"
})),
)
warn!("restore_from_trash failed: {:?}", e);
AppError::from(e).into_response()
}
}
}
@@ -295,7 +270,7 @@ pub async fn delete_permanently(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(trash_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
) -> axum::response::Response {
debug!("Request to permanently delete item {}", trash_id);
let trash_service = match state.trash_service.as_ref() {
@@ -306,7 +281,8 @@ pub async fn delete_permanently(
Json(json!({
"error": "Trash feature is not enabled"
})),
);
)
.into_response();
}
};
let result = trash_service
@@ -323,31 +299,11 @@ pub async fn delete_permanently(
"message": "Item deleted permanently"
})),
)
.into_response()
}
Err(e) => {
let err_str = format!("{}", e);
// If item not found, report success (it was already deleted)
if err_str.contains("not found") || err_str.contains("NotFound") {
warn!(
"Item not found in trash, but reporting success: {}",
trash_id
);
return (
StatusCode::OK,
Json(json!({
"success": true,
"message": "Item deleted (or was already removed from trash)"
})),
);
}
error!("Error permanently deleting item: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "Error deleting item permanently"
})),
)
warn!("delete_permanently failed: {:?}", e);
AppError::from(e).into_response()
}
}
}
@@ -405,3 +361,61 @@ pub async fn empty_trash(
}
}
}
/// `DELETE /api/trash/drive/{drive_id}` — per-drive empty trash.
///
/// Same destructive shape as the all-drives `DELETE /api/trash`, but
/// scoped to a single drive the caller can Delete in. Used by the
/// `/trash` page's Drive group-by, which exposes a per-row "Empty"
/// affordance so multi-drive owners don't have to wipe everything at
/// once.
///
/// Refused with `404` (anti-enum) when the caller has no Delete-bearing
/// role on the named drive — the user-facing drive listing would emit
/// the same shape for an unknown id.
#[utoipa::path(
delete,
path = "/api/trash/drive/{drive_id}",
params(("drive_id" = Uuid, Path, description = "Drive UUID")),
responses(
(status = 200, description = "Drive trash emptied successfully"),
(status = 404, description = "Caller lacks Delete on this drive"),
(status = 501, description = "Trash feature not enabled"),
),
security(("bearerAuth" = [])),
tag = "trash"
)]
#[instrument(skip_all)]
pub async fn empty_trash_for_drive(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(drive_id): Path<uuid::Uuid>,
) -> impl IntoResponse {
debug!(
"Request to empty trash for drive {} by user {}",
drive_id, auth_user.id
);
let trash_service = match state.trash_service.as_ref() {
Some(service) => service,
None => {
return (
StatusCode::NOT_IMPLEMENTED,
Json(json!({ "error": "Trash feature is not enabled" })),
)
.into_response();
}
};
match trash_service
.empty_trash_for_drive(auth_user.id, drive_id)
.await
{
Ok(_) => (
StatusCode::OK,
Json(json!({ "success": true, "drive_id": drive_id })),
)
.into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
File diff suppressed because it is too large Load Diff
+247 -33
View File
@@ -20,10 +20,13 @@ use axum::{
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
use crate::application::services::wopi_lock_service::WopiLockService;
use crate::application::services::wopi_token_service::WopiTokenService;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService;
/// Shared state for WOPI handlers.
@@ -64,6 +67,44 @@ pub struct CheckFileInfoResponse {
pub close_url: String,
}
/// Enforce that the WOPI caller (`claims.sub`) still has `perm` on the
/// file at redemption time — not just at token-mint time.
///
/// **Why every verb needs this.** WOPI tokens are validated locally
/// (HMAC over claims), so a token that was legitimately minted stays
/// verify-able until its TTL. If a grant is revoked after mint, or the
/// token was minted for view but is used to POST content, the token's
/// signature alone doesn't catch it. This helper re-checks against the
/// live authorization engine on every verb — the memory note
/// `wopi-authz-bypass` calls out the class of bugs this fences.
///
/// Returns 404 (anti-enumeration — same shape as "file doesn't exist")
/// on both bad UUID and authorization denial. The engine emits a
/// structured `audit` line on denial internally, so ops sees the real
/// reason without the attacker being able to distinguish "gone" from
/// "revoked".
/// Shared id parsing for the WOPI authz paths: a malformed caller sub is a
/// bad token (401), a malformed file id can't exist (404, anti-enum).
fn parse_wopi_ids(caller_sub: &str, file_id: &str) -> Result<(uuid::Uuid, uuid::Uuid), StatusCode> {
let caller_uuid = uuid::Uuid::parse_str(caller_sub).map_err(|_| StatusCode::UNAUTHORIZED)?;
let file_uuid = uuid::Uuid::parse_str(file_id).map_err(|_| StatusCode::NOT_FOUND)?;
Ok((caller_uuid, file_uuid))
}
async fn require_wopi_perm(
authz: &PgAclEngine,
caller_sub: &str,
file_id: &str,
perm: Permission,
) -> Result<(uuid::Uuid, uuid::Uuid), StatusCode> {
let (caller_uuid, file_uuid) = parse_wopi_ids(caller_sub, file_id)?;
authz
.require(Subject::User(caller_uuid), perm, Resource::File(file_uuid))
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
Ok((caller_uuid, file_uuid))
}
/// GET /wopi/files/{file_id} — CheckFileInfo
async fn check_file_info(
Path(file_id): Path<String>,
@@ -82,14 +123,54 @@ async fn check_file_info(
return StatusCode::UNAUTHORIZED.into_response();
}
// Fetch file metadata
let file = match state
.app_state
.applications
.file_retrieval_service
.get_file(&file_id)
.await
{
// Redemption-time authz: even with a valid token, the caller must
// still hold Read on this file. Catches revoked-grant-mid-session.
//
// The Read gate, the metadata fetch and the Update probe are three
// independent lookups keyed only off (caller, file) — overlapped with
// `tokio::join!` (benches/ROUND12.md §5). Results are evaluated in the
// original precedence: Read gate first, then file existence.
let (caller_uuid, file_uuid) = match parse_wopi_ids(&claims.sub, &file_id) {
Ok(ids) => ids,
Err(status) => return status.into_response(),
};
let authz = state.app_state.authorization.as_ref();
let (read_gate, file, can_write_now) = tokio::join!(
authz.require(
Subject::User(caller_uuid),
Permission::Read,
Resource::File(file_uuid)
),
state
.app_state
.applications
.file_retrieval_service
.get_file(&file_id),
// `user_can_write` = actual current Update permission ∧ token's
// can_write flag. If the caller's Update was revoked since the
// token was minted (e.g. their grant was downgraded from Editor
// to Viewer), the editor sees the file as read-only and won't
// even attempt PutFile. The stricter `require_wopi_perm(Update)`
// in put_file is the actual gate; this field is a UI hint.
async {
if claims.can_write {
authz
.check(
Subject::User(caller_uuid),
Permission::Update,
Resource::File(file_uuid),
)
.await
.unwrap_or(false)
} else {
false
}
}
);
if read_gate.is_err() {
return StatusCode::NOT_FOUND.into_response();
}
let file = match file {
Ok(f) => f,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
@@ -101,14 +182,20 @@ async fn check_file_info(
let response = CheckFileInfoResponse {
base_file_name: file.name.clone(),
owner_id: file.owner_id.clone().unwrap_or_else(|| claims.sub.clone()),
// WOPI's `OwnerId` field is required. Post-D7 the DTO no
// longer carries `owner_id`; fall back to `created_by`
// (§14 provenance) with the requesting user as a final default.
owner_id: file
.created_by
.map(|u| u.to_string())
.unwrap_or_else(|| claims.sub.clone()),
size: file.size,
user_id: claims.sub.clone(),
version: file.modified_at.to_string(),
supports_locks: true,
supports_update: claims.can_write,
supports_update: can_write_now,
supports_rename: false,
user_can_write: claims.can_write,
user_can_write: can_write_now,
user_friendly_name: claims.username.clone(),
post_message_origin: state.public_base_url.clone(),
last_modified_time: last_modified,
@@ -139,6 +226,18 @@ async fn get_file(
return StatusCode::UNAUTHORIZED.into_response();
}
// Redemption-time authz — see require_wopi_perm docstring.
if let Err(status) = require_wopi_perm(
state.app_state.authorization.as_ref(),
&claims.sub,
&file_id,
Permission::Read,
)
.await
{
return status.into_response();
}
match state
.app_state
.applications
@@ -178,6 +277,21 @@ async fn put_file(
return StatusCode::UNAUTHORIZED.into_response();
}
// Redemption-time authz: the token says the caller could write when
// it was minted, but Update permission may have been revoked since.
// Re-check now so a stale write-capable token can't survive a
// downgrade / share removal / drive-membership change until its TTL.
if let Err(status) = require_wopi_perm(
state.app_state.authorization.as_ref(),
&claims.sub,
&file_id,
Permission::Update,
)
.await
{
return status.into_response();
}
// Check lock
let request_lock = headers
.get("X-WOPI-Lock")
@@ -234,23 +348,57 @@ async fn put_file(
};
// ── Atomic store: swap the file row onto the ingested blob ──
// `drive_id` scopes the path-based lookups in `update_file_streaming`
// post-D0. WOPI tokens carry the user UUID in `claims.sub`; we resolve
// that to the caller's default drive (WOPI today is a single-drive
// editing surface — no drive marker travels in the token).
// `drive_id` scopes the path-based lookups in
// `update_file_streaming_with_perms` post-D0.
//
// AuthZ audit #18 (2026-07-12): the pre-fix path resolved
// `drive_id` via `find_default_for_user(claims_sub_uuid)` —
// ALWAYS the caller's own default personal drive, regardless of
// where the file actually lived. Shared-drive edits either
// misrouted the write into the caller's personal drive (if the
// filename happened to collide with a personal-drive path) or
// 500'd on the parent-folder lookup. Resolve from the file's
// own parent folder instead — one PK probe, returns the drive
// the file genuinely belongs to. Also unlocks shared-drive WOPI
// editing.
let claims_sub_uuid = match uuid::Uuid::parse_str(&claims.sub) {
Ok(u) => u,
Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
};
let Some(folder_id_str) = file.folder_id.as_deref() else {
// Files always live under a folder (drive-root files use the
// drive-root folder id). A `None` here means the file entity
// is malformed — safest is a 500.
tracing::error!(
"WOPI PutFile: file {} has no parent folder id — cannot resolve drive",
file_id
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
let folder_uuid = match uuid::Uuid::parse_str(folder_id_str) {
Ok(u) => u,
Err(_) => {
tracing::error!(
"WOPI PutFile: file {} parent folder id '{}' is not a UUID",
file_id,
folder_id_str
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let drive_id = match state
.app_state
.drive_repo
.find_default_for_user(claims_sub_uuid)
.drive_id_for_folder(folder_uuid)
.await
{
Ok(d) => d.drive.id,
Ok(id) => id,
Err(e) => {
tracing::error!("WOPI PutFile: default-drive lookup failed: {:?}", e);
tracing::error!(
"WOPI PutFile: drive-id lookup for folder {} failed: {:?}",
folder_uuid,
e
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
@@ -258,13 +406,14 @@ async fn put_file(
.app_state
.applications
.file_upload_service
.update_file_streaming(
.update_file_streaming_with_perms(
&file.path,
drive_id,
ingested.stored(),
&content_type,
None,
claims_sub_uuid,
None,
)
.await;
@@ -296,6 +445,22 @@ async fn file_operations(
return StatusCode::UNAUTHORIZED.into_response();
}
// Every lock op mutates shared state (LOCK / UNLOCK / REFRESH_LOCK
// change the lock; GET_LOCK reads it but the read is only useful
// to a caller who could subsequently take a write action — so gate
// on Update uniformly rather than splitting per-op). A Viewer with
// a stale token must not be able to hold or contend for a lock.
if let Err(status) = require_wopi_perm(
state.app_state.authorization.as_ref(),
&claims.sub,
&file_id,
Permission::Update,
)
.await
{
return status.into_response();
}
let override_header = headers
.get("X-WOPI-Override")
.and_then(|v| v.to_str().ok())
@@ -368,25 +533,68 @@ pub struct EditorUrlResponse {
pub access_token_ttl: i64,
}
/// Determines if `caller_id` can access `file_id` and with what permissions.
/// Resolve the WOPI mint target: gate on real permissions and derive
/// the `can_write` flag from the caller's ACTUAL Update rights.
///
/// Uses the SQL-level ownership check (`get_file_owned`) so that files
/// belonging to other users — or non-existent files — both return `NOT_FOUND`,
/// avoiding existence-leak oracles.
/// Prior behaviour used a naive `requested_action != "view"` heuristic
/// so a Viewer clicking "Edit in Collabora" received a write-capable
/// token, promoting themselves to Editor for the token's TTL. The
/// memory note `wopi-authz-bypass` fix #12 calls this out explicitly.
///
/// Returns `(FileDto, can_write)` on success.
/// Contract:
///
/// 1. **Read** is the bar to open the file in any mode. If the caller
/// has no Read grant, return 404 (anti-enum — same shape as "no such
/// file").
/// 2. **Update** determines the returned `can_write` bit — INDEPENDENT
/// of what the client's `requested_action` said. A Viewer who
/// requested `action=edit` gets `can_write=false` and Collabora
/// opens in view mode; the token stays authorised for view-only
/// ops and put_file will 404 at redemption regardless.
/// 3. `requested_action == "view"` is respected as a downgrade — an
/// Editor can explicitly request view mode (co-browsing a doc
/// without accidentally editing) and get `can_write=false`.
///
/// The `PgAclEngine::require`/`check` calls emit structured audit
/// lines on denial (`authz.denied` event), so a Viewer's "edit"
/// attempt shows up in the audit stream as a rejected Update check.
async fn authorize_wopi_access<S: FileRetrievalUseCase>(
authz: &PgAclEngine,
file_retrieval: &S,
file_id: &str,
caller_id: uuid::Uuid,
requested_action: &str,
) -> Result<(crate::application::dtos::file_dto::FileDto, bool), StatusCode> {
let file = file_retrieval
.get_file_with_perms(file_id, caller_id)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// Owner verified — grant write unless explicitly requesting view-only.
let can_write = requested_action != "view";
let file_uuid = uuid::Uuid::parse_str(file_id).map_err(|_| StatusCode::NOT_FOUND)?;
// The Read gate (step 1), the metadata fetch and the Update probe
// (step 2) are independent — overlapped with `tokio::join!`
// (benches/ROUND12.md §5); results evaluated in the original order.
//
// Step 2 rationale — can_write reflects real Update, not the client's
// action-string. `check` returns bool without throwing; failure
// just means the caller lacks Update, so we degrade the token to
// read-only. Deliberately no `require` there — a Viewer opening
// the file is legitimate; only the write claim is suppressed.
let (read_gate, file, has_update) = tokio::join!(
authz.require(
Subject::User(caller_id),
Permission::Read,
Resource::File(file_uuid),
),
file_retrieval.get_file(file_id),
authz.check(
Subject::User(caller_id),
Permission::Update,
Resource::File(file_uuid),
)
);
read_gate.map_err(|_| StatusCode::NOT_FOUND)?;
let file = file.map_err(|_| StatusCode::NOT_FOUND)?;
let has_update = has_update.unwrap_or(false);
// Step 3 — allow explicit view-mode downgrade for Editors.
let can_write = has_update && requested_action != "view";
Ok((file, can_write))
}
@@ -403,6 +611,7 @@ pub async fn get_editor_url(
let username = &auth_user.username;
// Verify the caller owns the file (SQL-level check, no existence leak).
let (file, can_write) = match authorize_wopi_access(
state.app_state.authorization.as_ref(),
state.app_state.applications.file_retrieval_service.as_ref(),
&params.file_id,
user_id,
@@ -488,7 +697,8 @@ async fn host_page(
Ok(u) => u,
Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
};
let file = match authorize_wopi_access(
let (file, can_write_now) = match authorize_wopi_access(
state.app_state.authorization.as_ref(),
state.app_state.applications.file_retrieval_service.as_ref(),
&file_id,
caller_uuid,
@@ -496,7 +706,7 @@ async fn host_page(
)
.await
{
Ok((f, _)) => f,
Ok((f, cw)) => (f, cw),
Err(status) => return status.into_response(),
};
@@ -513,11 +723,15 @@ async fn host_page(
_ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
// Use the freshly-computed `can_write_now` (real Update permission
// ∧ requested_action) rather than the incoming token's `can_write`
// flag. Otherwise a Viewer who somehow reached this host page with
// a stale edit-capable token would get another one re-minted.
let (token, ttl) = match state.token_service.generate_token(
&file_id,
&claims.sub,
&claims.username,
claims.can_write,
can_write_now,
) {
Ok(t) => t,
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
+8
View File
@@ -2,6 +2,7 @@ pub mod cookie_auth;
pub mod deserializer;
pub mod handlers;
pub mod routes;
pub mod sized_json;
pub use routes::create_api_routes;
pub use routes::create_health_routes;
@@ -225,6 +226,13 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::admin_handler::complete_migration,
handlers::admin_handler::verify_migration,
handlers::admin_handler::generate_encryption_key,
// Admin internal-trigger handlers — gated by
// OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS (Off by default in
// prod; on for the Hurl suite). Documented in OpenAPI so
// integrators writing test harnesses can discover the surface.
handlers::admin_handler::internal_trigger_sweep,
handlers::admin_handler::internal_trigger_gc,
handlers::admin_handler::internal_trigger_grant_cleanup,
// Grant / ReBAC handlers (free functions)
handlers::grant_handler::create_grant,
handlers::grant_handler::revoke_grant,
+78 -22
View File
@@ -10,7 +10,6 @@ use axum::{
};
use serde_json::json;
use std::sync::Arc;
use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
/// Liveness probe — returns 200 if the process is running, no DB check.
@@ -46,12 +45,32 @@ async fn get_version() -> AxumJson<serde_json::Value> {
}))
}
async fn get_openapi_spec() -> AxumJson<utoipa::openapi::OpenApi> {
AxumJson(super::ApiDoc::openapi())
/// Pre-serialized OpenAPI spec. `ApiDoc::openapi()` reconstructs the whole
/// 171 KiB paths/schemas tree and re-serializes it per request (2.8 ms /
/// 12 474 allocs); the spec is process-invariant, so serialize once and
/// hand back a `Bytes` refcount bump (~18 ns — benches/ROUND11.md).
static OPENAPI_BODY: std::sync::OnceLock<bytes::Bytes> = std::sync::OnceLock::new();
async fn get_openapi_spec() -> axum::response::Response {
let body = OPENAPI_BODY.get_or_init(|| {
bytes::Bytes::from(
serde_json::to_vec(&super::ApiDoc::openapi()).expect("openapi spec serializes"),
)
});
axum::response::Response::builder()
.status(axum::http::StatusCode::OK)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(body.clone()))
.expect("static openapi response")
}
use crate::interfaces::api::handlers::admin_handler;
use crate::interfaces::api::handlers::batch_handler::{self, BatchHandlerState};
// `chunked_upload_handler::*` are marked `#[deprecated]` (prefer
// `/api/files/delta/*`); the router still needs to reference them
// until clients migrate. See the `chunked_upload_router` block
// below for the local `#[allow(deprecated)]`.
#[allow(deprecated)]
use crate::interfaces::api::handlers::chunked_upload_handler::{
cancel_upload, complete_upload, create_upload, get_upload_status, upload_chunk,
};
@@ -70,7 +89,7 @@ use crate::interfaces::api::handlers::i18n_handler::{
get_locales, get_translations_by_locale, translate,
};
use crate::interfaces::api::handlers::search_handler::{
clear_search_cache, search_files_get, search_files_post, suggest_files,
search_files_get, search_files_post, suggest_files,
};
use crate::interfaces::api::handlers::trash_handler;
@@ -275,8 +294,11 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
.route("/suggest", get(suggest_files))
// Advanced search with full criteria object
.route("/advanced", post(search_files_post))
// Clear search cache
.route("/cache", delete(clear_search_cache))
// `DELETE /api/search/cache` used to live here as a per-user-
// reachable endpoint. It's an operator-only debug lever
// (moka `invalidate_all()` — nukes every tenant), so it
// moved to `/api/admin/search/cache` where the URL declares
// intent. AuthZ audit #14 (2026-07-16).
.with_state(app_state.clone())
} else {
Router::new()
@@ -365,6 +387,13 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// Create routes for chunked uploads (large files >10MB).
// All five handlers are free functions — see chunked_upload_handler.rs for why
// #[utoipa::path] cannot be applied to ChunkedUploadHandler impl methods directly.
//
// Each handler carries `#[deprecated]` so utoipa marks the OpenAPI paths
// deprecated (Swagger UI shows the strikethrough + banner) and existing
// callers get a compile-time nudge to migrate to `/api/files/delta/*`.
// The route registration itself has to keep referencing them until the
// clients migrate off, so we suppress the local `deprecated` lint here.
#[allow(deprecated)]
let chunked_upload_router = Router::new()
.route("/", post(create_upload))
.route("/{upload_id}", axum::routing::patch(upload_chunk))
@@ -376,18 +405,19 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// Create routes for deduplication endpoints.
// All handlers are free functions — see dedup_handler.rs for why
// #[utoipa::path] cannot be applied to DedupHandler impl methods directly.
use super::handlers::dedup_handler::{
check_hash, check_hashes_batch, get_blob, get_stats, recalculate_stats,
};
use super::handlers::dedup_handler::{check_hash, check_hashes_batch, get_blob};
let dedup_router = Router::new()
.route("/check/{hash}", get(check_hash))
.route("/check-batch", post(check_hashes_batch))
.route("/stats", get(get_stats))
.route("/blob/{hash}", get(get_blob))
// NOTE: remove_reference is intentionally NOT exposed as a public
// endpoint — ref_count management is an internal concern handled
// automatically when files are deleted via the file API.
.route("/recalculate", post(recalculate_stats))
// NOTE: `remove_reference` is intentionally NOT exposed as a
// public endpoint — ref_count management is an internal concern
// handled automatically when files are deleted via the file API.
//
// `/stats` and `/recalculate` moved to `/api/admin/dedup/*`
// (AuthZ audit #24/#25, 2026-07-17) so the middleware admin
// gate covers them by construction. See
// `admin_handler::admin_routes()`.
.with_state(app_state.clone());
let mut router = Router::new()
@@ -425,6 +455,12 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
"/",
get(drive_handler::list_drives).post(drive_handler::create_drive),
)
.route("/{id}", axum::routing::delete(drive_handler::delete_drive))
.route(
"/{id}/policies",
patch(drive_handler::update_drive_policies),
)
.route("/{id}/quota", patch(drive_handler::update_drive_quota))
.route(
"/{id}/members",
get(drive_handler::list_drive_members).post(drive_handler::add_drive_member),
@@ -465,6 +501,13 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// when a wildcard like /{id} could otherwise capture them.
.route("/resources", get(trash_handler::get_trash_resources))
.route("/empty", delete(trash_handler::empty_trash))
// Per-drive empty (D2b stage 4 / per-drive UX). Scoped
// empty of one drive's trash; refused 404 when the caller
// lacks Delete on the named drive.
.route(
"/drive/{drive_id}",
delete(trash_handler::empty_trash_for_drive),
)
.route("/files/{id}", delete(trash_handler::move_file_to_trash))
.route("/folders/{id}", delete(trash_handler::move_folder_to_trash))
.route("/{id}/restore", post(trash_handler::restore_from_trash))
@@ -586,8 +629,19 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// NOTE: CalDAV and CardDAV routes are mounted at top-level (/caldav, /carddav)
// in main.rs for protocol compliance, NOT under /api.
// Admin settings routes (protected by admin_guard inside the handler)
let admin_router = admin_handler::admin_routes().with_state(app_state.clone());
// Admin settings routes — the whole subtree is admin-only by
// construction. The `require_admin` layer runs AFTER the outer
// `auth_middleware` (main.rs::protected_api), so it can rely on
// `CurrentUser` already being in the request extensions. Any new
// route added to `admin_handler::admin_routes()` inherits the
// gate automatically — implementors no longer have to remember
// to call `require_admin(&state, &headers).await?` inline, and a
// forgotten call can't silently expose a non-admin surface.
let admin_router = admin_handler::admin_routes()
.layer(axum::middleware::from_fn(
crate::interfaces::middleware::auth::require_admin,
))
.with_state(app_state.clone());
router = router.nest("/admin", admin_router);
// ReBAC subject-group management. All mutating routes are admin-gated;
@@ -618,12 +672,14 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// them on every overlapping request.
router = router.route("/{*rest}", any(api_not_found));
// Compression is applied once, globally, in `main.rs` with a content-type
// aware predicate that skips already-compressed media. Re-applying it here
// would double-wrap `/api`: this inner layer (no predicate) would compress
// media downloads, burning CPU for ~0 gain and stripping `Content-Length`.
// So this router only adds tracing; compression is the global layer's job.
router.layer(TraceLayer::new_for_http())
// No per-router layers: the global `TraceLayer` + request-id stack in
// `main.rs` wraps the whole app (this `/api` router is nested into it),
// so a second `TraceLayer` here just double-wrapped every `/api`
// request in a redundant span + response-future poll (benches/ROUND13.md
// §H1). Compression is likewise the global layer's job — re-applying it
// here (no predicate) would compress media downloads, burning CPU for
// ~0 gain and stripping `Content-Length`.
router
}
/// Catch-all 404 for unknown `/api/*` paths. Pure log-anchoring
+54
View File
@@ -0,0 +1,54 @@
//! Pre-sized JSON responses for listing endpoints.
//!
//! `axum::Json` serializes into a `BytesMut::with_capacity(128)` — a 500-row
//! listing grows that seed through ~11 doubling reallocations, memcpy-ing
//! ~1.3× the payload on every hot listing response (files, folder
//! resources, photos timeline, search). `sized_json` serializes into one
//! right-sized `Vec` instead: 2 allocations total and no copy chain
//! (benches/ROUND12.md §M1, 1.40x / −11 allocs on a 500-row page).
//!
//! The per-row estimates are calibrated against the serialized DTOs (a
//! realistic `FileDto` row measures ~380 B). Underestimates cost one extra
//! doubling — still far better than the 128-byte seed; overestimates waste
//! transient capacity only (the buffer is freed after the response).
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use serde::Serialize;
/// Serialized size estimate for one file/folder row (FileDto ≈ 380 B).
pub const EST_ROW_BYTES: usize = 384;
/// Serialized size estimate for one wrapped resource row (PhotoDto /
/// FolderResourcesDto items carry a FileDto plus wrapper fields).
pub const EST_WRAPPED_ROW_BYTES: usize = 448;
/// Serialize `value` into a single pre-sized buffer and wrap it as an
/// `application/json` response — drop-in for `Json(value).into_response()`
/// (byte-identical body, gated in `bench_round12_micro` §1), minus the
/// doubling-realloc chain.
pub fn sized_json<T: Serialize>(estimated_bytes: usize, value: &T) -> Response {
let mut buf = Vec::with_capacity(estimated_bytes.max(128));
match serde_json::to_writer(&mut buf, value) {
Ok(()) => (
StatusCode::OK,
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
)],
Bytes::from(buf),
)
.into_response(),
// Mirror axum's Json error arm: 500 + plain-text serializer error.
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
)],
err.to_string(),
)
.into_response(),
}
}