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(),
}
}
+29 -21
View File
@@ -3,6 +3,8 @@
//! This module contains error types specific to the HTTP/API layer.
//! These errors handle the conversion from domain errors to HTTP responses.
use std::borrow::Cow;
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
@@ -13,25 +15,28 @@ use crate::domain::errors::{DomainError, ErrorKind};
/// Error type for HTTP/API responses.
///
/// This struct represents errors that will be returned to HTTP clients.
/// It contains the HTTP status code, a user-friendly message, and an error type identifier.
/// It contains the HTTP status code, a user-friendly message, and an error
/// type identifier. `error_type` is a `Cow`: every built-in constructor and
/// the `DomainError` conversion use a `&'static str` from a closed set, so
/// the common 4xx path allocates nothing for it (benches/ROUND11.md §9).
#[derive(Debug)]
pub struct AppError {
pub status_code: StatusCode,
pub message: String,
pub error_type: String,
pub error_type: Cow<'static, str>,
}
/// JSON response structure for errors.
///
/// Both `error` and `message` carry the same content for backwards compatibility:
/// - Legacy ad-hoc handlers returned `{"error": "..."}` (frontend reads `.error`)
/// - AppError returned `{"message": "..."}` (admin panel reads `.message`)
/// JSON response structure for errors, borrowing from the `AppError` it
/// renders — `error` and `message` intentionally serialize the SAME string
/// for backwards compatibility (legacy handlers returned `{"error": …}`,
/// AppError returned `{"message": …}`); serializing one buffer twice
/// replaces the old per-response deep clone.
#[derive(Serialize)]
pub struct ErrorResponse {
pub status: String,
pub error: String,
pub message: String,
pub error_type: String,
pub struct ErrorResponse<'a> {
pub status: &'a str,
pub error: &'a str,
pub message: &'a str,
pub error_type: &'a str,
}
impl AppError {
@@ -39,7 +44,7 @@ impl AppError {
pub fn new(
status_code: StatusCode,
message: impl Into<String>,
error_type: impl Into<String>,
error_type: impl Into<Cow<'static, str>>,
) -> Self {
Self {
status_code,
@@ -125,12 +130,14 @@ impl From<DomainError> for AppError {
ErrorKind::UnsupportedOperation => StatusCode::METHOD_NOT_ALLOWED,
ErrorKind::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
ErrorKind::QuotaExceeded => StatusCode::INSUFFICIENT_STORAGE,
ErrorKind::Conflict => StatusCode::CONFLICT,
ErrorKind::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
};
Self {
status_code,
message: err.message,
error_type: err.kind.to_string(),
error_type: Cow::Borrowed(err.kind.as_str()),
}
}
}
@@ -143,25 +150,26 @@ impl IntoResponse for AppError {
// Log the full error server-side for debugging, return a generic
// message to the client. Other status codes (including 5xx like
// 501, 503, 507) keep their intentionally user-facing messages.
let client_message = if status == StatusCode::INTERNAL_SERVER_ERROR {
let client_message: &str = if status == StatusCode::INTERNAL_SERVER_ERROR {
tracing::error!(
error_type = %self.error_type,
"Internal server error: {}",
self.message
);
"An internal error occurred. Please try again later.".to_string()
"An internal error occurred. Please try again later."
} else {
self.message
&self.message
};
let status_line = status.to_string();
let error_response = ErrorResponse {
status: status.to_string(),
error: client_message.clone(),
status: &status_line,
error: client_message,
message: client_message,
error_type: self.error_type,
error_type: &self.error_type,
};
let body = Json(error_response);
let body = Json(&error_response);
(status, body).into_response()
}
}
+3 -2
View File
@@ -8,6 +8,7 @@
//! for audit / ownership purposes.
use axum::http::{HeaderMap, StatusCode, header};
use smol_str::SmolStr;
use uuid::Uuid;
use crate::application::ports::auth_ports::TokenServicePort;
@@ -26,7 +27,7 @@ use crate::interfaces::middleware::user::{LiveRole, resolve_live_role};
pub async fn require_admin(
state: &AppState,
headers: &HeaderMap,
) -> Result<(Uuid, String), AppError> {
) -> Result<(Uuid, SmolStr), AppError> {
let auth = state
.auth_service
.as_ref()
@@ -85,7 +86,7 @@ pub async fn require_admin(
pub async fn require_authenticated(
state: &AppState,
headers: &HeaderMap,
) -> Result<(Uuid, String), AppError> {
) -> Result<(Uuid, SmolStr), AppError> {
let auth = state
.auth_service
.as_ref()
+55 -28
View File
@@ -1,6 +1,6 @@
use axum::{
extract::{FromRequestParts, Request, State},
http::{HeaderMap, StatusCode, header, request::Parts},
http::{StatusCode, header, request::Parts},
middleware::Next,
response::{IntoResponse, Response},
};
@@ -163,11 +163,17 @@ impl IntoResponse for AuthError {
/// then the cookie fallback.
pub async fn auth_middleware(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Result<Response, AuthError> {
let auth_header = headers
// Borrow the Authorization header straight from the request instead of
// taking axum's `HeaderMap` extractor, which clones the whole map (~2
// allocs) on every authenticated request purely to read it
// (benches/ROUND14.md §A4). The borrow is dead by the time each arm
// reaches `request.extensions_mut()` / `next.run(request)` (NLL), so no
// owned copy is needed.
let auth_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok());
@@ -186,9 +192,14 @@ pub async fn auth_middleware(
"Token validated successfully for user: {}",
claims.username
);
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
AuthError::InvalidToken("Invalid user ID in token".to_string())
})?;
// Pre-parsed at decode time (benches/ROUND14.md §A3);
// nil only for a malformed sub, which we reject as before.
let user_id = claims.sub_id;
if user_id.is_nil() {
return Err(AuthError::InvalidToken(
"Invalid user ID in token".to_string(),
));
}
// A cryptographically valid token must not outlive the
// account: re-check the live record so deactivation,
// deletion and demotion take effect within the flags-cache
@@ -204,14 +215,19 @@ pub async fn auth_middleware(
LiveRole::Active(role) => role,
LiveRole::Revoked => return Err(AuthError::AccountInactive),
};
// `username`/`email` are `Arc<str>` refcount
// bumps out of the cached claims; `role` is an
// inline SmolStr — the whole build is 1 alloc
// (the `Arc::new`) instead of 4.
let current_user = Arc::new(CurrentUser {
id: user_id,
username: claims.username.clone(),
email: claims.email.clone(),
username: Arc::clone(&claims.username),
email: Arc::clone(&claims.email),
role,
});
request.extensions_mut().insert(current_user);
tracing::Span::current().record("user_id", user_id.to_string());
tracing::Span::current()
.record("user_id", tracing::field::display(user_id));
return Ok(next.run(request).await);
}
Err(e) => {
@@ -258,7 +274,8 @@ pub async fn auth_middleware(
role,
});
request.extensions_mut().insert(current_user);
tracing::Span::current().record("user_id", user_id.to_string());
tracing::Span::current()
.record("user_id", tracing::field::display(user_id));
return Ok(next.run(request).await);
}
Err(e) => {
@@ -290,19 +307,23 @@ pub async fn auth_middleware(
use crate::interfaces::api::cookie_auth;
if let Some(token_str) =
cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE)
cookie_auth::extract_cookie_str(request.headers(), cookie_auth::ACCESS_COOKIE)
&& !token_str.is_empty()
{
tracing::debug!("Processing cookie-based authentication");
if let Some(auth_service) = state.auth_service.as_ref() {
let token_service = &auth_service.token_service;
match token_service.validate_token(&token_str) {
match token_service.validate_token(token_str) {
Ok(claims) => {
tracing::debug!("Cookie token validated for user: {}", claims.username);
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
AuthError::InvalidToken("Invalid user ID in token".to_string())
})?;
// Pre-parsed at decode time (benches/ROUND14.md §A3).
let user_id = claims.sub_id;
if user_id.is_nil() {
return Err(AuthError::InvalidToken(
"Invalid user ID in token".to_string(),
));
}
// Same live-account re-check as the Bearer path. On
// revocation we fall through (rather than erroring) so the
// browser receives the standard 401 and redirects to
@@ -317,13 +338,14 @@ pub async fn auth_middleware(
LiveRole::Active(role) => {
let current_user = Arc::new(CurrentUser {
id: user_id,
username: claims.username.clone(),
email: claims.email.clone(),
username: Arc::clone(&claims.username),
email: Arc::clone(&claims.email),
role,
});
request.extensions_mut().insert(current_user);
request.extensions_mut().insert(CookieAuthenticated);
tracing::Span::current().record("user_id", user_id.to_string());
tracing::Span::current()
.record("user_id", tracing::field::display(user_id));
return Ok(next.run(request).await);
}
LiveRole::Revoked => {
@@ -389,6 +411,13 @@ fn dav_basic_auth_challenge(message: &'static str) -> Response {
/// `CurrentUser` is the *live* role resolved by `auth_middleware` (see
/// [`resolve_live_role`]), not the JWT claim, so a demotion is honoured
/// here within the flags-cache TTL.
///
/// Denial shapes distinguish authn from authz:
/// - `CurrentUser` present, role != "admin" → 403 Forbidden.
/// - `CurrentUser` absent → 401 Unauthorized. Should not happen in
/// practice (auth_middleware guards against it), but the
/// defensive fallback returns the honest shape: "we don't know
/// who you are" is 401, not "we know you and refuse" (403).
pub async fn require_admin(request: Request, next: Next) -> Response {
// Get the CurrentUser inserted by auth_middleware
if let Some(current_user) = request.extensions().get::<Arc<CurrentUser>>() {
@@ -404,18 +433,16 @@ pub async fn require_admin(request: Request, next: Next) -> Response {
role = %current_user.role,
"👮🏻‍♂️ admin-only route denied for non-admin caller"
);
} else {
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason = "unauthenticated",
"👮🏻‍♂️ admin-only route reached with no authenticated user"
);
return AuthError::AccessDenied("Admin role required".to_string()).into_response();
}
// Access denied
let error = AuthError::AccessDenied("Admin role required".to_string());
error.into_response()
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason = "unauthenticated",
"👮🏻‍♂️ admin-only route reached with no authenticated user"
);
AuthError::TokenNotProvided.into_response()
}
#[cfg(test)]
+7 -6
View File
@@ -39,16 +39,17 @@ pub async fn csrf_middleware(request: Request, next: Next) -> Result<Response, R
return Ok(next.run(request).await);
}
// Extract the CSRF token from the cookie.
let cookie_token =
cookie_auth::extract_cookie_value(request.headers(), cookie_auth::CSRF_COOKIE);
// Extract the CSRF token from the cookie (borrow-only).
let cookie_token = cookie_auth::extract_cookie_str(request.headers(), cookie_auth::CSRF_COOKIE);
// Extract the CSRF token from the request header.
// Extract the CSRF token from the request header. Borrow-only:
// `String: PartialEq<&str>` covers the comparison, so materializing an
// owned copy per state-changing request was a pure waste
// (benches/ROUND11.md §6: 15.7 → 1.3 ns, −1 alloc).
let header_token = request
.headers()
.get(cookie_auth::CSRF_HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
.and_then(|v| v.to_str().ok());
match (cookie_token, header_token) {
(Some(c), Some(h)) if !c.is_empty() && c == h => {
+9 -3
View File
@@ -64,9 +64,15 @@ impl FromRequestParts<Arc<AppState>> for RequestLocale {
.get(axum::http::header::ACCEPT_LANGUAGE)
.and_then(|v| v.to_str().ok())
{
let supported_owned: Vec<String> =
registry.iter().map(|l| l.as_str().to_string()).collect();
let supported: Vec<&str> = supported_owned.iter().map(String::as_str).collect();
// Borrow the precomputed supported-codes list (materialized
// once at registry build) instead of rebuilding N heap Strings
// per anonymous request (benches/ROUND13.md §L1). Only the
// `&[&str]` view the crate needs is built here.
let supported: Vec<&str> = registry
.supported_codes()
.iter()
.map(String::as_str)
.collect();
if let Some(matched) = accept_language::intersection(header_value, &supported).first()
&& let Some(locale) = registry.parse(matched)
{
+11 -11
View File
@@ -55,18 +55,18 @@ impl RateLimiter {
/// `Err(StatusCode::TOO_MANY_REQUESTS)`.
#[allow(clippy::result_unit_err)]
pub fn check_and_increment(&self, ip: &str) -> Result<u32, ()> {
let key = ip.to_string();
// moka's entry API lets us atomically read-modify-write.
// On first access the entry is inserted with count = 1 and the TTL
// starts. Subsequent accesses within the window increment the count.
let count = self.cache.entry(key).or_insert_with(|| 0).into_value() + 1;
// Lock-free read (borrows the key — no allocation), then one
// write-back. The previous shape allocated the key TWICE and paid
// a locking `entry()` op on top of the insert; moka's
// `and_upsert_with` alternative benchmarked slower still
// (benches/ROUND11.md §20). Read-then-write is not atomic, but it
// never was — under a concurrent burst both shapes can undercount
// the same way, which only makes the limiter marginally lenient,
// never wrongly strict.
let count = self.cache.get(ip).unwrap_or(0) + 1;
// Write back the incremented value. Because `or_insert_with` returns
// the *existing* value when the key was already present, we must always
// re-insert so the counter actually advances. The TTL of the **first**
// insert still governs eviction because moka uses insert-time TTL.
// However, on re-insert moka resets the TTL, for rate limiting this
// is fine because it means the window "slides" forward on activity.
// On re-insert moka resets the TTL; for rate limiting this is fine
// because it means the window "slides" forward on activity.
self.cache.insert(ip.to_string(), count);
if count > self.max_requests {
+10 -3
View File
@@ -69,8 +69,11 @@ pub struct UuidRequestId;
impl MakeRequestId for UuidRequestId {
fn make_request_id<B>(&mut self, _request: &axum::http::Request<B>) -> Option<RequestId> {
let id = Uuid::now_v7().to_string();
axum::http::HeaderValue::from_str(&id)
// Stack-encode the UUID: `to_string()` allocated an intermediate
// String per request just for HeaderValue to copy it again.
let mut buf = [0u8; uuid::fmt::Hyphenated::LENGTH];
let id = Uuid::now_v7();
axum::http::HeaderValue::from_str(id.hyphenated().encode_lower(&mut buf))
.ok()
.map(RequestId::new)
}
@@ -89,7 +92,11 @@ pub struct ClientIpMakeSpan;
impl<B> MakeSpan<B> for ClientIpMakeSpan {
fn make_span(&mut self, request: &axum::http::Request<B>) -> Span {
let ip = super::trusted_proxy::client_ip(request, true);
// Borrow-only IP resolution: the span records `client_ip` via `%ip`
// (Display), so a `ClientIpDisplay` that renders straight into the
// span's field storage avoids the per-request `String` the owned
// `client_ip()` allocated (benches/ROUND13.md §H2).
let ip = super::trusted_proxy::client_ip_display(request, true);
let request_id = request
.headers()
.get("x-request-id")
@@ -146,6 +146,82 @@ pub fn client_ip<B>(req: &Request<B>, include_port: bool) -> String {
client_ip_from_parts(req.headers(), peer, include_port)
}
/// A resolved client-IP source that borrows from the request instead of
/// allocating a `String`. [`std::fmt::Display`] renders it directly into the
/// caller's buffer (the tracing span's field storage), so the per-request
/// span factory no longer materializes an intermediate `String` on every
/// request (benches/ROUND13.md §H2). Bytes rendered are identical to
/// [`client_ip`]/[`client_ip_from_parts`] for all four cases.
pub enum ClientIpDisplay<'a> {
/// Proxy-forwarded client address (borrowed from `X-Forwarded-For` /
/// `X-Real-Ip`), already trimmed.
Forwarded(&'a str),
/// Direct TCP peer, rendered with the port.
PeerWithPort(SocketAddr),
/// Direct TCP peer, rendered as the bare IP.
PeerIp(IpAddr),
/// No connection info available.
Unknown,
}
impl std::fmt::Display for ClientIpDisplay<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClientIpDisplay::Forwarded(s) => f.write_str(s),
ClientIpDisplay::PeerWithPort(addr) => write!(f, "{addr}"),
ClientIpDisplay::PeerIp(ip) => write!(f, "{ip}"),
ClientIpDisplay::Unknown => f.write_str("unknown"),
}
}
}
/// Zero-allocation twin of [`client_ip_from_parts`]: resolves the client-IP
/// source without producing an owned `String`. The returned value borrows
/// `headers`, so it must be `Display`-rendered before `headers` is dropped
/// (the span factory does this synchronously).
pub fn client_ip_display_from_parts<'a>(
headers: &'a axum::http::HeaderMap,
peer: Option<SocketAddr>,
include_port: bool,
) -> ClientIpDisplay<'a> {
if let Some(peer_addr) = peer {
if is_trusted_proxy(peer_addr.ip()) {
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
&& let Some(ip) = xff
.split(',')
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
{
return ClientIpDisplay::Forwarded(ip);
}
if let Some(xri) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|s| !s.is_empty())
{
return ClientIpDisplay::Forwarded(xri);
}
}
return if include_port {
ClientIpDisplay::PeerWithPort(peer_addr)
} else {
ClientIpDisplay::PeerIp(peer_addr.ip())
};
}
ClientIpDisplay::Unknown
}
/// Zero-allocation twin of [`client_ip`] for the request-span factory.
pub fn client_ip_display<B>(req: &Request<B>, include_port: bool) -> ClientIpDisplay<'_> {
let peer: Option<SocketAddr> = req
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ci| ci.0);
client_ip_display_from_parts(req.headers(), peer, include_port)
}
/// Same as [`client_ip`], but operates on already-extracted parts (headers
/// plus an optional TCP peer). Handlers that don't take a full `Request<B>`,
/// e.g. those that consume the body via `Json<…>`, can still derive a stable
+9 -7
View File
@@ -27,6 +27,7 @@ use axum::extract::{Request, State};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use smol_str::SmolStr;
use std::sync::Arc;
use uuid::Uuid;
@@ -109,8 +110,9 @@ pub async fn require_admin_user(
pub enum LiveRole {
/// The account exists and is active. Carries the caller's *current*
/// role string (`"admin"` / `"user"`), which is authoritative and
/// supersedes the — possibly stale — JWT `role` claim.
Active(String),
/// supersedes the — possibly stale — JWT `role` claim. `SmolStr` so the
/// per-request render of the (≤23-byte) role never heap-allocates.
Active(SmolStr),
/// The account is deactivated or deleted: the request must be rejected
/// even though its token is still cryptographically valid.
Revoked,
@@ -152,7 +154,7 @@ fn decide_live_role(
claim_role: &str,
) -> LiveRole {
match flags {
Ok(flags) if flags.active => LiveRole::Active(flags.role.to_string()),
Ok(flags) if flags.active => LiveRole::Active(SmolStr::new_static(flags.role.as_str())),
Ok(_) => {
audit_token_revoked(user_id, "deactivated");
LiveRole::Revoked
@@ -170,7 +172,7 @@ fn decide_live_role(
error = %e,
"live-user re-check failed transiently; allowing request on the JWT claim role (fail-open)"
);
LiveRole::Active(claim_role.to_string())
LiveRole::Active(SmolStr::new(claim_role))
}
}
}
@@ -257,14 +259,14 @@ mod tests {
let live = decide_live_role(Ok(flags(UserRole::Admin, true)), Uuid::nil(), "user");
// The live record wins over the (stale) claim — a freshly promoted
// user is admin even though their token still says "user".
assert_eq!(live, LiveRole::Active("admin".to_string()));
assert_eq!(live, LiveRole::Active(SmolStr::new_static("admin")));
}
#[test]
fn active_user_yields_current_user_role() {
// A demoted admin: token claim still "admin", live record "user".
let live = decide_live_role(Ok(flags(UserRole::User, true)), Uuid::nil(), "admin");
assert_eq!(live, LiveRole::Active("user".to_string()));
assert_eq!(live, LiveRole::Active(SmolStr::new_static("user")));
}
#[test]
@@ -285,6 +287,6 @@ mod tests {
// A DB blip must not lock everyone out: allow on the claim role.
let err = DomainError::new(ErrorKind::InternalError, "User", "connection reset");
let live = decide_live_role(Err(err), Uuid::nil(), "admin");
assert_eq!(live, LiveRole::Active("admin".to_string()));
assert_eq!(live, LiveRole::Active(SmolStr::new_static("admin")));
}
}
+79 -31
View File
@@ -1,13 +1,32 @@
use axum::{
extract::{Path, State},
http::{StatusCode, header},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response},
};
use base64::Engine;
use bytes::Bytes;
use std::sync::Arc;
use crate::common::di::AppState;
/// Transcoded-avatar memo: `blake3(stored data URI)` → PNG bytes.
///
/// The WebP→PNG transcode below is a full image decode + PNG encode (tens
/// of ms of CPU) that used to run on EVERY avatar request once the
/// client's 1 h cache lapsed — per client, per surface. Avatars are tiny
/// and rarely change; 32 entries bounds the memo to a few MB.
static AVATAR_PNG_CACHE: std::sync::OnceLock<moka::sync::Cache<[u8; 32], Bytes>> =
std::sync::OnceLock::new();
fn avatar_png_cache() -> &'static moka::sync::Cache<[u8; 32], Bytes> {
AVATAR_PNG_CACHE.get_or_init(|| {
moka::sync::Cache::builder()
.max_capacity(32)
.time_to_live(std::time::Duration::from_secs(24 * 3600))
.build()
})
}
/// Re-encode WebP image bytes as PNG. Returns `None` on decode/encode
/// failure (treated upstream as "fall through to SVG" — a bad stored
/// blob shouldn't break the rendering pipeline). PNG is universal:
@@ -77,10 +96,11 @@ fn parse_data_uri(uri: &str) -> Option<(String, Vec<u8>)> {
pub async fn handle_dav_avatar(
state: State<Arc<AppState>>,
Path((username, size_with_ext)): Path<(String, String)>,
headers: HeaderMap,
) -> Response {
let size_str = size_with_ext.strip_suffix(".png").unwrap_or(&size_with_ext);
let size: u32 = size_str.parse().unwrap_or(64);
handle_avatar(state, Path((username, size))).await
handle_avatar(state, Path((username, size)), headers).await
}
/// GET /index.php/avatar/{user}/{size}
@@ -98,6 +118,7 @@ pub async fn handle_dav_avatar(
pub async fn handle_avatar(
State(state): State<Arc<AppState>>,
Path((username, size)): Path<(String, u32)>,
headers: HeaderMap,
) -> Response {
let size = size.clamp(16, 1024);
@@ -113,39 +134,66 @@ pub async fn handle_avatar(
.get_user_by_username(&username)
.await
&& let Some(image_uri) = user.image.as_deref()
&& let Some((mime, bytes)) = parse_data_uri(image_uri)
{
// WebP is OxiCloud's storage format of choice (smaller files,
// better quality at a given size) but NextCloud clients have
// patchy WebP support — older Qt-based desktop builds, some
// mobile image stacks. Transcode to PNG before serving on the
// NC surface so every client renders it. PNG is bigger on the
// wire but small enough at avatar dimensions that the
// tradeoff is worth it. Decode failure falls through to SVG.
let (final_mime, final_bytes): (&str, Vec<u8>) = if mime == "image/webp" {
match webp_to_png(&bytes) {
Some(png) => ("image/png", png),
None => return svg_initials_response(&username, size),
}
} else {
// Whatever MIME we stored (`image/png`, `image/jpeg`,
// `image/gif`) is universally supported by NC clients.
// The `mime` String is moved out via `.as_str()` here, so
// bind it locally to keep the borrow alive for the response.
(mime_as_static_str(&mime), bytes)
};
return (
StatusCode::OK,
[
(header::CONTENT_TYPE, final_mime),
// Content-derived ETag over the STORED value — computable before
// any base64 decode or image work. NC desktop/mobile revalidate
// avatars every cache lapse (1 h) per surface; this endpoint used
// to re-decode (and for WebP re-transcode to PNG — a full image
// decode + encode) and re-ship the body every time (ROUND10).
let content_hash: [u8; 32] = blake3::hash(image_uri.as_bytes()).into();
let etag = format!(
"\"av-{}\"",
crate::common::fmt::hex_lower(&content_hash[..12])
);
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
&& let Ok(client_etag) = inm.to_str()
&& (client_etag == etag || client_etag == "*")
{
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::CACHE_CONTROL, "public, max-age=3600")
.header(header::ETAG, etag)
.body(axum::body::Body::empty())
.unwrap();
}
if let Some((mime, bytes)) = parse_data_uri(image_uri) {
// WebP is OxiCloud's storage format of choice (smaller files,
// better quality at a given size) but NextCloud clients have
// patchy WebP support — older Qt-based desktop builds, some
// mobile image stacks. Transcode to PNG before serving on the
// NC surface so every client renders it. The transcode result
// is memoised by content hash — decode+encode ran per request
// before. Decode failure falls through to SVG.
let (final_mime, final_bytes): (&str, Bytes) = if mime == "image/webp" {
if let Some(png) = avatar_png_cache().get(&content_hash) {
("image/png", png)
} else {
match webp_to_png(&bytes) {
Some(png) => {
let png = Bytes::from(png);
avatar_png_cache().insert(content_hash, png.clone());
("image/png", png)
}
None => return svg_initials_response(&username, size),
}
}
} else {
// Whatever MIME we stored (`image/png`, `image/jpeg`,
// `image/gif`) is universally supported by NC clients.
(mime_as_static_str(&mime), Bytes::from(bytes))
};
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, final_mime)
// Shorter cache than the SVG fallback because users can
// re-upload their picture at any time — the URL is the
// same so a long immutable cache would pin the old one.
(header::CACHE_CONTROL, "public, max-age=3600"),
],
final_bytes,
)
.into_response();
.header(header::CACHE_CONTROL, "public, max-age=3600")
.header(header::ETAG, etag)
.body(axum::body::Body::from(final_bytes))
.unwrap();
}
}
svg_initials_response(&username, size)
@@ -1,15 +1,44 @@
use axum::{
extract::{Request, State},
http::{HeaderMap, StatusCode, header},
http::{StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
use base64::Engine;
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use crate::application::dtos::folder_dto::FolderDto;
use crate::common::di::AppState;
use crate::interfaces::middleware::auth::CurrentUser;
/// Markerless-chroot cache: default-drive root folder id → `FolderDto`.
///
/// This middleware wraps EVERY protected NextCloud route (DAV files,
/// per-chunk uploads, trashbin, previews, avatars, OCS polls). With the
/// app-password verification already cached, the chroot resolution was the
/// last per-request DB work: `find_default_for_user` (now cached in
/// `DrivePgRepository`) plus this folder-by-PK fetch. A desktop sync run
/// issues hundreds of these per minute for a value that changes only on a
/// root-folder rename — the 30 s TTL bounds that staleness (mirrors
/// `drive_role_cache` / the default-drive cache; measured in
/// `benches/CHROOT-CACHE.md`).
///
/// Only the MARKERLESS branch is cached: it targets the caller's own
/// default drive root, so no per-request authorization decision is being
/// skipped. The drive-marker branch keeps its `get_folder_with_perms`
/// check on every request.
// `Arc<FolderDto>` values: a hit hands back a refcount bump instead of a
// deep clone of the DTO's ~5 owned Strings (moka's `get` clones `V`), and
// the same `Arc` then rides inside `NcSession` for the whole request.
static NC_CHROOT_CACHE: LazyLock<moka::sync::Cache<uuid::Uuid, Arc<FolderDto>>> =
LazyLock::new(|| {
moka::sync::Cache::builder()
.max_capacity(100_000)
.time_to_live(Duration::from_secs(30))
.build()
});
#[derive(Debug, thiserror::Error)]
pub enum NextcloudAuthError {
#[error("Unauthorized")]
@@ -41,13 +70,16 @@ impl IntoResponse for NextcloudAuthError {
pub async fn basic_auth_middleware(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Result<Response, NextcloudAuthError> {
tracing::debug!("[NC] {} {}", request.method(), request.uri());
let auth_header = headers
// Borrow the Authorization header directly rather than cloning the whole
// HeaderMap per NC sync request; the borrow ends at `parse_basic_auth`
// below, before any request mutation (benches/ROUND14.md §A4).
let auth_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
@@ -77,7 +109,12 @@ pub async fn basic_auth_middleware(
// at the auth boundary rather than treating them as "missing
// marker" — they are unambiguous typos that would otherwise
// silently fall into a different code path.
let (username, drive_marker): (String, Option<String>) = match raw_username.split_once('~') {
// Borrow the prefix / marker out of the already-owned `raw_username`
// (`split_once` yields `&str` slices) instead of allocating a duplicate
// `String` per request — `username` is only ever passed by reference, and
// `raw_username` outlives every use before it moves into `NcSession`
// (benches/ROUND29.md §E).
let (username, drive_marker): (&str, Option<&str>) = match raw_username.split_once('~') {
Some(("", _)) => {
tracing::warn!(
"[NC] 401 malformed composite username (empty prefix): {}",
@@ -92,15 +129,15 @@ pub async fn basic_auth_middleware(
);
return Err(NextcloudAuthError::Unauthorized);
}
Some((u, m)) => (u.to_string(), Some(m.to_string())),
None => (raw_username.clone(), None),
Some((u, m)) => (u, Some(m)),
None => (raw_username.as_str(), None),
};
// Check account lockout before attempting password verification (saves CPU).
// The lockout is per (account, IP), see #323 for rationale.
let client_ip = crate::interfaces::middleware::rate_limit::extract_client_ip(&request);
if let Some(auth_svc) = state.auth_service.as_ref()
&& let Err(secs) = auth_svc.login_lockout.check(&username, &client_ip)
&& let Err(secs) = auth_svc.login_lockout.check(username, &client_ip)
{
tracing::warn!(
username = %username,
@@ -118,13 +155,13 @@ pub async fn basic_auth_middleware(
match nextcloud
.app_passwords
.verify_basic_auth(&username, &password)
.verify_basic_auth(username, &password)
.await
{
Ok((user_id, uname, email, role)) => {
// Reset lockout counter on success
if let Some(auth_svc) = state.auth_service.as_ref() {
auth_svc.login_lockout.record_success(&username, &client_ip);
auth_svc.login_lockout.record_success(username, &client_ip);
}
// External users must never authenticate against the NC
// surface — that whole subtree (WebDAV files, uploads,
@@ -159,13 +196,19 @@ pub async fn basic_auth_middleware(
// request would appear in the logs with `user_id=-`,
// making it harder to correlate WebDAV / OCS activity to
// a specific principal.
tracing::Span::current().record("user_id", user_id.to_string());
let current_user = CurrentUser {
// `field::display` renders lazily into the subscriber's buffer —
// no per-request `to_string` (mirrors the JWT path since ROUND5).
tracing::Span::current().record("user_id", tracing::field::display(user_id));
// One shared identity: the same `Arc` serves the
// `Arc<CurrentUser>` extension AND `NcSession.user` (the old
// code built the struct, cloned it for the extension, then
// moved the original — 2-3 String allocs per request).
let current_user = Arc::new(CurrentUser {
id: user_id,
username: uname,
email,
role,
};
});
// ── Resolve chroot from the Basic Auth drive marker ─────
// No marker → caller's default personal drive's root folder
@@ -184,19 +227,32 @@ pub async fn basic_auth_middleware(
// is the right one: name-independent, secondary-drive-safe.
use crate::application::ports::folder_ports::FolderUseCase;
use crate::domain::repositories::drive_repository::DriveRepository;
let chroot = match drive_marker.as_deref() {
let chroot = match drive_marker {
None => {
match state
.drive_repo
.find_default_for_user(current_user.id)
.await
{
Ok(drive_with_name) => state
.applications
.folder_service
.get_folder(&drive_with_name.drive.root_folder_id.to_string())
.await
.ok(),
Ok(drive_with_name) => {
let root_id = drive_with_name.drive.root_folder_id;
match NC_CHROOT_CACHE.get(&root_id) {
Some(cached) => Some(cached),
None => {
let fetched = state
.applications
.folder_service
.get_folder(&root_id.to_string())
.await
.ok()
.map(Arc::new);
if let Some(f) = &fetched {
NC_CHROOT_CACHE.insert(root_id, Arc::clone(f));
}
fetched
}
}
}
Err(_) => None,
}
}
@@ -205,7 +261,8 @@ pub async fn basic_auth_middleware(
.folder_service
.get_folder_with_perms(folder_id, current_user.id)
.await
.ok(),
.ok()
.map(Arc::new),
};
if chroot.is_none() {
tracing::warn!(
@@ -216,31 +273,26 @@ pub async fn basic_auth_middleware(
return Err(NextcloudAuthError::Unauthorized);
}
request
.extensions_mut()
.insert(Arc::new(current_user.clone()));
// Record from the local before it moves into the session —
// the old code re-read the just-inserted extension and paid a
// `to_string` for the span value.
if let Some(c) = &chroot {
tracing::Span::current().record("chroot_id", tracing::field::display(&c.id));
}
request.extensions_mut().insert(Arc::clone(&current_user));
request.extensions_mut().insert(Arc::new(
crate::interfaces::nextcloud::session::NcSession {
user: current_user,
raw_username: raw_username.clone(),
raw_username,
chroot,
},
));
tracing::Span::current().record(
"chroot_id",
request
.extensions()
.get::<Arc<crate::interfaces::nextcloud::session::NcSession>>()
.and_then(|s| s.chroot.as_ref())
.map(|c| c.id.to_string())
.unwrap_or_default(),
);
Ok(next.run(request).await)
}
Err(_) => {
// Record failed attempt for lockout tracking
if let Some(auth_svc) = state.auth_service.as_ref() {
auth_svc.login_lockout.record_failure(&username, &client_ip);
auth_svc.login_lockout.record_failure(username, &client_ip);
}
Err(NextcloudAuthError::Unauthorized)
}
+178 -21
View File
@@ -196,7 +196,7 @@ pub async fn handle_login_submit(
// the common case stays one click. With ≥2 drives we pause the
// flow, stash the user_id, and render the picker — drive selection
// resumes the flow via `handle_drive_pick`.
let mut drives = match state
let drives = match state
.applications
.folder_service
.list_folders_with_perms(None, current_user.id)
@@ -209,6 +209,37 @@ pub async fn handle_login_submit(
}
};
resolve_drive_or_complete(
&state,
nextcloud,
&token,
&current_user,
"Nextcloud",
drives,
)
.await
}
/// Shared "multi-drive fork" step used by both the password path
/// (`handle_login_submit`) and the OIDC path
/// (`handle_oidc_login_completion`).
///
/// - `label` is the app-password label persisted when `complete_flow`
/// creates the credential. Callers pass a channel-identifying string
/// (`"Nextcloud"` for password, `"Nextcloud (OIDC)"` for OIDC) so the
/// audit trail can distinguish provenance without another column.
/// - `drives` is the caller's pre-fetched drive list — the two callers
/// already list drives before invoking us (the password path lists
/// after `verify_credentials`, the OIDC path lists after
/// `get_user_by_id`), so re-listing here would be a wasted query.
async fn resolve_drive_or_complete(
state: &Arc<AppState>,
nextcloud: &crate::common::di::NextcloudServices,
token: &str,
current_user: &CurrentUser,
label: &'static str,
mut drives: Vec<crate::application::dtos::folder_dto::FolderDto>,
) -> Response {
if drives.len() >= 2 {
// Reorder so home is at index 0. The picker template ties
// both the default-checked radio and the "Home" badge to
@@ -236,18 +267,105 @@ pub async fn handle_login_submit(
if !nextcloud
.login_flow
.mark_awaiting_drive(&token, current_user.id)
.mark_awaiting_drive(token, current_user.id)
{
// Flow token vanished (TTL?) between password submit and
// here — extremely unlikely but treat the same as any
// Flow token vanished (TTL?) between auth and here —
// extremely unlikely but treat the same as any
// session-expired case.
return axum::response::Redirect::to("/nextcloud/error?type=session-expired")
.into_response();
}
return render_drive_picker(&token, &drives);
// Persist the label so `handle_drive_pick` can pass the correct
// provenance string when it later calls `complete_flow`. Set
// even for the password path (where label == "Nextcloud") so
// the read-back is uniform.
nextcloud
.login_flow
.set_pending_app_password_label(token, label);
return render_drive_picker(token, &drives);
}
complete_flow(&state, &nextcloud.login_flow, &token, &current_user, None).await
complete_flow(
state,
&nextcloud.login_flow,
token,
current_user,
None,
label,
)
.await
}
/// Complete an OIDC-authenticated NC Login Flow v2.
///
/// Called from the OIDC callback (`auth_handler::oidc_callback`) when
/// the state carried an `nc_flow_token`. Mirrors the password path's
/// multi-drive fork exactly — the browser lands on the drive picker
/// when the user has ≥ 2 drives, or on the success page when they
/// have one. NC clients pick up credentials via the poll endpoint in
/// both cases (backchannel), so no `nc://` frontchannel URL is emitted.
///
/// Prior to this refactor the OIDC callback minted the app password
/// inline and completed the flow with the bare username (no `~<uuid>`
/// marker) — customers with multiple drives had no way to pick a
/// non-home drive under SSO. Routing through `resolve_drive_or_complete`
/// fixes that and dedups the branching logic against the password path.
pub async fn handle_oidc_login_completion(
state: &Arc<AppState>,
token: &str,
user_id: uuid::Uuid,
username: &str,
) -> Response {
let nextcloud = match state.nextcloud.as_ref() {
Some(nc) => nc,
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
};
let auth = match state.auth_service.as_ref() {
Some(a) => a,
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
};
// Full user record — needed to build the `CurrentUser` the shared
// helpers expect (email + role in particular). We already have the
// username from the OIDC claims, but not the rest.
let user_dto = match auth.auth_application_service.get_user_by_id(user_id).await {
Ok(u) => u,
Err(e) => {
tracing::error!(error = %e, %user_id, user = %username, "OIDC+NC: failed to fetch user by id");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let current_user = CurrentUser {
id: user_id,
username: std::sync::Arc::from(username),
email: std::sync::Arc::from(user_dto.email.as_str()),
role: smol_str::SmolStr::new(&user_dto.role),
};
let drives = match state
.applications
.folder_service
.list_folders_with_perms(None, current_user.id)
.await
{
Ok(d) => d,
Err(e) => {
tracing::error!(error = %e, user = %current_user.username, "OIDC+NC: failed to list drives");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
resolve_drive_or_complete(
state,
nextcloud,
token,
&current_user,
"Nextcloud (OIDC)",
drives,
)
.await
}
/// Render the drive picker page. The form posts to
@@ -299,17 +417,18 @@ async fn complete_flow(
token: &str,
user: &CurrentUser,
drive_id: Option<&str>,
// Persisted verbatim as `auth.app_passwords.label`. Callers pass
// `"Nextcloud"` for the password path and `"Nextcloud (OIDC)"` for
// the OIDC path so operators can distinguish provenance from the
// audit log alone.
label: &str,
) -> Response {
let nextcloud = match state.nextcloud.as_ref() {
Some(nc) => nc,
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
};
let app_password = match nextcloud
.app_passwords
.create_nc(user.id, "Nextcloud")
.await
{
let app_password = match nextcloud.app_passwords.create_nc(user.id, label).await {
Ok((_id, password)) => password,
Err(e) => {
tracing::error!(error = %e, user = %user.username, "Login Flow v2: failed to create app password");
@@ -319,7 +438,7 @@ async fn complete_flow(
let login_name = match drive_id {
Some(uuid) => format!("{}~{}", user.username, uuid),
None => user.username.clone(),
None => user.username.to_string(),
};
let base_url = state.core.config.base_url();
@@ -332,11 +451,30 @@ async fn complete_flow(
base_url = %base_url,
"Login Flow v2: flow completed successfully"
);
let nc_url = format!(
"nc://login/server:{}&user:{}&password:{}",
base_url, login_name, app_password
);
axum::response::Redirect::to(&nc_url).into_response()
// Redirect the browser to a visible success page. NC clients
// that use the LFv2 poll endpoint (the standard pattern) have
// already received the credentials server-to-server through
// `login_flow.complete()` above — they don't need any browser
// hand-off.
//
// We deliberately do NOT redirect to `nc://login/…` here:
// 1. Plain browsers can't follow it → the tab looks stuck
// on the picker → user clicks Continue again → second
// click hits an already-consumed flow token → ends up
// on `/nextcloud/error?type=session-expired`.
// 2. NC desktop clients that pick it up while their poll
// has already succeeded try to complete the flow a
// second time, which fails validation ("Impossible de
// valider la requête") — the poll session is fine, the
// dialog is spurious noise.
//
// If a client ever needs a frontchannel `nc://` handoff
// (older NC releases, mobile), reintroduce the URL as a
// client-side-only fragment (`#target=…`) and add a manual
// "Open Nextcloud" fallback on the success page. Keep the
// credentials out of the query string either way — the query
// string reaches server access logs.
axum::response::Redirect::to("/nextcloud/success").into_response()
} else {
tracing::error!(
user = %user.username,
@@ -412,9 +550,9 @@ pub async fn handle_drive_pick(
};
let user = CurrentUser {
id: user_id,
username,
email: user_dto.email.clone(),
role: user_dto.role.clone(),
username: std::sync::Arc::from(username.as_str()),
email: std::sync::Arc::from(user_dto.email.as_str()),
role: smol_str::SmolStr::new(&user_dto.role),
};
let _folder = match state
@@ -473,7 +611,26 @@ pub async fn handle_drive_pick(
Some(drive_id.as_str())
};
complete_flow(&state, &nextcloud.login_flow, &token, &user, drive_marker).await
// Preserved label from the auth step ("Nextcloud" for password
// flow, "Nextcloud (OIDC)" for OIDC). Stashed by
// `resolve_drive_or_complete` when the picker was rendered; falls
// back to `"Nextcloud"` if the stash is missing (defensive — should
// never happen post-refactor, but keeps behaviour identical to the
// pre-refactor hardcoded label if some future path forgets to set).
let label = nextcloud
.login_flow
.take_pending_app_password_label(&token)
.unwrap_or_else(|| "Nextcloud".to_string());
complete_flow(
&state,
&nextcloud.login_flow,
&token,
&user,
drive_marker,
&label,
)
.await
}
/// GET /login/v2/flow/{token}/oidc — Start an OIDC authorization flow that is
+121 -44
View File
@@ -35,20 +35,51 @@ fn ocs_err(statuscode: u16, message: &str) -> serde_json::Value {
}
pub async fn handle_capabilities_v1(State(state): State<Arc<AppState>>) -> Response {
let payload = capabilities_payload(&state, 1);
tracing::info!("[NC] capabilities v1 requested, returning payload");
Json(payload).into_response()
tracing::debug!("[NC] capabilities v1 requested, returning payload");
capabilities_response(&state, 1)
}
pub async fn handle_capabilities_v2(State(state): State<Arc<AppState>>) -> Response {
let payload = capabilities_payload(&state, 2);
tracing::info!("[NC] capabilities v2 requested, returning payload");
Json(payload).into_response()
tracing::debug!("[NC] capabilities v2 requested, returning payload");
capabilities_response(&state, 2)
}
/// Pre-serialized capabilities bodies, `[v1, v2]`. The payload is
/// process-invariant (pure config: base URL + emulated NC version), yet
/// every desktop/mobile client polls it periodically — the old handler
/// re-built the ~40-node `json!` tree, re-read `OXICLOUD_BASE_URL` from
/// the environment and re-serialized on every poll. Now that work runs
/// once; a poll is a `Bytes` refcount bump.
static CAPABILITIES_BODIES: std::sync::OnceLock<[bytes::Bytes; 2]> = std::sync::OnceLock::new();
fn capabilities_response(state: &AppState, ocs_version: u8) -> Response {
let bodies = CAPABILITIES_BODIES.get_or_init(|| {
let base_url = state.core.config.base_url();
let emulated = state.core.config.nextcloud.emulated_version;
let version_string = state.core.config.nextcloud.version_string();
[1u8, 2u8].map(|v| {
bytes::Bytes::from(
serde_json::to_vec(&capabilities_payload(
&base_url,
emulated,
&version_string,
v,
))
.expect("static capabilities JSON serializes"),
)
})
});
let body = bodies[usize::from(ocs_version != 1)].clone();
(
[(axum::http::header::CONTENT_TYPE, "application/json")],
body,
)
.into_response()
}
pub async fn handle_user_info(
State(state): State<Arc<AppState>>,
session: crate::interfaces::nextcloud::session::NcSession,
session: crate::interfaces::nextcloud::session::SharedNcSession,
) -> Response {
let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
Some(service) => match service.get_user_storage_info(session.user.id).await {
@@ -78,11 +109,11 @@ pub async fn handle_user_info(
// than the raw UUID the wire form carries.
let id = session.raw_username.clone();
let displayname = if session.is_home() {
session.user.username.clone()
session.user.username.to_string()
} else {
match session.chroot.as_ref() {
Some(chroot) => format!("{}@{}", session.user.username, chroot.name),
None => session.user.username.clone(),
None => session.user.username.to_string(),
}
};
@@ -135,19 +166,40 @@ async fn user_provisioning_response(
) -> Response {
let statuscode = if ocs_version == 1 { 100 } else { 200 };
// Only allow users to view their own profile, unless they are admin.
if user.username != userid && user.role != "admin" {
return Json(ocs_err(403, "Insufficient privileges")).into_response();
}
// AuthZ audit #11 (2026-07-12): the pre-fix path here rolled its
// own gate ("caller is `userid`, else must be admin") and then
// called bare `get_user_by_username` — bypassing every visibility
// rule the id-keyed `/api/users/{id}` endpoint enforces. Cross-user
// probes returned 403 (leaking existence via the differential vs a
// genuine 404 for missing users); admins bypassed
// `expose_system_users`; no audit line ever fired.
//
// Now routing through `get_user_profile_by_username_with_perms`,
// which delegates to the same visibility engine as the REST
// endpoint (self / shared-grant / expose_system_users / admin
// paths, all audit-logged on denial). The OCS wire shape stays
// `ocs_err(404, ...)` for every denied case — the NC client can't
// tell "no such user" from "you can't see this user" from "you're
// not admin" apart, which is the anti-enum invariant.
let auth_service = match state.auth_service.as_ref() {
Some(svc) => &svc.auth_application_service,
None => {
return Json(ocs_err(997, "Authentication not configured")).into_response();
}
};
let Some(pool) = state.db_pool.as_ref() else {
return Json(ocs_err(997, "Database pool not available")).into_response();
};
let user_dto = match auth_service.get_user_by_username(&userid).await {
let user_dto = match auth_service
.get_user_profile_by_username_with_perms(
user.id,
&userid,
state.core.config.features.expose_system_users,
pool,
)
.await
{
Ok(u) => u,
Err(_) => {
return Json(ocs_err(404, "User not found")).into_response();
@@ -290,21 +342,22 @@ pub async fn handle_sharees_search(
None => return sharees_response(vec![]).into_response(),
};
// SQL-level ILIKE search with limit — avoids loading all users into memory.
let users = auth_service
.search_users(&search, 26)
// SQL-level ILIKE search with limit — avoids loading all users into
// memory. Username-only projection: the wide `search_users` row drags
// the up-to-512 KiB avatar `image` per matched user, per keystroke
// (benches/ROUND12.md §1). NULL-username (email-only signup) rows are
// already filtered by the service, preserving the old post-limit
// filtering semantics.
let usernames = auth_service
.search_sharee_usernames(&search, 26)
.await
.unwrap_or_default();
// Skip users with no claimed username — NC sharees autocomplete relies
// on a username being typeable; users still on the email-only signup
// path can't be addressed here. Also skip self (don't suggest sharing
// with yourself).
let matches: Vec<serde_json::Value> = users
// Skip self (don't suggest sharing with yourself).
let matches: Vec<serde_json::Value> = usernames
.into_iter()
.filter_map(|u| {
let handle = u.username.clone()?;
if handle == user.username {
.filter_map(|handle| {
if handle.as_str() == &*user.username {
return None;
}
Some(json!({
@@ -411,8 +464,8 @@ pub async fn handle_search(
// Pre-resolve numeric ids for every file result in a single batch query
// (was one INSERT round-trip per result).
let file_uuids: Vec<String> = results.files.iter().map(|f| f.id.clone()).collect();
let file_id_map: HashMap<String, i64> = match file_id_svc {
let file_uuids: Vec<&str> = results.files.iter().map(|f| f.id.as_str()).collect();
let file_id_map: HashMap<uuid::Uuid, i64> = match file_id_svc {
Some(svc) => svc
.get_or_create_file_ids(&file_uuids)
.await
@@ -422,16 +475,21 @@ pub async fn handle_search(
let mut entries: Vec<serde_json::Value> = Vec::new();
// Map file results
// TODO(D1): drop the hardcoded "Personal/" prefix and read the
// caller's default-drive root folder name from `drives.root_folder_id`
// instead. Correct for D0-provisioned default drives; secondary
// drives keep their original root name.
// Map file results.
//
// `strip_drive_root_segment` handles both default and secondary
// drives — post-D0 the first path segment is the drive's root
// folder name (`"Personal"` for D0-provisioned defaults, the
// original sibling-root name for M2 backfilled secondaries).
// Read-scope is upstream in `state.applications.search_service`;
// this handler only formats display paths.
for file in &results.files {
let display_path = file.path.strip_prefix("Personal/").unwrap_or(&file.path);
let display_path =
crate::interfaces::nextcloud::webdav_handler::strip_drive_root_segment(&file.path);
let display_path = format!("/{}", display_path);
let numeric_id = file_id_map.get(&file.id).copied();
let numeric_id =
crate::interfaces::nextcloud::webdav_handler::nc_id_of(&file_id_map, &file.id);
let thumbnail_url = match numeric_id {
Some(nid) => format!("/index.php/core/preview?fileId={}&x=32&y=32", nid),
@@ -452,12 +510,10 @@ pub async fn handle_search(
}));
}
// Map folder results — same TODO(D1) as above.
// Map folder results — same drive-agnostic strip as above.
for folder in &results.folders {
let display_path = folder
.path
.strip_prefix("Personal/")
.unwrap_or(&folder.path);
let display_path =
crate::interfaces::nextcloud::webdav_handler::strip_drive_root_segment(&folder.path);
let display_path = format!("/{}", display_path);
entries.push(json!({
@@ -506,11 +562,19 @@ fn empty_search_response() -> Json<serde_json::Value> {
}))
}
fn capabilities_payload(state: &AppState, ocs_version: u8) -> serde_json::Value {
/// Build the capabilities JSON tree from its three config inputs. Public
/// only under the `bench` feature caller path via
/// [`capabilities_payload_for_bench`]; production reaches it once through
/// the [`CAPABILITIES_BODIES`] init.
fn capabilities_payload(
base_url: &str,
emulated_version: (u32, u32, u32),
version_string: &str,
ocs_version: u8,
) -> serde_json::Value {
let statuscode = if ocs_version == 1 { 100 } else { 200 };
let base_url = state.core.config.base_url();
let (nc_major, nc_minor, nc_micro) = state.core.config.nextcloud.emulated_version;
let nc_version_str = state.core.config.nextcloud.version_string();
let (nc_major, nc_minor, nc_micro) = emulated_version;
let nc_version_str = version_string;
json!({
"ocs": {
@@ -578,6 +642,19 @@ fn capabilities_payload(state: &AppState, ocs_version: u8) -> serde_json::Value
})
}
/// Bench-only public wrapper (feature = "bench") over the private payload
/// builder so `examples/bench_capabilities_static.rs` can A/B the
/// rebuild-per-poll flow against the memoized bytes.
#[cfg(feature = "bench")]
pub fn capabilities_payload_for_bench(
base_url: &str,
emulated_version: (u32, u32, u32),
version_string: &str,
ocs_version: u8,
) -> serde_json::Value {
capabilities_payload(base_url, emulated_version, version_string, ocs_version)
}
fn extract_basic_password(headers: &axum::http::HeaderMap) -> Option<String> {
let value = headers
.get(axum::http::header::AUTHORIZATION)?
+70 -20
View File
@@ -11,11 +11,14 @@ use axum::{
use serde::Deserialize;
use std::sync::Arc;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::storage_ports::FileReadPort;
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailPort, ThumbnailSize};
use crate::common::di::AppState;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::interfaces::middleware::auth::AuthUser;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct PreviewParams {
@@ -36,15 +39,17 @@ pub async fn handle_preview(
State(state): State<Arc<AppState>>,
user: AuthUser,
Query(params): Query<PreviewParams>,
req: axum::extract::Request,
) -> impl IntoResponse {
// Parse the Nextcloud file ID — the NC app may append an instance suffix
// (e.g. "00000326ocnca"), so strip non-digit characters first.
let numeric_part: String = params
let digit_end = params
.file_id
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
let nc_file_id: i64 = match numeric_part.parse() {
.as_bytes()
.iter()
.position(|b| !b.is_ascii_digit())
.unwrap_or(params.file_id.len());
let nc_file_id: i64 = match params.file_id[..digit_end].parse() {
Ok(id) => id,
Err(_) => {
return Response::builder()
@@ -89,9 +94,28 @@ pub async fn handle_preview(
}
};
// Verify the authenticated user owns this file
let user_id_str = user.id.to_string();
if file.owner_id.as_deref() != Some(user_id_str.as_str()) {
// Verify the authenticated user can Read this file. Anti-enum: any
// AuthZ denial surfaces as 404 (same shape as "unknown file" above),
// and the engine emits an `authz.denied` audit line internally.
let file_uuid = match Uuid::parse_str(&file.id) {
Ok(u) => u,
Err(_) => {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("File not found"))
.unwrap();
}
};
if state
.authorization
.require(
Subject::User(user.id),
Permission::Read,
Resource::File(file_uuid),
)
.await
.is_err()
{
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("File not found"))
@@ -113,6 +137,36 @@ pub async fn handle_preview(
}
};
// Conditional revalidation — the ETag is derived from (object id, size)
// only, so it is computable right here, BEFORE the blob-hash query and
// the thumbnail cache/disk read. NC clients revalidate gallery previews
// constantly; the REST thumbnail endpoint has honoured `If-None-Match`
// since PHOTOS-ETAG — this endpoint set an immutable ETag but never
// compared it, so every revalidation re-ran the whole pipeline and
// re-shipped the body (ROUND10). Authz already passed above; a 304
// must never skip the Read check.
let etag = {
let s = thumb_size.as_str();
let mut e = String::with_capacity(9 + object_id.len() + s.len());
e.push_str("\"thumb-");
e.push_str(&object_id);
e.push('-');
e.push_str(s);
e.push('"');
e
};
if let Some(inm) = req.headers().get(header::IF_NONE_MATCH)
&& let Ok(client_etag) = inm.to_str()
&& (client_etag == etag || client_etag == "*")
{
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::ETAG, etag)
.body(Body::empty())
.unwrap();
}
// Check if file is an image
if !state
.core
@@ -153,7 +207,6 @@ pub async fn handle_preview(
)
.await
{
let etag = format!("\"thumb-{}-{:?}\"", object_id, thumb_size);
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
@@ -179,17 +232,14 @@ pub async fn handle_preview(
)
.await
{
Ok(data) => {
let etag = format!("\"thumb-{}-{:?}\"", object_id, thumb_size);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::ETAG, etag)
.body(Body::from(data))
.unwrap()
}
Ok(data) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::ETAG, etag)
.body(Body::from(data))
.unwrap(),
Err(err) => {
tracing::error!("Thumbnail generation failed for {}: {}", object_id, err);
Response::builder()
+205 -72
View File
@@ -10,9 +10,7 @@ use quick_xml::{
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
};
use crate::application::dtos::display_helpers::{format_file_size, intern_display};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::dtos::search_dto::SearchCriteriaDto;
@@ -21,9 +19,13 @@ use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::inbound::SearchUseCase;
use crate::common::di::AppState;
use crate::domain::entities::file::File;
use crate::interfaces::api::handlers::webdav_handler::{
dead_props_for, files_dead_props_map, folders_dead_props_map,
};
use crate::interfaces::errors::AppError;
use crate::interfaces::nextcloud::webdav_handler::{
batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response,
batch_resolve_ids, format_oc_id_into, nc_collection_href_into, nc_href_into, nc_id_of,
write_file_response, write_folder_response,
};
/// Handle WebDAV REPORT and SEARCH methods for Nextcloud compatibility.
@@ -62,6 +64,15 @@ async fn handle_filter_files(
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let url_user = &session.raw_username;
// Chroot-scope the response: NC's `oc:filter-files` REPORT is a
// single-drive surface (the client PROPFINDs favorites under its
// "home" URL and has no cross-drive concept). Favorites that live
// in another drive the caller is a member of are dropped from
// this response; they're still reachable via REST
// `/api/favorites/resources`. `session.require_chroot()` is safe
// here — the REPORT verb only reaches this handler through a
// path-scoped route.
let chroot = session.require_chroot()?;
let fav_svc = match state.favorites_service.as_ref() {
Some(svc) => svc,
None => return Ok(empty_multistatus()),
@@ -84,11 +95,11 @@ async fn handle_filter_files(
// All items in this response are favorites.
let favorite_ids: HashSet<String> = favorites.iter().map(|f| f.item_id.clone()).collect();
// TODO(D1): replace the hardcoded "Personal/" prefix with the
// caller's default-drive root folder name read from
// `drives.root_folder_id`. Correct for D0-provisioned default
// drives; secondary drives keep their original root name.
let home_prefix = "Personal/";
// `home_prefix` is unused after the chroot-aware strip
// (see `strip_home_prefix`); kept as a positional argument in
// the emit calls below for signature stability with the
// report-handler tests and the parallel search-pass caller.
let home_prefix = "";
// Pass 1: resolve the favorited DTOs in two batch queries (was one
// get_* per favorite — up to N serial round-trips on a sync client's
@@ -104,14 +115,14 @@ async fn handle_filter_files(
}
}
let file_map: HashMap<String, FileDto> = file_service
let mut file_map: HashMap<String, FileDto> = file_service
.get_files_by_ids(&file_ids)
.await
.map_err(|e| AppError::internal_error(format!("Failed to resolve favorite files: {e}")))?
.into_iter()
.map(|f| (f.id.clone(), f))
.collect();
let folder_map: HashMap<String, FolderDto> = folder_service
let mut folder_map: HashMap<String, FolderDto> = folder_service
.get_folders_by_ids(&folder_ids)
.await
.map_err(|e| AppError::internal_error(format!("Failed to resolve favorite folders: {e}")))?
@@ -121,16 +132,21 @@ async fn handle_filter_files(
let mut files: Vec<FileDto> = Vec::new();
let mut folders: Vec<FolderDto> = Vec::new();
// Move the DTO out of the map instead of cloning it: the maps are built
// just above solely to hydrate `files`/`folders` in favorites order and are
// dropped at fn end, so the clone was pure waste. `favorites.item_id` is
// unique per user, so `remove` drops nothing needed and the favorites order
// is preserved (benches/ROUND20.md §C3).
for fav in &favorites {
match fav.item_type.as_str() {
"file" => {
if let Some(f) = file_map.get(&fav.item_id) {
files.push(f.clone());
if let Some(f) = file_map.remove(&fav.item_id) {
files.push(f);
}
}
"folder" => {
if let Some(f) = folder_map.get(&fav.item_id) {
folders.push(f.clone());
if let Some(f) = folder_map.remove(&fav.item_id) {
folders.push(f);
}
}
_ => {}
@@ -138,8 +154,8 @@ async fn handle_filter_files(
}
// Pass 2: resolve every oc:fileid in two batch queries (was one per item).
let file_uuids: Vec<String> = files.iter().map(|f| f.id.clone()).collect();
let folder_uuids: Vec<String> = folders.iter().map(|f| f.id.clone()).collect();
let file_uuids: Vec<&str> = files.iter().map(|f| f.id.as_str()).collect();
let folder_uuids: Vec<&str> = folders.iter().map(|f| f.id.as_str()).collect();
let (file_id_map, folder_id_map) =
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
@@ -150,40 +166,89 @@ async fn handle_filter_files(
write_multistatus_start(&mut xml)?;
// Batched dead-props: one = ANY($1) query per type, not one per
// result (benches/DEAD-PROPS.md).
let file_deads = files_dead_props_map(&state.webdav_dead_props, &files).await;
let folder_deads = folders_dead_props_map(&state.webdav_dead_props, &folders).await;
// Keep main's batched-resolution structure (one batch query
// per type, not 2N round-trips). Hrefs use `url_user` so the
// multi-drive `~{drive}` form is echoed back to the client;
// owner-id stays canonical via `&user.username`.
// One oc:id buffer reused across both emit loops (benches/ROUND27.md §H1).
let mut oc_buf = String::new();
// One href buffer reused across both emit loops, with the URL-encoded
// user computed once for the page instead of re-encoded per row — the
// reused-buffer shape the PROPFIND child loop already uses
// (benches/ROUND29.md §A).
let encoded_user = urlencoding::encode(url_user);
let mut href_buf = String::new();
for file in &files {
let subpath = strip_home_prefix(&file.path, home_prefix);
let href = nc_href(url_user, subpath);
let fid = file_id_map.get(&file.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
// Skip favorites that live outside the caller's chroot
// (other-drive favorites); reachable via REST if needed.
let Some(subpath) = strip_home_prefix(chroot, &file.path, home_prefix) else {
tracing::debug!(
target: "oxicloud::nc",
"REPORT filter-files: dropping cross-chroot favorite '{}' at '{}'",
file.id,
file.path,
);
continue;
};
nc_href_into(&mut href_buf, &encoded_user, subpath);
let fid = nc_id_of(&file_id_map, &file.id);
let oc_id: Option<&str> = match fid {
Some(id) => {
format_oc_id_into(&mut oc_buf, id, file_id_svc);
Some(oc_buf.as_str())
}
None => None,
};
let dead = dead_props_for(&file.id, &file_deads);
write_file_response(
&mut xml,
file,
&href,
fid,
oc_id.as_deref(),
&href_buf,
(fid, oc_id),
&user.username,
&favorite_ids,
dead,
)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
}
for folder in &folders {
let subpath = strip_home_prefix(&folder.path, home_prefix);
let href = format!("{}/", nc_href(url_user, subpath));
let fid = folder_id_map.get(&folder.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
let Some(subpath) = strip_home_prefix(chroot, &folder.path, home_prefix) else {
tracing::debug!(
target: "oxicloud::nc",
"REPORT filter-files: dropping cross-chroot favorite folder '{}' at '{}'",
folder.id,
folder.path,
);
continue;
};
nc_collection_href_into(&mut href_buf, &encoded_user, subpath);
let fid = nc_id_of(&folder_id_map, &folder.id);
let oc_id: Option<&str> = match fid {
Some(id) => {
format_oc_id_into(&mut oc_buf, id, file_id_svc);
Some(oc_buf.as_str())
}
None => None,
};
let dead = dead_props_for(&folder.id, &folder_deads);
write_folder_response(
&mut xml,
folder,
&href,
fid,
oc_id.as_deref(),
&href_buf,
(fid, oc_id),
&user.username,
&favorite_ids,
// REPORT results are a flat filter/search listing, not a
// PROPFIND on a specific collection — quota isn't
// meaningful here (see `AppState::resolve_webdav_quota`).
None,
dead,
)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
}
@@ -207,9 +272,13 @@ async fn handle_search(
session: &crate::interfaces::nextcloud::session::NcSession,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
// Validate chroot up-front (path-scoped handler); `resolve_scope_folder`
// below re-pulls it from the session for the path-mapping step.
session.require_chroot()?;
// Chroot-scope the response: NC's search REPORT is a single-drive
// surface. Results that live outside the chroot (other drives the
// caller is a member of) are dropped from the multistatus and
// recorded at debug — reachable via REST search if needed.
// `resolve_scope_folder` below re-pulls chroot from the session
// for the path-mapping step.
let chroot = session.require_chroot()?;
let url_user = &session.raw_username;
let search_svc = match state.applications.search_service.as_ref() {
Some(svc) => svc,
@@ -241,10 +310,9 @@ async fn handle_search(
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
// TODO(D1): same as the favorites pass above — replace the
// hardcoded "Personal/" with the caller's actual default-drive
// root folder name from `drives.root_folder_id`.
let home_prefix = "Personal/";
// See the favorites pass above: `home_prefix` is unused after the
// chroot-aware strip, kept only for signature stability.
let home_prefix = "";
// No favorite checking for search results -- pass an empty set.
let favorite_ids: HashSet<String> = HashSet::new();
@@ -253,8 +321,8 @@ async fn handle_search(
// (was one INSERT round-trip per result).
let files: Vec<FileDto> = results.files.iter().map(file_dto_from_search).collect();
let folders: Vec<FolderDto> = results.folders.iter().map(folder_dto_from_search).collect();
let file_uuids: Vec<String> = files.iter().map(|f| f.id.clone()).collect();
let folder_uuids: Vec<String> = folders.iter().map(|f| f.id.clone()).collect();
let file_uuids: Vec<&str> = files.iter().map(|f| f.id.as_str()).collect();
let folder_uuids: Vec<&str> = folders.iter().map(|f| f.id.as_str()).collect();
let (file_id_map, folder_id_map) =
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
@@ -264,38 +332,85 @@ async fn handle_search(
write_multistatus_start(&mut xml)?;
// Batched dead-props: one = ANY($1) query per type, not one per
// result (benches/DEAD-PROPS.md).
let file_deads = files_dead_props_map(&state.webdav_dead_props, &files).await;
let folder_deads = folders_dead_props_map(&state.webdav_dead_props, &folders).await;
// Files.
// One oc:id buffer reused across both emit loops (benches/ROUND27.md §H1).
let mut oc_buf = String::new();
// One href buffer reused across both emit loops, with the URL-encoded
// user computed once for the page instead of re-encoded per row — the
// reused-buffer shape the PROPFIND child loop already uses
// (benches/ROUND29.md §A).
let encoded_user = urlencoding::encode(url_user);
let mut href_buf = String::new();
for file in &files {
let subpath = strip_home_prefix(&file.path, home_prefix);
let href = nc_href(url_user, subpath);
let fid = file_id_map.get(&file.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
let Some(subpath) = strip_home_prefix(chroot, &file.path, home_prefix) else {
tracing::debug!(
target: "oxicloud::nc",
"REPORT search: dropping cross-chroot file '{}' at '{}'",
file.id,
file.path,
);
continue;
};
nc_href_into(&mut href_buf, &encoded_user, subpath);
let fid = nc_id_of(&file_id_map, &file.id);
let oc_id: Option<&str> = match fid {
Some(id) => {
format_oc_id_into(&mut oc_buf, id, file_id_svc);
Some(oc_buf.as_str())
}
None => None,
};
let dead = dead_props_for(&file.id, &file_deads);
write_file_response(
&mut xml,
file,
&href,
fid,
oc_id.as_deref(),
&href_buf,
(fid, oc_id),
&user.username,
&favorite_ids,
dead,
)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
}
// Folders.
for folder in &folders {
let subpath = strip_home_prefix(&folder.path, home_prefix);
let href = format!("{}/", nc_href(url_user, subpath));
let fid = folder_id_map.get(&folder.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
let Some(subpath) = strip_home_prefix(chroot, &folder.path, home_prefix) else {
tracing::debug!(
target: "oxicloud::nc",
"REPORT search: dropping cross-chroot folder '{}' at '{}'",
folder.id,
folder.path,
);
continue;
};
nc_collection_href_into(&mut href_buf, &encoded_user, subpath);
let fid = nc_id_of(&folder_id_map, &folder.id);
let oc_id: Option<&str> = match fid {
Some(id) => {
format_oc_id_into(&mut oc_buf, id, file_id_svc);
Some(oc_buf.as_str())
}
None => None,
};
let dead = dead_props_for(&folder.id, &folder_deads);
write_folder_response(
&mut xml,
folder,
&href,
fid,
oc_id.as_deref(),
&href_buf,
(fid, oc_id),
&user.username,
&favorite_ids,
// REPORT results are a flat filter/search listing, not a
// PROPFIND on a specific collection — quota isn't
// meaningful here (see `AppState::resolve_webdav_quota`).
None,
dead,
)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
}
@@ -330,17 +445,17 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes
name: fr.name.clone(),
path: fr.path.clone(),
size: fr.size,
mime_type: fr.mime_type.clone().into(),
// Interned `Arc<str>` carried through from enrichment — refcount
// bumps; the old code re-ran all three display classifiers and
// re-allocated each value per converted search row.
mime_type: fr.mime_type.clone(),
folder_id: fr.folder_id.clone(),
created_at: fr.created_at,
modified_at: fr.modified_at,
icon_class: icon_class_for(&fr.name, &fr.mime_type).to_string().into(),
icon_special_class: icon_special_class_for(&fr.name, &fr.mime_type)
.to_string()
.into(),
category: category_for(&fr.name, &fr.mime_type).to_string().into(),
icon_class: fr.icon_class.clone(),
icon_special_class: fr.icon_special_class.clone(),
category: fr.category.clone(),
size_formatted: format_file_size(fr.size),
owner_id: None,
sort_date: None,
content_hash: fr.blob_hash.clone(),
etag,
@@ -350,6 +465,16 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes
}
}
/// Bench-only public wrapper (feature = "bench") over the private
/// search→FileDto conversion so `examples/bench_search_enrich.rs` can
/// measure and equivalence-gate it.
#[cfg(feature = "bench")]
pub fn file_dto_from_search_for_bench(
fr: &crate::application::dtos::search_dto::SearchFileResultDto,
) -> FileDto {
file_dto_from_search(fr)
}
/// Build a `FolderDto` from a search folder result.
fn folder_dto_from_search(
sr: &crate::application::dtos::search_dto::SearchFolderResultDto,
@@ -360,17 +485,13 @@ fn folder_dto_from_search(
name: sr.name.clone(),
path: sr.path.clone(),
parent_id: sr.parent_id.clone(),
owner_id: None,
// Search result — drive_id is informational. The search row
// doesn't currently SELECT it, and path-based lookups never
// enter this code path.
drive_id: uuid::Uuid::nil(),
drive_id: sr.drive_id,
created_at: sr.created_at,
modified_at: sr.modified_at,
is_root: sr.is_root,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
// §14 provenance not selected by search results.
created_by: None,
updated_by: None,
@@ -550,7 +671,19 @@ fn extract_subpath_from_scope(href: &str, url_user: &str) -> Option<String> {
None
}
/// Strip the `My Folder - {username}/` prefix to get the DAV subpath.
fn strip_home_prefix<'a>(path: &'a str, prefix: &str) -> &'a str {
path.strip_prefix(prefix).unwrap_or(path)
/// Strip the caller's chroot prefix from an internal path so the
/// caller-facing DAV subpath is chroot-relative. Delegates to
/// `webdav_handler::strip_chroot_prefix` — chroot-aware, multi-segment
/// safe, and rejects items outside the chroot. Callers must decide
/// per-response whether an out-of-chroot item is dropped or falls
/// back to the naive strip.
///
/// See `strip_chroot_prefix` for the full contract. The `_prefix`
/// legacy arg stays for signature stability with the emit helpers.
fn strip_home_prefix<'a>(
chroot: &crate::application::dtos::folder_dto::FolderDto,
path: &'a str,
_prefix: &str,
) -> Option<&'a str> {
crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(chroot, path)
}
+7 -7
View File
@@ -17,7 +17,7 @@ use crate::interfaces::nextcloud::basic_auth_middleware::basic_auth_middleware;
use crate::interfaces::nextcloud::login_v2_handler;
use crate::interfaces::nextcloud::ocs_handler;
use crate::interfaces::nextcloud::preview_handler;
use crate::interfaces::nextcloud::session::NcSession;
use crate::interfaces::nextcloud::session::SharedNcSession;
use crate::interfaces::nextcloud::status_handler;
use crate::interfaces::nextcloud::trashbin_handler;
use crate::interfaces::nextcloud::uploads_handler;
@@ -216,7 +216,7 @@ pub fn nextcloud_routes_with_state(state: Arc<AppState>) -> Router<Arc<AppState>
async fn handle_dav_files(
State(state): State<Arc<AppState>>,
Path((_url_user, subpath)): Path<(String, String)>,
session: NcSession,
session: SharedNcSession,
req: Request<Body>,
) -> Result<Response, Response> {
webdav_handler::handle_nc_webdav(state, req, session, subpath)
@@ -227,7 +227,7 @@ async fn handle_dav_files(
async fn handle_dav_files_root(
State(state): State<Arc<AppState>>,
Path(_url_user): Path<String>,
session: NcSession,
session: SharedNcSession,
req: Request<Body>,
) -> Result<Response, Response> {
webdav_handler::handle_nc_webdav(state, req, session, String::new())
@@ -238,7 +238,7 @@ async fn handle_dav_files_root(
async fn handle_dav_uploads(
State(state): State<Arc<AppState>>,
Path((_url_user, upload_id, rest)): Path<(String, String, String)>,
session: NcSession,
session: SharedNcSession,
req: Request<Body>,
) -> Result<Response, Response> {
uploads_handler::handle_nc_uploads(state, req, session, upload_id, rest)
@@ -249,7 +249,7 @@ async fn handle_dav_uploads(
async fn handle_dav_uploads_root(
State(state): State<Arc<AppState>>,
Path((_url_user, upload_id)): Path<(String, String)>,
session: NcSession,
session: SharedNcSession,
req: Request<Body>,
) -> Result<Response, Response> {
uploads_handler::handle_nc_uploads(state, req, session, upload_id, String::new())
@@ -279,7 +279,7 @@ async fn handle_legacy_webdav_root(user_ext: AuthUser) -> Response {
async fn handle_dav_trashbin(
State(state): State<Arc<AppState>>,
Path((_url_user, subpath)): Path<(String, String)>,
session: NcSession,
session: SharedNcSession,
req: Request<Body>,
) -> Result<Response, Response> {
trashbin_handler::handle_nc_trashbin(state, req, session, subpath)
@@ -290,7 +290,7 @@ async fn handle_dav_trashbin(
async fn handle_dav_trashbin_root(
State(state): State<Arc<AppState>>,
Path(_url_user): Path<String>,
session: NcSession,
session: SharedNcSession,
req: Request<Body>,
) -> Result<Response, Response> {
trashbin_handler::handle_nc_trashbin(state, req, session, String::new())
+46 -15
View File
@@ -3,8 +3,9 @@
//! Bundles WHO the caller is, the raw wire username they presented,
//! and (for path-scoped endpoints) WHERE they're confined to. Built
//! by `basic_auth_middleware` and stashed in request extensions as
//! `Arc<NcSession>`; handlers extract it via the [`FromRequestParts`]
//! impl below — just declare `session: NcSession` in the signature.
//! `Arc<NcSession>`; handlers extract it via [`SharedNcSession`]
//! (derefs to `NcSession`) — declare `session: SharedNcSession` in
//! the signature.
//!
//! ## Source of truth
//!
@@ -46,9 +47,13 @@ use crate::interfaces::middleware::auth::CurrentUser;
#[derive(Debug, Clone)]
pub struct NcSession {
pub user: CurrentUser,
/// Shared with the `Arc<CurrentUser>` request extension — one identity
/// build per request instead of a clone per consumer.
pub user: Arc<CurrentUser>,
pub raw_username: String,
pub chroot: Option<FolderDto>,
/// Shared with `NC_CHROOT_CACHE` (markerless branch) — a cache hit is
/// an `Arc` bump, not a `FolderDto` deep-clone.
pub chroot: Option<Arc<FolderDto>>,
}
impl NcSession {
@@ -56,7 +61,7 @@ impl NcSession {
/// without one. Documents the invariant that every NC route
/// today is path-scoped — if this fires, route wiring is wrong.
pub fn require_chroot(&self) -> Result<&FolderDto, AppError> {
self.chroot.as_ref().ok_or_else(|| {
self.chroot.as_deref().ok_or_else(|| {
AppError::internal_error(
"NcSession: path-scoped handler reached without a chroot — route wiring bug",
)
@@ -82,7 +87,7 @@ impl NcSession {
///
/// Returns `None` for anything that doesn't follow this shape (notably
/// the OCS surfaces, where there is no `{user}` segment to compare).
fn extract_url_user(path: &str) -> Option<String> {
fn extract_url_user(path: &str) -> Option<std::borrow::Cow<'_, str>> {
let mut segments = path.split('/');
if !segments.next()?.is_empty() {
return None;
@@ -98,13 +103,20 @@ fn extract_url_user(path: &str) -> Option<String> {
if user_seg.is_empty() {
return None;
}
urlencoding::decode(user_seg).ok().map(|s| s.into_owned())
// Keep the `Cow` — a plain-ASCII username decodes to `Cow::Borrowed`, so the
// common path allocates nothing; only a percent-encoded username owns. The
// old `.into_owned()` forced a `String` on EVERY path-scoped NC DAV request
// (benches/ROUND19.md §M7). The caller compares by slice.
urlencoding::decode(user_seg).ok()
}
/// Axum extractor: pulls the `Arc<NcSession>` that
/// `basic_auth_middleware` stashed in request extensions and clones
/// it (cheap — one `Arc` increment, no field copy) into an owned
/// `NcSession` for handler use.
/// Axum extractor: the shared handle to the request's [`NcSession`].
///
/// Derefs to `NcSession`, so handler bodies read `session.user`,
/// `session.require_chroot()`, … unchanged. Extraction is one `Arc`
/// refcount increment — the previous extractor deep-cloned the whole
/// session (`CurrentUser` + `raw_username` + chroot `FolderDto`, ~8-9
/// `String` allocs) on every authenticated NC request.
///
/// On path-scoped DAV routes (`/remote.php/dav/{files,uploads,
/// trashbin}/{user}/…`), the URL `{user}` segment is cross-checked
@@ -113,22 +125,41 @@ fn extract_url_user(path: &str) -> Option<String> {
/// (`get_folder_with_perms`) is what actually prevents cross-user
/// access. It just surfaces malformed requests early (403) instead
/// of silently letting them through.
impl<S: Send + Sync> FromRequestParts<S> for NcSession {
#[derive(Debug, Clone)]
pub struct SharedNcSession(Arc<NcSession>);
impl SharedNcSession {
/// Wrap an already-shared session (used by the bench harness; the
/// middleware inserts the `Arc` into request extensions directly).
pub fn from_arc(session: Arc<NcSession>) -> Self {
Self(session)
}
}
impl std::ops::Deref for SharedNcSession {
type Target = NcSession;
fn deref(&self) -> &NcSession {
&self.0
}
}
impl<S: Send + Sync> FromRequestParts<S> for SharedNcSession {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let session = parts
.extensions
.get::<Arc<NcSession>>()
.map(|arc| (**arc).clone())
.cloned()
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
if let Some(url_user) = extract_url_user(parts.uri.path())
&& url_user != session.raw_username
&& url_user.as_ref() != session.raw_username.as_str()
{
return Err(StatusCode::FORBIDDEN.into_response());
}
Ok(session)
Ok(Self(session))
}
}
+28 -14
View File
@@ -1,22 +1,36 @@
use axum::Json;
use axum::extract::State;
use axum::response::{IntoResponse, Response};
use axum::http::header;
use axum::response::Response;
use serde_json::json;
use std::sync::Arc;
use crate::common::di::AppState;
/// Pre-serialized `/status.php` body. The payload is process-invariant
/// (pure config: emulated NC version), yet every NC desktop/mobile client
/// polls it on connect and periodically — the old handler re-built the
/// `json!` tree and re-serialized on every poll (793 ns / 14 allocs;
/// now a `Bytes` refcount bump at ~29 ns / 0 allocs — benches/ROUND11.md).
static STATUS_BODY: std::sync::OnceLock<bytes::Bytes> = std::sync::OnceLock::new();
pub async fn handle_status(State(state): State<Arc<AppState>>) -> Response {
let (major, minor, patch) = state.core.config.nextcloud.emulated_version;
let version_string = state.core.config.nextcloud.version_string();
Json(json!({
"installed": true,
"maintenance": false,
"needsDbUpgrade": false,
"version": format!("{}.{}.{}.1", major, minor, patch),
"versionstring": version_string,
"productname": "OxiCloud",
"edition": ""
}))
.into_response()
let body = STATUS_BODY.get_or_init(|| {
let (major, minor, patch) = state.core.config.nextcloud.emulated_version;
let version_string = state.core.config.nextcloud.version_string();
let v = json!({
"installed": true,
"maintenance": false,
"needsDbUpgrade": false,
"version": format!("{}.{}.{}.1", major, minor, patch),
"versionstring": version_string,
"productname": "OxiCloud",
"edition": ""
});
bytes::Bytes::from(serde_json::to_vec(&v).expect("status.php body serializes"))
});
Response::builder()
.status(axum::http::StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(body.clone()))
.expect("static status.php response")
}
+107 -40
View File
@@ -15,8 +15,8 @@ use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::nextcloud::webdav_handler::{
batch_resolve_ids, extract_nc_subpath_from_dest, format_oc_id, nc_to_internal_path,
write_text_element,
batch_resolve_ids, extract_nc_subpath_from_dest, format_oc_id, nc_id_of, nc_to_internal_path,
write_date_element, write_etag_element, write_text_element,
};
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
@@ -27,7 +27,7 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
pub async fn handle_nc_trashbin(
state: Arc<AppState>,
req: Request<Body>,
session: crate::interfaces::nextcloud::session::NcSession,
session: crate::interfaces::nextcloud::session::SharedNcSession,
subpath: String,
) -> Result<Response<Body>, AppError> {
let method = req.method().clone();
@@ -81,6 +81,14 @@ async fn handle_propfind(
session: &crate::interfaces::nextcloud::session::NcSession,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
// Chroot-scope the trashbin view: `get_trash_items(user.id)`
// spans every drive the caller is a member of, but NC's
// trashbin surface is a single-drive concept from the client's
// POV. Items outside the chroot are dropped from the multistatus
// (see `write_trashbin_multistatus` → `strip_home_prefix` →
// `webdav_handler::strip_chroot_prefix`) and remain reachable
// via REST `/api/trash/resources`.
let chroot = session.require_chroot()?;
let trash_svc = state
.trash_service
.as_ref()
@@ -94,8 +102,16 @@ async fn handle_propfind(
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
// Emit hrefs with `session.raw_username` (composite `admin~<uuid>` on
// non-home drives), NOT `user.username` (bare `admin`). The
// `NcSession` extractor cross-checks the URL `{user}` segment
// against `raw_username` and 403s on mismatch (see
// `session.rs::from_request_parts`). Emitting the bare form here
// would make every follow-up MOVE/DELETE from a non-home client
// 403 before the handler runs — the composite-credential Hurl
// regression caught this (B5 in `nc_multidrive_move_regression`).
let mut buf = Vec::new();
write_trashbin_multistatus(&mut buf, &items, &user.username, file_id_svc)
write_trashbin_multistatus(&mut buf, &items, &session.raw_username, chroot, file_id_svc)
.await
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
@@ -131,8 +147,17 @@ async fn handle_restore(
// with 412 — there is no `Overwrite: T` workflow for trash restore in
// either Sabre/DAV or the NC desktop client (a live file being
// silently replaced by an undeleted one would be a footgun).
// Use `session.raw_username` (composite `admin~<drive-uuid>` on
// non-home drives) to strip the destination prefix, NOT
// `user.username` (bare `admin`). NC clients send `Destination:
// /remote.php/dav/files/{raw_username}/…`; passing the bare
// username would leave the `~<uuid>/` marker glued to the leading
// subpath segment and turn the collision-check into a lookup at
// a fabricated path. See `uploads_handler::handle_assemble` for
// the same fix in the chunked-upload MOVE.
if let Some(dest_header) = dest_header
&& let Some(dest_subpath) = extract_nc_subpath_from_dest(&dest_header, &user.username)
&& let Some(dest_subpath) =
extract_nc_subpath_from_dest(&dest_header, &session.raw_username)
{
let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?;
let folder_service = &state.applications.folder_service;
@@ -259,18 +284,23 @@ fn mime_from_name(name: &str) -> String {
.to_string()
}
/// Strip the home-folder prefix from an original path to produce the
/// Nextcloud-relative original location.
/// Strip the caller's chroot prefix from an original path to produce
/// the Nextcloud-relative original-location value.
///
/// TODO(D1): replace the hardcoded "Personal/" with the caller's actual
/// default-drive root folder name read from `drives.root_folder_id`.
/// Correct for D0-provisioned default drives; secondary drives keep
/// their original root name. The `_username` arg stays for now so the
/// upcoming dynamic lookup has a way to identify the caller.
fn strip_home_prefix<'a>(original_path: &'a str, _username: &str) -> &'a str {
original_path
.strip_prefix("Personal/")
.unwrap_or(original_path)
/// Delegates to `webdav_handler::strip_chroot_prefix` — chroot-aware,
/// multi-segment safe, and returns `None` when the item is outside
/// the chroot (e.g. a trashed item in another drive the caller is a
/// member of). The `_username` arg stays for signature stability
/// with call sites that thread it; the strip itself no longer uses it.
///
/// See the doc on `strip_chroot_prefix` for the AuthZ caveat — this
/// is a display helper, not an ownership check.
fn strip_home_prefix<'a>(
original_path: &'a str,
_username: &str,
chroot: &crate::application::dtos::folder_dto::FolderDto,
) -> Option<&'a str> {
crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(chroot, original_path)
}
// ────────────── Trashbin PROPFIND XML Generation ──────────────
@@ -278,12 +308,19 @@ fn strip_home_prefix<'a>(original_path: &'a str, _username: &str) -> &'a str {
use crate::application::dtos::trash_dto::TrashedItemDto;
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
use std::collections::HashMap;
use uuid::Uuid;
/// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin.
///
/// `chroot` scopes the response — items whose original path is outside
/// the chroot (other drives the caller is a member of) are dropped
/// silently. NC's trashbin surface is single-drive from the client's
/// perspective; cross-drive items remain reachable via REST.
async fn write_trashbin_multistatus<W: std::io::Write>(
writer: W,
items: &[TrashedItemDto],
username: &str,
chroot: &crate::application::dtos::folder_dto::FolderDto,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
) -> Result<(), String> {
let mut xml = Writer::new(writer);
@@ -301,23 +338,38 @@ async fn write_trashbin_multistatus<W: std::io::Write>(
// Pre-resolve every oc:fileid in two batch queries by object type (was one
// INSERT round-trip per item). File and folder UUIDs are disjoint, so the
// two maps merge cleanly into one keyed by original_id.
let mut file_uuids: Vec<String> = Vec::new();
let mut folder_uuids: Vec<String> = Vec::new();
// two maps merge cleanly into one keyed by parsed original-id UUID.
let mut file_uuids: Vec<&str> = Vec::new();
let mut folder_uuids: Vec<&str> = Vec::new();
for item in items {
if item.item_type == "folder" {
folder_uuids.push(item.original_id.clone());
folder_uuids.push(item.original_id.as_str());
} else {
file_uuids.push(item.original_id.clone());
file_uuids.push(item.original_id.as_str());
}
}
let (mut id_map, folder_id_map) =
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
id_map.extend(folder_id_map);
// Individual trashed items.
// Individual trashed items — skip those whose original path is
// outside the chroot (other-drive trash reachable via REST).
for item in items {
write_trash_item_response(&mut xml, item, username, file_id_svc, &id_map)?;
if crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(
chroot,
&item.original_path,
)
.is_none()
{
tracing::debug!(
target: "oxicloud::nc",
"trashbin PROPFIND: dropping cross-chroot item '{}' at '{}'",
item.id,
item.original_path,
);
continue;
}
write_trash_item_response(&mut xml, item, username, chroot, file_id_svc, &id_map)?;
}
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
@@ -363,12 +415,20 @@ fn write_trash_root_response<W: std::io::Write>(
}
/// Write a single trashed item as a `<d:response>` element.
///
/// Caller is expected to have already verified the item is inside
/// `chroot` — see the guard in `write_trashbin_multistatus`. This
/// function trusts the invariant and expects `strip_home_prefix` to
/// return `Some(_)`; if it ever returns `None` (chroot drift between
/// the guard and the emit, defensive-only), the original-location
/// falls back to an empty string.
fn write_trash_item_response<W: std::io::Write>(
xml: &mut Writer<W>,
item: &TrashedItemDto,
username: &str,
chroot: &crate::application::dtos::folder_dto::FolderDto,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
id_map: &HashMap<String, i64>,
id_map: &HashMap<Uuid, i64>,
) -> Result<(), String> {
xml.write_event(Event::Start(BytesStart::new("d:response")))
.map_err(|e| e.to_string())?;
@@ -385,11 +445,12 @@ fn write_trash_item_response<W: std::io::Write>(
// d:displayname
write_text_element(xml, "d:displayname", &item.name)?;
// d:getlastmodified
write_text_element(xml, "d:getlastmodified", &item.trashed_at.to_rfc2822())?;
// d:getlastmodified — stack-rendered (common::fmt), chrono fallback for
// out-of-range timestamps; byte-identical to the old `to_rfc2822()`.
write_date_element(xml, "d:getlastmodified", item.trashed_at.timestamp(), true)?;
// d:getetag
write_text_element(xml, "d:getetag", &format!("\"{}\"", item.original_id))?;
// d:getetag — exact-size quoted alloc instead of the format! interpreter.
write_etag_element(xml, "d:getetag", &item.original_id)?;
// d:resourcetype
if item.item_type == "folder" {
@@ -404,11 +465,13 @@ fn write_trash_item_response<W: std::io::Write>(
.map_err(|e| e.to_string())?;
}
// d:getcontenttype
let content_type = if item.item_type == "folder" {
"httpd/unix-directory".to_string()
// d:getcontenttype — the folder constant is borrowed (`Cow::Borrowed`, 0
// allocs per trashed folder row); only the file branch (mime_guess) still
// allocates its owned String (ROUND16 §M1 `Cow<'static, str>` pattern).
let content_type: std::borrow::Cow<'static, str> = if item.item_type == "folder" {
std::borrow::Cow::Borrowed("httpd/unix-directory")
} else {
mime_from_name(&item.name)
std::borrow::Cow::Owned(mime_from_name(&item.name))
};
write_text_element(xml, "d:getcontenttype", &content_type)?;
@@ -416,9 +479,10 @@ fn write_trash_item_response<W: std::io::Write>(
write_text_element(xml, "d:getcontentlength", "0")?;
// oc:fileid and oc:id — resolved up front in a batch query.
let file_id = id_map.get(&item.original_id).copied();
let file_id = nc_id_of(id_map, &item.original_id);
if let Some(id) = file_id {
write_text_element(xml, "oc:fileid", &id.to_string())?;
let mut ibuf = [0u8; 21];
write_text_element(xml, "oc:fileid", crate::common::fmt::i64_str(&mut ibuf, id))?;
let oc_id = format_oc_id(id, file_id_svc);
write_text_element(xml, "oc:id", &oc_id)?;
}
@@ -427,15 +491,18 @@ fn write_trash_item_response<W: std::io::Write>(
write_text_element(xml, "nc:trashbin-filename", &item.name)?;
// nc:trashbin-original-location
let original_location = strip_home_prefix(&item.original_path, username);
let original_location = strip_home_prefix(&item.original_path, username, chroot).unwrap_or("");
write_text_element(xml, "nc:trashbin-original-location", original_location)?;
// nc:trashbin-deletion-time
write_text_element(
xml,
"nc:trashbin-deletion-time",
&item.trashed_at.timestamp().to_string(),
)?;
{
let mut ibuf = [0u8; 21];
write_text_element(
xml,
"nc:trashbin-deletion-time",
crate::common::fmt::i64_str(&mut ibuf, item.trashed_at.timestamp()),
)?;
}
// oc:permissions — empty in trash
write_text_element(xml, "oc:permissions", "")?;
+252 -94
View File
@@ -4,8 +4,9 @@ use axum::{
response::Response,
};
use std::sync::Arc;
use uuid::Uuid;
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
use crate::application::ports::file_ports::FileUploadUseCase;
use crate::common::di::AppState;
use crate::common::mime_detect::filename_from_path;
use crate::interfaces::errors::AppError;
@@ -13,6 +14,90 @@ use crate::interfaces::upload_ingest::{
discard_ingested, ingest_stream_to_cas, stream_body_to_path, stream_from_files,
};
/// Per-chunk quota gate (D4 / project_drive_quota_timing).
///
/// Pre-D4 the NC chunked path never declared a total size up front, so
/// quota only fired at the final MOVE — meaning a client could waste GB
/// of upload bandwidth before learning it was over. The drive is known
/// from the session's chroot and the user is on the session, so we can
/// gate at every wire moment now:
///
/// - MKCOL: refuse if either the drive or the user envelope is
/// already at quota (call with `additional = 0`).
/// - PUT : refuse if `used + already_uploaded_for_session +
/// content-length` would breach either cap.
/// `already_uploaded_for_session` is the sum of chunk sizes the
/// session already holds on disk.
/// - MOVE : defence in depth via `file_upload_service`'s own gates.
///
/// Both checks run because the two caps cover different cases:
/// `check_drive_quota` is the per-drive `drives.quota_bytes` cap
/// (shared drives carry a value; personal drives are `NULL` and
/// short-circuit to OK). `check_storage_quota` is the user envelope
/// `users.storage_quota_bytes` that caps the SUM across the caller's
/// personal drives (shared-drive uploads short-circuit because the
/// envelope only sums personal drives — see
/// `project_user_envelope_quota_model`). Mirrors what every other
/// upload entry point (multipart, native chunked, delta, instant)
/// already does.
async fn refuse_if_over_quota(
state: &AppState,
user_id: Uuid,
drive_id: Uuid,
additional: u64,
) -> Result<(), AppError> {
let Some(svc) = state.storage_usage_service.as_ref() else {
// Quota tracking disabled in this config; MOVE-time gate
// remains authoritative.
return Ok(());
};
// Fused single round-trip (user envelope + drive cap) — this gate runs
// on EVERY chunk PUT, and the serial pair cost two point reads per
// chunk (benches/ROUND12.md §6). Verdict precedence unchanged.
svc.check_upload_quotas(user_id, drive_id, additional)
.await
.map_err(AppError::from)
}
/// Sum of bytes already accepted into a chunked-upload session.
///
/// Reads the session directory once via `list_chunks` and totals every
/// chunk's on-disk size. O(N) stat calls per check, but N is the chunk
/// count (NC clients use 10 MB chunks by default — a 10 GB upload sits
/// around 1000 entries; PUT throughput dominates the cost). A
/// per-session counter file would amortise it to O(1) but adds a
/// separate write-and-sync path with its own crash semantics — defer
/// until profiling actually demands it.
async fn session_bytes_so_far(
nc: &crate::common::di::NextcloudServices,
username: &str,
upload_id: &str,
) -> Result<u64, AppError> {
// Warm path: O(1) in-RAM counter maintained by the PUT handler and
// the service (seeded on MKCOL, dropped on cleanup/overwrite). The
// directory walk below only runs cold (restart / eviction) — the old
// shape ran it on EVERY chunk PUT: O(k) stats for chunk k, O(N²/2)
// over the upload (benches/NC-CHUNK-GATE.md).
if let Some(bytes) = nc.chunked_uploads.cached_session_bytes(username, upload_id) {
return Ok(bytes);
}
let listing = nc
.chunked_uploads
.list_chunks(username, upload_id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list chunks: {}", e)))?;
let Some(listing) = listing else {
// Missing session — handler maps this elsewhere; treat as zero
// here so the gate doesn't fire spuriously on the very first
// chunk after MKCOL (race-tolerant).
return Ok(0);
};
let total = listing.chunks.iter().map(|c| c.size).sum();
nc.chunked_uploads
.set_session_bytes(username, upload_id, total);
Ok(total)
}
/// Dispatch Nextcloud chunked upload WebDAV requests.
///
/// Routes:
@@ -24,7 +109,7 @@ use crate::interfaces::upload_ingest::{
pub async fn handle_nc_uploads(
state: Arc<AppState>,
req: Request<Body>,
session: crate::interfaces::nextcloud::session::NcSession,
session: crate::interfaces::nextcloud::session::SharedNcSession,
upload_id: String,
rest: String, // chunk name or ".file" or empty
) -> Result<Response<Body>, AppError> {
@@ -75,50 +160,81 @@ async fn handle_propfind_session(
.map_err(|e| AppError::internal_error(format!("Failed to list chunks: {}", e)))?
.ok_or_else(|| AppError::not_found("Upload session not found"))?;
let session_href = format!("/remote.php/dav/uploads/{}/{}/", user.username, upload_id);
let session_last_modified =
chrono::DateTime::<chrono::Utc>::from_timestamp(listing.session_mtime as i64, 0)
.unwrap_or_else(chrono::Utc::now)
.to_rfc2822();
// Href MUST use `session.raw_username` (composite `admin~<uuid>` on
// non-home drives), NOT `user.username` (bare `admin`). The
// `NcSession` extractor cross-checks the URL `{user}` segment
// against `raw_username` and 403s on mismatch — a composite-cred
// client that PROPFINDs, then MOVEs a chunk href back to us, would
// otherwise 403 at the extractor before any handler runs. Same
// fix shape as `trashbin_handler::handle_propfind` and
// `handle_assemble`'s destination-URL parsing. Storage-side keying
// stays on `user.username` — upload sessions are per-user, not
// per-drive.
// `write!` formats every element straight into a pre-sized `body`; the
// old `push_str(&format!(…))` chain allocated a throwaway String per
// element per chunk plus growth reallocations from `String::new()`, and
// ran the chrono format interpreter per chunk (benches/ROUND11.md §4:
// 2.3-2.6x, allocs 2582 → 772 on a 256-chunk session).
use std::fmt::Write as _;
let mut body = String::new();
/// `<d:getlastmodified>` via the stack renderer; chrono fallback for
/// out-of-range timestamps (same shape as `nextcloud/webdav_handler`).
/// RFC 2822 output contains no XML-special characters by construction.
fn write_lastmodified(body: &mut String, secs: i64) {
let mut buf = [0u8; 31];
match crate::common::fmt::rfc2822_utc(&mut buf, secs) {
Some(s) => {
let _ = write!(body, "<d:getlastmodified>{}</d:getlastmodified>", s);
}
None => {
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)
.unwrap_or_else(chrono::Utc::now)
.to_rfc2822();
let _ = write!(
body,
"<d:getlastmodified>{}</d:getlastmodified>",
xml_escape(&dt)
);
}
}
}
let mut body = String::with_capacity(256 + listing.chunks.len() * 256);
body.push_str(r#"<?xml version="1.0" encoding="utf-8"?>"#);
body.push_str(r#"<d:multistatus xmlns:d="DAV:">"#);
// Session collection itself.
body.push_str("<d:response>");
body.push_str(&format!("<d:href>{}</d:href>", xml_escape(&session_href)));
let _ = write!(
body,
"<d:href>/remote.php/dav/uploads/{}/{}/</d:href>",
xml_escape(&session.raw_username),
xml_escape(upload_id)
);
body.push_str("<d:propstat><d:prop>");
body.push_str("<d:resourcetype><d:collection/></d:resourcetype>");
body.push_str(&format!(
"<d:getlastmodified>{}</d:getlastmodified>",
xml_escape(&session_last_modified)
));
write_lastmodified(&mut body, listing.session_mtime as i64);
body.push_str("</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>");
body.push_str("</d:response>");
// One entry per chunk file.
for chunk in &listing.chunks {
let chunk_href = format!(
"/remote.php/dav/uploads/{}/{}/{}",
user.username, upload_id, chunk.name
);
let chunk_modified = chrono::DateTime::<chrono::Utc>::from_timestamp(chunk.mtime as i64, 0)
.unwrap_or_else(chrono::Utc::now)
.to_rfc2822();
body.push_str("<d:response>");
body.push_str(&format!("<d:href>{}</d:href>", xml_escape(&chunk_href)));
let _ = write!(
body,
"<d:href>/remote.php/dav/uploads/{}/{}/{}</d:href>",
xml_escape(&session.raw_username),
xml_escape(upload_id),
xml_escape(&chunk.name)
);
body.push_str("<d:propstat><d:prop>");
body.push_str("<d:resourcetype/>");
body.push_str(&format!(
let _ = write!(
body,
"<d:getcontentlength>{}</d:getcontentlength>",
chunk.size
));
body.push_str(&format!(
"<d:getlastmodified>{}</d:getlastmodified>",
xml_escape(&chunk_modified)
));
);
write_lastmodified(&mut body, chunk.mtime as i64);
body.push_str("</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>");
body.push_str("</d:response>");
}
@@ -145,6 +261,13 @@ fn xml_escape(s: &str) -> String {
}
/// MKCOL — create upload session directory.
///
/// Quota gate (D4): refuse 507 if the bound drive is already at quota,
/// before allocating the session directory. The chunked path doesn't
/// declare a total size up front — `additional = 0` so the gate only
/// fires when the drive is already exactly full (or beyond, after a
/// burst of concurrent writes). Subsequent PUTs run the proper
/// "used + session_so_far + chunk" projection.
async fn handle_mkcol(
state: Arc<AppState>,
session: &crate::interfaces::nextcloud::session::NcSession,
@@ -156,6 +279,9 @@ async fn handle_mkcol(
.as_ref()
.ok_or_else(|| AppError::internal_error("Nextcloud services unavailable"))?;
let chroot = session.require_chroot()?;
refuse_if_over_quota(&state, user.id, chroot.drive_id, 0).await?;
nc.chunked_uploads
.create_session(&user.username, upload_id)
.await
@@ -194,6 +320,22 @@ async fn handle_put_chunk(
return Err(AppError::bad_request("Missing chunk name"));
}
// Per-chunk quota gate (D4): refuse 507 BEFORE accepting body
// bytes when `drive.used_bytes + session_so_far + chunk_size`
// would cross the drive cap. Closes the wasted-bandwidth wart
// where over-quota clients only learned at MOVE.
//
// Without a Content-Length we can't project ahead — fall back to
// the assemble-time check. NC desktop / Android / iOS clients
// always send CL on PUT chunks (they read the chunk file into a
// length-known body), so this branch is rare in practice.
let chroot = session.require_chroot()?;
if let Some(chunk_size) = content_length_from(&req) {
let so_far = session_bytes_so_far(nc, &user.username, upload_id).await?;
let projected = so_far.saturating_add(chunk_size);
refuse_if_over_quota(&state, user.id, chroot.drive_id, projected).await?;
}
let chunk_path = nc
.chunked_uploads
.safe_chunk_path(&user.username, upload_id, chunk_name)
@@ -204,7 +346,18 @@ async fn handle_put_chunk(
// NC desktop client validates the assembled-file ETag against the
// server-side `oc:checksums` after MOVE. So we skip per-chunk
// hashing here (peak heap stays at ~one HTTP frame).
stream_body_to_path(req.into_body(), &chunk_path, max_chunk, None).await?;
//
// Retry detection (a re-PUT makes the running session counter stale)
// rides on the open itself now — `created_fresh` from the `create_new`
// probe replaces the extra per-chunk `stat` this path used to issue.
let streamed = stream_body_to_path(req.into_body(), &chunk_path, max_chunk, None).await?;
if !streamed.created_fresh {
nc.chunked_uploads
.forget_session_bytes(&user.username, upload_id);
} else {
nc.chunked_uploads
.bump_session_bytes(&user.username, upload_id, streamed.bytes_written);
}
Ok(Response::builder()
.status(StatusCode::CREATED)
@@ -241,7 +394,18 @@ async fn handle_assemble(
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<i64>().ok());
let dest_subpath = extract_files_subpath(&destination, &user.username)
// Strip the destination URL prefix using the SESSION's raw username
// (`admin~<drive-uuid>` on non-home drives), NOT `user.username`
// (bare `admin`). NC clients send `Destination: /remote.php/dav/files/
// {raw_username}/…` — the URL user-segment mirrors the credential
// they authenticated with. Passing bare `admin` here strips only
// `admin/` from a `admin~<uuid>/…` destination, leaving the tilde
// marker glued to the leading path segment; the write then targets
// `<drive-root>/~<uuid>/…` and fails with a parent-folder lookup
// error. Matches `webdav_handler::handle_move`'s call to
// `extract_nc_subpath_from_dest(&destination, url_user)` where
// `url_user = &session.raw_username` (webdav_handler.rs:1177).
let dest_subpath = extract_files_subpath(&destination, &session.raw_username)
.ok_or_else(|| AppError::bad_request("Invalid Destination URL"))?;
// Stream the chunk parts, in order, straight into the CDC chunk store —
@@ -256,8 +420,6 @@ async fn handle_assemble(
.map_err(|e| AppError::internal_error(format!("Failed to list chunks: {}", e)))?;
let upload_service = &state.applications.file_upload_service;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
// Path-based lookups below scope by `drive_id`. The NC session's
// chroot is always populated for path-scoped handlers (see
@@ -266,12 +428,14 @@ async fn handle_assemble(
let chroot = session.require_chroot()?;
let drive_id = chroot.drive_id;
// TODO(D1): read the caller's default-drive root folder name from
// `drives.root_folder_id` instead of hardcoding "Personal". The
// constant is correct for every default personal drive provisioned
// by the D0 lifecycle hook, but secondary drives (M2 backfill from
// SQL-created sibling root folders) keep their original name.
let internal_path = format!("Personal/{}", dest_subpath.trim_matches('/'));
// Route through `nc_to_internal_path(chroot, …)` so the write
// lands under the caller's actual default-drive root (not the
// literal "Personal" folder). Post-D3 chroot resolution puts the
// correct FolderDto — including the drive's real root name — on
// the NcSession; secondary drives with SQL-provisioned sibling
// root names now work.
let internal_path =
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, &dest_subpath)?;
let filename = filename_from_path(&dest_subpath).to_string();
let ingested = ingest_stream_to_cas(
@@ -285,63 +449,46 @@ async fn handle_assemble(
.await?;
let content_type = ingested.content_type.clone();
// Check if file exists (update vs create).
let existing = file_service
.get_file_by_path(&internal_path, drive_id)
.await;
let etag: Option<String> = if existing.is_ok() {
let dto = upload_service
.update_file_streaming(
&internal_path,
drive_id,
ingested.stored(),
&content_type,
oc_mtime,
user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
Some(dto.etag)
} else {
// New-file branch: resolve the parent folder by path and register
// the file row against the already-ingested blob.
let (parent_sub, filename) = match dest_subpath.rsplit_once('/') {
Some((p, n)) => (p, n),
None => ("", dest_subpath.as_str()),
};
let parent_internal = format!("Personal/{}", parent_sub.trim_matches('/'));
let parent_internal = parent_internal.trim_end_matches('/');
use crate::application::ports::folder_ports::FolderUseCase;
let parent_folder = match folder_service
.get_folder_by_path(parent_internal, drive_id)
.await
{
Ok(folder) => folder,
Err(e) => {
discard_ingested(&state.core.dedup_service, &ingested).await;
return Err(AppError::internal_error(format!(
"Parent folder lookup failed: {}",
e
)));
}
};
let dto = upload_service
.upload_file_streaming(
filename.to_string(),
Some(parent_folder.id),
content_type.to_string(),
ingested.stored(),
user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?;
Some(dto.etag)
// AuthZ audit #12 (2026-07-12): the previous shape branched on
// file existence — `update_file_streaming_with_perms` on the
// overwrite path (correct), plain `upload_file_streaming` on
// the create path (NO `authz.require`). Viewer/Commenter on a
// shared drive could MKCOL → PUT chunks → MOVE and land a
// brand-new file, skipping the `Create`-on-parent-folder gate.
//
// `update_file_streaming_with_perms` handles both branches
// atomically: `Update` on the existing file OR `Create` on the
// parent folder / drive root (per the service's own internal
// fork). Funneling everything through the one method also
// deletes the duplicated parent-folder lookup that used to
// live here.
//
// AuthZ audit #2 (2026-07-12): route DomainError through
// `AppError::from` so authz denials keep the graduated 403/404
// shape instead of collapsing into 500.
//
// No client-supplied ETag to enforce here (NC chunked MOVE has no
// If-Match semantics) — `expected_hash: None`, same as every other
// plain-write callsite; only PATCH's CAS passes `Some(&hash)`.
let dto = match upload_service
.update_file_streaming_with_perms(
&internal_path,
drive_id,
ingested.stored(),
&content_type,
oc_mtime,
user.id,
None,
)
.await
{
Ok(dto) => dto,
Err(e) => {
discard_ingested(&state.core.dedup_service, &ingested).await;
return Err(AppError::from(e));
}
};
let etag: Option<String> = Some(dto.etag);
// Cleanup session.
let _ = nc.chunked_uploads.cleanup(&user.username, upload_id).await;
@@ -384,6 +531,17 @@ async fn handle_abort(
.unwrap())
}
/// Read `Content-Length` off a request as a `u64`. Returns `None` if
/// the header is absent or malformed — the PUT-chunk quota gate
/// (`handle_put_chunk`) treats that as "skip the early gate, the
/// stream cap + MOVE-time check will still catch over-quota writes".
fn content_length_from(req: &Request<Body>) -> Option<u64> {
req.headers()
.get(header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
}
/// Extract the file subpath from a Destination header pointing to the files DAV namespace.
///
/// For full URLs the host is ignored — only the path component is used.
File diff suppressed because it is too large Load Diff
+24 -16
View File
@@ -13,7 +13,7 @@ use http_range_header::parse_range_header;
use std::sync::Arc;
use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::file_ports::RangeContent;
use crate::application::services::file_retrieval_service::FileRetrievalService;
/// `If-None-Match` short-circuit: returns a `304 Not Modified` response
@@ -71,24 +71,32 @@ pub async fn range_response(
let end = *range.end();
let range_length = end - start + 1;
// Cache-aware: sub-threshold files already in the RAM content cache are
// answered with a zero-copy Bytes slice — no PG, no disk (benches/RANGE-CACHE.md).
match retrieval
.get_file_range_stream(&file.id, start, Some(end + 1))
.get_file_range_preloaded(file, start, Some(end + 1))
.await
{
Ok(stream) => Some(
Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, &*file.mime_type)
.header(header::CONTENT_LENGTH, range_length)
.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{}", start, end, file.size),
)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::ETAG, etag)
.body(Body::from_stream(Box::into_pin(stream)))
.unwrap(),
),
Ok(content) => {
let body = match content {
RangeContent::Bytes(b) => Body::from(b),
RangeContent::Stream(s) => Body::from_stream(Box::into_pin(s)),
};
Some(
Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, &*file.mime_type)
.header(header::CONTENT_LENGTH, range_length)
.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{}", start, end, file.size),
)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::ETAG, etag)
.body(body)
.unwrap(),
)
}
Err(err) => {
tracing::error!("Error creating range stream: {}", err);
None // fall through to the full download
+153 -6
View File
@@ -13,9 +13,10 @@
//! detection before being forwarded unchanged.
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use axum::body::Body;
use bytes::Bytes;
@@ -80,6 +81,24 @@ pub async fn discard_ingested(dedup: &DedupService, blob: &IngestedBlob) {
/// ingest pass (REST chunked uploads) — no post-store re-read needed.
pub type ChecksumTee = Arc<StdMutex<Option<IncrementalHasher>>>;
/// A byte-range stream paired with its known length, used by
/// [`ingest_range_patch_to_cas`] for the untouched prefix/suffix either
/// side of a PATCH edit.
pub type RangeSegment = (
Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
u64,
);
/// Size cap and expected-length validation for [`ingest_range_patch_to_cas`].
pub struct PatchIngestBudget {
/// Caps the size of the *edit* (the request body), not the whole
/// spliced file — see the field's use in [`ingest_range_patch_to_cas`].
pub max_bytes: usize,
/// Declared `X-Update-Range` span, `None` for `append`. Validated
/// against the actual streamed body length, not `Content-Length`.
pub expected_body_len: Option<u64>,
}
/// Create a checksum tee for [`ingest_stream_to_cas`].
pub fn checksum_tee(alg: ChecksumAlg) -> ChecksumTee {
Arc::new(StdMutex::new(Some(IncrementalHasher::new(alg))))
@@ -249,6 +268,95 @@ pub async fn ingest_body_to_cas(
ingest_stream_to_cas(source, dedup, filename, claimed_type, max_bytes, None).await
}
/// Splice a PATCH request body ([RFC 5789]) between the file's untouched
/// `prefix`/`suffix` byte ranges and ingest the result as one continuous
/// stream into the CDC chunk store.
///
/// `prefix`/`suffix` are `stream::empty()`-backed when the edit starts at
/// byte 0 or reaches EOF respectively — callers build the real ranges from
/// [`FileRetrievalUseCase::get_file_range_stream_with_perms`](crate::application::ports::file_ports::FileRetrievalUseCase::get_file_range_stream_with_perms).
/// Because FastCDC chunking is content-defined rather than offset-defined,
/// unedited chunks on either side of the edit typically dedup for free.
///
/// Each of `prefix`/`suffix` is paired with its known byte length (from the
/// file's size and the requested range — callers compute it, not this
/// function). `budget.max_bytes` bounds the size of the *edit* (the request
/// body) — it is widened by the prefix/suffix lengths before being applied
/// to the combined stream, so that already-stored, untouched bytes being
/// re-ingested unchanged don't count against the cap. Without this, any
/// PATCH against a file at or above `max_bytes` would be rejected
/// regardless of how small the edit itself is.
///
/// `budget.expected_body_len`, when `Some`, is validated against the
/// *actual* number of body bytes streamed — not the client-supplied
/// `Content-Length` header, which chunked-transfer-encoded requests may
/// omit entirely. Counting the real bytes means a request that declares
/// `X-Update-Range: bytes=5-9` (a 5-byte span) but streams a
/// differently-sized body is caught regardless of whether `Content-Length`
/// was present. On mismatch the already-ingested blob is discarded and
/// never reaches the atomic store, so a rejected PATCH can't leave stray
/// unreferenced content.
///
/// [RFC 5789]: https://www.rfc-editor.org/rfc/rfc5789
pub async fn ingest_range_patch_to_cas(
prefix: RangeSegment,
body: Body,
suffix: RangeSegment,
dedup: &Arc<DedupService>,
filename: &str,
claimed_type: &str,
budget: PatchIngestBudget,
) -> Result<IngestedBlob, AppError> {
let PatchIngestBudget {
max_bytes,
expected_body_len,
} = budget;
let (prefix_stream, prefix_len) = prefix;
let (suffix_stream, suffix_len) = suffix;
let body_len = Arc::new(AtomicU64::new(0));
let counter = body_len.clone();
let body_stream = BodyStream::new(body).filter_map(move |item| {
let counter = counter.clone();
async move {
match item {
Ok(frame) => {
let bytes = frame.into_data().ok()?;
counter.fetch_add(bytes.len() as u64, Ordering::Relaxed);
Some(Ok(bytes))
}
Err(e) => Some(Err(std::io::Error::other(e.to_string()))),
}
}
});
let effective_max = max_bytes.saturating_add((prefix_len + suffix_len) as usize);
let combined = prefix_stream.chain(body_stream).chain(suffix_stream);
let ingested =
ingest_stream_to_cas(combined, dedup, filename, claimed_type, effective_max, None)
.await
.map_err(|e| {
if e.error_type == "PayloadTooLarge" {
AppError::payload_too_large(format!(
"PATCH body exceeds the direct-PATCH edit-size cap ({max_bytes} bytes). \
Use the chunked-upload protocol for edits larger than this."
))
} else {
e
}
})?;
if let Some(expected) = expected_body_len {
let actual = body_len.load(Ordering::Relaxed);
if actual != expected {
discard_ingested(dedup, &ingested).await;
return Err(AppError::bad_request(format!(
"PATCH body ({actual} bytes) does not match the X-Update-Range span ({expected} bytes)"
)));
}
}
Ok(ingested)
}
/// Adapt a multipart field into a byte stream for [`ingest_stream_to_cas`].
///
/// Terminates after the first error — multipart fields are not resumable.
@@ -273,11 +381,16 @@ pub fn multipart_field_stream(
pub fn stream_from_files(
paths: Vec<PathBuf>,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
// 512 KiB per poll: each ReaderStream poll on a tokio::fs::File is one
// blocking-pool dispatch + one read(2) of the buffer size. The old
// 64 KiB buffer paid 8x the dispatches/syscalls of every other blob
// read path (STREAM_CHUNK_SIZE = 256 KiB) for the single read pass
// over every completed chunked upload (benches/UPLOAD-SPOOL.md).
stream::iter(paths.into_iter().map(Ok::<_, std::io::Error>))
.and_then(|path| async move {
tokio::fs::File::open(path)
.await
.map(|file| ReaderStream::with_capacity(file, 64 * 1024))
.map(|file| ReaderStream::with_capacity(file, 512 * 1024))
})
.try_flatten()
}
@@ -286,6 +399,10 @@ pub fn stream_from_files(
pub struct StreamedToPath {
/// Total bytes written.
pub bytes_written: u64,
/// `true` when the destination did not exist before this call — the
/// open itself detects it (`create_new` + AlreadyExists fallback), so
/// retry-detection callers don't need a separate `stat` per chunk.
pub created_fresh: bool,
/// Lowercase hex digest, populated only when `checksum_alg=Some(_)`
/// was passed. The algorithm is identified by [`StreamedToPath::alg`].
pub checksum_hex: Option<String>,
@@ -319,9 +436,38 @@ pub async fn stream_body_to_path(
max_bytes: usize,
checksum_alg: Option<ChecksumAlg>,
) -> Result<StreamedToPath, AppError> {
let mut file = tokio::fs::File::create(path)
// BufWriter coalesces the per-HTTP-frame writes (~16-64 KiB each) into
// 512 KiB write(2)s — a bare tokio File dispatches one blocking-pool op
// per frame (benches/UPLOAD-SPOOL.md). Same capacity as the dedup
// handler's spool loop. On the error paths below the partial file is
// removed, so silently dropping unflushed buffer contents is fine.
//
// `create_new` first: the common fresh-chunk case stays one open AND
// doubles as the retry probe (AlreadyExists → truncate-open), so callers
// that need overwrite detection no longer pay a separate stat per chunk.
let (file, created_fresh) = match tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.await
.map_err(|e| AppError::internal_error(format!("Failed to open chunk file: {e}")))?;
{
Ok(f) => (f, true),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let f = tokio::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(path)
.await
.map_err(|e| AppError::internal_error(format!("Failed to open chunk file: {e}")))?;
(f, false)
}
Err(e) => {
return Err(AppError::internal_error(format!(
"Failed to open chunk file: {e}"
)));
}
};
let mut file = tokio::io::BufWriter::with_capacity(512 * 1024, file);
let mut total_bytes: usize = 0;
let mut stream = BodyStream::new(body);
@@ -366,6 +512,7 @@ pub async fn stream_body_to_path(
Ok(StreamedToPath {
bytes_written: total_bytes as u64,
created_fresh,
checksum_hex: hasher.map(IncrementalHasher::finalize_hex),
alg: checksum_alg,
})
@@ -408,8 +555,8 @@ impl IncrementalHasher {
fn finalize_hex(self) -> String {
match self {
Self::Md5(h) => h.finalize().iter().map(|b| format!("{b:02x}")).collect(),
Self::Sha256(h) => h.finalize().iter().map(|b| format!("{b:02x}")).collect(),
Self::Md5(h) => crate::common::fmt::hex_lower(&h.finalize()),
Self::Sha256(h) => crate::common::fmt::hex_lower(&h.finalize()),
Self::Blake3(h) => h.finalize().to_hex().to_string(),
}
}
+85 -4
View File
@@ -46,10 +46,23 @@ pub fn create_web_routes() -> Router<Arc<AppState>> {
let static_path = resolve_static_path(&config);
// SPA fallback: serve the file if it exists, else the app shell.
let spa = ServeDir::new(&static_path).fallback(ServeFile::new(static_path.join("index.html")));
//
// `precompressed_*`: if the frontend build emitted a sibling `.br`/`.gz`
// (frontend/scripts/precompress.mjs runs at build time), serve those
// bytes directly with the right Content-Encoding instead of re-running
// Brotli over the same immutable bundle on EVERY request — the
// `CompressionLayer` below then skips the already-encoded response and
// remains only the fallback for assets without a precompressed sibling
// (benches/STATIC-PRECOMPRESSED.md).
let spa = ServeDir::new(&static_path)
.precompressed_br()
.precompressed_gzip()
.fallback(ServeFile::new(static_path.join("index.html")));
// Hashed, immutable assets (SvelteKit emits these under /_app/immutable).
let app_immutable = ServeDir::new(static_path.join("_app").join("immutable"));
let app_immutable = ServeDir::new(static_path.join("_app").join("immutable"))
.precompressed_br()
.precompressed_gzip();
Router::new()
.nest_service(
@@ -60,7 +73,17 @@ pub fn create_web_routes() -> Router<Arc<AppState>> {
)),
)
.fallback_service(spa)
.layer(CompressionLayer::new().br(true).gzip(true))
// Fallback compression for assets without a precompressed sibling.
// Quality 4, NOT the default: the default maps to Brotli q11 —
// ~1.3 s of CPU per 700 KiB bundle per request (measured in
// benches/STATIC-PRECOMPRESSED.md; the .br siblings above carry the
// real q11 bytes, paid once at build time).
.layer(
CompressionLayer::new()
.quality(tower_http::CompressionLevel::Precise(4))
.br(true)
.gzip(true),
)
// `if_not_present` so the immutable assets above keep their long cache;
// the shell itself must always revalidate so a deploy can't pin a stale
// app in browsers.
@@ -169,10 +192,43 @@ fn csp_hash(script: &str) -> String {
/// Text content of every inline `<script>` (no `src`) in `html`, returned as
/// byte-exact slices suitable for CSP hashing.
///
/// Skips HTML comments (`<!-- ... -->`) before matching `<script`. Without this,
/// a comment containing the literal string `<script>` (e.g. the theme-init
/// explanatory block in the SvelteKit shell) causes the scanner to match the
/// comment first, consume through the real script's `</script>`, and emit the
/// wrong hash — the real inline script then fails CSP with `script-src 'self'`.
fn inline_scripts(html: &str) -> Vec<&str> {
let mut scripts = Vec::new();
let mut cursor = 0;
while let Some(rel) = find_ci(&html[cursor..], "<script") {
while cursor < html.len() {
let tail = &html[cursor..];
// Skip past HTML comments — they may contain the literal
// string `<script>` in prose and would otherwise poison the
// scanner. Comment-nesting is not a spec concern.
let next_comment = find_ci(tail, "<!--");
let next_script = find_ci(tail, "<script");
match (next_comment, next_script) {
(Some(c), Some(s)) if c < s => {
let end_rel = find_ci(&tail[c + 4..], "-->").map(|r| c + 4 + r + 3);
cursor = match end_rel {
Some(e) => cursor + e,
None => break, // unterminated comment; give up
};
continue;
}
(Some(c), None) => {
let end_rel = find_ci(&tail[c + 4..], "-->").map(|r| c + 4 + r + 3);
cursor = match end_rel {
Some(e) => cursor + e,
None => break,
};
continue;
}
(None, None) => break,
_ => {} // next thing is a real <script
}
let rel = next_script.unwrap();
let tag_start = cursor + rel;
// End of the opening tag.
let Some(gt) = html[tag_start..].find('>') else {
@@ -263,6 +319,31 @@ mod tests {
assert_eq!(set.len(), 1);
}
#[test]
fn html_comment_mentioning_script_does_not_poison_scanner() {
// The SvelteKit shell has an explanatory comment referring to
// `<script>` in its prose (see static-dist/index.html theme-init
// block). Without comment skipping the scanner matches the
// comment's substring first, consumes through the real script's
// close tag, and emits the wrong hash — the real script then
// fails CSP with `script-src 'self'`.
let html = concat!(
"<!-- svelte.config.js finds this <script> by id and adds its hash -->\n",
"<script id=\"theme-init\">alert(1);</script>\n",
"<script>boot();</script>\n",
);
let scripts = inline_scripts(html);
assert_eq!(scripts, vec!["alert(1);", "boot();"]);
}
#[test]
fn unterminated_comment_bails_out_gracefully() {
// Malformed input: `<!--` never closed. Must not loop forever
// and must not falsely capture anything downstream.
let html = "<!-- unterminated <script>evil()</script>";
assert!(inline_scripts(html).is_empty());
}
#[test]
fn distinct_scripts_produce_distinct_hashes() {
assert_ne!(csp_hash("a()"), csp_hash("b()"));