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:
@@ -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
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
¶ms,
|
||||
&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, ¶ms);
|
||||
|
||||
// `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(
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶ms.query, params.folder_id.as_deref(), limit)
|
||||
.suggest_with_perms(
|
||||
¶ms.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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
¶ms.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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t_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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
¤t_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, ¤t_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,
|
||||
¤t_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
|
||||
|
||||
@@ -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)?
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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", "")?;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()"));
|
||||
|
||||
Reference in New Issue
Block a user