Merge pull request #160 from zjean/feature/nextcloud-compatible-api
Feature/nextcloud compatible api
This commit is contained in:
+15
@@ -55,6 +55,7 @@ npm-debug.log
|
||||
# Log files
|
||||
*.log
|
||||
logs/
|
||||
logs.txt
|
||||
|
||||
# Storage data (user files, blobs — never commit)
|
||||
storage/
|
||||
@@ -73,3 +74,17 @@ storage/
|
||||
*.swp
|
||||
*.swo
|
||||
nohup.out
|
||||
|
||||
# Agent planning docs (live on 'planning' branch)
|
||||
docs/plans/
|
||||
.planning/
|
||||
|
||||
# Local dev compose (not in upstream)
|
||||
docker-compose.dev.yml
|
||||
|
||||
# Test scripts with hardcoded credentials
|
||||
test-nextcloud-*.sh
|
||||
|
||||
# Claude Code artifacts
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
Generated
+7
@@ -1844,6 +1844,7 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -3270,6 +3271,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
||||
@@ -52,10 +52,12 @@ async-compression = { version = "0.4", features = ["tokio", "gzip"] }
|
||||
async_zip = { version = "0.0.18", features = ["tokio", "deflate"] }
|
||||
dashmap = "6"
|
||||
socket2 = { version = "0.6.2", features = ["all"] }
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
test_utils = ["mockall"]
|
||||
integration_tests = []
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] }
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
-- All tables use IF NOT EXISTS for idempotent re-runs.
|
||||
-- ============================================================
|
||||
|
||||
-- ── Extensions required by indexes below ──
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- ============================================================
|
||||
-- 0. REQUIRED EXTENSIONS (must come before any table/index)
|
||||
-- ============================================================
|
||||
@@ -73,6 +76,7 @@ $$ LANGUAGE plpgsql IMMUTABLE;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_active ON auth.sessions(user_id, revoked)
|
||||
WHERE NOT revoked AND auth.is_session_active(expires_at);
|
||||
|
||||
|
||||
-- File ownership tracking
|
||||
CREATE TABLE IF NOT EXISTS auth.user_files (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -467,6 +471,16 @@ CREATE INDEX IF NOT EXISTS idx_folders_path ON storage.folders (path text_patter
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_name_trgm
|
||||
ON storage.folders USING gin (name gin_trgm_ops);
|
||||
|
||||
-- Nextcloud object ID mapping (stable numeric fileids)
|
||||
CREATE TABLE IF NOT EXISTS storage.nextcloud_object_ids (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
object_type TEXT NOT NULL CHECK (object_type IN ('file', 'folder')),
|
||||
object_id UUID NOT NULL,
|
||||
UNIQUE (object_type, object_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nc_object_ids_type ON storage.nextcloud_object_ids(object_type);
|
||||
|
||||
-- ── ltree trigger: compute path & lpath on INSERT or UPDATE of name/parent_id ──
|
||||
CREATE OR REPLACE FUNCTION storage.compute_folder_path()
|
||||
RETURNS trigger AS $$
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
|
||||
oxicloud:
|
||||
image: oxicloud
|
||||
restart: always
|
||||
|
||||
@@ -120,6 +120,62 @@ pub enum LockType {
|
||||
Write,
|
||||
}
|
||||
|
||||
/// Extra property context for Nextcloud/ownCloud WebDAV extensions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NextcloudPropContext {
|
||||
pub file_id: Option<i64>,
|
||||
pub oc_id: Option<String>,
|
||||
pub owner_id: Option<String>,
|
||||
pub owner_display_name: Option<String>,
|
||||
pub permissions: String,
|
||||
pub size: u64,
|
||||
pub has_preview: bool,
|
||||
pub is_encrypted: bool,
|
||||
pub mount_type: String,
|
||||
pub contained_file_count: u64,
|
||||
pub contained_folder_count: u64,
|
||||
}
|
||||
|
||||
impl NextcloudPropContext {
|
||||
pub fn for_folder(
|
||||
file_id: Option<i64>,
|
||||
oc_id: Option<String>,
|
||||
owner: &str,
|
||||
contained_files: u64,
|
||||
contained_folders: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
file_id,
|
||||
oc_id,
|
||||
owner_id: Some(owner.to_string()),
|
||||
owner_display_name: Some(owner.to_string()),
|
||||
permissions: "RGDNVCK".to_string(),
|
||||
size: 0,
|
||||
has_preview: false,
|
||||
is_encrypted: false,
|
||||
mount_type: "dir".to_string(),
|
||||
contained_file_count: contained_files,
|
||||
contained_folder_count: contained_folders,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_file(file_id: Option<i64>, oc_id: Option<String>, owner: &str, size: u64) -> Self {
|
||||
Self {
|
||||
file_id,
|
||||
oc_id,
|
||||
owner_id: Some(owner.to_string()),
|
||||
owner_display_name: Some(owner.to_string()),
|
||||
permissions: "RGDNVW".to_string(),
|
||||
size,
|
||||
has_preview: false,
|
||||
is_encrypted: false,
|
||||
mount_type: "file".to_string(),
|
||||
contained_file_count: 0,
|
||||
contained_folder_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WebDAV adapter for converting between XML and domain objects
|
||||
pub struct WebDavAdapter;
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
// Shared display helpers for DTOs.
|
||||
//
|
||||
// These functions centralise the mime→icon / mime→category / size→human-string
|
||||
// logic so that every API response carries pre-computed display fields and the
|
||||
// frontend does **not** need to duplicate these mappings.
|
||||
//
|
||||
// The approach is: try MIME first (specific matches beat prefix matches),
|
||||
// then fall back to the file extension when the MIME is generic
|
||||
// (`application/octet-stream` or empty).
|
||||
//! Shared display helpers for DTOs.
|
||||
//!
|
||||
//! These functions centralise the mime→icon / mime→category / size→human-string
|
||||
//! logic so that every API response carries pre-computed display fields and the
|
||||
//! frontend does **not** need to duplicate these mappings.
|
||||
//!
|
||||
//! The approach is: try MIME first (specific matches beat prefix matches),
|
||||
//! then fall back to the file extension when the MIME is generic
|
||||
//! (`application/octet-stream` or empty).
|
||||
|
||||
// ─── Private: extract lowercase extension from a filename ────────────
|
||||
|
||||
fn ext_of(name: &str) -> Option<&str> {
|
||||
let name = name.rsplit('/').next().unwrap_or(name); // strip path
|
||||
let after_dot = name.rsplit('.').next()?;
|
||||
|
||||
@@ -87,6 +87,30 @@ pub struct CurrentUser {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// App Password DTOs
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateAppPasswordDto {
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AppPasswordCreatedDto {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AppPasswordDto {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC DTOs
|
||||
// ============================================================================
|
||||
|
||||
@@ -93,6 +93,9 @@ pub trait UserStoragePort: Send + Sync + 'static {
|
||||
/// Lists users with pagination
|
||||
async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError>;
|
||||
|
||||
/// Searches users by username or email (SQL ILIKE) with a limit.
|
||||
async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<User>, DomainError>;
|
||||
|
||||
/// Lists users by role (e.g., "admin" or "user")
|
||||
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError>;
|
||||
|
||||
@@ -249,8 +252,19 @@ pub trait AppPasswordStoragePort: Send + Sync + 'static {
|
||||
/// Update the `last_used_at` timestamp after a successful authentication.
|
||||
async fn touch_last_used(&self, id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Deactivate (soft-delete) an app password.
|
||||
async fn revoke(&self, id: &str) -> Result<(), DomainError>;
|
||||
/// Get active app passwords for a user filtered by token prefix (first 8 chars).
|
||||
/// More efficient than `get_active_by_user_id` when the password prefix is known.
|
||||
async fn get_active_by_user_prefix(
|
||||
&self,
|
||||
user_id: &str,
|
||||
prefix: &str,
|
||||
) -> Result<Vec<AppPassword>, DomainError>;
|
||||
|
||||
/// Deactivate (soft-delete) an app password, scoped to the owning user.
|
||||
async fn revoke(&self, id: &str, user_id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Delete an app password owned by a specific user. Returns true if found and deleted.
|
||||
async fn delete_by_user_and_id(&self, id: &str, user_id: &str) -> Result<bool, DomainError>;
|
||||
|
||||
/// Hard-delete expired/revoked app passwords (cleanup).
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
||||
|
||||
@@ -80,7 +80,11 @@ pub trait ChunkedUploadPort: Send + Sync + 'static {
|
||||
) -> Result<ChunkUploadResponseDto, DomainError>;
|
||||
|
||||
/// Get the current status of an upload session.
|
||||
async fn get_status(&self, upload_id: &str, user_id: &str) -> Result<UploadStatusResponseDto, DomainError>;
|
||||
async fn get_status(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<UploadStatusResponseDto, DomainError>;
|
||||
|
||||
/// Assemble all chunks into the final file.
|
||||
///
|
||||
|
||||
@@ -152,6 +152,12 @@ pub trait DedupPort: Send + Sync + 'static {
|
||||
/// Calculate BLAKE3 hash of a file (streaming).
|
||||
async fn hash_file(&self, path: &Path) -> Result<String, DomainError>;
|
||||
|
||||
/// Get the physical filesystem path for a blob by its hash.
|
||||
///
|
||||
/// Returns the path where the blob is stored on disk.
|
||||
/// Used by services that need direct filesystem access (e.g., thumbnail generation).
|
||||
fn blob_path(&self, hash: &str) -> PathBuf;
|
||||
|
||||
/// Get deduplication statistics.
|
||||
async fn get_stats(&self) -> DedupStatsDto;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::application::dtos::favorites_dto::{BatchFavoritesResult, FavoriteItemDto};
|
||||
use crate::common::errors::Result;
|
||||
|
||||
@@ -27,6 +29,14 @@ pub trait FavoritesUseCase: Send + Sync {
|
||||
user_id: &str,
|
||||
items: &[(String, String)],
|
||||
) -> Result<BatchFavoritesResult>;
|
||||
|
||||
/// Check which of the given item IDs are favorites for this user.
|
||||
/// Returns the set of item_ids that are favorites.
|
||||
async fn batch_check_favorites(
|
||||
&self,
|
||||
user_id: &str,
|
||||
item_ids: &[(&str, &str)], // (item_id, item_type) pairs
|
||||
) -> Result<HashSet<String>>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
@@ -54,4 +64,12 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static {
|
||||
/// Insert multiple items in a single transaction.
|
||||
/// Returns the number of rows actually inserted (ignoring duplicates).
|
||||
async fn add_favorites_batch(&self, user_id: &str, items: &[(String, String)]) -> Result<u64>;
|
||||
|
||||
/// Check which of the given item IDs are favorites for this user.
|
||||
/// Returns the set of item_ids that are favorites.
|
||||
async fn batch_check_favorites(
|
||||
&self,
|
||||
user_id: &str,
|
||||
item_ids: &[(&str, &str)], // (item_id, item_type) pairs
|
||||
) -> Result<HashSet<String>>;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,12 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
|
||||
) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// Updates the content of an existing file (for WebDAV)
|
||||
async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError>;
|
||||
async fn update_file(
|
||||
&self,
|
||||
path: &str,
|
||||
content: &[u8],
|
||||
content_type: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Streaming update — spools body to a temp file with incremental hash,
|
||||
/// then atomically replaces the file content via dedup store.
|
||||
@@ -240,7 +245,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
let all = self.list_files_batch(folder_id, offset, limit).await?;
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| f.owner_id.as_deref().map_or(false, |o| o == owner_id))
|
||||
.filter(|f| f.owner_id.as_deref().is_some_and(|o| o == owner_id))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,7 @@ pub trait ShareUseCase: Send + Sync + 'static {
|
||||
) -> Result<ShareDto, DomainError>;
|
||||
|
||||
/// Get a shared link by its ID (ownership-verified)
|
||||
async fn get_shared_link(
|
||||
&self,
|
||||
id: &str,
|
||||
requester_id: &str,
|
||||
) -> Result<ShareDto, DomainError>;
|
||||
async fn get_shared_link(&self, id: &str, requester_id: &str) -> Result<ShareDto, DomainError>;
|
||||
|
||||
/// Get a shared link by its token (for access by non-users)
|
||||
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError>;
|
||||
|
||||
@@ -58,7 +58,7 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
let all = self.list_files(folder_id).await?;
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| f.owner_id().map_or(false, |o| o == owner_id))
|
||||
.filter(|f| f.owner_id().is_some_and(|o| o == owner_id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
/// Gets the parent folder ID from a path (WebDAV).
|
||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
|
||||
|
||||
/// Gets a folder ID by its path.
|
||||
async fn get_folder_id_by_path(&self, folder_path: &str) -> Result<String, DomainError>;
|
||||
|
||||
/// Gets the content-addressable blob hash for a file (O(1) DB lookup).
|
||||
///
|
||||
/// Returns the BLAKE3 hash stored in `storage.files.blob_hash`.
|
||||
@@ -139,7 +142,7 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
let all = self.list_files_batch(folder_id, offset, limit).await?;
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| f.owner_id().map_or(false, |o| o == owner_id))
|
||||
.filter(|f| f.owner_id().is_some_and(|o| o == owner_id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
|
||||
@@ -384,10 +384,7 @@ impl AdminSettingsService {
|
||||
/// initialized (the caller "won" the race), or `Ok(false)` if another
|
||||
/// request already did it. This eliminates the race-condition window
|
||||
/// between `is_system_initialized()` and `mark_system_initialized()`.
|
||||
pub async fn try_claim_initialization(
|
||||
&self,
|
||||
admin_user_id: &str,
|
||||
) -> Result<bool, DomainError> {
|
||||
pub async fn try_claim_initialization(&self, admin_user_id: &str) -> Result<bool, DomainError> {
|
||||
self.settings_repo
|
||||
.try_claim_initialization(admin_user_id)
|
||||
.await
|
||||
|
||||
@@ -7,13 +7,14 @@ use crate::application::dtos::app_password_dto::*;
|
||||
use crate::application::ports::auth_ports::{
|
||||
AppPasswordStoragePort, PasswordHasherPort, UserStoragePort,
|
||||
};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::app_password::AppPassword;
|
||||
use crate::infrastructure::repositories::pg::AppPasswordPgRepository;
|
||||
use crate::infrastructure::repositories::pg::UserPgRepository;
|
||||
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
||||
use chrono::{Duration, Utc};
|
||||
use moka::future::Cache;
|
||||
use rand_core::RngCore;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
@@ -22,6 +23,11 @@ const TOKEN_LENGTH: usize = 32;
|
||||
/// Prefix for all app password tokens (makes them easily identifiable).
|
||||
const TOKEN_PREFIX: &str = "oxicloud-";
|
||||
|
||||
// ── Nextcloud-format app password constants ──
|
||||
const NC_APP_PASSWORD_GROUPS: usize = 5;
|
||||
const NC_APP_PASSWORD_GROUP_LEN: usize = 5;
|
||||
const NC_PREFIX_LEN: usize = 8;
|
||||
|
||||
/// TTL for cached Basic Auth verification results.
|
||||
/// Balances performance (avoids repeated Argon2id + DB queries) with security
|
||||
/// (limits the window during which a revoked app password remains usable).
|
||||
@@ -231,13 +237,16 @@ impl AppPasswordService {
|
||||
user_id: &str,
|
||||
id: &str,
|
||||
) -> Result<AppPasswordRevokeResponseDto, DomainError> {
|
||||
// Ownership enforced at SQL level (WHERE user_id = $2).
|
||||
// The get_by_id pre-check gives a clear error message when
|
||||
// the password doesn't belong to the caller.
|
||||
let ap = self.repo.get_by_id(id).await?;
|
||||
if ap.user_id != user_id {
|
||||
return Err(DomainError::unauthorized(
|
||||
"You can only revoke your own app passwords",
|
||||
));
|
||||
}
|
||||
self.repo.revoke(id).await?;
|
||||
self.repo.revoke(id, user_id).await?;
|
||||
|
||||
// Invalidate all cached auth entries for this user so the
|
||||
// revocation is effective immediately.
|
||||
@@ -262,23 +271,19 @@ impl AppPasswordService {
|
||||
///
|
||||
/// Returns `(user_id, username, email, role)` on success.
|
||||
///
|
||||
/// ## Performance
|
||||
/// Handles both `oxicloud-` format and Nextcloud format (`XXXXX-XXXXX-...`)
|
||||
/// passwords. Uses prefix-based DB lookup to minimize Argon2id attempts.
|
||||
///
|
||||
/// Successful verifications are cached for `BASIC_AUTH_CACHE_TTL_SECS`
|
||||
/// (default 30 s) keyed by `blake3(username:password)`. This avoids
|
||||
/// the expensive Argon2id computation **and** the three PostgreSQL
|
||||
/// round-trips on every repeated DAV request from the same client.
|
||||
///
|
||||
/// Failed verifications are **never** cached, preserving the full
|
||||
/// Argon2id cost as a brute-force deterrent.
|
||||
/// keyed by `blake3(username:password)`. Failed verifications are
|
||||
/// **never** cached, preserving the full Argon2id cost as a brute-force
|
||||
/// deterrent.
|
||||
pub async fn verify_basic_auth(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(String, String, String, String), DomainError> {
|
||||
// ── 1. Compute cache key = blake3("username:password") ────────
|
||||
// The plain-text password is never stored; only the 32-byte
|
||||
// cryptographic digest is used as lookup key.
|
||||
let cache_key: [u8; 32] =
|
||||
blake3::hash(format!("{}:{}", username, password).as_bytes()).into();
|
||||
|
||||
@@ -288,30 +293,57 @@ impl AppPasswordService {
|
||||
}
|
||||
|
||||
// ── 3. Cache miss → full verification ────────────────────────
|
||||
// Look up user by username
|
||||
let user = self
|
||||
.user_repo
|
||||
.get_user_by_username(username)
|
||||
.await
|
||||
.map_err(|_| DomainError::unauthorized("Invalid username or app password"))?;
|
||||
|
||||
// Get all active app passwords for this user
|
||||
let app_passwords = self.repo.get_active_by_user_id(user.id()).await?;
|
||||
|
||||
if app_passwords.is_empty() {
|
||||
if !user.is_active() {
|
||||
return Err(DomainError::unauthorized(
|
||||
"Invalid username or app password",
|
||||
));
|
||||
}
|
||||
|
||||
// Try each app password hash (Argon2id — CPU-intensive)
|
||||
for ap in &app_passwords {
|
||||
// Determine the password form and prefix for DB lookup.
|
||||
// oxicloud- format: use raw password, prefix = first 17 chars
|
||||
// NC format: normalize (strip dashes/whitespace, uppercase), prefix = first 8 chars
|
||||
let (verify_password, prefix) = if password.starts_with(TOKEN_PREFIX) {
|
||||
let pfx = password
|
||||
.get(..TOKEN_PREFIX.len() + 8)
|
||||
.unwrap_or(password)
|
||||
.to_string();
|
||||
(password.to_string(), pfx)
|
||||
} else {
|
||||
let norm = nc_normalize_password(password);
|
||||
match nc_token_prefix(&norm) {
|
||||
Ok(pfx) => (norm, pfx),
|
||||
Err(_) => {
|
||||
return Err(DomainError::unauthorized(
|
||||
"Invalid username or app password",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Use prefix-based lookup for efficiency (fewer Argon2id attempts)
|
||||
let candidates = self
|
||||
.repo
|
||||
.get_active_by_user_prefix(user.id(), &prefix)
|
||||
.await?;
|
||||
|
||||
if candidates.is_empty() {
|
||||
return Err(DomainError::unauthorized(
|
||||
"Invalid username or app password",
|
||||
));
|
||||
}
|
||||
|
||||
for ap in &candidates {
|
||||
if let Ok(true) = self
|
||||
.hasher
|
||||
.verify_password(password, &ap.password_hash)
|
||||
.verify_password(&verify_password, &ap.password_hash)
|
||||
.await
|
||||
{
|
||||
// Update last_used_at (fire-and-forget; don't fail auth on touch error)
|
||||
let _ = self.repo.touch_last_used(&ap.id).await;
|
||||
|
||||
let result = CachedBasicAuthResult {
|
||||
@@ -321,17 +353,198 @@ impl AppPasswordService {
|
||||
role: user.role().to_string(),
|
||||
};
|
||||
|
||||
// ── 4. Cache the successful result ────────────────────
|
||||
self.auth_cache.insert(cache_key, result.clone()).await;
|
||||
|
||||
return Ok((result.user_id, result.username, result.email, result.role));
|
||||
}
|
||||
}
|
||||
|
||||
// Failed verifications are intentionally NOT cached so that
|
||||
// brute-force attackers always pay the full Argon2id cost.
|
||||
Err(DomainError::unauthorized(
|
||||
"Invalid username or app password",
|
||||
))
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Nextcloud-format app password methods
|
||||
// ========================================================================
|
||||
|
||||
/// Create a Nextcloud-format app password (`XXXXX-XXXXX-XXXXX-XXXXX-XXXXX`).
|
||||
///
|
||||
/// Returns `(id, plain_password)`.
|
||||
pub async fn create_nc(
|
||||
&self,
|
||||
user_id: &str,
|
||||
label: &str,
|
||||
) -> Result<(String, String), DomainError> {
|
||||
let password = generate_nc_app_password();
|
||||
let normalized = nc_normalize_password(&password);
|
||||
let prefix = nc_token_prefix(&normalized)?;
|
||||
let hash = self.hasher.hash_password(&normalized).await?;
|
||||
|
||||
let ap = AppPassword::new(
|
||||
user_id.to_string(),
|
||||
label.to_string(),
|
||||
hash,
|
||||
prefix,
|
||||
"all".to_string(),
|
||||
None,
|
||||
);
|
||||
|
||||
let saved = self.repo.create(ap).await?;
|
||||
Ok((saved.id, password))
|
||||
}
|
||||
|
||||
/// Revoke an app password by matching the raw password value.
|
||||
/// Scoped to the authenticated user (fixes I3 — no global prefix search).
|
||||
pub async fn revoke_by_password(
|
||||
&self,
|
||||
user_id: &str,
|
||||
password: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let normalized = nc_normalize_password(password);
|
||||
let prefix = match nc_token_prefix(&normalized) {
|
||||
Ok(pfx) => pfx,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
let candidates = self
|
||||
.repo
|
||||
.get_active_by_user_prefix(user_id, &prefix)
|
||||
.await?;
|
||||
|
||||
for ap in candidates {
|
||||
if let Ok(true) = self
|
||||
.hasher
|
||||
.verify_password(&normalized, &ap.password_hash)
|
||||
.await
|
||||
{
|
||||
self.repo.revoke(&ap.id, user_id).await?;
|
||||
|
||||
// Invalidate cache for this user
|
||||
let uid = user_id.to_string();
|
||||
self.auth_cache
|
||||
.invalidate_entries_if(move |_key, val| val.user_id == uid)
|
||||
.ok();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List app passwords for a user (simple summary for NC UI).
|
||||
pub async fn list_nc(&self, user_id: &str) -> Result<Vec<AppPassword>, DomainError> {
|
||||
self.repo.list_by_user(user_id).await
|
||||
}
|
||||
|
||||
/// Delete an app password by ID, scoped to the owning user.
|
||||
pub async fn delete_by_user(&self, id: &str, user_id: &str) -> Result<(), DomainError> {
|
||||
let deleted = self.repo.delete_by_user_and_id(id, user_id).await?;
|
||||
if !deleted {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"AppPassword",
|
||||
"App password not found",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Nextcloud app password helpers (module-private)
|
||||
// ============================================================================
|
||||
|
||||
/// Generate a Nextcloud-format app password: `XXXXX-XXXXX-XXXXX-XXXXX-XXXXX`
|
||||
/// using rejection sampling to avoid modulo bias.
|
||||
fn generate_nc_app_password() -> String {
|
||||
let mut rng = rand_core::OsRng;
|
||||
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
let len = chars.len() as u32; // 36
|
||||
let mut groups = Vec::with_capacity(NC_APP_PASSWORD_GROUPS);
|
||||
|
||||
for _ in 0..NC_APP_PASSWORD_GROUPS {
|
||||
let mut group = String::with_capacity(NC_APP_PASSWORD_GROUP_LEN);
|
||||
for _ in 0..NC_APP_PASSWORD_GROUP_LEN {
|
||||
let threshold = u32::MAX - (u32::MAX % len);
|
||||
let idx = loop {
|
||||
let val = rng.next_u32();
|
||||
if val < threshold {
|
||||
break (val % len) as usize;
|
||||
}
|
||||
};
|
||||
group.push(chars[idx] as char);
|
||||
}
|
||||
groups.push(group);
|
||||
}
|
||||
|
||||
groups.join("-")
|
||||
}
|
||||
|
||||
/// Normalize a Nextcloud-format password: strip dashes/whitespace, uppercase.
|
||||
fn nc_normalize_password(password: &str) -> String {
|
||||
password
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != '-')
|
||||
.map(|c| c.to_ascii_uppercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract the first 8 characters as the token prefix for DB lookup.
|
||||
fn nc_token_prefix(normalized: &str) -> Result<String, DomainError> {
|
||||
if normalized.len() < NC_PREFIX_LEN {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"AppPassword",
|
||||
"App password too short",
|
||||
));
|
||||
}
|
||||
Ok(normalized[..NC_PREFIX_LEN].to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_nc_app_password_format() {
|
||||
let password = generate_nc_app_password();
|
||||
let groups: Vec<&str> = password.split('-').collect();
|
||||
assert_eq!(groups.len(), NC_APP_PASSWORD_GROUPS);
|
||||
for group in &groups {
|
||||
assert_eq!(group.len(), NC_APP_PASSWORD_GROUP_LEN);
|
||||
assert!(group.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nc_normalize_password_strips_dashes_and_whitespace() {
|
||||
assert_eq!(
|
||||
nc_normalize_password("AB12C-DE34F-GH56I"),
|
||||
"AB12CDE34FGH56I"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nc_normalize_password_uppercases() {
|
||||
assert_eq!(nc_normalize_password("abc-def"), "ABCDEF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nc_token_prefix_extracts_first_8_chars() {
|
||||
assert_eq!(nc_token_prefix("ABCDEFGHIJKLMNOP").unwrap(), "ABCDEFGH");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nc_token_prefix_too_short() {
|
||||
assert!(nc_token_prefix("SHORT").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generated_nc_password_produces_valid_prefix() {
|
||||
let password = generate_nc_app_password();
|
||||
let normalized = nc_normalize_password(&password);
|
||||
let prefix = nc_token_prefix(&normalized);
|
||||
assert!(prefix.is_ok());
|
||||
assert_eq!(prefix.unwrap().len(), NC_PREFIX_LEN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,31 @@ use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Result of a successful OIDC callback. The handler layer inspects this to
|
||||
/// decide whether to redirect to the regular frontend or complete a Nextcloud
|
||||
/// Login Flow v2 session.
|
||||
pub enum OidcCallbackResult {
|
||||
/// Regular web login — contains a one-time exchange code for the frontend.
|
||||
WebLogin { exchange_code: String },
|
||||
/// Nextcloud Login Flow v2 — the user authenticated via OIDC but the flow
|
||||
/// was initiated from the Nextcloud login page. The handler must create an
|
||||
/// app password and complete the NC login flow.
|
||||
NextcloudLogin {
|
||||
nc_flow_token: String,
|
||||
user_id: String,
|
||||
username: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Tracks a pending OIDC authorization flow (CSRF + PKCE + nonce)
|
||||
#[derive(Clone)]
|
||||
struct PendingOidcFlow {
|
||||
pkce_verifier: String,
|
||||
nonce: String,
|
||||
/// When set, this OIDC flow was initiated from the Nextcloud Login Flow v2
|
||||
/// page. On successful callback the flow will mint an app-password and
|
||||
/// complete the Nextcloud login flow instead of issuing internal JWTs.
|
||||
nc_flow_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Tracks a pending one-time token exchange after successful OIDC callback
|
||||
@@ -410,6 +430,49 @@ impl AuthApplicationService {
|
||||
})
|
||||
}
|
||||
|
||||
/// Verifies username/password credentials without creating a session.
|
||||
pub async fn verify_credentials(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<crate::application::dtos::user_dto::CurrentUser, DomainError> {
|
||||
let user = self
|
||||
.user_storage
|
||||
.get_user_by_username(username)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
DomainError::new(ErrorKind::AccessDenied, "Auth", "Invalid credentials")
|
||||
})?;
|
||||
|
||||
if !user.is_active() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
"Account deactivated",
|
||||
));
|
||||
}
|
||||
|
||||
let is_valid = self
|
||||
.password_hasher
|
||||
.verify_password(password, user.password_hash())
|
||||
.await?;
|
||||
|
||||
if !is_valid {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
"Invalid credentials",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(crate::application::dtos::user_dto::CurrentUser {
|
||||
id: user.id().to_string(),
|
||||
username: user.username().to_string(),
|
||||
email: user.email().to_string(),
|
||||
role: user.role().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn refresh_token(
|
||||
&self,
|
||||
dto: RefreshTokenDto,
|
||||
@@ -605,6 +668,11 @@ impl AuthApplicationService {
|
||||
Ok(users.into_iter().map(UserDto::from).collect())
|
||||
}
|
||||
|
||||
pub async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<UserDto>, DomainError> {
|
||||
let users = self.user_storage.search_users(query, limit).await?;
|
||||
Ok(users.into_iter().map(UserDto::from).collect())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Admin User Management Methods
|
||||
// ========================================================================
|
||||
@@ -856,6 +924,7 @@ impl AuthApplicationService {
|
||||
PendingOidcFlow {
|
||||
pkce_verifier,
|
||||
nonce: nonce.clone(),
|
||||
nc_flow_token: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -872,11 +941,77 @@ impl AuthApplicationService {
|
||||
Ok(authorize_url)
|
||||
}
|
||||
|
||||
/// Prepare an OIDC authorization flow for a Nextcloud Login Flow v2 session.
|
||||
///
|
||||
/// Works like [`prepare_oidc_authorize`] but associates the Nextcloud flow
|
||||
/// token with the OIDC state so that [`oidc_callback`] can complete the
|
||||
/// Nextcloud login flow (app-password + poll result) instead of issuing
|
||||
/// internal JWTs.
|
||||
pub async fn prepare_oidc_authorize_for_nextcloud(
|
||||
&self,
|
||||
nc_flow_token: &str,
|
||||
) -> Result<String, DomainError> {
|
||||
let oidc = self.oidc_service().ok_or_else(|| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"OIDC",
|
||||
"OIDC service not configured",
|
||||
)
|
||||
})?;
|
||||
|
||||
use rand_core::{OsRng, RngCore};
|
||||
let mut state_bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut state_bytes);
|
||||
let state_token = hex::encode(state_bytes);
|
||||
|
||||
let mut nonce_bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = hex::encode(nonce_bytes);
|
||||
|
||||
let mut verifier_bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut verifier_bytes);
|
||||
let pkce_verifier = base64_url_encode(&verifier_bytes);
|
||||
let pkce_challenge = {
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = Sha256::digest(pkce_verifier.as_bytes());
|
||||
base64_url_encode(&hash)
|
||||
};
|
||||
|
||||
// Store pending flow (auto-expires after 10 min via moka TTL)
|
||||
self.pending_oidc_flows.insert(
|
||||
state_token.clone(),
|
||||
PendingOidcFlow {
|
||||
pkce_verifier,
|
||||
nonce: nonce.clone(),
|
||||
nc_flow_token: Some(nc_flow_token.to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
let authorize_url = oidc
|
||||
.get_authorize_url(&state_token, &nonce, &pkce_challenge)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
"OIDC authorize flow prepared for Nextcloud Login Flow v2 (state={}...)",
|
||||
&state_token[..8]
|
||||
);
|
||||
|
||||
Ok(authorize_url)
|
||||
}
|
||||
|
||||
/// Handle the OIDC callback: validate CSRF state, exchange code with PKCE,
|
||||
/// validate ID token nonce, find or create user (JIT provisioning),
|
||||
/// issue internal tokens, and return a one-time exchange code.
|
||||
pub async fn oidc_callback(&self, code: &str, state: &str) -> Result<String, DomainError> {
|
||||
// 0. Validate CSRF state and retrieve PKCE verifier + nonce
|
||||
///
|
||||
/// If the pending flow carries a Nextcloud flow token, this method returns
|
||||
/// `Err(NcOidcComplete { .. })` with a special error kind so the handler
|
||||
/// layer can complete the Nextcloud flow instead.
|
||||
pub async fn oidc_callback(
|
||||
&self,
|
||||
code: &str,
|
||||
state: &str,
|
||||
) -> Result<OidcCallbackResult, DomainError> {
|
||||
// 0. Validate CSRF state and retrieve PKCE verifier + nonce + optional NC token
|
||||
// (entry is auto-expired by moka TTL — remove returns None if expired)
|
||||
let flow = self.pending_oidc_flows.remove(state).ok_or_else(|| {
|
||||
tracing::warn!("OIDC callback with invalid/expired state token");
|
||||
@@ -885,7 +1020,8 @@ impl AuthApplicationService {
|
||||
"Invalid or expired OIDC state — possible CSRF attack. Please try logging in again.",
|
||||
)
|
||||
})?;
|
||||
let (pkce_verifier, nonce) = (flow.pkce_verifier, flow.nonce);
|
||||
let (pkce_verifier, nonce, nc_flow_token) =
|
||||
(flow.pkce_verifier, flow.nonce, flow.nc_flow_token);
|
||||
|
||||
// Clone the Arc and config out of the RwLock so we don't hold the lock across await points
|
||||
let (oidc, oidc_config) = {
|
||||
@@ -1063,6 +1199,21 @@ impl AuthApplicationService {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Branch: Nextcloud Login Flow v2 vs regular web login ──
|
||||
if let Some(nc_token) = nc_flow_token {
|
||||
// Nextcloud path: return user info so the handler can mint an
|
||||
// app-password and complete the NC login flow.
|
||||
tracing::info!(
|
||||
user = %user.username(),
|
||||
"OIDC login successful for Nextcloud Login Flow v2"
|
||||
);
|
||||
return Ok(OidcCallbackResult::NextcloudLogin {
|
||||
nc_flow_token: nc_token,
|
||||
user_id: user.id().to_string(),
|
||||
username: user.username().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Issue internal tokens (same as regular login)
|
||||
let access_token = self.token_service.generate_access_token(&user)?;
|
||||
let refresh_token = self.token_service.generate_refresh_token();
|
||||
@@ -1096,7 +1247,7 @@ impl AuthApplicationService {
|
||||
|
||||
tracing::info!("OIDC login successful, one-time exchange code generated");
|
||||
|
||||
Ok(exchange_code)
|
||||
Ok(OidcCallbackResult::WebLogin { exchange_code })
|
||||
}
|
||||
|
||||
/// Exchange a one-time code for the authentication tokens.
|
||||
|
||||
@@ -682,7 +682,11 @@ impl BatchOperationService {
|
||||
|
||||
// ── Add folders as sub-trees (bulk subtree queries, not N+1) ─────
|
||||
for folder_id in &folder_ids {
|
||||
match self.folder_service.get_folder_owned(folder_id, caller_id).await {
|
||||
match self
|
||||
.folder_service
|
||||
.get_folder_owned(folder_id, caller_id)
|
||||
.await
|
||||
{
|
||||
Ok(root_folder) => {
|
||||
if let Err(e) = self
|
||||
.add_folder_subtree_to_zip(&mut zip, folder_id, &root_folder, caller_id)
|
||||
@@ -928,11 +932,11 @@ impl BatchOperationService {
|
||||
|
||||
async move {
|
||||
// If a parent is specified, verify the caller owns it
|
||||
if let Some(ref pid) = parent_id {
|
||||
if let Err(e) = folder_service.get_folder_owned(pid, &caller).await {
|
||||
let id = format!("{}:{}", name, pid);
|
||||
return (id, Err(e.into()));
|
||||
}
|
||||
if let Some(ref pid) = parent_id
|
||||
&& let Err(e) = folder_service.get_folder_owned(pid, &caller).await
|
||||
{
|
||||
let id = format!("{}:{}", name, pid);
|
||||
return (id, Err(e));
|
||||
}
|
||||
let dto = crate::application::dtos::folder_dto::CreateFolderDto {
|
||||
name: name.clone(),
|
||||
@@ -1041,19 +1045,28 @@ impl BatchOperationService {
|
||||
|
||||
#[cfg(integration_tests)]
|
||||
mod tests {
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use crate::common::stubs::{StubFileManagementUseCase, StubFileRetrievalUseCase};
|
||||
#[allow(unused_imports)]
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
#[allow(unused_imports)]
|
||||
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
|
||||
#[allow(unused_imports)]
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
#[allow(unused_imports)]
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_generic_batch_operation() {
|
||||
// Create the batch service with stubs
|
||||
// Create the batch service with stub repositories (lazy pool — no SQL is executed
|
||||
// in this test; generic_batch_operation never touches file/folder services).
|
||||
let folder_repo = Arc::new(FolderDbRepository::new_stub());
|
||||
let file_read_repo = Arc::new(FileBlobReadRepository::new_stub());
|
||||
let file_write_repo = Arc::new(FileBlobWriteRepository::new_stub());
|
||||
let batch_service = BatchOperationService::new(
|
||||
Arc::new(StubFileRetrievalUseCase),
|
||||
Arc::new(StubFileManagementUseCase),
|
||||
Arc::new(FolderService::new(Arc::new(
|
||||
crate::common::stubs::StubFolderStoragePort,
|
||||
))),
|
||||
Arc::new(FileRetrievalService::new(file_read_repo)),
|
||||
Arc::new(FileManagementService::new(file_write_repo)),
|
||||
Arc::new(FolderService::new(folder_repo)),
|
||||
AppConfig::default(),
|
||||
);
|
||||
|
||||
|
||||
@@ -262,6 +262,10 @@ impl DeviceAuthService {
|
||||
let refresh_token = dc.refresh_token().unwrap_or_default().to_string();
|
||||
let scope = dc.scopes().to_string();
|
||||
|
||||
// Delete the device code row now that tokens have been retrieved.
|
||||
// This prevents plain-text tokens from lingering in the database.
|
||||
let _ = self.device_code_storage.delete_by_id(dc.id()).await;
|
||||
|
||||
Ok(DeviceTokenSuccessDto {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tracing::info;
|
||||
|
||||
use crate::application::dtos::favorites_dto::{
|
||||
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto,
|
||||
};
|
||||
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
/// Implementation of the FavoritesUseCase for managing user favorites.
|
||||
///
|
||||
@@ -142,4 +145,12 @@ impl FavoritesUseCase for FavoritesService {
|
||||
favorites,
|
||||
})
|
||||
}
|
||||
|
||||
async fn batch_check_favorites(
|
||||
&self,
|
||||
user_id: &str,
|
||||
item_ids: &[(&str, &str)],
|
||||
) -> Result<HashSet<String>> {
|
||||
self.repo.batch_check_favorites(user_id, item_ids).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +228,10 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
folder_id: Option<&str>,
|
||||
owner_id: &str,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
let files = self.file_read.list_files_for_owner(folder_id, owner_id).await?;
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_for_owner(folder_id, owner_id)
|
||||
.await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
|
||||
|
||||
@@ -182,9 +182,11 @@ impl FileUploadUseCase for FileUploadService {
|
||||
content: &[u8],
|
||||
content_type: &str,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
// Look up the folder ID by folder path
|
||||
let parent_id = if !parent_path.is_empty() {
|
||||
if let Some(file_read) = &self.file_read {
|
||||
file_read.get_parent_folder_id(parent_path).await.ok()
|
||||
// Use get_folder_id_by_path to look up the folder directly
|
||||
file_read.get_folder_id_by_path(parent_path).await.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -220,7 +222,12 @@ impl FileUploadUseCase for FileUploadService {
|
||||
///
|
||||
/// Spools the in-memory `&[u8]` to a temp file with hash-on-write,
|
||||
/// then delegates to the streaming update/create path.
|
||||
async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError> {
|
||||
async fn update_file(
|
||||
&self,
|
||||
path: &str,
|
||||
content: &[u8],
|
||||
content_type: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
// Spool to temp file + hash
|
||||
let temp = tempfile::NamedTempFile::new()
|
||||
.map_err(|e| DomainError::internal_error("FileUpload", format!("temp file: {e}")))?;
|
||||
@@ -233,7 +240,7 @@ impl FileUploadUseCase for FileUploadService {
|
||||
path,
|
||||
temp.path(),
|
||||
content.len() as u64,
|
||||
"application/octet-stream",
|
||||
content_type,
|
||||
Some(hash),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -32,7 +32,11 @@ impl FolderService {
|
||||
Ok(FolderDto::empty())
|
||||
}
|
||||
|
||||
async fn get_folder_owned(&self, _id: &str, _caller_id: &str) -> Result<FolderDto, DomainError> {
|
||||
async fn get_folder_owned(
|
||||
&self,
|
||||
_id: &str,
|
||||
_caller_id: &str,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::empty())
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
@@ -117,6 +118,10 @@ impl FileReadPort for MockFileReadPort {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn stream_files_in_subtree(
|
||||
&self,
|
||||
_folder_id: &str,
|
||||
@@ -125,6 +130,118 @@ impl FileReadPort for MockFileReadPort {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal mock write port — only `move_file` and `rename_file` need real logic.
|
||||
#[allow(dead_code)]
|
||||
struct MockFileWritePort {
|
||||
files: Mutex<HashMap<String, File>>,
|
||||
}
|
||||
|
||||
impl MockFileWritePort {
|
||||
#[allow(dead_code)]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
files: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn insert(&self, id: &str, name: &str) {
|
||||
let file = File::new(
|
||||
id.to_string(),
|
||||
name.to_string(),
|
||||
StoragePath::from_string(&format!("/{}", name)),
|
||||
42,
|
||||
"text/plain".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
self.files.lock().unwrap().insert(id.to_string(), file);
|
||||
}
|
||||
}
|
||||
|
||||
impl FileWritePort for MockFileWritePort {
|
||||
async fn save_file_from_temp(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_temp_path: &Path,
|
||||
_size: u64,
|
||||
_pre_computed_hash: Option<String>,
|
||||
) -> Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
_target_folder_id: Option<String>,
|
||||
) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(file_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id.to_string()))
|
||||
}
|
||||
|
||||
async fn rename_file(&self, file_id: &str, _new_name: &str) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(file_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id.to_string()))
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_file_content_from_temp(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_temp_path: &Path,
|
||||
_size: u64,
|
||||
_content_type: Option<String>,
|
||||
_pre_computed_hash: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn register_file_deferred(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_size: u64,
|
||||
) -> Result<(File, PathBuf), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn copy_file(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_target_folder_id: Option<String>,
|
||||
) -> Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_to_trash(&self, _file_id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_from_trash(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_original_path: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_file_permanently(&self, _file_id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests — FileReadPort::get_file_for_owner (Repository layer, Solution C)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -12,6 +12,8 @@ pub mod file_upload_service;
|
||||
pub mod file_use_case_factory;
|
||||
pub mod folder_service;
|
||||
pub mod i18n_application_service;
|
||||
pub mod nextcloud_file_id_service;
|
||||
pub mod nextcloud_login_flow_service;
|
||||
pub mod recent_service;
|
||||
pub mod search_service;
|
||||
pub mod share_service;
|
||||
@@ -22,7 +24,7 @@ pub mod wopi_token_service;
|
||||
|
||||
#[cfg(test)]
|
||||
mod idor_protection_test;
|
||||
#[cfg(all(test, integration_tests))]
|
||||
#[cfg(test)]
|
||||
mod trash_service_test;
|
||||
|
||||
// Re-exportar para facilitar acceso
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::infrastructure::repositories::pg::NextcloudObjectIdRepository;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NextcloudFileIdService {
|
||||
repo: Option<Arc<NextcloudObjectIdRepository>>,
|
||||
instance_id: String,
|
||||
}
|
||||
|
||||
impl NextcloudFileIdService {
|
||||
pub fn new(repo: Arc<NextcloudObjectIdRepository>, instance_id: String) -> Self {
|
||||
Self {
|
||||
repo: Some(repo),
|
||||
instance_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_stub() -> Self {
|
||||
Self {
|
||||
repo: None,
|
||||
instance_id: "ocnca".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_or_create_file_id(&self, file_id: &str) -> Result<i64> {
|
||||
let repo = self.repo.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("NextcloudFileId", "Repository not initialized")
|
||||
})?;
|
||||
repo.get_or_create("file", file_id).await
|
||||
}
|
||||
|
||||
pub async fn get_or_create_folder_id(&self, folder_id: &str) -> Result<i64> {
|
||||
let repo = self.repo.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("NextcloudFileId", "Repository not initialized")
|
||||
})?;
|
||||
repo.get_or_create("folder", folder_id).await
|
||||
}
|
||||
|
||||
/// Get the OxiCloud file UUID from a Nextcloud numeric ID.
|
||||
pub async fn get_oxicloud_id(&self, nc_file_id: i64) -> Result<String> {
|
||||
let repo = self.repo.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("NextcloudFileId", "Repository not initialized")
|
||||
})?;
|
||||
repo.get_object_id(nc_file_id, "file").await
|
||||
}
|
||||
|
||||
pub fn format_oc_id(&self, id: i64) -> String {
|
||||
format!("{:08}{}", id, self.instance_id)
|
||||
}
|
||||
|
||||
pub fn instance_id(&self) -> &str {
|
||||
&self.instance_id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_test(instance_id: &str) -> Self {
|
||||
Self {
|
||||
repo: None,
|
||||
instance_id: instance_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_ready(&self) -> Result<()> {
|
||||
if self.repo.is_none() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"NextcloudFileId",
|
||||
"Repository not initialized",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_oc_id_default_instance() {
|
||||
let svc = NextcloudFileIdService::new_stub();
|
||||
assert_eq!(svc.format_oc_id(42), "00000042ocnca");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_oc_id_custom_instance() {
|
||||
let svc = NextcloudFileIdService::new_test("myinst");
|
||||
assert_eq!(svc.format_oc_id(1), "00000001myinst");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_oc_id_large_number() {
|
||||
let svc = NextcloudFileIdService::new_stub();
|
||||
assert_eq!(svc.format_oc_id(123456789), "123456789ocnca");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_instance_id() {
|
||||
let svc = NextcloudFileIdService::new_stub();
|
||||
assert_eq!(svc.instance_id(), "ocnca");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_ready_fails_on_stub() {
|
||||
let svc = NextcloudFileIdService::new_stub();
|
||||
assert!(svc.ensure_ready().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rand_core::RngCore;
|
||||
|
||||
/// Maximum number of concurrent pending login flows to prevent memory exhaustion.
|
||||
const MAX_PENDING_FLOWS: usize = 1000;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoginFlowInfo {
|
||||
pub poll_token: String,
|
||||
pub poll_endpoint: String,
|
||||
pub login_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LoginFlowError {
|
||||
TooManyPendingFlows,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoginResult {
|
||||
pub server: String,
|
||||
pub login_name: String,
|
||||
pub app_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PendingFlow {
|
||||
created_at: Instant,
|
||||
poll_token: String,
|
||||
completed: Option<LoginResult>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FlowState {
|
||||
flows: HashMap<String, PendingFlow>,
|
||||
poll_to_flow: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NextcloudLoginFlowService {
|
||||
ttl: Duration,
|
||||
/// Uses `std::sync::Mutex` (not `tokio::sync::Mutex`) because the lock is
|
||||
/// never held across an `.await` point — all operations are synchronous
|
||||
/// HashMap lookups/inserts. This avoids the overhead of an async mutex.
|
||||
/// **Constraint:** Do not add `.await` calls inside any `self.state.lock()` scope.
|
||||
state: Arc<Mutex<FlowState>>,
|
||||
}
|
||||
|
||||
impl NextcloudLoginFlowService {
|
||||
pub fn new(ttl: Duration) -> Self {
|
||||
Self {
|
||||
ttl,
|
||||
state: Arc::new(Mutex::new(FlowState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_stub() -> Self {
|
||||
Self::new(Duration::from_secs(600))
|
||||
}
|
||||
|
||||
pub fn initiate(&self, base_url: &str) -> Result<LoginFlowInfo, LoginFlowError> {
|
||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
prune_expired(&mut state, self.ttl);
|
||||
|
||||
if state.flows.len() >= MAX_PENDING_FLOWS {
|
||||
return Err(LoginFlowError::TooManyPendingFlows);
|
||||
}
|
||||
|
||||
let poll_token = random_hex(64);
|
||||
let flow_token = random_hex(48);
|
||||
|
||||
state
|
||||
.poll_to_flow
|
||||
.insert(poll_token.clone(), flow_token.clone());
|
||||
state.flows.insert(
|
||||
flow_token.clone(),
|
||||
PendingFlow {
|
||||
created_at: Instant::now(),
|
||||
poll_token: poll_token.clone(),
|
||||
completed: None,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(LoginFlowInfo {
|
||||
poll_token: poll_token.clone(),
|
||||
poll_endpoint: format!("{}/login/v2/poll", base_url.trim_end_matches('/')),
|
||||
login_url: format!(
|
||||
"{}/login/v2/flow/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
flow_token
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn flow_exists(&self, flow_token: &str) -> bool {
|
||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
prune_expired(&mut state, self.ttl);
|
||||
state.flows.contains_key(flow_token)
|
||||
}
|
||||
|
||||
pub fn complete(
|
||||
&self,
|
||||
flow_token: &str,
|
||||
username: &str,
|
||||
server: &str,
|
||||
app_password: &str,
|
||||
) -> bool {
|
||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
prune_expired(&mut state, self.ttl);
|
||||
|
||||
let pending = match state.flows.get_mut(flow_token) {
|
||||
Some(pending) => pending,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
pending.completed = Some(LoginResult {
|
||||
server: server.to_string(),
|
||||
login_name: username.to_string(),
|
||||
app_password: app_password.to_string(),
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn poll(&self, poll_token: &str) -> Option<LoginResult> {
|
||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
prune_expired(&mut state, self.ttl);
|
||||
|
||||
let flow_token = state.poll_to_flow.get(poll_token).cloned()?;
|
||||
let pending = state.flows.get_mut(&flow_token)?;
|
||||
|
||||
if let Some(result) = pending.completed.take() {
|
||||
state.poll_to_flow.remove(poll_token);
|
||||
state.flows.remove(&flow_token);
|
||||
Some(result)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_expired(state: &mut FlowState, ttl: Duration) {
|
||||
let now = Instant::now();
|
||||
let expired: Vec<String> = state
|
||||
.flows
|
||||
.iter()
|
||||
.filter(|(_, flow)| now.duration_since(flow.created_at) > ttl)
|
||||
.map(|(token, _)| token.clone())
|
||||
.collect();
|
||||
|
||||
for flow_token in expired {
|
||||
if let Some(flow) = state.flows.remove(&flow_token) {
|
||||
state.poll_to_flow.remove(&flow.poll_token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn random_hex(len: usize) -> String {
|
||||
let mut bytes = vec![0u8; len.div_ceil(2)];
|
||||
rand_core::OsRng.fill_bytes(&mut bytes);
|
||||
let mut out = hex::encode(bytes);
|
||||
out.truncate(len);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn service() -> NextcloudLoginFlowService {
|
||||
NextcloudLoginFlowService::new(Duration::from_secs(600))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initiate_returns_valid_tokens() {
|
||||
let svc = service();
|
||||
let info = svc.initiate("https://cloud.example.com").unwrap();
|
||||
|
||||
assert!(!info.poll_token.is_empty());
|
||||
assert!(
|
||||
info.login_url
|
||||
.starts_with("https://cloud.example.com/login/v2/flow/")
|
||||
);
|
||||
assert_eq!(
|
||||
info.poll_endpoint,
|
||||
"https://cloud.example.com/login/v2/poll"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flow_exists_after_initiate() {
|
||||
let svc = service();
|
||||
let info = svc.initiate("https://cloud.example.com").unwrap();
|
||||
|
||||
// Extract flow token from login URL.
|
||||
let flow_token = info.login_url.rsplit('/').next().unwrap();
|
||||
assert!(svc.flow_exists(flow_token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flow_not_found_for_unknown_token() {
|
||||
let svc = service();
|
||||
assert!(!svc.flow_exists("nonexistent-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_poll_returns_none_before_completion() {
|
||||
let svc = service();
|
||||
let info = svc.initiate("https://cloud.example.com").unwrap();
|
||||
assert!(svc.poll(&info.poll_token).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_and_poll_full_sequence() {
|
||||
let svc = service();
|
||||
let info = svc.initiate("https://cloud.example.com").unwrap();
|
||||
let flow_token = info.login_url.rsplit('/').next().unwrap();
|
||||
|
||||
// Complete the flow.
|
||||
let completed = svc.complete(
|
||||
flow_token,
|
||||
"alice",
|
||||
"https://cloud.example.com",
|
||||
"APP-PASS-12345",
|
||||
);
|
||||
assert!(completed);
|
||||
|
||||
// Poll should return the result exactly once.
|
||||
let result = svc.poll(&info.poll_token).expect("should return result");
|
||||
assert_eq!(result.login_name, "alice");
|
||||
assert_eq!(result.server, "https://cloud.example.com");
|
||||
assert_eq!(result.app_password, "APP-PASS-12345");
|
||||
|
||||
// Second poll should return None (consumed).
|
||||
assert!(svc.poll(&info.poll_token).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_unknown_flow_returns_false() {
|
||||
let svc = service();
|
||||
assert!(!svc.complete("nonexistent", "alice", "https://x.com", "pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expired_flows_are_pruned() {
|
||||
let svc = NextcloudLoginFlowService::new(Duration::from_millis(1));
|
||||
let info = svc.initiate("https://cloud.example.com").unwrap();
|
||||
let flow_token = info.login_url.rsplit('/').next().unwrap();
|
||||
|
||||
// Wait for expiry.
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
|
||||
assert!(!svc.flow_exists(flow_token));
|
||||
assert!(svc.poll(&info.poll_token).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_pending_flows_cap() {
|
||||
let svc = NextcloudLoginFlowService::new(Duration::from_secs(600));
|
||||
for _ in 0..MAX_PENDING_FLOWS {
|
||||
svc.initiate("https://cloud.example.com").unwrap();
|
||||
}
|
||||
// The next initiate should fail
|
||||
assert!(svc.initiate("https://cloud.example.com").is_err());
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,10 @@ use crate::{
|
||||
storage_ports::FileReadPort,
|
||||
},
|
||||
},
|
||||
common::{config::AppConfig, errors::{DomainError, ErrorKind}},
|
||||
common::{
|
||||
config::AppConfig,
|
||||
errors::{DomainError, ErrorKind},
|
||||
},
|
||||
domain::entities::share::{Share, ShareItemType, SharePermissions},
|
||||
};
|
||||
|
||||
@@ -147,11 +150,7 @@ impl ShareService {
|
||||
/// but belongs to a different user — this prevents share-ID enumeration
|
||||
/// attacks where an attacker probes IDs and uses 403-vs-404 to learn
|
||||
/// which ones are valid.
|
||||
async fn fetch_owned_share(
|
||||
&self,
|
||||
id: &str,
|
||||
requester_id: &str,
|
||||
) -> Result<Share, DomainError> {
|
||||
async fn fetch_owned_share(&self, id: &str, requester_id: &str) -> Result<Share, DomainError> {
|
||||
let share = self
|
||||
.share_repository
|
||||
.find_share_by_id_for_user(id, requester_id)
|
||||
@@ -206,11 +205,7 @@ impl ShareUseCase for ShareService {
|
||||
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
|
||||
}
|
||||
|
||||
async fn get_shared_link(
|
||||
&self,
|
||||
id: &str,
|
||||
requester_id: &str,
|
||||
) -> Result<ShareDto, DomainError> {
|
||||
async fn get_shared_link(&self, id: &str, requester_id: &str) -> Result<ShareDto, DomainError> {
|
||||
// SECURITY: ownership-verified lookup — returns 404 if the share
|
||||
// doesn't exist OR belongs to another user.
|
||||
let share = self.fetch_owned_share(id, requester_id).await?;
|
||||
@@ -430,16 +425,295 @@ impl ShareUseCase for ShareService {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(integration_tests)]
|
||||
#[cfg(feature = "integration_tests")]
|
||||
#[allow(dead_code)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[allow(unused_imports)]
|
||||
use crate::application::dtos::share_dto::SharePermissionsDto;
|
||||
use crate::application::ports::auth_ports::PasswordHasherPort;
|
||||
use crate::application::ports::share_ports::ShareStoragePort;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::common::config::AppConfig;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Test-only service that mirrors `ShareService` logic but accepts generic repos.
|
||||
struct ShareServiceForTest<SR, FR, FoR, PH> {
|
||||
config: Arc<AppConfig>,
|
||||
share_repository: Arc<SR>,
|
||||
file_repository: Arc<FR>,
|
||||
folder_repository: Arc<FoR>,
|
||||
password_hasher: Arc<PH>,
|
||||
hash_semaphore: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl<SR, FR, FoR, PH> ShareServiceForTest<SR, FR, FoR, PH>
|
||||
where
|
||||
SR: ShareStoragePort,
|
||||
FR: FileReadPort,
|
||||
FoR: FolderRepository,
|
||||
PH: PasswordHasherPort,
|
||||
{
|
||||
fn new(
|
||||
config: Arc<AppConfig>,
|
||||
share_repository: Arc<SR>,
|
||||
file_repository: Arc<FR>,
|
||||
folder_repository: Arc<FoR>,
|
||||
password_hasher: Arc<PH>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
share_repository,
|
||||
file_repository,
|
||||
folder_repository,
|
||||
password_hasher,
|
||||
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_item_exists(
|
||||
&self,
|
||||
item_id: &str,
|
||||
item_type: &ShareItemType,
|
||||
) -> Result<(), ShareServiceError> {
|
||||
match item_type {
|
||||
ShareItemType::File => {
|
||||
self.file_repository.get_file(item_id).await.map_err(|_| {
|
||||
ShareServiceError::ItemNotFound(format!(
|
||||
"File with ID {} not found",
|
||||
item_id
|
||||
))
|
||||
})?;
|
||||
}
|
||||
ShareItemType::Folder => {
|
||||
self.folder_repository
|
||||
.get_folder(item_id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ShareServiceError::ItemNotFound(format!(
|
||||
"Folder with ID {} not found",
|
||||
item_id
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn hash_password_async(&self, password: &str) -> Result<String, DomainError> {
|
||||
let _permit = self.hash_semaphore.acquire().await.map_err(|_| {
|
||||
DomainError::internal_error("ShareService", "Hash semaphore closed".to_string())
|
||||
})?;
|
||||
self.password_hasher.hash_password(password).await
|
||||
}
|
||||
}
|
||||
|
||||
impl<SR, FR, FoR, PH> ShareUseCase for ShareServiceForTest<SR, FR, FoR, PH>
|
||||
where
|
||||
SR: ShareStoragePort,
|
||||
FR: FileReadPort,
|
||||
FoR: FolderRepository,
|
||||
PH: PasswordHasherPort,
|
||||
{
|
||||
async fn create_shared_link(
|
||||
&self,
|
||||
user_id: &str,
|
||||
dto: CreateShareDto,
|
||||
) -> Result<ShareDto, DomainError> {
|
||||
let item_type = ShareItemType::try_from(dto.item_type.as_str())
|
||||
.map_err(|e| ShareServiceError::InvalidItemType(e.to_string()))?;
|
||||
self.verify_item_exists(&dto.item_id, &item_type).await?;
|
||||
let permissions = dto.permissions.map(|p| p.to_entity());
|
||||
let password_hash = match dto.password {
|
||||
Some(p) => Some(self.hash_password_async(&p).await?),
|
||||
None => None,
|
||||
};
|
||||
let share = Share::new(
|
||||
dto.item_id.clone(),
|
||||
dto.item_name.clone(),
|
||||
item_type,
|
||||
user_id.to_string(),
|
||||
permissions,
|
||||
password_hash,
|
||||
dto.expires_at,
|
||||
)
|
||||
.map_err(|e| ShareServiceError::Validation(e.to_string()))?;
|
||||
let saved_share = self
|
||||
.share_repository
|
||||
.save_share(&share)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
|
||||
}
|
||||
|
||||
async fn get_shared_link(
|
||||
&self,
|
||||
id: &str,
|
||||
requester_id: &str,
|
||||
) -> Result<ShareDto, DomainError> {
|
||||
let share = self
|
||||
.share_repository
|
||||
.find_share_by_id_for_user(id, requester_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ShareServiceError::NotFound(format!("Share {} not found: {}", id, e))
|
||||
})?;
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
}
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
}
|
||||
|
||||
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError> {
|
||||
let share = self
|
||||
.share_repository
|
||||
.find_share_by_token(token)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ShareServiceError::NotFound(format!("Share token {} not found: {}", token, e))
|
||||
})?;
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
}
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
}
|
||||
|
||||
async fn get_shared_links_for_item(
|
||||
&self,
|
||||
item_id: &str,
|
||||
item_type: &ShareItemType,
|
||||
requester_id: &str,
|
||||
) -> Result<Vec<ShareDto>, DomainError> {
|
||||
let shares = self
|
||||
.share_repository
|
||||
.find_shares_by_item_for_user(item_id, item_type, requester_id)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
Ok(shares
|
||||
.into_iter()
|
||||
.filter(|s| !s.is_expired())
|
||||
.map(|s| ShareDto::from_entity(&s, &self.config.base_url()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn update_shared_link(
|
||||
&self,
|
||||
id: &str,
|
||||
requester_id: &str,
|
||||
dto: UpdateShareDto,
|
||||
) -> Result<ShareDto, DomainError> {
|
||||
let mut share = self
|
||||
.share_repository
|
||||
.find_share_by_id_for_user(id, requester_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ShareServiceError::NotFound(format!("Share {} not found: {}", id, e))
|
||||
})?;
|
||||
if let Some(p) = dto.permissions {
|
||||
share = share.with_permissions(SharePermissions::new(p.read, p.write, p.reshare));
|
||||
}
|
||||
if let Some(password) = dto.password {
|
||||
let hash = if password.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.hash_password_async(&password).await?)
|
||||
};
|
||||
share = share.with_password(hash);
|
||||
}
|
||||
if dto.expires_at.is_some() {
|
||||
share = share.with_expiration(dto.expires_at);
|
||||
}
|
||||
let updated = self
|
||||
.share_repository
|
||||
.update_share(&share)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
Ok(ShareDto::from_entity(&updated, &self.config.base_url()))
|
||||
}
|
||||
|
||||
async fn delete_shared_link(
|
||||
&self,
|
||||
id: &str,
|
||||
requester_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.share_repository
|
||||
.delete_share_for_user(id, requester_id)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user_shared_links(
|
||||
&self,
|
||||
user_id: &str,
|
||||
page: usize,
|
||||
per_page: usize,
|
||||
) -> Result<PaginatedResponseDto<ShareDto>, DomainError> {
|
||||
let offset = (page - 1) * per_page;
|
||||
let (shares, total) = self
|
||||
.share_repository
|
||||
.find_shares_by_user(user_id, offset, per_page)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
let dtos = shares
|
||||
.iter()
|
||||
.map(|s| ShareDto::from_entity(s, &self.config.base_url()))
|
||||
.collect();
|
||||
Ok(PaginatedResponseDto::new(dtos, page, per_page, total))
|
||||
}
|
||||
|
||||
async fn verify_shared_link_password(
|
||||
&self,
|
||||
token: &str,
|
||||
password: &str,
|
||||
) -> Result<ShareDto, DomainError> {
|
||||
let share = self
|
||||
.share_repository
|
||||
.find_share_by_token(token)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ShareServiceError::NotFound(format!("Share token {} not found: {}", token, e))
|
||||
})?;
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
}
|
||||
match share.password_hash() {
|
||||
Some(hash) => {
|
||||
let valid = self.password_hasher.verify_password(password, hash).await?;
|
||||
if !valid {
|
||||
return Err(DomainError::new(
|
||||
crate::common::errors::ErrorKind::AccessDenied,
|
||||
"Share",
|
||||
"Invalid share password",
|
||||
));
|
||||
}
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
}
|
||||
None => Ok(ShareDto::from_entity(&share, &self.config.base_url())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
|
||||
let share = self
|
||||
.share_repository
|
||||
.find_share_by_token(token)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ShareServiceError::NotFound(format!("Share token {} not found: {}", token, e))
|
||||
})?;
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
}
|
||||
let updated = share.increment_access_count();
|
||||
self.share_repository
|
||||
.update_share(&updated)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct MockPasswordHasher;
|
||||
|
||||
@@ -519,6 +793,10 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> {
|
||||
Ok(String::new())
|
||||
}
|
||||
@@ -831,7 +1109,7 @@ mod tests {
|
||||
let password_hasher = Arc::new(MockPasswordHasher);
|
||||
|
||||
let service =
|
||||
ShareService::new(config, share_repo, file_repo, folder_repo, password_hasher);
|
||||
ShareServiceForTest::new(config, share_repo, file_repo, folder_repo, password_hasher);
|
||||
|
||||
// Test creating a file share
|
||||
let dto = CreateShareDto {
|
||||
|
||||
@@ -186,7 +186,11 @@ impl TrashUseCase for TrashService {
|
||||
// Returns NotFound if the file does not exist OR belongs to
|
||||
// another user, preventing cross-user trash operations.
|
||||
debug!("Getting file data (owner-scoped): {}", item_id);
|
||||
let file = match self.file_read_port.get_file_for_owner(item_id, user_id).await {
|
||||
let file = match self
|
||||
.file_read_port
|
||||
.get_file_for_owner(item_id, user_id)
|
||||
.await
|
||||
{
|
||||
Ok(file) => {
|
||||
debug!("File found: {} ({})", file.name(), item_id);
|
||||
file
|
||||
@@ -272,7 +276,7 @@ impl TrashUseCase for TrashService {
|
||||
|
||||
// Ownership check — return NotFound (not Forbidden) to
|
||||
// prevent leaking whether the folder exists.
|
||||
if folder.owner_id().map_or(true, |o| o != user_id) {
|
||||
if folder.owner_id().is_none_or(|o| o != user_id) {
|
||||
return Err(DomainError::not_found(
|
||||
"Folder",
|
||||
format!("Folder not found: {}", item_id),
|
||||
|
||||
@@ -7,9 +7,10 @@ use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::trash_dto::TrashedItemDto;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::application::services::trash_service::TrashService;
|
||||
use crate::common::errors::{DomainError, Result};
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
|
||||
@@ -17,7 +18,299 @@ use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
/// Test-only service that mirrors `TrashService` logic but accepts generic repos,
|
||||
/// allowing mock repositories to be injected in unit tests.
|
||||
#[allow(dead_code)]
|
||||
struct TrashServiceForTest<TR, FR, FW, FoR> {
|
||||
trash_repository: Arc<TR>,
|
||||
file_read_port: Arc<FR>,
|
||||
file_write_port: Arc<FW>,
|
||||
folder_storage_port: Arc<FoR>,
|
||||
retention_days: u32,
|
||||
}
|
||||
|
||||
impl<TR, FR, FW, FoR> TrashServiceForTest<TR, FR, FW, FoR>
|
||||
where
|
||||
TR: TrashRepository,
|
||||
FR: FileReadPort,
|
||||
FW: FileWritePort,
|
||||
FoR: FolderRepository,
|
||||
{
|
||||
#[allow(dead_code)]
|
||||
fn new(
|
||||
trash_repository: Arc<TR>,
|
||||
file_read_port: Arc<FR>,
|
||||
file_write_port: Arc<FW>,
|
||||
folder_storage_port: Arc<FoR>,
|
||||
retention_days: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
trash_repository,
|
||||
file_read_port,
|
||||
file_write_port,
|
||||
folder_storage_port,
|
||||
retention_days,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<TR, FR, FW, FoR> TrashUseCase for TrashServiceForTest<TR, FR, FW, FoR>
|
||||
where
|
||||
TR: TrashRepository,
|
||||
FR: FileReadPort,
|
||||
FW: FileWritePort,
|
||||
FoR: FolderRepository,
|
||||
{
|
||||
async fn get_trash_items(&self, user_id: &str) -> Result<Vec<TrashedItemDto>> {
|
||||
let user_uuid = Uuid::parse_str(user_id)
|
||||
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
|
||||
let items = self.trash_repository.get_trash_items(&user_uuid).await?;
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let days_until_deletion = item.days_until_deletion();
|
||||
TrashedItemDto {
|
||||
id: item.id().to_string(),
|
||||
original_id: item.original_id().to_string(),
|
||||
item_type: match item.item_type() {
|
||||
TrashedItemType::File => "file".to_string(),
|
||||
TrashedItemType::Folder => "folder".to_string(),
|
||||
},
|
||||
name: item.name().to_string(),
|
||||
original_path: item.original_path().to_string(),
|
||||
trashed_at: item.trashed_at(),
|
||||
days_until_deletion,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn move_to_trash(&self, item_id: &str, item_type: &str, user_id: &str) -> Result<()> {
|
||||
let item_uuid = Uuid::parse_str(item_id)
|
||||
.map_err(|e| DomainError::validation_error(format!("Invalid item ID: {}", e)))?;
|
||||
let user_uuid = Uuid::parse_str(user_id)
|
||||
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
|
||||
|
||||
match item_type {
|
||||
"file" => {
|
||||
let file = self.file_read_port.get_file(item_id).await.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"File",
|
||||
format!("Error retrieving file {}: {}", item_id, e),
|
||||
)
|
||||
})?;
|
||||
let original_path = file.storage_path().to_string();
|
||||
let trashed_item = TrashedItem::new(
|
||||
item_uuid,
|
||||
user_uuid,
|
||||
TrashedItemType::File,
|
||||
file.name().to_string(),
|
||||
original_path,
|
||||
self.retention_days,
|
||||
);
|
||||
self.trash_repository
|
||||
.add_to_trash(&trashed_item)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"TrashRepository",
|
||||
format!("Failed to add file to trash: {}", e),
|
||||
)
|
||||
})?;
|
||||
self.file_write_port
|
||||
.move_to_trash(item_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"File",
|
||||
format!("Error moving file {} to trash: {}", item_id, e),
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
"folder" => {
|
||||
let folder = self
|
||||
.folder_storage_port
|
||||
.get_folder(item_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Folder",
|
||||
format!("Error retrieving folder {}: {}", item_id, e),
|
||||
)
|
||||
})?;
|
||||
let original_path = folder.storage_path().to_string();
|
||||
let trashed_item = TrashedItem::new(
|
||||
item_uuid,
|
||||
user_uuid,
|
||||
TrashedItemType::Folder,
|
||||
folder.name().to_string(),
|
||||
original_path,
|
||||
self.retention_days,
|
||||
);
|
||||
self.trash_repository
|
||||
.add_to_trash(&trashed_item)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"TrashRepository",
|
||||
format!("Failed to add folder to trash: {}", e),
|
||||
)
|
||||
})?;
|
||||
self.folder_storage_port
|
||||
.move_to_trash(item_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
format!("Error moving folder {} to trash: {}", item_id, e),
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(DomainError::validation_error(format!(
|
||||
"Invalid item type: {}",
|
||||
item_type
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_item(&self, trash_id: &str, user_id: &str) -> Result<()> {
|
||||
let trash_uuid = Uuid::parse_str(trash_id)
|
||||
.map_err(|e| DomainError::validation_error(format!("Invalid trash ID: {}", e)))?;
|
||||
let user_uuid = Uuid::parse_str(user_id)
|
||||
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
|
||||
|
||||
let item = self
|
||||
.trash_repository
|
||||
.get_trash_item(&trash_uuid, &user_uuid)
|
||||
.await?;
|
||||
match item {
|
||||
Some(item) => {
|
||||
match item.item_type() {
|
||||
TrashedItemType::File => {
|
||||
let file_id = item.original_id().to_string();
|
||||
let original_path = item.original_path().to_string();
|
||||
let result = self
|
||||
.file_write_port
|
||||
.restore_from_trash(&file_id, &original_path)
|
||||
.await;
|
||||
if let Err(e) = result
|
||||
&& !format!("{}", e).contains("not found")
|
||||
{
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"File",
|
||||
format!("Error restoring file {} from trash: {}", file_id, e),
|
||||
));
|
||||
}
|
||||
}
|
||||
TrashedItemType::Folder => {
|
||||
let folder_id = item.original_id().to_string();
|
||||
let original_path = item.original_path().to_string();
|
||||
let result = self
|
||||
.folder_storage_port
|
||||
.restore_from_trash(&folder_id, &original_path)
|
||||
.await;
|
||||
if let Err(e) = result
|
||||
&& !format!("{}", e).contains("not found")
|
||||
{
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
format!("Error restoring folder {} from trash: {}", folder_id, e),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.trash_repository
|
||||
.restore_from_trash(&trash_uuid, &user_uuid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Trash",
|
||||
format!("Error removing trash entry after restoration: {}", e),
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_permanently(&self, trash_id: &str, user_id: &str) -> Result<()> {
|
||||
let trash_uuid = Uuid::parse_str(trash_id)
|
||||
.map_err(|e| DomainError::validation_error(format!("Invalid trash ID: {}", e)))?;
|
||||
let user_uuid = Uuid::parse_str(user_id)
|
||||
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
|
||||
|
||||
let item = self
|
||||
.trash_repository
|
||||
.get_trash_item(&trash_uuid, &user_uuid)
|
||||
.await?;
|
||||
match item {
|
||||
Some(item) => {
|
||||
match item.item_type() {
|
||||
TrashedItemType::File => {
|
||||
let file_id = item.original_id().to_string();
|
||||
let result = self.file_write_port.delete_file_permanently(&file_id).await;
|
||||
if let Err(e) = result
|
||||
&& !format!("{}", e).contains("not found")
|
||||
{
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"File",
|
||||
format!("Error deleting file {} permanently: {}", file_id, e),
|
||||
));
|
||||
}
|
||||
}
|
||||
TrashedItemType::Folder => {
|
||||
let folder_id = item.original_id().to_string();
|
||||
let result = self
|
||||
.folder_storage_port
|
||||
.delete_folder_permanently(&folder_id)
|
||||
.await;
|
||||
if let Err(e) = result
|
||||
&& !format!("{}", e).contains("not found")
|
||||
{
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Folder",
|
||||
format!("Error deleting folder {} permanently: {}", folder_id, e),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.trash_repository
|
||||
.delete_permanently(&trash_uuid, &user_uuid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Trash",
|
||||
format!("Error removing trash entry: {}", e),
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn empty_trash(&self, user_id: &str) -> Result<()> {
|
||||
let user_uuid = Uuid::parse_str(user_id)
|
||||
.map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?;
|
||||
self.trash_repository.clear_trash(&user_uuid).await
|
||||
}
|
||||
}
|
||||
|
||||
// Mock repositories for testing
|
||||
#[allow(dead_code)]
|
||||
struct MockTrashRepository {
|
||||
trash_items: Mutex<HashMap<Uuid, TrashedItem>>,
|
||||
/// Shared refs to the file/folder trashed maps so `clear_trash` can
|
||||
@@ -27,6 +320,7 @@ struct MockTrashRepository {
|
||||
}
|
||||
|
||||
impl MockTrashRepository {
|
||||
#[allow(dead_code)]
|
||||
fn new(
|
||||
trashed_files: Arc<Mutex<HashMap<String, File>>>,
|
||||
trashed_folders: Arc<Mutex<HashMap<String, Folder>>>,
|
||||
@@ -104,12 +398,14 @@ impl TrashRepository for MockTrashRepository {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct MockFileRepository {
|
||||
files: Mutex<HashMap<String, File>>,
|
||||
trashed_files: Arc<Mutex<HashMap<String, File>>>,
|
||||
}
|
||||
|
||||
impl MockFileRepository {
|
||||
#[allow(dead_code)]
|
||||
fn new(trashed_files: Arc<Mutex<HashMap<String, File>>>) -> Self {
|
||||
Self {
|
||||
files: Mutex::new(HashMap::new()),
|
||||
@@ -117,6 +413,7 @@ impl MockFileRepository {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn add_test_file(&self, id: &str, name: &str, path: &str) {
|
||||
let file = File::new(
|
||||
id.to_string(),
|
||||
@@ -180,6 +477,13 @@ impl FileReadPort for MockFileRepository {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_folder_id_by_path(
|
||||
&self,
|
||||
_folder_path: &str,
|
||||
) -> std::result::Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_blob_hash(&self, _file_id: &str) -> std::result::Result<String, DomainError> {
|
||||
Ok(String::new())
|
||||
}
|
||||
@@ -328,12 +632,14 @@ impl FileWritePort for MockFileRepository {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
struct MockFolderRepository {
|
||||
folders: Mutex<HashMap<String, Folder>>,
|
||||
trashed_folders: Arc<Mutex<HashMap<String, Folder>>>,
|
||||
}
|
||||
|
||||
impl MockFolderRepository {
|
||||
#[allow(dead_code)]
|
||||
fn new(trashed_folders: Arc<Mutex<HashMap<String, Folder>>>) -> Self {
|
||||
Self {
|
||||
folders: Mutex::new(HashMap::new()),
|
||||
@@ -341,6 +647,7 @@ impl MockFolderRepository {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn add_test_folder(&self, id: &str, name: &str, path: &str) {
|
||||
let folder = Folder::new(
|
||||
id.to_string(),
|
||||
@@ -501,9 +808,13 @@ impl FolderRepository for MockFolderRepository {
|
||||
|
||||
#[cfg(integration_tests)]
|
||||
mod tests {
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
#[allow(unused_imports)]
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
#[allow(unused_imports)]
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
#[allow(unused_imports)]
|
||||
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -518,10 +829,10 @@ mod tests {
|
||||
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
||||
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
||||
|
||||
let service = TrashService::new(
|
||||
let service = TrashServiceForTest::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone() as Arc<FileBlobReadRepository>,
|
||||
file_repo.clone() as Arc<FileBlobWriteRepository>,
|
||||
file_repo.clone(),
|
||||
file_repo.clone(),
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
@@ -592,10 +903,10 @@ mod tests {
|
||||
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
||||
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
||||
|
||||
let service = TrashService::new(
|
||||
let service = TrashServiceForTest::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone() as Arc<FileBlobReadRepository>,
|
||||
file_repo.clone() as Arc<FileBlobWriteRepository>,
|
||||
file_repo.clone(),
|
||||
file_repo.clone(),
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
@@ -657,10 +968,10 @@ mod tests {
|
||||
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
||||
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
||||
|
||||
let service = TrashService::new(
|
||||
let service = TrashServiceForTest::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone() as Arc<FileBlobReadRepository>,
|
||||
file_repo.clone() as Arc<FileBlobWriteRepository>,
|
||||
file_repo.clone(),
|
||||
file_repo.clone(),
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
@@ -727,10 +1038,10 @@ mod tests {
|
||||
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
||||
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
||||
|
||||
let service = TrashService::new(
|
||||
let service = TrashServiceForTest::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone() as Arc<FileBlobReadRepository>,
|
||||
file_repo.clone() as Arc<FileBlobWriteRepository>,
|
||||
file_repo.clone(),
|
||||
file_repo.clone(),
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
@@ -796,10 +1107,10 @@ mod tests {
|
||||
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
||||
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
||||
|
||||
let service = TrashService::new(
|
||||
let service = TrashServiceForTest::new(
|
||||
trash_repo.clone(),
|
||||
file_repo.clone() as Arc<FileBlobReadRepository>,
|
||||
file_repo.clone() as Arc<FileBlobWriteRepository>,
|
||||
file_repo.clone(),
|
||||
file_repo.clone(),
|
||||
folder_repo.clone(),
|
||||
30, // 30 days retention
|
||||
);
|
||||
|
||||
+61
-1
@@ -238,7 +238,7 @@ impl Default for DatabaseConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Updated connection string with default credentials that PostgreSQL often uses
|
||||
connection_string: "postgres://postgres:postgres@localhost:5439/oxicloud".to_string(),
|
||||
connection_string: "postgres://postgres:postgres@localhost:5432/oxicloud".to_string(),
|
||||
max_connections: 20,
|
||||
min_connections: 5,
|
||||
connect_timeout_secs: 10,
|
||||
@@ -435,6 +435,39 @@ impl Default for WopiConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Nextcloud compatibility configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NextcloudConfig {
|
||||
/// Whether the Nextcloud compatibility layer is enabled
|
||||
pub enabled: bool,
|
||||
/// Instance ID suffix for oc:id formatting (e.g., "ocnca")
|
||||
pub instance_id: String,
|
||||
/// Emulated Nextcloud version (major.minor.patch).
|
||||
/// Clients use this to decide which features to enable.
|
||||
pub emulated_version: (u32, u32, u32),
|
||||
/// Login Flow v2 token TTL in seconds (default: 600 = 10 minutes)
|
||||
pub login_flow_ttl_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for NextcloudConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
instance_id: "ocnca".to_string(),
|
||||
emulated_version: (28, 0, 4),
|
||||
login_flow_ttl_secs: 600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NextcloudConfig {
|
||||
/// Version string, e.g. "28.0.4".
|
||||
pub fn version_string(&self) -> String {
|
||||
let (maj, min, pat) = self.emulated_version;
|
||||
format!("{}.{}.{}", maj, min, pat)
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature configuration (feature flags)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeaturesConfig {
|
||||
@@ -488,6 +521,8 @@ pub struct AppConfig {
|
||||
pub oidc: OidcConfig,
|
||||
/// WOPI configuration
|
||||
pub wopi: WopiConfig,
|
||||
/// Nextcloud compatibility configuration
|
||||
pub nextcloud: NextcloudConfig,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -507,6 +542,7 @@ impl Default for AppConfig {
|
||||
features: FeaturesConfig::default(),
|
||||
oidc: OidcConfig::default(),
|
||||
wopi: WopiConfig::default(),
|
||||
nextcloud: NextcloudConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -797,6 +833,30 @@ impl AppConfig {
|
||||
tracing::info!("WOPI secret not set, falling back to JWT secret");
|
||||
}
|
||||
|
||||
// Nextcloud compatibility configuration
|
||||
if let Ok(v) = env::var("OXICLOUD_NEXTCLOUD_ENABLED") {
|
||||
config.nextcloud.enabled = v.parse::<bool>().unwrap_or(false);
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_NEXTCLOUD_INSTANCE_ID") {
|
||||
let trimmed = v.trim();
|
||||
if !trimmed.is_empty() {
|
||||
config.nextcloud.instance_id = trimmed.to_string();
|
||||
}
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_NEXTCLOUD_VERSION") {
|
||||
// Expected format: "28.0.4"
|
||||
let parts: Vec<&str> = v.trim().splitn(3, '.').collect();
|
||||
if parts.len() == 3
|
||||
&& let (Ok(maj), Ok(min), Ok(pat)) = (
|
||||
parts[0].parse::<u32>(),
|
||||
parts[1].parse::<u32>(),
|
||||
parts[2].parse::<u32>(),
|
||||
)
|
||||
{
|
||||
config.nextcloud.emulated_version = (maj, min, pat);
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
|
||||
+77
-25
@@ -11,6 +11,8 @@ use crate::application::ports::file_ports::FileUseCaseFactory;
|
||||
use crate::application::services::favorites_service::FavoritesService;
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
use crate::application::services::i18n_application_service::I18nApplicationService;
|
||||
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
|
||||
use crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService;
|
||||
use crate::application::services::recent_service::RecentService;
|
||||
use crate::application::services::search_service::SearchService;
|
||||
use crate::application::services::share_service::ShareService;
|
||||
@@ -28,6 +30,7 @@ use crate::infrastructure::services::file_content_cache::{
|
||||
FileContentCache, FileContentCacheConfig,
|
||||
};
|
||||
use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService;
|
||||
use crate::infrastructure::services::nextcloud_chunked_upload_service::NextcloudChunkedUploadService;
|
||||
use crate::infrastructure::services::path_service::PathService;
|
||||
use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService;
|
||||
|
||||
@@ -463,6 +466,7 @@ impl AppServiceFactory {
|
||||
let recent_service: Option<Arc<RecentService>>;
|
||||
let storage_usage_service: Option<Arc<StorageUsageService>>;
|
||||
let mut auth_services: Option<crate::common::di::AuthServices> = None;
|
||||
let mut nextcloud_services: Option<NextcloudServices> = None;
|
||||
|
||||
{
|
||||
let favs = self.create_favorites_service(&pool);
|
||||
@@ -507,6 +511,66 @@ impl AppServiceFactory {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared App Password service — created once, used by both NC routes and native API
|
||||
let shared_app_pw_svc: Option<Arc<AppPasswordService>> =
|
||||
if self.config.nextcloud.enabled || self.config.features.enable_auth {
|
||||
let app_pw_repo: Arc<AppPasswordPgRepository> =
|
||||
Arc::new(AppPasswordPgRepository::new(pool.clone()));
|
||||
let hasher: Arc<Argon2PasswordHasher> = Arc::new(
|
||||
crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new(
|
||||
self.config.auth.hash_memory_cost,
|
||||
self.config.auth.hash_time_cost,
|
||||
self.config.auth.hash_parallelism,
|
||||
),
|
||||
);
|
||||
let user_repo: Arc<UserPgRepository> = Arc::new(
|
||||
crate::infrastructure::repositories::pg::UserPgRepository::new(pool.clone()),
|
||||
);
|
||||
let svc = Arc::new(AppPasswordService::new(
|
||||
app_pw_repo,
|
||||
hasher,
|
||||
user_repo,
|
||||
self.config.base_url(),
|
||||
));
|
||||
tracing::info!("App Password service initialized (shared)");
|
||||
Some(svc)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Nextcloud compatibility services
|
||||
if self.config.nextcloud.enabled {
|
||||
if !self.config.features.enable_auth {
|
||||
tracing::warn!(
|
||||
"Nextcloud compatibility enabled but auth is disabled; Nextcloud routes will be unusable"
|
||||
);
|
||||
}
|
||||
|
||||
let chunk_base = self.storage_path.join(".uploads/nextcloud");
|
||||
let chunked_uploads = Arc::new(NextcloudChunkedUploadService::new(chunk_base));
|
||||
|
||||
let file_id_repo = Arc::new(
|
||||
crate::infrastructure::repositories::pg::NextcloudObjectIdRepository::new(
|
||||
pool.clone(),
|
||||
),
|
||||
);
|
||||
let file_ids = Arc::new(NextcloudFileIdService::new(
|
||||
file_id_repo,
|
||||
self.config.nextcloud.instance_id.clone(),
|
||||
));
|
||||
|
||||
nextcloud_services = Some(NextcloudServices {
|
||||
login_flow: Arc::new(NextcloudLoginFlowService::new(
|
||||
std::time::Duration::from_secs(self.config.nextcloud.login_flow_ttl_secs),
|
||||
)),
|
||||
app_passwords: shared_app_pw_svc
|
||||
.clone()
|
||||
.expect("AppPasswordService must be available when NC is enabled"),
|
||||
file_ids,
|
||||
chunked_uploads,
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Preload translations
|
||||
self.preload_translations(&apps.i18n_service).await;
|
||||
|
||||
@@ -528,6 +592,7 @@ impl AppServiceFactory {
|
||||
db_pool: Some(pool.clone()),
|
||||
maintenance_pool: Some(maintenance_pool),
|
||||
auth_service: auth_services,
|
||||
nextcloud: nextcloud_services,
|
||||
admin_settings_service: None,
|
||||
trash_service,
|
||||
share_service,
|
||||
@@ -642,31 +707,8 @@ impl AppServiceFactory {
|
||||
tracing::info!("Device Authorization Grant (RFC 8628) service initialized");
|
||||
}
|
||||
|
||||
// 9d. Wire App Password service
|
||||
{
|
||||
let app_pw_repo: Arc<AppPasswordPgRepository> =
|
||||
Arc::new(AppPasswordPgRepository::new(pool.clone()));
|
||||
let hasher: Arc<Argon2PasswordHasher> = Arc::new(
|
||||
crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new(
|
||||
self.config.auth.hash_memory_cost,
|
||||
self.config.auth.hash_time_cost,
|
||||
self.config.auth.hash_parallelism,
|
||||
),
|
||||
);
|
||||
let user_repo: Arc<UserPgRepository> = Arc::new(
|
||||
crate::infrastructure::repositories::UserPgRepository::new(pool.clone()),
|
||||
);
|
||||
let base_url = self.config.base_url();
|
||||
|
||||
let app_pw_svc = Arc::new(AppPasswordService::new(
|
||||
app_pw_repo,
|
||||
hasher,
|
||||
user_repo,
|
||||
base_url,
|
||||
));
|
||||
app_state.app_password_service = Some(app_pw_svc);
|
||||
tracing::info!("App Password service initialized");
|
||||
}
|
||||
// 9d. Wire App Password service (reuse shared instance)
|
||||
app_state.app_password_service = shared_app_pw_svc.clone();
|
||||
}
|
||||
|
||||
// 9e. Wire PathResolver for single-query WebDAV path resolution
|
||||
@@ -816,6 +858,15 @@ pub struct AuthServices {
|
||||
Arc<crate::infrastructure::services::login_lockout_service::LoginLockoutService>,
|
||||
}
|
||||
|
||||
/// Container for Nextcloud compatibility services
|
||||
#[derive(Clone)]
|
||||
pub struct NextcloudServices {
|
||||
pub login_flow: Arc<NextcloudLoginFlowService>,
|
||||
pub app_passwords: Arc<AppPasswordService>,
|
||||
pub file_ids: Arc<NextcloudFileIdService>,
|
||||
pub chunked_uploads: Arc<NextcloudChunkedUploadService>,
|
||||
}
|
||||
|
||||
/// Global application state for dependency injection
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
@@ -826,6 +877,7 @@ pub struct AppState {
|
||||
/// Isolated pool for background / batch operations.
|
||||
pub maintenance_pool: Option<Arc<PgPool>>,
|
||||
pub auth_service: Option<AuthServices>,
|
||||
pub nextcloud: Option<NextcloudServices>,
|
||||
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
|
||||
pub trash_service: Option<Arc<TrashService>>,
|
||||
pub share_service: Option<Arc<ShareService>>,
|
||||
|
||||
+207
-85
@@ -1,85 +1,207 @@
|
||||
//! MIME type detection using magic bytes (infer) + extension fallback (mime_guess).
|
||||
//!
|
||||
//! Priority order:
|
||||
//! 1. If the claimed Content-Type is specific (not `application/octet-stream`), trust it.
|
||||
//! 2. Read first bytes of the file and detect via magic bytes (`infer` crate).
|
||||
//! 3. Fall back to extension-based detection (`mime_guess`).
|
||||
//! 4. If nothing matches, return the original claimed type.
|
||||
//!
|
||||
//! Performance: < 1µs for the `infer` check (reads only header bytes, no allocation).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Maximum bytes to read for magic-byte detection.
|
||||
const MAGIC_BYTES_LEN: usize = 8192;
|
||||
|
||||
/// Refine a claimed MIME type using magic bytes and filename extension.
|
||||
///
|
||||
/// This is a synchronous function — the caller should already have the first
|
||||
/// bytes of the file available (or call the async wrapper below).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `buf` — first bytes of the file (at least 8192 for best results)
|
||||
/// * `filename` — original filename (used for extension fallback)
|
||||
/// * `claimed` — the Content-Type sent by the client
|
||||
pub fn refine_content_type(buf: &[u8], filename: &str, claimed: &str) -> String {
|
||||
// If the client sent a specific type (not generic), trust it
|
||||
if !claimed.is_empty()
|
||||
&& claimed != "application/octet-stream"
|
||||
&& claimed != "binary/octet-stream"
|
||||
{
|
||||
return claimed.to_string();
|
||||
}
|
||||
|
||||
// 1. Try magic bytes detection
|
||||
if let Some(kind) = infer::get(buf) {
|
||||
return kind.mime_type().to_string();
|
||||
}
|
||||
|
||||
// 2. Try extension-based detection
|
||||
let guess = mime_guess::from_path(filename);
|
||||
if let Some(mime) = guess.first() {
|
||||
return mime.to_string();
|
||||
}
|
||||
|
||||
// 3. Fall back to claimed type
|
||||
claimed.to_string()
|
||||
}
|
||||
|
||||
/// Async helper: reads the first bytes of a file on disk and refines the MIME type.
|
||||
///
|
||||
/// Designed for the upload path where the file has been spooled to a temp path.
|
||||
pub async fn refine_content_type_from_file(
|
||||
temp_path: &Path,
|
||||
filename: &str,
|
||||
claimed: &str,
|
||||
) -> String {
|
||||
// Fast path: if the client gave us a specific type, trust it
|
||||
if !claimed.is_empty()
|
||||
&& claimed != "application/octet-stream"
|
||||
&& claimed != "binary/octet-stream"
|
||||
{
|
||||
return claimed.to_string();
|
||||
}
|
||||
|
||||
// Read first bytes for magic detection
|
||||
match tokio::fs::read(temp_path).await {
|
||||
Ok(full) => {
|
||||
let len = full.len().min(MAGIC_BYTES_LEN);
|
||||
refine_content_type(&full[..len], filename, claimed)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"MIME detection: failed to read {} for magic bytes: {}",
|
||||
temp_path.display(),
|
||||
e
|
||||
);
|
||||
// Fall back to extension
|
||||
let guess = mime_guess::from_path(filename);
|
||||
if let Some(mime) = guess.first() {
|
||||
return mime.to_string();
|
||||
}
|
||||
claimed.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
//! MIME type detection using magic bytes (infer) + extension fallback (mime_guess).
|
||||
//!
|
||||
//! Priority order:
|
||||
//! 1. If the claimed Content-Type is specific (not `application/octet-stream`), trust it.
|
||||
//! 2. Read first bytes of the file and detect via magic bytes (`infer` crate).
|
||||
//! 3. Fall back to extension-based detection (`mime_guess`).
|
||||
//! 4. If nothing matches, return the original claimed type.
|
||||
//!
|
||||
//! Performance: < 1µs for the `infer` check (reads only header bytes, no allocation).
|
||||
|
||||
use std::path::Path;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
/// Maximum bytes to read for magic-byte detection.
|
||||
const MAGIC_BYTES_LEN: usize = 8192;
|
||||
|
||||
/// Extract the filename component from a `/`-separated path.
|
||||
pub fn filename_from_path(path: &str) -> &str {
|
||||
path.rsplit('/').next().unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Refine a claimed MIME type using magic bytes and filename extension.
|
||||
///
|
||||
/// This is a synchronous function — the caller should already have the first
|
||||
/// bytes of the file available (or call the async wrapper below).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `buf` — first bytes of the file (at least 8192 for best results)
|
||||
/// * `filename` — original filename (used for extension fallback)
|
||||
/// * `claimed` — the Content-Type sent by the client
|
||||
pub fn refine_content_type(buf: &[u8], filename: &str, claimed: &str) -> String {
|
||||
// If the client sent a specific type (not generic), trust it
|
||||
if !claimed.is_empty()
|
||||
&& claimed != "application/octet-stream"
|
||||
&& claimed != "binary/octet-stream"
|
||||
{
|
||||
return claimed.to_string();
|
||||
}
|
||||
|
||||
// 1. Try magic bytes detection
|
||||
if let Some(kind) = infer::get(buf) {
|
||||
return kind.mime_type().to_string();
|
||||
}
|
||||
|
||||
// 2. Try extension-based detection
|
||||
let guess = mime_guess::from_path(filename);
|
||||
if let Some(mime) = guess.first() {
|
||||
return mime.to_string();
|
||||
}
|
||||
|
||||
// 3. Fall back to claimed type
|
||||
claimed.to_string()
|
||||
}
|
||||
|
||||
/// Async helper: reads the first bytes of a file on disk and refines the MIME type.
|
||||
///
|
||||
/// Designed for the upload path where the file has been spooled to a temp path.
|
||||
pub async fn refine_content_type_from_file(
|
||||
temp_path: &Path,
|
||||
filename: &str,
|
||||
claimed: &str,
|
||||
) -> String {
|
||||
// Fast path: if the client gave us a specific type, trust it
|
||||
if !claimed.is_empty()
|
||||
&& claimed != "application/octet-stream"
|
||||
&& claimed != "binary/octet-stream"
|
||||
{
|
||||
return claimed.to_string();
|
||||
}
|
||||
|
||||
// Read only the first bytes needed for magic detection (not the whole file).
|
||||
match tokio::fs::File::open(temp_path).await {
|
||||
Ok(mut file) => {
|
||||
let mut buf = vec![0u8; MAGIC_BYTES_LEN];
|
||||
let n = file.read(&mut buf).await.unwrap_or(0);
|
||||
refine_content_type(&buf[..n], filename, claimed)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"MIME detection: failed to read {} for magic bytes: {}",
|
||||
temp_path.display(),
|
||||
e
|
||||
);
|
||||
// Fall back to extension
|
||||
let guess = mime_guess::from_path(filename);
|
||||
if let Some(mime) = guess.first() {
|
||||
return mime.to_string();
|
||||
}
|
||||
claimed.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
// ── refine_content_type (sync) ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn specific_claimed_type_is_trusted() {
|
||||
let result = refine_content_type(b"garbage", "file.txt", "image/png");
|
||||
assert_eq!(result, "image/png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn octet_stream_triggers_magic_detection_png() {
|
||||
// PNG magic bytes
|
||||
let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
|
||||
let result = refine_content_type(png, "noext", "application/octet-stream");
|
||||
assert_eq!(result, "image/png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn octet_stream_triggers_magic_detection_jpeg() {
|
||||
let jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF";
|
||||
let result = refine_content_type(jpeg, "noext", "application/octet-stream");
|
||||
assert_eq!(result, "image/jpeg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_octet_stream_also_triggers_detection() {
|
||||
let jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF";
|
||||
let result = refine_content_type(jpeg, "noext", "binary/octet-stream");
|
||||
assert_eq!(result, "image/jpeg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_fallback_when_no_magic_match() {
|
||||
let result = refine_content_type(b"plain text", "style.css", "application/octet-stream");
|
||||
assert_eq!(result, "text/css");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_claimed_when_nothing_matches() {
|
||||
let result = refine_content_type(b"unknown stuff", "noext", "application/octet-stream");
|
||||
assert_eq!(result, "application/octet-stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_claimed_triggers_detection() {
|
||||
let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
|
||||
let result = refine_content_type(png, "photo.png", "");
|
||||
assert_eq!(result, "image/png");
|
||||
}
|
||||
|
||||
// ── refine_content_type_from_file (async) ───────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_file_detects_png() {
|
||||
let mut tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
|
||||
tmp.write_all(png).unwrap();
|
||||
tmp.flush().unwrap();
|
||||
|
||||
let result =
|
||||
refine_content_type_from_file(tmp.path(), "photo", "application/octet-stream").await;
|
||||
assert_eq!(result, "image/png");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_file_falls_back_to_extension() {
|
||||
let mut tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
tmp.write_all(b"not magic").unwrap();
|
||||
tmp.flush().unwrap();
|
||||
|
||||
let result =
|
||||
refine_content_type_from_file(tmp.path(), "doc.css", "application/octet-stream").await;
|
||||
assert_eq!(result, "text/css");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_file_trusts_specific_claimed() {
|
||||
let result =
|
||||
refine_content_type_from_file(Path::new("/nonexistent"), "file", "image/webp").await;
|
||||
assert_eq!(result, "image/webp");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_file_missing_file_falls_back_to_extension() {
|
||||
let result = refine_content_type_from_file(
|
||||
Path::new("/nonexistent/file"),
|
||||
"photo.jpg",
|
||||
"application/octet-stream",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result, "image/jpeg");
|
||||
}
|
||||
|
||||
// ── filename_from_path ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extracts_filename_from_deep_path() {
|
||||
assert_eq!(filename_from_path("a/b/c/photo.jpg"), "photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_input_when_no_slash() {
|
||||
assert_eq!(filename_from_path("photo.jpg"), "photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_trailing_slash() {
|
||||
assert_eq!(filename_from_path("a/b/"), "");
|
||||
}
|
||||
}
|
||||
|
||||
+19
-2
@@ -96,6 +96,10 @@ impl FileReadPort for StubFileReadPort {
|
||||
Ok("root".to_string())
|
||||
}
|
||||
|
||||
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
|
||||
Ok("stub-folder-id".to_string())
|
||||
}
|
||||
|
||||
async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> {
|
||||
Ok(String::new())
|
||||
}
|
||||
@@ -355,7 +359,11 @@ impl FolderUseCase for StubFolderUseCase {
|
||||
Ok(FolderDto::default())
|
||||
}
|
||||
|
||||
async fn get_folder_owned(&self, _id: &str, _caller_id: &str) -> Result<FolderDto, DomainError> {
|
||||
async fn get_folder_owned(
|
||||
&self,
|
||||
_id: &str,
|
||||
_caller_id: &str,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::default())
|
||||
}
|
||||
|
||||
@@ -463,7 +471,12 @@ impl FileUploadUseCase for StubFileUploadUseCase {
|
||||
Ok(FileDto::default())
|
||||
}
|
||||
|
||||
async fn update_file(&self, _path: &str, _content: &[u8]) -> Result<(), DomainError> {
|
||||
async fn update_file(
|
||||
&self,
|
||||
_path: &str,
|
||||
_content: &[u8],
|
||||
_content_type: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -773,6 +786,10 @@ impl DedupPort for StubDedupPort {
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
fn blob_path(&self, hash: &str) -> PathBuf {
|
||||
PathBuf::from(format!("stub_blob_{}.blob", hash))
|
||||
}
|
||||
|
||||
async fn get_stats(&self) -> DedupStatsDto {
|
||||
DedupStatsDto::default()
|
||||
}
|
||||
|
||||
@@ -292,8 +292,10 @@ impl User {
|
||||
));
|
||||
}
|
||||
// Disallow leading/trailing dots or hyphens
|
||||
if username.starts_with('.') || username.starts_with('-')
|
||||
|| username.ends_with('.') || username.ends_with('-')
|
||||
if username.starts_with('.')
|
||||
|| username.starts_with('-')
|
||||
|| username.ends_with('.')
|
||||
|| username.ends_with('-')
|
||||
{
|
||||
return Err(UserError::InvalidUsername(
|
||||
"Username must not start or end with a dot or hyphen".to_string(),
|
||||
@@ -310,7 +312,9 @@ impl User {
|
||||
fn validate_email(email: &str) -> UserResult<()> {
|
||||
let parts: Vec<&str> = email.splitn(2, '@').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(UserError::ValidationError("Invalid email: missing @".to_string()));
|
||||
return Err(UserError::ValidationError(
|
||||
"Invalid email: missing @".to_string(),
|
||||
));
|
||||
}
|
||||
let (local, domain) = (parts[0], parts[1]);
|
||||
if local.is_empty() || domain.is_empty() {
|
||||
@@ -324,7 +328,9 @@ impl User {
|
||||
));
|
||||
}
|
||||
// Reject characters commonly used in XSS / header injection
|
||||
let forbidden = ['<', '>', '"', '\'', '\\', ' ', '\t', '\n', '\r', '(', ')', ',', ';'];
|
||||
let forbidden = [
|
||||
'<', '>', '"', '\'', '\\', ' ', '\t', '\n', '\r', '(', ')', ',', ';',
|
||||
];
|
||||
if email.chars().any(|c| forbidden.contains(&c)) {
|
||||
return Err(UserError::ValidationError(
|
||||
"Invalid email: contains forbidden characters".to_string(),
|
||||
|
||||
@@ -34,10 +34,7 @@ pub trait SettingsRepository: Send + Sync + 'static {
|
||||
/// The default implementation falls back to the non-atomic
|
||||
/// get-then-set pattern for repositories that don't support a native
|
||||
/// atomic upsert.
|
||||
async fn try_claim_initialization(
|
||||
&self,
|
||||
admin_user_id: &str,
|
||||
) -> Result<bool, DomainError> {
|
||||
async fn try_claim_initialization(&self, admin_user_id: &str) -> Result<bool, DomainError> {
|
||||
// Default: non-atomic fallback (overridden by PG implementation)
|
||||
match self.get("system_initialized").await? {
|
||||
Some(v) if v == "true" => Ok(false),
|
||||
|
||||
@@ -69,6 +69,9 @@ pub trait UserRepository: Send + Sync + 'static {
|
||||
/// Lists users with pagination
|
||||
async fn list_users(&self, limit: i64, offset: i64) -> UserRepositoryResult<Vec<User>>;
|
||||
|
||||
/// Searches users by username or email (SQL ILIKE) with a limit.
|
||||
async fn search_users(&self, query: &str, limit: i64) -> UserRepositoryResult<Vec<User>>;
|
||||
|
||||
/// Activates or deactivates a user
|
||||
async fn set_user_active_status(&self, user_id: &str, active: bool)
|
||||
-> UserRepositoryResult<()>;
|
||||
|
||||
@@ -103,6 +103,34 @@ impl AppPasswordStoragePort for AppPasswordPgRepository {
|
||||
Ok(rows.into_iter().map(|r| r.into()).collect())
|
||||
}
|
||||
|
||||
async fn get_active_by_user_prefix(
|
||||
&self,
|
||||
user_id: &str,
|
||||
prefix: &str,
|
||||
) -> Result<Vec<AppPassword>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, AppPasswordRow>(
|
||||
r#"
|
||||
SELECT id, user_id, label, password_hash, prefix, scopes,
|
||||
created_at, last_used_at, expires_at, active
|
||||
FROM auth.app_passwords
|
||||
WHERE user_id = $1
|
||||
AND prefix = $2
|
||||
AND active = TRUE
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(prefix)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("AppPasswordPg", format!("get_active_by_prefix: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.into()).collect())
|
||||
}
|
||||
|
||||
async fn touch_last_used(&self, id: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE auth.app_passwords SET last_used_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
@@ -112,12 +140,15 @@ impl AppPasswordStoragePort for AppPasswordPgRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke(&self, id: &str) -> Result<(), DomainError> {
|
||||
let result = sqlx::query("UPDATE auth.app_passwords SET active = FALSE WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("revoke: {e}")))?;
|
||||
async fn revoke(&self, id: &str, user_id: &str) -> Result<(), DomainError> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE auth.app_passwords SET active = FALSE WHERE id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("revoke: {e}")))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::not_found("AppPassword", id));
|
||||
@@ -125,6 +156,19 @@ impl AppPasswordStoragePort for AppPasswordPgRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_by_user_and_id(&self, id: &str, user_id: &str) -> Result<bool, DomainError> {
|
||||
let result = sqlx::query("DELETE FROM auth.app_passwords WHERE id = $1 AND user_id = $2")
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("AppPasswordPg", format!("delete_by_user_and_id: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
@@ -247,4 +248,37 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
||||
|
||||
Ok(total_inserted)
|
||||
}
|
||||
|
||||
async fn batch_check_favorites(
|
||||
&self,
|
||||
user_id: &str,
|
||||
item_ids: &[(&str, &str)],
|
||||
) -> Result<HashSet<String>> {
|
||||
if item_ids.is_empty() {
|
||||
return Ok(HashSet::new());
|
||||
}
|
||||
|
||||
let user_uuid = Uuid::parse_str(user_id)?;
|
||||
|
||||
// Collect just the IDs for the IN clause
|
||||
let ids: Vec<String> = item_ids.iter().map(|(id, _)| id.to_string()).collect();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT item_id FROM auth.user_favorites WHERE user_id = $1::TEXT AND item_id = ANY($2)",
|
||||
)
|
||||
.bind(user_uuid)
|
||||
.bind(&ids)
|
||||
.fetch_all(&*self.db_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Database error batch-checking favorites: {}", e);
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"Favorites",
|
||||
format!("Failed to batch-check favorites: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(rows.iter().map(|r| r.get::<String, _>("item_id")).collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,25 @@ impl FileBlobReadRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a stub instance for testing — never hits PG.
|
||||
#[cfg(test)]
|
||||
pub fn new_stub() -> Self {
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
Self {
|
||||
pool: Arc::new(
|
||||
sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
|
||||
.max_connections(1)
|
||||
.connect_lazy("postgres://invalid:5432/none")
|
||||
.unwrap(),
|
||||
),
|
||||
dedup: Arc::new(DedupService::new_stub()),
|
||||
hash_cache: Cache::builder()
|
||||
.max_capacity(10_000)
|
||||
.time_to_idle(Duration::from_secs(30))
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `StoragePath` from the materialized folder path + file name.
|
||||
fn make_file_path(folder_path: Option<&str>, file_name: &str) -> StoragePath {
|
||||
match folder_path {
|
||||
@@ -216,6 +235,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
|
||||
let rows: Vec<FileRow> = if let Some(fid) = folder_id {
|
||||
sqlx::query_as(
|
||||
@@ -305,9 +325,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("list_for_owner: {e}"))
|
||||
})?;
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_for_owner: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, fid, fpath, size, mime, ca, ma, uid)| {
|
||||
@@ -324,6 +342,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
///
|
||||
/// Uses a single SQL query with `LIMIT/OFFSET` to avoid loading the full
|
||||
/// folder contents into memory. Ideal for streaming WebDAV PROPFIND.
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_files_batch(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
@@ -508,14 +527,24 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
));
|
||||
}
|
||||
|
||||
self.get_folder_id_by_path(&folder_path).await
|
||||
}
|
||||
|
||||
async fn get_folder_id_by_path(&self, folder_path: &str) -> Result<String, DomainError> {
|
||||
let folder_path = folder_path.trim_start_matches('/').trim_end_matches('/');
|
||||
|
||||
if folder_path.is_empty() {
|
||||
return Err(DomainError::not_found("Folder", "empty path"));
|
||||
}
|
||||
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT id::text FROM storage.folders WHERE path = $1 AND NOT is_trashed",
|
||||
)
|
||||
.bind(&folder_path)
|
||||
.bind(folder_path)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("parent lookup: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", format!("parent for path: {path}")))
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("folder lookup: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", format!("path: {folder_path}")))
|
||||
}
|
||||
|
||||
/// Direct SQL lookup using materialized folder paths.
|
||||
@@ -957,6 +986,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn suggest_files_by_name(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
@@ -1032,9 +1062,11 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(integration_tests)]
|
||||
#[cfg(feature = "integration_tests")]
|
||||
#[allow(dead_code)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[allow(unused_imports)]
|
||||
use crate::common::stubs::StubDedupPort;
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
|
||||
@@ -1042,13 +1074,9 @@ mod tests {
|
||||
/// Only the moka `hash_cache` is exercised — no SQL is executed.
|
||||
fn make_repo() -> FileBlobReadRepository {
|
||||
let _folder_repo = Arc::new(FolderDbRepository::new_stub());
|
||||
// StubDedupPort satisfies the trait but is never called in cache-only tests
|
||||
let dedup: Arc<DedupService> = Arc::new(StubDedupPort);
|
||||
// PgPool is required by the struct but we won't hit any SQL in these tests.
|
||||
// We create a repo with a stub pool placeholder — only hash_cache is tested.
|
||||
let dedup: Arc<DedupService> = Arc::new(DedupService::new_stub());
|
||||
FileBlobReadRepository {
|
||||
pool: Arc::new(
|
||||
// Use an intentionally invalid URL; tests never reach PG.
|
||||
sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
|
||||
.max_connections(1)
|
||||
.connect_lazy("postgres://invalid:5432/none")
|
||||
@@ -1135,7 +1163,7 @@ mod tests {
|
||||
.connect_lazy("postgres://invalid:5432/none")
|
||||
.unwrap(),
|
||||
),
|
||||
dedup: Arc::new(StubDedupPort),
|
||||
dedup: Arc::new(DedupService::new_stub()),
|
||||
hash_cache: Cache::builder()
|
||||
.max_capacity(2) // only 2 entries
|
||||
.build(),
|
||||
|
||||
@@ -39,6 +39,22 @@ impl FileBlobWriteRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a stub instance for testing — never hits PG.
|
||||
#[cfg(test)]
|
||||
pub fn new_stub() -> Self {
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
Self {
|
||||
pool: Arc::new(
|
||||
sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
|
||||
.max_connections(1)
|
||||
.connect_lazy("postgres://invalid:5432/none")
|
||||
.unwrap(),
|
||||
),
|
||||
dedup: Arc::new(DedupService::new_stub()),
|
||||
folder_repo: Arc::new(super::folder_db_repository::FolderDbRepository::new_stub()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `StoragePath` from the materialized folder path + file name.
|
||||
fn make_file_path(folder_path: Option<&str>, file_name: &str) -> StoragePath {
|
||||
match folder_path {
|
||||
|
||||
@@ -198,6 +198,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError> {
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
@@ -236,6 +237,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_folders_by_owner(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
@@ -283,6 +285,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
/// Paginated folder listing — single query with `COUNT(*) OVER()` window
|
||||
/// function so the total matching count comes back alongside the data rows,
|
||||
/// eliminating a separate COUNT round-trip.
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
@@ -346,6 +349,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
|
||||
/// Paginated folder listing filtered by owner — single query with
|
||||
/// `COUNT(*) OVER()` to avoid a separate COUNT round-trip.
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_folders_by_owner_paginated(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
@@ -685,6 +689,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
///
|
||||
/// Single GiST-indexed query: `fo.lpath <@ (root's lpath)`.
|
||||
/// Ordered by `fo.path` so callers can iterate in directory order.
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_subtree_folders(&self, folder_id: &str) -> Result<Vec<Folder>, DomainError> {
|
||||
let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||
fo.user_id::text, \
|
||||
@@ -716,6 +721,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
/// - Non-recursive: `WHERE parent_id = $1 AND user_id = $2 [AND LIKE]`
|
||||
/// - Recursive + folder_id: delegates to `list_descendant_folders`
|
||||
/// - Recursive + no folder_id: `WHERE user_id = $1 [AND LIKE]`
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn search_folders(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
@@ -854,6 +860,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
///
|
||||
/// Single SQL query: `fo.lpath <@ (root's lpath)` fetches the entire
|
||||
/// subtree in one indexed scan. Optional name filter is pushed to SQL.
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn list_descendant_folders(
|
||||
&self,
|
||||
folder_id: &str,
|
||||
@@ -861,7 +868,9 @@ impl FolderRepository for FolderDbRepository {
|
||||
user_id: &str,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let (where_extra, name_pattern) = match name_contains {
|
||||
Some(name) if name.len() >= 3 => (" AND fo.name ILIKE $3", Some(super::like_escape(name))),
|
||||
Some(name) if name.len() >= 3 => {
|
||||
(" AND fo.name ILIKE $3", Some(super::like_escape(name)))
|
||||
}
|
||||
_ => ("", None),
|
||||
};
|
||||
|
||||
@@ -902,6 +911,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn suggest_folders_by_name(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
|
||||
@@ -7,6 +7,7 @@ mod contact_persistence_dto;
|
||||
mod contact_pg_repository;
|
||||
mod device_code_pg_repository;
|
||||
mod favorites_pg_repository;
|
||||
mod nextcloud_object_id_repository;
|
||||
mod recent_items_pg_repository;
|
||||
mod session_pg_repository;
|
||||
mod settings_pg_repository;
|
||||
@@ -32,6 +33,7 @@ pub use favorites_pg_repository::FavoritesPgRepository;
|
||||
pub use file_blob_read_repository::FileBlobReadRepository;
|
||||
pub use file_blob_write_repository::FileBlobWriteRepository;
|
||||
pub use folder_db_repository::FolderDbRepository;
|
||||
pub use nextcloud_object_id_repository::NextcloudObjectIdRepository;
|
||||
pub use recent_items_pg_repository::RecentItemsPgRepository;
|
||||
pub use session_pg_repository::SessionPgRepository;
|
||||
pub use settings_pg_repository::SettingsPgRepository;
|
||||
@@ -48,6 +50,9 @@ pub use user_pg_repository::UserPgRepository;
|
||||
/// `%` is a wildcard in LIKE patterns.
|
||||
#[inline]
|
||||
pub fn like_escape(raw: &str) -> String {
|
||||
let escaped = raw.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
||||
let escaped = raw
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_");
|
||||
format!("%{escaped}%")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
|
||||
pub struct NextcloudObjectIdRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl NextcloudObjectIdRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn get_or_create(&self, object_type: &str, object_id: &str) -> Result<i64> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO storage.nextcloud_object_ids (object_type, object_id)
|
||||
VALUES ($1, $2::uuid)
|
||||
ON CONFLICT (object_type, object_id)
|
||||
DO UPDATE SET object_id = EXCLUDED.object_id
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(object_type)
|
||||
.bind(object_id)
|
||||
.fetch_one(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"NextcloudFileId",
|
||||
format!("Failed to get/create Nextcloud ID: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(row.get::<i64, _>("id"))
|
||||
}
|
||||
|
||||
/// Get the OxiCloud object ID from a Nextcloud numeric ID.
|
||||
pub async fn get_object_id(&self, nc_id: i64, object_type: &str) -> Result<String> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT object_id
|
||||
FROM storage.nextcloud_object_ids
|
||||
WHERE id = $1 AND object_type = $2
|
||||
"#,
|
||||
)
|
||||
.bind(nc_id)
|
||||
.bind(object_type)
|
||||
.fetch_optional(&*self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"NextcloudFileId",
|
||||
format!("Failed to lookup Nextcloud ID: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
match row {
|
||||
Some(row) => {
|
||||
let uuid: sqlx::types::Uuid = row.get("object_id");
|
||||
Ok(uuid.to_string())
|
||||
}
|
||||
None => Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"NextcloudFileId",
|
||||
format!("No mapping found for Nextcloud ID: {}", nc_id),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,10 +102,7 @@ impl SettingsRepository for SettingsPgRepository {
|
||||
///
|
||||
/// Only the first caller that inserts the row gets `rows_affected == 1`;
|
||||
/// concurrent callers see 0 rows affected and receive `false`.
|
||||
async fn try_claim_initialization(
|
||||
&self,
|
||||
admin_user_id: &str,
|
||||
) -> Result<bool, DomainError> {
|
||||
async fn try_claim_initialization(&self, admin_user_id: &str) -> Result<bool, DomainError> {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO auth.admin_settings (key, value, category, is_secret, updated_by, updated_at)
|
||||
VALUES ('system_initialized', 'true', 'system', false, $1, NOW())
|
||||
|
||||
@@ -22,6 +22,19 @@ impl SharePgRepository {
|
||||
Self { db_pool }
|
||||
}
|
||||
|
||||
/// Creates a stub instance for testing — never hits PG.
|
||||
#[cfg(test)]
|
||||
pub fn new_stub() -> Self {
|
||||
Self {
|
||||
db_pool: Arc::new(
|
||||
sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
|
||||
.max_connections(1)
|
||||
.connect_lazy("postgres://invalid:5432/none")
|
||||
.unwrap(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity.
|
||||
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
|
||||
let id: String = row
|
||||
@@ -189,10 +202,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error deleting share for user: {}", e);
|
||||
DomainError::internal_error(
|
||||
"Share",
|
||||
format!("Failed to delete share: {e}"),
|
||||
)
|
||||
DomainError::internal_error("Share", format!("Failed to delete share: {e}"))
|
||||
})?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
@@ -229,10 +239,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error finding shares by item for user: {}", e);
|
||||
DomainError::internal_error(
|
||||
"Share",
|
||||
format!("Failed to find shares by item: {e}"),
|
||||
)
|
||||
DomainError::internal_error("Share", format!("Failed to find shares by item: {e}"))
|
||||
})?;
|
||||
|
||||
rows.iter().map(Self::row_to_entity).collect()
|
||||
|
||||
@@ -30,6 +30,20 @@ impl TrashDbRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a stub instance for testing — never hits PG.
|
||||
#[cfg(test)]
|
||||
pub fn new_stub() -> Self {
|
||||
Self {
|
||||
pool: Arc::new(
|
||||
sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
|
||||
.max_connections(1)
|
||||
.connect_lazy("postgres://invalid:5432/none")
|
||||
.unwrap(),
|
||||
),
|
||||
retention_days: 30,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a trash_items view row into a TrashedItem entity.
|
||||
fn row_to_trashed_item(
|
||||
&self,
|
||||
|
||||
@@ -369,6 +369,57 @@ impl UserRepository for UserPgRepository {
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
async fn search_users(&self, query: &str, limit: i64) -> UserRepositoryResult<Vec<User>> {
|
||||
let pattern = format!("%{}%", query);
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject
|
||||
FROM auth.users
|
||||
WHERE username ILIKE $1 OR email ILIKE $1
|
||||
ORDER BY username
|
||||
LIMIT $2
|
||||
"#,
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(limit)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
|
||||
let users = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
|
||||
let role = match role_str.as_deref() {
|
||||
Some("admin") => UserRole::Admin,
|
||||
_ => UserRole::User,
|
||||
};
|
||||
|
||||
User::from_data_full(
|
||||
row.get("id"),
|
||||
row.get("username"),
|
||||
row.get("email"),
|
||||
row.get("password_hash"),
|
||||
role,
|
||||
row.get("storage_quota_bytes"),
|
||||
row.get("storage_used_bytes"),
|
||||
row.get("created_at"),
|
||||
row.get("updated_at"),
|
||||
row.get("last_login_at"),
|
||||
row.get("active"),
|
||||
row.get("oidc_provider"),
|
||||
row.get("oidc_subject"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
/// Activates or deactivates a user
|
||||
async fn set_user_active_status(
|
||||
&self,
|
||||
@@ -664,6 +715,12 @@ impl UserStoragePort for UserPgRepository {
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<User>, DomainError> {
|
||||
UserRepository::search_users(self, query, limit)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError> {
|
||||
UserRepository::list_users_by_role(self, role)
|
||||
.await
|
||||
|
||||
@@ -371,11 +371,7 @@ impl ChunkedUploadService {
|
||||
|
||||
/// Verify that the given session belongs to the given user.
|
||||
/// Returns 404 (not 403) to avoid revealing the existence of other users' sessions.
|
||||
fn verify_session_owner(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), String> {
|
||||
fn verify_session_owner(&self, upload_id: &str, user_id: &str) -> Result<(), String> {
|
||||
let session = self
|
||||
.sessions
|
||||
.get(upload_id)
|
||||
@@ -592,7 +588,11 @@ impl ChunkedUploadService {
|
||||
}
|
||||
|
||||
/// Get upload status
|
||||
async fn get_status_inner(&self, upload_id: &str, user_id: &str) -> Result<UploadStatusResponseDto, String> {
|
||||
async fn get_status_inner(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<UploadStatusResponseDto, String> {
|
||||
self.verify_session_owner(upload_id, user_id)?;
|
||||
|
||||
let session = self
|
||||
@@ -808,9 +808,16 @@ impl ChunkedUploadPort for ChunkedUploadService {
|
||||
total_size: u64,
|
||||
chunk_size: Option<usize>,
|
||||
) -> Result<CreateUploadResponseDto, DomainError> {
|
||||
self.create_session_inner(user_id.to_owned(), filename, folder_id, content_type, total_size, chunk_size)
|
||||
.await
|
||||
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))
|
||||
self.create_session_inner(
|
||||
user_id.to_owned(),
|
||||
filename,
|
||||
folder_id,
|
||||
content_type,
|
||||
total_size,
|
||||
chunk_size,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))
|
||||
}
|
||||
|
||||
async fn upload_chunk(
|
||||
@@ -826,7 +833,11 @@ impl ChunkedUploadPort for ChunkedUploadService {
|
||||
.map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e))
|
||||
}
|
||||
|
||||
async fn get_status(&self, upload_id: &str, user_id: &str) -> Result<UploadStatusResponseDto, DomainError> {
|
||||
async fn get_status(
|
||||
&self,
|
||||
upload_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<UploadStatusResponseDto, DomainError> {
|
||||
self.get_status_inner(upload_id, user_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::new(ErrorKind::NotFound, "ChunkedUpload", e))
|
||||
@@ -1097,14 +1108,19 @@ mod tests {
|
||||
assert_eq!(r1.bytes_received, 1024);
|
||||
|
||||
// 3. Status check
|
||||
let status = service.get_status_inner(&id, "test-user").await.expect("status");
|
||||
let status = service
|
||||
.get_status_inner(&id, "test-user")
|
||||
.await
|
||||
.expect("status");
|
||||
assert!(status.is_complete);
|
||||
assert_eq!(status.completed_chunks, 2);
|
||||
assert!(status.pending_chunks.is_empty());
|
||||
|
||||
// 4. Complete (assemble)
|
||||
let (path, filename, _folder, _ct, size, hash) =
|
||||
service.complete_upload_inner(&id, "test-user").await.expect("complete");
|
||||
let (path, filename, _folder, _ct, size, hash) = service
|
||||
.complete_upload_inner(&id, "test-user")
|
||||
.await
|
||||
.expect("complete");
|
||||
assert_eq!(filename, "test.txt");
|
||||
assert_eq!(size, 1024);
|
||||
assert!(!hash.is_empty());
|
||||
@@ -1116,7 +1132,10 @@ mod tests {
|
||||
assert_eq!(&content[512..], &[b'B'; 512]);
|
||||
|
||||
// 6. Finalize
|
||||
service.finalize_upload_inner(&id, "test-user").await.expect("finalize");
|
||||
service
|
||||
.finalize_upload_inner(&id, "test-user")
|
||||
.await
|
||||
.expect("finalize");
|
||||
assert_eq!(service.active_sessions().await, 0);
|
||||
|
||||
let _ = fs::remove_dir_all(&base).await;
|
||||
|
||||
@@ -110,6 +110,23 @@ impl DedupService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a stub instance for testing — never hits PG or the filesystem.
|
||||
#[cfg(any(test, feature = "integration_tests"))]
|
||||
pub fn new_stub() -> Self {
|
||||
let stub_pool = Arc::new(
|
||||
sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
|
||||
.max_connections(1)
|
||||
.connect_lazy("postgres://invalid:5432/none")
|
||||
.unwrap(),
|
||||
);
|
||||
Self {
|
||||
blob_root: std::path::PathBuf::from("/tmp/oxicloud_stub_blobs"),
|
||||
temp_root: std::path::PathBuf::from("/tmp/oxicloud_stub_temp"),
|
||||
pool: stub_pool.clone(),
|
||||
maintenance_pool: stub_pool,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the service (create blob directories on the filesystem).
|
||||
pub async fn initialize(&self) -> Result<(), DomainError> {
|
||||
// Create directories
|
||||
@@ -903,6 +920,10 @@ impl DedupPort for DedupService {
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
fn blob_path(&self, hash: &str) -> PathBuf {
|
||||
self.blob_path(hash)
|
||||
}
|
||||
|
||||
async fn get_stats(&self) -> DedupStatsDto {
|
||||
self.get_stats().await
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::fs;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::domain::services::i18n_service::{I18nError, I18nResult, I18nService, Locale};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod file_system_i18n_service;
|
||||
pub mod image_transcode_service;
|
||||
pub mod jwt_service;
|
||||
pub mod login_lockout_service;
|
||||
pub mod nextcloud_chunked_upload_service;
|
||||
pub mod oidc_service;
|
||||
pub mod password_hasher;
|
||||
pub mod path_resolver_service;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::common::errors::{DomainError, Result};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NextcloudChunkedUploadService {
|
||||
pub base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl NextcloudChunkedUploadService {
|
||||
pub fn new(base_dir: PathBuf) -> Self {
|
||||
Self { base_dir }
|
||||
}
|
||||
|
||||
pub fn new_stub() -> Self {
|
||||
Self {
|
||||
base_dir: PathBuf::from("./storage/.uploads/nextcloud"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a path component contains no traversal characters.
|
||||
fn validate_path_component(name: &str, label: &str) -> Result<()> {
|
||||
if name.is_empty()
|
||||
|| name.contains('/')
|
||||
|| name.contains('\\')
|
||||
|| name.contains("..")
|
||||
|| name == "."
|
||||
{
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"ChunkedUpload: invalid {}: contains path traversal characters",
|
||||
label
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a session directory path and verify it's inside base_dir.
|
||||
fn safe_session_dir(&self, user: &str, upload_id: &str) -> Result<PathBuf> {
|
||||
Self::validate_path_component(user, "username")?;
|
||||
Self::validate_path_component(upload_id, "upload_id")?;
|
||||
Ok(self.base_dir.join(user).join(upload_id))
|
||||
}
|
||||
|
||||
/// Create a new upload session directory.
|
||||
pub async fn create_session(&self, user: &str, upload_id: &str) -> Result<()> {
|
||||
let session_dir = self.safe_session_dir(user, upload_id)?;
|
||||
fs::create_dir_all(&session_dir)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store a chunk in the session directory.
|
||||
pub async fn store_chunk(
|
||||
&self,
|
||||
user: &str,
|
||||
upload_id: &str,
|
||||
chunk_name: &str,
|
||||
data: &[u8],
|
||||
) -> Result<()> {
|
||||
Self::validate_path_component(chunk_name, "chunk_name")?;
|
||||
let chunk_path = self.safe_session_dir(user, upload_id)?.join(chunk_name);
|
||||
let mut file = fs::File::create(&chunk_path)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?;
|
||||
file.write_all(data)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Assemble all chunks in numeric order into a temp file.
|
||||
///
|
||||
/// Returns `(temp_path, total_size)`. The caller is responsible for
|
||||
/// cleaning up the temp file after use.
|
||||
pub async fn assemble(&self, user: &str, upload_id: &str) -> Result<(PathBuf, u64)> {
|
||||
let session_dir = self.safe_session_dir(user, upload_id)?;
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
|
||||
let mut dir = fs::read_dir(&session_dir)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?;
|
||||
|
||||
while let Some(entry) = dir
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?
|
||||
{
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name == ".file" {
|
||||
continue; // Skip the assembly marker.
|
||||
}
|
||||
entries.push(name);
|
||||
}
|
||||
|
||||
// Sort chunks numerically (Nextcloud sends them as "00001", "00002", ...).
|
||||
entries.sort();
|
||||
|
||||
// Stream chunks to a temp file instead of buffering in memory.
|
||||
let temp_path = session_dir.join(".assembled");
|
||||
let mut out = fs::File::create(&temp_path)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?;
|
||||
|
||||
let mut total_size: u64 = 0;
|
||||
for chunk_name in &entries {
|
||||
let mut chunk_file = fs::File::open(session_dir.join(chunk_name))
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?;
|
||||
let copied = tokio::io::copy(&mut chunk_file, &mut out)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?;
|
||||
total_size += copied;
|
||||
}
|
||||
|
||||
out.flush()
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?;
|
||||
|
||||
Ok((temp_path, total_size))
|
||||
}
|
||||
|
||||
/// Delete the upload session directory.
|
||||
pub async fn cleanup(&self, user: &str, upload_id: &str) -> Result<()> {
|
||||
let session_dir = self.safe_session_dir(user, upload_id)?;
|
||||
if session_dir.exists() {
|
||||
fs::remove_dir_all(&session_dir)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a session directory exists.
|
||||
pub async fn session_exists(&self, user: &str, upload_id: &str) -> bool {
|
||||
self.safe_session_dir(user, upload_id)
|
||||
.map(|p| p.exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_service() -> (NextcloudChunkedUploadService, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create temp dir");
|
||||
let svc = NextcloudChunkedUploadService::new(dir.path().to_path_buf());
|
||||
(svc, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_session() {
|
||||
let (svc, _dir) = test_service();
|
||||
svc.create_session("alice", "upload-001").await.unwrap();
|
||||
assert!(svc.session_exists("alice", "upload-001").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_not_exists_before_create() {
|
||||
let (svc, _dir) = test_service();
|
||||
assert!(!svc.session_exists("alice", "upload-999").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_and_assemble_chunks() {
|
||||
let (svc, _dir) = test_service();
|
||||
svc.create_session("alice", "upload-002").await.unwrap();
|
||||
|
||||
svc.store_chunk("alice", "upload-002", "00001", b"Hello, ")
|
||||
.await
|
||||
.unwrap();
|
||||
svc.store_chunk("alice", "upload-002", "00002", b"World!")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (temp_path, size) = svc.assemble("alice", "upload-002").await.unwrap();
|
||||
let assembled = fs::read(&temp_path).await.unwrap();
|
||||
assert_eq!(assembled, b"Hello, World!");
|
||||
assert_eq!(size, 13);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_assemble_chunks_in_sorted_order() {
|
||||
let (svc, _dir) = test_service();
|
||||
svc.create_session("alice", "upload-003").await.unwrap();
|
||||
|
||||
// Store out of order.
|
||||
svc.store_chunk("alice", "upload-003", "00003", b"C")
|
||||
.await
|
||||
.unwrap();
|
||||
svc.store_chunk("alice", "upload-003", "00001", b"A")
|
||||
.await
|
||||
.unwrap();
|
||||
svc.store_chunk("alice", "upload-003", "00002", b"B")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (temp_path, size) = svc.assemble("alice", "upload-003").await.unwrap();
|
||||
let assembled = fs::read(&temp_path).await.unwrap();
|
||||
assert_eq!(assembled, b"ABC");
|
||||
assert_eq!(size, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cleanup_removes_session() {
|
||||
let (svc, _dir) = test_service();
|
||||
svc.create_session("alice", "upload-004").await.unwrap();
|
||||
assert!(svc.session_exists("alice", "upload-004").await);
|
||||
|
||||
svc.cleanup("alice", "upload-004").await.unwrap();
|
||||
assert!(!svc.session_exists("alice", "upload-004").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cleanup_nonexistent_session_is_ok() {
|
||||
let (svc, _dir) = test_service();
|
||||
// Should not error.
|
||||
svc.cleanup("alice", "nonexistent").await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
//! Compatible with Authentik, Keycloak, and any standard OIDC provider.
|
||||
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::RwLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::application::ports::auth_ports::{OidcIdClaims, OidcServicePort, OidcTokenSet};
|
||||
use crate::common::config::OidcConfig;
|
||||
|
||||
@@ -119,10 +119,10 @@ impl PathResolverService {
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(path) // $1
|
||||
.bind(filename) // $2
|
||||
.bind(&folder_path) // $3
|
||||
.bind(user_id) // $4
|
||||
.bind(path) // $1
|
||||
.bind(filename) // $2
|
||||
.bind(&folder_path) // $3
|
||||
.bind(user_id) // $4
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_for_user: {e}")))?
|
||||
@@ -215,7 +215,9 @@ impl PathResolverService {
|
||||
.bind(user_id)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PathResolver", format!("exists_for_user: {e}")))?;
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("PathResolver", format!("exists_for_user: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
@@ -8,11 +8,8 @@ fn tiny_png() -> Vec<u8> {
|
||||
let mut img = image::RgbaImage::new(1, 1);
|
||||
img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(
|
||||
&mut std::io::Cursor::new(&mut buf),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.expect("encode test PNG");
|
||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
|
||||
.expect("encode test PNG");
|
||||
buf
|
||||
}
|
||||
|
||||
|
||||
@@ -63,12 +63,13 @@ impl WebDavLockStore {
|
||||
///
|
||||
/// Returns `Ok(LockEntry)` on success, or `Err(existing)` if the resource
|
||||
/// is already exclusively locked by a different token.
|
||||
pub fn acquire(&self, path: &str, info: LockInfo) -> Result<LockEntry, Box<LockEntry>> {
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn acquire(&self, path: &str, info: LockInfo) -> Result<LockEntry, LockEntry> {
|
||||
// Check for existing conflicting lock
|
||||
if let Some(existing) = self.by_path.get(path)
|
||||
&& existing.info.scope == LockScope::Exclusive
|
||||
{
|
||||
return Err(Box::new(existing));
|
||||
return Err(existing);
|
||||
}
|
||||
|
||||
let ttl = Self::parse_timeout(info.timeout.as_deref());
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Json, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
extract::{Json, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
routing::{get, post, put},
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::user_dto::{
|
||||
ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto,
|
||||
RefreshTokenDto, RegisterDto, SetupAdminDto,
|
||||
AppPasswordCreatedDto, AppPasswordDto, ChangePasswordDto, CreateAppPasswordDto, LoginDto,
|
||||
OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto, RefreshTokenDto, RegisterDto,
|
||||
SetupAdminDto,
|
||||
};
|
||||
use crate::application::ports::auth_ports::TokenServicePort;
|
||||
use crate::application::services::auth_application_service::OidcCallbackResult;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::api::cookie_auth;
|
||||
use crate::interfaces::errors::AppError;
|
||||
@@ -34,6 +37,11 @@ pub fn auth_protected_routes() -> Router<Arc<AppState>> {
|
||||
.route("/me", get(get_current_user))
|
||||
.route("/change-password", put(change_password))
|
||||
.route("/logout", post(logout))
|
||||
.route(
|
||||
"/app-passwords",
|
||||
get(list_app_passwords).post(create_app_password),
|
||||
)
|
||||
.route("/app-passwords/{id}", delete(delete_app_password))
|
||||
}
|
||||
|
||||
/// Rate-limited auth routes — split out so main.rs can apply per-endpoint
|
||||
@@ -334,7 +342,9 @@ async fn logout(
|
||||
.ok()
|
||||
.map(|dto| dto.refresh_token)
|
||||
.or_else(|| cookie_auth::extract_cookie_value(&headers, cookie_auth::REFRESH_COOKIE))
|
||||
.ok_or_else(|| AppError::unauthorized("Refresh token required for logout (JSON body or cookie)"))?;
|
||||
.ok_or_else(|| {
|
||||
AppError::unauthorized("Refresh token required for logout (JSON body or cookie)")
|
||||
})?;
|
||||
|
||||
auth_service
|
||||
.auth_application_service
|
||||
@@ -522,6 +532,140 @@ async fn get_system_status(
|
||||
Ok((StatusCode::OK, Json(status)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// App Password Handlers
|
||||
// ============================================================================
|
||||
|
||||
async fn create_app_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(dto): Json<CreateAppPasswordDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let auth_service = state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
||||
|
||||
let token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::unauthorized("Authorization token not found"))?;
|
||||
|
||||
let claims = auth_service
|
||||
.token_service
|
||||
.validate_token(token)
|
||||
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
|
||||
|
||||
let nextcloud = state
|
||||
.nextcloud
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?;
|
||||
|
||||
let label = dto.label.trim();
|
||||
if label.is_empty() || label.len() > 128 {
|
||||
return Err(AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Label must be between 1 and 128 characters",
|
||||
"InvalidInput",
|
||||
));
|
||||
}
|
||||
|
||||
let (id, password) = nextcloud
|
||||
.app_passwords
|
||||
.create_nc(&claims.sub, label)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(AppPasswordCreatedDto {
|
||||
id,
|
||||
label: label.to_string(),
|
||||
password,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_app_passwords(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let auth_service = state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
||||
|
||||
let token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::unauthorized("Authorization token not found"))?;
|
||||
|
||||
let claims = auth_service
|
||||
.token_service
|
||||
.validate_token(token)
|
||||
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
|
||||
|
||||
let nextcloud = state
|
||||
.nextcloud
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?;
|
||||
|
||||
let records = nextcloud
|
||||
.app_passwords
|
||||
.list_nc(&claims.sub)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let passwords: Vec<AppPasswordDto> = records
|
||||
.into_iter()
|
||||
.map(|r| AppPasswordDto {
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
created_at: r.created_at,
|
||||
last_used_at: r.last_used_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((StatusCode::OK, Json(passwords)))
|
||||
}
|
||||
|
||||
async fn delete_app_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let auth_service = state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
||||
|
||||
let token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::unauthorized("Authorization token not found"))?;
|
||||
|
||||
let claims = auth_service
|
||||
.token_service
|
||||
.validate_token(token)
|
||||
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
|
||||
|
||||
let nextcloud = state
|
||||
.nextcloud
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?;
|
||||
|
||||
nextcloud
|
||||
.app_passwords
|
||||
.delete_by_user(&id, &claims.sub)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Handlers
|
||||
// ============================================================================
|
||||
@@ -602,7 +746,7 @@ async fn oidc_callback(
|
||||
tracing::info!("OIDC callback received with code");
|
||||
|
||||
// Exchange code, validate state/nonce/PKCE, authenticate user
|
||||
let exchange_code = auth_app
|
||||
let result = auth_app
|
||||
.oidc_callback(&query.code, &query.state)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -610,14 +754,58 @@ async fn oidc_callback(
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
// Redirect to frontend with one-time exchange code (NOT raw tokens)
|
||||
let config = auth_app.oidc_config().unwrap();
|
||||
let frontend_url = config.frontend_url.trim_end_matches('/');
|
||||
let redirect_url = format!("{}/?oidc_code={}", frontend_url, exchange_code,);
|
||||
match result {
|
||||
OidcCallbackResult::WebLogin { exchange_code } => {
|
||||
// Regular web login — redirect to frontend with exchange code
|
||||
let config = auth_app.oidc_config().unwrap();
|
||||
let frontend_url = config.frontend_url.trim_end_matches('/');
|
||||
let redirect_url = format!("{}/?oidc_code={}", frontend_url, exchange_code);
|
||||
tracing::info!("OIDC login successful, redirecting with exchange code");
|
||||
Ok(Redirect::temporary(&redirect_url))
|
||||
}
|
||||
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"))?;
|
||||
|
||||
tracing::info!("OIDC login successful, redirecting with exchange code");
|
||||
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)
|
||||
})?;
|
||||
|
||||
Ok(Redirect::temporary(&redirect_url))
|
||||
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"
|
||||
);
|
||||
Ok(Redirect::temporary("/nextcloud-success.html"))
|
||||
} else {
|
||||
tracing::error!(
|
||||
user = %username,
|
||||
"OIDC+NC: login flow token expired or not found"
|
||||
);
|
||||
Ok(Redirect::temporary(
|
||||
"/nextcloud-error.html?type=session-expired",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/auth/oidc/exchange — Exchange one-time code for auth tokens
|
||||
|
||||
@@ -164,7 +164,10 @@ pub async fn move_files_batch(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Batch move_files failed: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Batch operation failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Convert result to DTO
|
||||
@@ -217,7 +220,10 @@ pub async fn copy_files_batch(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Batch copy_files failed: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Batch operation failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Convert result to DTO
|
||||
@@ -270,7 +276,10 @@ pub async fn delete_files_batch(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Batch delete_files failed: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Batch operation failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Create custom response for string IDs
|
||||
@@ -331,7 +340,10 @@ pub async fn delete_folders_batch(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Batch delete_folders failed: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Batch operation failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Create custom response for string IDs
|
||||
@@ -399,7 +411,10 @@ pub async fn create_folders_batch(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Batch create_folders failed: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Batch operation failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Convert result to DTO
|
||||
@@ -452,7 +467,10 @@ pub async fn get_files_batch(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Batch get_files failed: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Batch operation failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Convert result to DTO
|
||||
@@ -505,7 +523,10 @@ pub async fn get_folders_batch(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Batch get_folders failed: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Batch operation failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Convert result to DTO
|
||||
@@ -690,7 +711,10 @@ pub async fn move_folders_batch(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Batch move_folders failed: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch operation failed".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Batch operation failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let response: BatchOperationResponse<FolderDto> = result.into();
|
||||
@@ -727,7 +751,10 @@ pub async fn download_batch(
|
||||
if combined_size > MAX_BATCH_SIZE {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Batch size {} exceeds maximum of {}", combined_size, MAX_BATCH_SIZE),
|
||||
format!(
|
||||
"Batch size {} exceeds maximum of {}",
|
||||
combined_size, MAX_BATCH_SIZE
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -737,7 +764,10 @@ pub async fn download_batch(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Batch download ZIP failed: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Batch download failed".to_string())
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Batch download failed".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Read file size for Content-Length before splitting ownership
|
||||
|
||||
@@ -190,7 +190,13 @@ impl ChunkedUploadHandler {
|
||||
});
|
||||
|
||||
match chunked_service
|
||||
.upload_chunk(&upload_id, &auth_user.id, params.chunk_index, body, checksum)
|
||||
.upload_chunk(
|
||||
&upload_id,
|
||||
&auth_user.id,
|
||||
params.chunk_index,
|
||||
body,
|
||||
checksum,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
@@ -213,7 +219,7 @@ impl ChunkedUploadHandler {
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => AppError::from(e).into_response()
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +267,10 @@ impl ChunkedUploadHandler {
|
||||
|
||||
// Assemble chunks (hash-on-write: SHA-256 computed during assembly)
|
||||
let (assembled_path, filename, folder_id, content_type, total_size, hash) =
|
||||
match chunked_service.complete_upload(&upload_id, &auth_user.id).await {
|
||||
match chunked_service
|
||||
.complete_upload(&upload_id, &auth_user.id)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
return AppError::from(e).into_response();
|
||||
@@ -289,7 +298,9 @@ impl ChunkedUploadHandler {
|
||||
{
|
||||
Ok(file) => {
|
||||
// Cleanup session
|
||||
let _ = chunked_service.finalize_upload(&upload_id, &auth_user.id).await;
|
||||
let _ = chunked_service
|
||||
.finalize_upload(&upload_id, &auth_user.id)
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
"✅ CHUNKED UPLOAD COMPLETE: {} (ID: {}, {} bytes)",
|
||||
@@ -311,8 +322,7 @@ impl ChunkedUploadHandler {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create file from assembled upload: {:?}", e);
|
||||
AppError::internal_error(format!("Failed to create file: {}", e))
|
||||
.into_response()
|
||||
AppError::internal_error(format!("Failed to create file: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,10 +337,14 @@ impl ChunkedUploadHandler {
|
||||
) -> impl IntoResponse {
|
||||
let chunked_service = &state.core.chunked_upload_service;
|
||||
|
||||
match chunked_service.cancel_upload(&upload_id, &auth_user.id).await {
|
||||
match chunked_service
|
||||
.cancel_upload(&upload_id, &auth_user.id)
|
||||
.await
|
||||
{
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => AppError::internal_error(format!("Failed to cancel upload: {}", e))
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
AppError::internal_error(format!("Failed to cancel upload: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,9 +231,7 @@ impl DedupHandler {
|
||||
return Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"error": "Upload failed"}"#,
|
||||
))
|
||||
.body(Body::from(r#"{"error": "Upload failed"}"#))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
@@ -408,9 +406,7 @@ impl DedupHandler {
|
||||
return Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"error": "Verification failed"}"#,
|
||||
))
|
||||
.body(Body::from(r#"{"error": "Verification failed"}"#))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -106,7 +106,11 @@ impl FileHandler {
|
||||
if let Some(ref fid) = folder_id {
|
||||
use crate::application::ports::inbound::FolderUseCase;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
if folder_service.get_folder_owned(fid, &auth_user.id).await.is_err() {
|
||||
if folder_service
|
||||
.get_folder_owned(fid, &auth_user.id)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
"⛔ UPLOAD REJECTED (IDOR): user='{}' attempted upload to folder '{}' owned by another user",
|
||||
auth_user.username,
|
||||
@@ -323,7 +327,12 @@ impl FileHandler {
|
||||
}
|
||||
|
||||
// Resolve the actual blob path on disk (not the logical file path).
|
||||
let blob_hash = match state.repositories.file_read_repository.get_blob_hash(&id).await {
|
||||
let blob_hash = match state
|
||||
.repositories
|
||||
.file_read_repository
|
||||
.get_blob_hash(&id)
|
||||
.await
|
||||
{
|
||||
Ok(h) => h,
|
||||
Err(err) => {
|
||||
return (
|
||||
@@ -335,10 +344,10 @@ impl FileHandler {
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let file_path = state.core.dedup_service.blob_path(&blob_hash);
|
||||
let blob_path = state.core.dedup_service.blob_path(&blob_hash);
|
||||
|
||||
match thumbnail_service
|
||||
.get_thumbnail(&id, thumb_size.into(), &file_path)
|
||||
.get_thumbnail(&id, thumb_size.into(), &blob_path)
|
||||
.await
|
||||
{
|
||||
Ok(data) => {
|
||||
@@ -353,10 +362,8 @@ impl FileHandler {
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
AppError::internal_error(format!("Thumbnail generation failed: {}", err))
|
||||
.into_response()
|
||||
}
|
||||
Err(err) => AppError::internal_error(format!("Thumbnail generation failed: {}", err))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,9 +536,7 @@ impl FileHandler {
|
||||
.unwrap()
|
||||
.into_response(),
|
||||
},
|
||||
Err(err) => {
|
||||
AppError::from(err).into_response()
|
||||
}
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,9 +586,7 @@ impl FileHandler {
|
||||
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
|
||||
resp
|
||||
}
|
||||
Err(err) => {
|
||||
AppError::from(err).into_response()
|
||||
}
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,7 +667,7 @@ impl FileHandler {
|
||||
|
||||
match result {
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,7 +699,7 @@ impl FileHandler {
|
||||
let mgmt = &state.applications.file_management_service;
|
||||
match mgmt.rename_file_owned(&id, &auth_user.id, &new_name).await {
|
||||
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,7 +719,7 @@ impl FileHandler {
|
||||
.await
|
||||
{
|
||||
Ok(file) => (StatusCode::OK, Json(file)).into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,7 +738,7 @@ impl FileHandler {
|
||||
let mgmt = &state.applications.file_management_service;
|
||||
match mgmt.move_file_owned(&id, &auth_user.id, folder_id).await {
|
||||
Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,9 +797,7 @@ impl FileHandler {
|
||||
})
|
||||
.collect();
|
||||
|
||||
format!(
|
||||
"{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}"
|
||||
)
|
||||
format!("{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}")
|
||||
}
|
||||
|
||||
/// Build a 201 Created JSON response.
|
||||
|
||||
@@ -70,19 +70,24 @@ impl FolderHandler {
|
||||
// ── SECURITY: Verify parent folder ownership (IDOR V-04 fix) ──
|
||||
if let Some(ref parent_id) = dto.parent_id {
|
||||
use crate::application::ports::inbound::FolderUseCase;
|
||||
if service.get_folder_owned(parent_id, &auth_user.id).await.is_err() {
|
||||
if service
|
||||
.get_folder_owned(parent_id, &auth_user.id)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
"create_folder: user '{}' attempted to create folder in parent '{}' owned by another user",
|
||||
auth_user.username,
|
||||
parent_id,
|
||||
);
|
||||
return AppError::not_found(format!("Parent folder not found: {}", parent_id)).into_response();
|
||||
return AppError::not_found(format!("Parent folder not found: {}", parent_id))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
match service.create_folder(dto).await {
|
||||
Ok(folder) => (StatusCode::CREATED, Json(folder)).into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +114,7 @@ impl FolderHandler {
|
||||
}
|
||||
(StatusCode::OK, Json(folder)).into_response()
|
||||
}
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +159,7 @@ impl FolderHandler {
|
||||
.await
|
||||
{
|
||||
Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +176,7 @@ impl FolderHandler {
|
||||
.await
|
||||
{
|
||||
Ok(folders) => (StatusCode::OK, Json(folders)).into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +242,7 @@ impl FolderHandler {
|
||||
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
|
||||
resp
|
||||
}
|
||||
(Err(err), _) | (_, Err(err)) => AppError::from(err).into_response()
|
||||
(Err(err), _) | (_, Err(err)) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +255,7 @@ impl FolderHandler {
|
||||
) -> impl IntoResponse {
|
||||
match service.rename_folder(&id, dto, &auth_user.id).await {
|
||||
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +268,7 @@ impl FolderHandler {
|
||||
) -> impl IntoResponse {
|
||||
match service.move_folder(&id, dto, &auth_user.id).await {
|
||||
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +280,7 @@ impl FolderHandler {
|
||||
) -> impl IntoResponse {
|
||||
match service.delete_folder(&id, &auth_user.id).await {
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,9 +318,7 @@ impl FolderHandler {
|
||||
tracing::info!("Folder permanently deleted: {}", id);
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Err(err) => {
|
||||
AppError::from(err).into_response()
|
||||
}
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,12 +40,9 @@ pub async fn create_shared_link(
|
||||
auth_user: AuthUser,
|
||||
Json(dto): Json<CreateShareDto>,
|
||||
) -> impl IntoResponse {
|
||||
match share_use_case
|
||||
.create_shared_link(&auth_user.id, dto)
|
||||
.await
|
||||
{
|
||||
match share_use_case.create_shared_link(&auth_user.id, dto).await {
|
||||
Ok(share) => (StatusCode::CREATED, Json(share)).into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +54,7 @@ pub async fn get_shared_link(
|
||||
) -> impl IntoResponse {
|
||||
match share_use_case.get_shared_link(&id, &auth_user.id).await {
|
||||
Ok(share) => (StatusCode::OK, Json(share)).into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +113,7 @@ pub async fn update_shared_link(
|
||||
.await
|
||||
{
|
||||
Ok(share) => (StatusCode::OK, Json(share)).into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +123,9 @@ pub async fn delete_shared_link(
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match share_use_case
|
||||
.delete_shared_link(&id, &auth_user.id)
|
||||
.await
|
||||
{
|
||||
match share_use_case.delete_shared_link(&id, &auth_user.id).await {
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => AppError::from(err).into_response()
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,8 +154,7 @@ pub async fn access_shared_item(
|
||||
.into_response();
|
||||
}
|
||||
if err.message.contains("expired") {
|
||||
return AppError::new(StatusCode::GONE, err.message, "Expired")
|
||||
.into_response();
|
||||
return AppError::new(StatusCode::GONE, err.message, "Expired").into_response();
|
||||
}
|
||||
}
|
||||
AppError::from(err).into_response()
|
||||
@@ -183,8 +176,7 @@ pub async fn verify_shared_item_password(
|
||||
Err(err) => {
|
||||
if err.kind == ErrorKind::AccessDenied {
|
||||
if err.message.contains("expired") {
|
||||
return AppError::new(StatusCode::GONE, err.message, "Expired")
|
||||
.into_response();
|
||||
return AppError::new(StatusCode::GONE, err.message, "Expired").into_response();
|
||||
}
|
||||
if err.message.contains("password") {
|
||||
return AppError::unauthorized("Invalid password").into_response();
|
||||
|
||||
@@ -434,6 +434,7 @@ async fn handle_propfind(
|
||||
/// (sub-folders and files) are fetched in batches of `PROPFIND_BATCH_SIZE`.
|
||||
/// Each batch is serialised to XML and sent as a chunk, so memory stays
|
||||
/// constant at O(batch_size) regardless of the total number of children.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn build_streaming_propfind_response(
|
||||
folder: FolderDto,
|
||||
folder_id: Option<String>,
|
||||
@@ -1160,10 +1161,7 @@ async fn handle_move(
|
||||
|
||||
// Resolve source: single-query when PathResolver is available (user-scoped)
|
||||
if let Some(resolver) = &state.path_resolver {
|
||||
match resolver
|
||||
.resolve_path_for_user(&source_path, &user.id)
|
||||
.await
|
||||
{
|
||||
match resolver.resolve_path_for_user(&source_path, &user.id).await {
|
||||
Ok(ResolvedResource::Folder(folder)) => {
|
||||
let dest_folder_name = destination_path
|
||||
.split('/')
|
||||
@@ -1182,7 +1180,11 @@ async fn handle_move(
|
||||
match folder_service.get_folder_by_path(dest_parent_path).await {
|
||||
Ok(parent) => {
|
||||
// SECURITY: verify destination parent belongs to caller (V-08)
|
||||
assert_owner(parent.owner_id.as_deref(), &user.id, dest_parent_path)?;
|
||||
assert_owner(
|
||||
parent.owner_id.as_deref(),
|
||||
&user.id,
|
||||
dest_parent_path,
|
||||
)?;
|
||||
Some(parent.id)
|
||||
}
|
||||
Err(_) => None,
|
||||
@@ -1227,10 +1229,11 @@ async fn handle_move(
|
||||
|
||||
if source_parent_path != dest_parent_path {
|
||||
// SECURITY: verify destination parent belongs to caller (V-08)
|
||||
if !dest_parent_path.is_empty() {
|
||||
if let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await {
|
||||
assert_owner(parent.owner_id.as_deref(), &user.id, dest_parent_path)?;
|
||||
}
|
||||
if !dest_parent_path.is_empty()
|
||||
&& let Ok(parent) =
|
||||
folder_service.get_folder_by_path(dest_parent_path).await
|
||||
{
|
||||
assert_owner(parent.owner_id.as_deref(), &user.id, dest_parent_path)?;
|
||||
}
|
||||
file_management_service
|
||||
.move_file(&file.id, Some(dest_parent_path.to_string()))
|
||||
@@ -1328,10 +1331,10 @@ async fn handle_move(
|
||||
|
||||
if source_parent_path != dest_parent_path {
|
||||
// SECURITY: verify destination parent belongs to caller (V-08)
|
||||
if !dest_parent_path.is_empty() {
|
||||
if let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await {
|
||||
assert_owner(parent.owner_id.as_deref(), &user.id, dest_parent_path)?;
|
||||
}
|
||||
if !dest_parent_path.is_empty()
|
||||
&& let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await
|
||||
{
|
||||
assert_owner(parent.owner_id.as_deref(), &user.id, dest_parent_path)?;
|
||||
}
|
||||
file_management_service
|
||||
.move_file(&file.id, Some(dest_parent_path.to_string()))
|
||||
@@ -1439,10 +1442,7 @@ async fn handle_copy(
|
||||
|
||||
// Resolve source: single-query when PathResolver is available (user-scoped)
|
||||
if let Some(resolver) = &state.path_resolver {
|
||||
match resolver
|
||||
.resolve_path_for_user(&source_path, &user.id)
|
||||
.await
|
||||
{
|
||||
match resolver.resolve_path_for_user(&source_path, &user.id).await {
|
||||
Ok(ResolvedResource::Folder(folder)) => {
|
||||
let recursive = depth != "0";
|
||||
|
||||
|
||||
@@ -57,6 +57,22 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// Implement FromRequestParts for CurrentUser — full user extractor from extensions
|
||||
impl<S> FromRequestParts<S> for CurrentUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<CurrentUser>()
|
||||
.cloned()
|
||||
.ok_or(AuthError::UserNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// Implement FromRequestParts for CurrentUserId — lightweight extractor for user_id only
|
||||
impl<S> FromRequestParts<S> for CurrentUserId
|
||||
where
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod api;
|
||||
pub mod errors;
|
||||
pub mod middleware;
|
||||
pub mod nextcloud;
|
||||
pub mod web;
|
||||
|
||||
pub use api::create_api_routes;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
|
||||
/// GET /index.php/avatar/{user}/{size}
|
||||
///
|
||||
/// Returns an SVG avatar with the user's initials on a colored background.
|
||||
pub async fn handle_avatar(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
Path((username, size)): Path<(String, u32)>,
|
||||
) -> Response {
|
||||
let size = size.clamp(16, 1024);
|
||||
let initials = extract_initials(&username);
|
||||
let color = pick_color(&username);
|
||||
let font_size = (size as f32 * 0.45) as u32;
|
||||
|
||||
let safe_initials = xml_escape(&initials);
|
||||
|
||||
let svg = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{s}" height="{s}" viewBox="0 0 {s} {s}">
|
||||
<rect width="{s}" height="{s}" rx="{r}" fill="{c}"/>
|
||||
<text x="50%" y="50%" dy="0.36em" fill="#fff" font-family="-apple-system,BlinkMacSystemFont,sans-serif" font-size="{fs}" font-weight="600" text-anchor="middle">{i}</text>
|
||||
</svg>"##,
|
||||
s = size,
|
||||
r = size / 2,
|
||||
c = color,
|
||||
fs = font_size,
|
||||
i = safe_initials,
|
||||
);
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, "image/svg+xml"),
|
||||
(header::CACHE_CONTROL, "public, max-age=86400, immutable"),
|
||||
(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
"default-src 'none'; style-src 'unsafe-inline'",
|
||||
),
|
||||
],
|
||||
svg,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Escape XML special characters to prevent XSS in SVG output.
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn extract_initials(username: &str) -> String {
|
||||
let parts: Vec<&str> = username.split_whitespace().collect();
|
||||
match parts.len() {
|
||||
0 => "?".to_string(),
|
||||
1 => parts[0]
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('?')
|
||||
.to_uppercase()
|
||||
.to_string(),
|
||||
_ => {
|
||||
let first = parts[0].chars().next().unwrap_or('?');
|
||||
let last = parts[parts.len() - 1].chars().next().unwrap_or('?');
|
||||
format!("{}{}", first.to_uppercase(), last.to_uppercase())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_color(username: &str) -> &'static str {
|
||||
const PALETTE: [&str; 10] = [
|
||||
"#0082c9", "#e9322d", "#2d8a0f", "#c37200", "#6c2d9e", "#007a87", "#b02e7c", "#465a64",
|
||||
"#a65d00", "#3b5998",
|
||||
];
|
||||
let hash: u32 = username
|
||||
.bytes()
|
||||
.fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32));
|
||||
PALETTE[(hash as usize) % PALETTE.len()]
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use base64::Engine;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NextcloudAuthError {
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("Nextcloud services unavailable")]
|
||||
ServiceUnavailable,
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for NextcloudAuthError {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
NextcloudAuthError::Unauthorized => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[(header::WWW_AUTHENTICATE, "Basic realm=\"OxiCloud\"")],
|
||||
"Unauthorized",
|
||||
)
|
||||
.into_response(),
|
||||
NextcloudAuthError::ServiceUnavailable => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "Nextcloud unavailable").into_response()
|
||||
}
|
||||
NextcloudAuthError::Internal(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
tracing::warn!(
|
||||
"[NC] 401 no auth header: {} {}",
|
||||
request.method(),
|
||||
request.uri()
|
||||
);
|
||||
NextcloudAuthError::Unauthorized
|
||||
})?;
|
||||
|
||||
let (username, password) =
|
||||
parse_basic_auth(auth_header).ok_or(NextcloudAuthError::Unauthorized)?;
|
||||
|
||||
// Check account lockout before attempting password verification (saves CPU)
|
||||
if let Some(auth_svc) = state.auth_service.as_ref()
|
||||
&& let Err(secs) = auth_svc.login_lockout.check(&username)
|
||||
{
|
||||
tracing::warn!(
|
||||
username = %username,
|
||||
lockout_remaining_secs = secs,
|
||||
"[NC] Account locked — too many failed attempts"
|
||||
);
|
||||
return Err(NextcloudAuthError::Unauthorized);
|
||||
}
|
||||
|
||||
let nextcloud = state
|
||||
.nextcloud
|
||||
.as_ref()
|
||||
.ok_or(NextcloudAuthError::ServiceUnavailable)?;
|
||||
|
||||
match nextcloud
|
||||
.app_passwords
|
||||
.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);
|
||||
}
|
||||
request.extensions_mut().insert(CurrentUser {
|
||||
id: user_id,
|
||||
username: uname,
|
||||
email,
|
||||
role,
|
||||
});
|
||||
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);
|
||||
}
|
||||
Err(NextcloudAuthError::Unauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `Basic` Authorization header into `(username, password)`.
|
||||
pub fn parse_basic_auth(header_value: &str) -> Option<(String, String)> {
|
||||
let mut parts = header_value.splitn(2, ' ');
|
||||
let scheme = parts.next()?.trim();
|
||||
let encoded = parts.next()?.trim();
|
||||
|
||||
if !scheme.eq_ignore_ascii_case("Basic") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.ok()?;
|
||||
let decoded = String::from_utf8(decoded).ok()?;
|
||||
let (user, pass) = decoded.split_once(':')?;
|
||||
|
||||
Some((user.to_string(), pass.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_valid_basic_auth() {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode("alice:secret123");
|
||||
let header = format!("Basic {}", encoded);
|
||||
let (user, pass) = parse_basic_auth(&header).expect("should parse");
|
||||
assert_eq!(user, "alice");
|
||||
assert_eq!(pass, "secret123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_basic_auth_with_colon_in_password() {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode("user:pass:with:colons");
|
||||
let header = format!("Basic {}", encoded);
|
||||
let (user, pass) = parse_basic_auth(&header).expect("should parse");
|
||||
assert_eq!(user, "user");
|
||||
assert_eq!(pass, "pass:with:colons");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_basic_auth_bearer_scheme_rejected() {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode("user:pass");
|
||||
let header = format!("Bearer {}", encoded);
|
||||
assert!(parse_basic_auth(&header).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_basic_auth_missing_colon() {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode("nocolon");
|
||||
let header = format!("Basic {}", encoded);
|
||||
assert!(parse_basic_auth(&header).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_basic_auth_invalid_base64() {
|
||||
assert!(parse_basic_auth("Basic not-valid-base64!!!").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_basic_auth_case_insensitive_scheme() {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode("user:pass");
|
||||
let header = format!("BASIC {}", encoded);
|
||||
let result = parse_basic_auth(&header);
|
||||
assert!(result.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{Html, IntoResponse, Json, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
/// Serve an HTML page with a Content-Security-Policy header as defense-in-depth.
|
||||
fn html_with_csp(html: &'static str) -> Response {
|
||||
(
|
||||
[(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; form-action 'self'",
|
||||
)],
|
||||
Html(html),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn handle_login_initiate(State(state): State<Arc<AppState>>) -> Response {
|
||||
let nextcloud = match state.nextcloud.as_ref() {
|
||||
Some(nextcloud) => nextcloud,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
let base_url = state.core.config.base_url();
|
||||
let flow = match nextcloud.login_flow.initiate(&base_url) {
|
||||
Ok(flow) => flow,
|
||||
Err(_) => {
|
||||
tracing::warn!("Login Flow v2: too many pending flows, rejecting");
|
||||
return StatusCode::TOO_MANY_REQUESTS.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
base_url = %base_url,
|
||||
login_url = %flow.login_url,
|
||||
poll_endpoint = %flow.poll_endpoint,
|
||||
"Login Flow v2 initiated"
|
||||
);
|
||||
|
||||
Json(json!({
|
||||
"poll": {
|
||||
"token": flow.poll_token,
|
||||
"endpoint": flow.poll_endpoint,
|
||||
},
|
||||
"login": flow.login_url,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn handle_login_poll(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
body: String,
|
||||
) -> Response {
|
||||
let nextcloud = match state.nextcloud.as_ref() {
|
||||
Some(nextcloud) => nextcloud,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
let content_type = headers
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("(none)");
|
||||
|
||||
tracing::debug!(
|
||||
body = %body,
|
||||
content_type = %content_type,
|
||||
query_has_token = query.contains_key("token"),
|
||||
"Login Flow v2 poll request"
|
||||
);
|
||||
|
||||
// Try to extract token from multiple sources:
|
||||
// 1. Form-encoded body (token=xxx)
|
||||
// 2. JSON body ({"token": "xxx"})
|
||||
// 3. Query parameter (?token=xxx)
|
||||
let token = parse_form_value(&body, "token")
|
||||
.or_else(|| {
|
||||
serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("token")?.as_str().map(String::from))
|
||||
})
|
||||
.or_else(|| query.get("token").cloned());
|
||||
|
||||
let token = match token {
|
||||
Some(token) => token,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
body = %body,
|
||||
content_type = %content_type,
|
||||
"Login Flow v2 poll: could not extract token from body, JSON, or query"
|
||||
);
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match nextcloud.login_flow.poll(&token) {
|
||||
Some(result) => {
|
||||
tracing::info!(
|
||||
login_name = %result.login_name,
|
||||
server = %result.server,
|
||||
"Login Flow v2 poll: returning completed credentials"
|
||||
);
|
||||
Json(json!({
|
||||
"server": result.server,
|
||||
"loginName": result.login_name,
|
||||
"appPassword": result.app_password,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("Login Flow v2 poll: not yet completed");
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_login_page(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
) -> Response {
|
||||
let nextcloud = match state.nextcloud.as_ref() {
|
||||
Some(nextcloud) => nextcloud,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
if !nextcloud.login_flow.flow_exists(&token) {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
html_with_csp(include_str!("../../../static/nextcloud-login.html"))
|
||||
}
|
||||
|
||||
pub async fn handle_login_submit(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
body: String,
|
||||
) -> Response {
|
||||
let nextcloud = match state.nextcloud.as_ref() {
|
||||
Some(nextcloud) => nextcloud,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
let params = parse_form(&body);
|
||||
let username = match params.get("user") {
|
||||
Some(value) if !value.is_empty() => value,
|
||||
_ => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
let password = match params.get("password") {
|
||||
Some(value) if !value.is_empty() => value,
|
||||
_ => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
let auth = match state.auth_service.as_ref() {
|
||||
Some(auth) => auth,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
let current_user = match auth
|
||||
.auth_application_service
|
||||
.verify_credentials(username, password)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user,
|
||||
Err(e) => return login_failed_response(e),
|
||||
};
|
||||
|
||||
let app_password = match nextcloud
|
||||
.app_passwords
|
||||
.create_nc(¤t_user.id, "Nextcloud")
|
||||
.await
|
||||
{
|
||||
Ok((_id, password)) => password,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, user = %current_user.username, "Login Flow v2: failed to create app password");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let base_url = state.core.config.base_url();
|
||||
let completed =
|
||||
nextcloud
|
||||
.login_flow
|
||||
.complete(&token, ¤t_user.username, &base_url, &app_password);
|
||||
|
||||
if completed {
|
||||
tracing::info!(
|
||||
user = %current_user.username,
|
||||
base_url = %base_url,
|
||||
"Login Flow v2: flow completed successfully"
|
||||
);
|
||||
} else {
|
||||
tracing::error!(
|
||||
user = %current_user.username,
|
||||
"Login Flow v2: complete() returned false — flow token not found"
|
||||
);
|
||||
return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
html_with_csp(include_str!("../../../static/nextcloud-success.html"))
|
||||
}
|
||||
|
||||
/// GET /login/v2/flow/{token}/oidc — Start an OIDC authorization flow that is
|
||||
/// tied to a Nextcloud Login Flow v2 session. After successful IdP
|
||||
/// authentication the regular `/api/auth/oidc/callback` endpoint will detect
|
||||
/// the NC flow token and complete the Nextcloud login instead of issuing
|
||||
/// internal JWTs.
|
||||
pub async fn handle_login_oidc(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
) -> Response {
|
||||
// Verify Nextcloud services are configured
|
||||
let nextcloud = match state.nextcloud.as_ref() {
|
||||
Some(nc) => nc,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
// Verify the NC login flow token exists
|
||||
if !nextcloud.login_flow.flow_exists(&token) {
|
||||
return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Verify auth + OIDC are configured and enabled
|
||||
let auth = match state.auth_service.as_ref() {
|
||||
Some(auth) => auth,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
if !auth.auth_application_service.oidc_enabled() {
|
||||
tracing::warn!("OIDC login requested on NC login page but OIDC is not enabled");
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
// Prepare an OIDC authorize flow that carries the NC flow token
|
||||
match auth
|
||||
.auth_application_service
|
||||
.prepare_oidc_authorize_for_nextcloud(&token)
|
||||
.await
|
||||
{
|
||||
Ok(authorize_url) => {
|
||||
tracing::info!("OIDC authorize redirect for Nextcloud Login Flow v2");
|
||||
axum::response::Redirect::temporary(&authorize_url).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to prepare OIDC authorize for NC login");
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn login_failed_response(_err: DomainError) -> Response {
|
||||
axum::response::Redirect::to("/nextcloud-error.html?type=invalid-credentials").into_response()
|
||||
}
|
||||
|
||||
fn parse_form(body: &str) -> HashMap<String, String> {
|
||||
body.split('&')
|
||||
.filter_map(|pair| {
|
||||
let (key, value) = pair.split_once('=')?;
|
||||
let key = urlencoding::decode(key).ok()?.to_string();
|
||||
let value = urlencoding::decode(value).ok()?.to_string();
|
||||
Some((key, value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_form_value(body: &str, key: &str) -> Option<String> {
|
||||
parse_form(body).remove(key)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod avatar_handler;
|
||||
pub mod basic_auth_middleware;
|
||||
pub mod login_v2_handler;
|
||||
pub mod ocs_handler;
|
||||
pub mod preview_handler;
|
||||
pub mod report_handler;
|
||||
pub mod routes;
|
||||
pub mod status_handler;
|
||||
pub mod trashbin_handler;
|
||||
pub mod uploads_handler;
|
||||
pub mod webdav_handler;
|
||||
@@ -0,0 +1,531 @@
|
||||
use axum::Json;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::search_dto::SearchCriteriaDto;
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
|
||||
/// Build an OCS success response with the given statuscode and data.
|
||||
fn ocs_ok(statuscode: u16, data: serde_json::Value) -> serde_json::Value {
|
||||
json!({
|
||||
"ocs": {
|
||||
"meta": { "status": "ok", "statuscode": statuscode, "message": "OK" },
|
||||
"data": data,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Build an OCS error response.
|
||||
fn ocs_err(statuscode: u16, message: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"ocs": {
|
||||
"meta": { "status": "failure", "statuscode": statuscode, "message": message },
|
||||
"data": {},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
pub async fn handle_user_info(State(state): State<Arc<AppState>>, user: CurrentUser) -> Response {
|
||||
let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
|
||||
Some(service) => match service.get_user_storage_info(&user.id).await {
|
||||
Ok((used, total)) => (used, total),
|
||||
Err(_) => (0, 0),
|
||||
},
|
||||
None => (0, 0),
|
||||
};
|
||||
|
||||
let free = quota.1.saturating_sub(quota.0);
|
||||
let relative = if quota.1 > 0 {
|
||||
(quota.0 as f64 / quota.1 as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Json(json!({
|
||||
"ocs": {
|
||||
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"id": user.username,
|
||||
"display-name": user.username,
|
||||
"displayname": user.username,
|
||||
"email": user.email,
|
||||
"quota": {
|
||||
"used": quota.0,
|
||||
"total": quota.1,
|
||||
"free": free,
|
||||
"relative": relative
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// GET /ocs/v1.php/cloud/users/{userid}
|
||||
pub async fn handle_user_provisioning_v1(
|
||||
state: State<Arc<AppState>>,
|
||||
path: Path<String>,
|
||||
user: CurrentUser,
|
||||
) -> Response {
|
||||
user_provisioning_response(state, path, user, 1).await
|
||||
}
|
||||
|
||||
/// GET /ocs/v2.php/cloud/users/{userid}
|
||||
pub async fn handle_user_provisioning_v2(
|
||||
state: State<Arc<AppState>>,
|
||||
path: Path<String>,
|
||||
user: CurrentUser,
|
||||
) -> Response {
|
||||
user_provisioning_response(state, path, user, 2).await
|
||||
}
|
||||
|
||||
/// Returns user details in Nextcloud OCS provisioning API format.
|
||||
/// Used by the Nextcloud mobile app to fetch the user profile screen.
|
||||
async fn user_provisioning_response(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(userid): Path<String>,
|
||||
user: CurrentUser,
|
||||
ocs_version: u8,
|
||||
) -> 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();
|
||||
}
|
||||
|
||||
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 user_dto = match auth_service.get_user_by_username(&userid).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return Json(ocs_err(404, "User not found")).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Determine groups based on role
|
||||
let groups = if user_dto.role == "admin" {
|
||||
vec!["admin", "users"]
|
||||
} else {
|
||||
vec!["users"]
|
||||
};
|
||||
|
||||
// Determine backend based on auth provider
|
||||
let backend = if user_dto.auth_provider.to_lowercase().contains("oidc") {
|
||||
"OIDC"
|
||||
} else {
|
||||
"Database"
|
||||
};
|
||||
|
||||
// Convert last_login_at to JS milliseconds
|
||||
let last_login = user_dto
|
||||
.last_login_at
|
||||
.map(|dt| dt.timestamp() * 1000)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Fetch quota from storage usage service
|
||||
let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
|
||||
Some(service) => match service.get_user_storage_info(&user_dto.id).await {
|
||||
Ok((used, total)) => (used, total),
|
||||
Err(_) => (0, 0),
|
||||
},
|
||||
None => (0, 0),
|
||||
};
|
||||
|
||||
let free = quota.1.saturating_sub(quota.0);
|
||||
let relative = if quota.1 > 0 {
|
||||
(quota.0 as f64 / quota.1 as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Json(json!({
|
||||
"ocs": {
|
||||
"meta": { "status": "ok", "statuscode": statuscode, "message": "OK" },
|
||||
"data": {
|
||||
"enabled": user_dto.active,
|
||||
"id": user_dto.username,
|
||||
"display-name": user_dto.username,
|
||||
"displayname": user_dto.username,
|
||||
"email": user_dto.email,
|
||||
"phone": "",
|
||||
"address": "",
|
||||
"website": "",
|
||||
"twitter": "",
|
||||
"groups": groups,
|
||||
"language": "en",
|
||||
"locale": "en_US",
|
||||
"backend": backend,
|
||||
"lastLogin": last_login,
|
||||
"quota": {
|
||||
"used": quota.0,
|
||||
"total": quota.1,
|
||||
"free": free,
|
||||
"relative": relative
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn handle_revoke_apppassword(
|
||||
State(state): State<Arc<AppState>>,
|
||||
user: CurrentUser,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let nextcloud = match state.nextcloud.as_ref() {
|
||||
Some(nextcloud) => nextcloud,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
let app_password = match extract_basic_password(&headers) {
|
||||
Some(password) => password,
|
||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
if let Err(e) = nextcloud
|
||||
.app_passwords
|
||||
.revoke_by_password(&user.id, &app_password)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to revoke app password for {}: {}", user.id, e);
|
||||
}
|
||||
|
||||
Json(ocs_ok(200, json!({}))).into_response()
|
||||
}
|
||||
|
||||
pub async fn handle_notifications_list() -> Response {
|
||||
Json(ocs_ok(200, json!([]))).into_response()
|
||||
}
|
||||
|
||||
pub async fn handle_notifications_push() -> Response {
|
||||
Json(ocs_ok(200, json!({}))).into_response()
|
||||
}
|
||||
|
||||
/// GET /ocs/v2.php/apps/files_sharing/api/v1/sharees?search={query}&itemType={type}
|
||||
///
|
||||
/// Returns matching users for the sharing autocomplete UI.
|
||||
/// Even though sharing is disabled, the Nextcloud mobile app still calls
|
||||
/// this endpoint and expects a well-formed OCS response rather than a 404.
|
||||
pub async fn handle_sharees_search(
|
||||
State(state): State<Arc<AppState>>,
|
||||
user: CurrentUser,
|
||||
axum::extract::Query(params): axum::extract::Query<ShareeSearchParams>,
|
||||
) -> Response {
|
||||
let search = params.search.unwrap_or_default();
|
||||
if search.is_empty() {
|
||||
return sharees_response(vec![]).into_response();
|
||||
}
|
||||
|
||||
let auth_service = match state.auth_service.as_ref() {
|
||||
Some(svc) => &svc.auth_application_service,
|
||||
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)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let matches: Vec<serde_json::Value> = users
|
||||
.into_iter()
|
||||
.filter(|u| u.username != user.username) // Don't suggest self
|
||||
.take(25)
|
||||
.map(|u| {
|
||||
json!({
|
||||
"label": u.username,
|
||||
"value": {
|
||||
"shareType": 0,
|
||||
"shareWith": u.username
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
sharees_response(matches).into_response()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ShareeSearchParams {
|
||||
search: Option<String>,
|
||||
#[serde(rename = "itemType")]
|
||||
#[allow(dead_code)]
|
||||
item_type: Option<String>,
|
||||
#[serde(rename = "perPage")]
|
||||
#[allow(dead_code)]
|
||||
per_page: Option<u32>,
|
||||
}
|
||||
|
||||
fn sharees_response(users: Vec<serde_json::Value>) -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"ocs": {
|
||||
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
|
||||
"data": {
|
||||
"exact": { "users": [], "groups": [], "remotes": [] },
|
||||
"users": users,
|
||||
"groups": [],
|
||||
"remotes": []
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /ocs/v2.php/search/providers
|
||||
///
|
||||
/// Returns the list of available Unified Search providers.
|
||||
/// We only expose the "files" provider.
|
||||
pub async fn handle_search_providers() -> Response {
|
||||
Json(json!({
|
||||
"ocs": {
|
||||
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
|
||||
"data": [
|
||||
{
|
||||
"id": "files",
|
||||
"appId": "files",
|
||||
"name": "Files",
|
||||
"icon": "/apps/files/img/app.svg",
|
||||
"order": 5,
|
||||
"filters": {},
|
||||
"isPaginated": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// GET /ocs/v2.php/search/providers/{provider_id}/search?term=…&limit=…&cursor=…
|
||||
///
|
||||
/// Executes a Unified Search query against the given provider.
|
||||
/// Only the "files" provider is implemented; all others return empty results.
|
||||
pub async fn handle_search(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(provider_id): Path<String>,
|
||||
axum::extract::Query(params): axum::extract::Query<UnifiedSearchParams>,
|
||||
user: CurrentUser,
|
||||
) -> Response {
|
||||
// Only the "files" provider is supported
|
||||
if provider_id != "files" {
|
||||
return empty_search_response().into_response();
|
||||
}
|
||||
|
||||
let search_service = match state.applications.search_service.as_ref() {
|
||||
Some(svc) => svc,
|
||||
None => return empty_search_response().into_response(),
|
||||
};
|
||||
|
||||
let term = params.term.unwrap_or_default();
|
||||
if term.is_empty() {
|
||||
return empty_search_response().into_response();
|
||||
}
|
||||
|
||||
let criteria = SearchCriteriaDto {
|
||||
name_contains: Some(term),
|
||||
recursive: true,
|
||||
limit: params.limit.unwrap_or(25),
|
||||
..SearchCriteriaDto::default()
|
||||
};
|
||||
|
||||
let results = match search_service.search(criteria, &user.id).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return empty_search_response().into_response(),
|
||||
};
|
||||
|
||||
let file_id_svc = state.nextcloud.as_ref().map(|n| &n.file_ids);
|
||||
|
||||
let mut entries: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// Map file results
|
||||
for file in &results.files {
|
||||
let display_path = file
|
||||
.path
|
||||
.strip_prefix(&format!("My Folder - {}/", user.username))
|
||||
.unwrap_or(&file.path);
|
||||
let display_path = format!("/{}", display_path);
|
||||
|
||||
let numeric_id = if let Some(svc) = file_id_svc {
|
||||
svc.get_or_create_file_id(&file.id).await.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let thumbnail_url = match numeric_id {
|
||||
Some(nid) => format!("/index.php/core/preview?fileId={}&x=32&y=32", nid),
|
||||
None => String::new(),
|
||||
};
|
||||
let resource_url = match numeric_id {
|
||||
Some(nid) => format!("/f/{}", nid),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
entries.push(json!({
|
||||
"thumbnailUrl": thumbnail_url,
|
||||
"title": file.name,
|
||||
"subline": display_path,
|
||||
"resourceUrl": resource_url,
|
||||
"icon": "",
|
||||
"rounded": false
|
||||
}));
|
||||
}
|
||||
|
||||
// Map folder results
|
||||
for folder in &results.folders {
|
||||
let display_path = folder
|
||||
.path
|
||||
.strip_prefix(&format!("My Folder - {}/", user.username))
|
||||
.unwrap_or(&folder.path);
|
||||
let display_path = format!("/{}", display_path);
|
||||
|
||||
entries.push(json!({
|
||||
"thumbnailUrl": "",
|
||||
"title": folder.name,
|
||||
"subline": display_path,
|
||||
"resourceUrl": "",
|
||||
"icon": "/apps/files/img/folder.svg",
|
||||
"rounded": false
|
||||
}));
|
||||
}
|
||||
|
||||
Json(json!({
|
||||
"ocs": {
|
||||
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
|
||||
"data": {
|
||||
"name": "Files",
|
||||
"isPaginated": false,
|
||||
"entries": entries,
|
||||
"cursor": null
|
||||
}
|
||||
}
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct UnifiedSearchParams {
|
||||
term: Option<String>,
|
||||
limit: Option<usize>,
|
||||
#[allow(dead_code)]
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
fn empty_search_response() -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"ocs": {
|
||||
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
|
||||
"data": {
|
||||
"name": "Files",
|
||||
"isPaginated": false,
|
||||
"entries": [],
|
||||
"cursor": null
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn capabilities_payload(state: &AppState, 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();
|
||||
|
||||
json!({
|
||||
"ocs": {
|
||||
"meta": {
|
||||
"status": "ok",
|
||||
"statuscode": statuscode,
|
||||
"message": "OK"
|
||||
},
|
||||
"data": {
|
||||
"version": {
|
||||
"major": nc_major,
|
||||
"minor": nc_minor,
|
||||
"micro": nc_micro,
|
||||
"string": nc_version_str,
|
||||
"edition": "",
|
||||
"extendedSupport": false
|
||||
},
|
||||
"capabilities": {
|
||||
"core": {
|
||||
"pollinterval": 60,
|
||||
"webdav-root": "remote.php/dav",
|
||||
"reference-api": false,
|
||||
"reference-regex": ""
|
||||
},
|
||||
"files": {
|
||||
"bigfilechunking": true,
|
||||
"favorites": true,
|
||||
"undelete": true,
|
||||
"versioning": false
|
||||
},
|
||||
"dav": {
|
||||
"chunking": "1.0"
|
||||
},
|
||||
"checksums": {
|
||||
"preferredUploadType": "SHA1",
|
||||
"supportedTypes": ["SHA1", "MD5"]
|
||||
},
|
||||
"files_sharing": {
|
||||
"api_enabled": false,
|
||||
"public": { "enabled": false },
|
||||
"user": { "send_mail": false },
|
||||
"resharing": false
|
||||
},
|
||||
"notifications": {
|
||||
"ocs-endpoints": ["list", "get", "delete", "delete-all"]
|
||||
},
|
||||
"theming": {
|
||||
"name": "OxiCloud",
|
||||
"url": base_url,
|
||||
"logo": format!("{}/logo.png", base_url),
|
||||
"color": "#0082c9",
|
||||
"color-text": "#ffffff",
|
||||
"color-element": "#0082c9",
|
||||
"color-element-bright": "#0082c9",
|
||||
"color-element-dark": "#0082c9",
|
||||
"background": "#0082c9",
|
||||
"background-plain": true,
|
||||
"background-default": true,
|
||||
"logoheader": format!("{}/logo.png", base_url),
|
||||
"favicon": format!("{}/favicon.ico", base_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_basic_password(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let value = headers
|
||||
.get(axum::http::header::AUTHORIZATION)?
|
||||
.to_str()
|
||||
.ok()?;
|
||||
super::basic_auth_middleware::parse_basic_auth(value).map(|(_, pass)| pass)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Nextcloud-compatible preview/thumbnail endpoint.
|
||||
//!
|
||||
//! Maps Nextcloud preview requests to OxiCloud's thumbnail service.
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Query, State},
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::application::ports::thumbnail_ports::{ThumbnailPort, ThumbnailSize};
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PreviewParams {
|
||||
#[serde(rename = "fileId")]
|
||||
file_id: String,
|
||||
x: Option<u32>,
|
||||
y: Option<u32>,
|
||||
#[serde(rename = "forceIcon")]
|
||||
force_icon: Option<u8>,
|
||||
}
|
||||
|
||||
/// Handle Nextcloud preview requests.
|
||||
///
|
||||
/// Maps:
|
||||
/// - `/index.php/core/preview?fileId=X` to thumbnail generation
|
||||
/// - Size selection based on request dimensions and forceIcon param
|
||||
pub async fn handle_preview(
|
||||
State(state): State<Arc<AppState>>,
|
||||
user: CurrentUser,
|
||||
Query(params): Query<PreviewParams>,
|
||||
) -> impl IntoResponse {
|
||||
// Parse the Nextcloud file ID (numeric) to get the OxiCloud UUID
|
||||
let nc_file_id: i64 = match params.file_id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(Body::from("Invalid file ID"))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
// Look up the OxiCloud file UUID from the Nextcloud ID
|
||||
let object_id = match state.nextcloud.as_ref() {
|
||||
Some(nc) => match nc.file_ids.get_oxicloud_id(nc_file_id).await {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("File not found"))
|
||||
.unwrap();
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(Body::from("Nextcloud integration not configured"))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
// Get file details
|
||||
let file = match state
|
||||
.applications
|
||||
.file_retrieval_service
|
||||
.get_file(&object_id)
|
||||
.await
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(_) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("File not found"))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
// Verify the authenticated user owns this file
|
||||
if file.owner_id.as_deref() != Some(&user.id) {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("File not found"))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Determine thumbnail size based on request params
|
||||
let thumb_size = if params.force_icon == Some(1) {
|
||||
ThumbnailSize::Icon
|
||||
} else {
|
||||
// Map requested dimensions to our thumbnail sizes
|
||||
let max_dim = params.x.unwrap_or(400).max(params.y.unwrap_or(400));
|
||||
if max_dim <= 150 {
|
||||
ThumbnailSize::Icon
|
||||
} else if max_dim <= 400 {
|
||||
ThumbnailSize::Preview
|
||||
} else {
|
||||
ThumbnailSize::Large
|
||||
}
|
||||
};
|
||||
|
||||
// Check if file is an image
|
||||
if !state
|
||||
.core
|
||||
.thumbnail_service
|
||||
.is_supported_image(&file.mime_type)
|
||||
{
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("Preview not available for this file type"))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Get the physical blob path (content-addressable storage)
|
||||
let blob_hash = match state
|
||||
.repositories
|
||||
.file_read_repository
|
||||
.get_blob_hash(&object_id)
|
||||
.await
|
||||
{
|
||||
Ok(hash) => hash,
|
||||
Err(_) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("File blob not found"))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
let blob_path = state.core.dedup_service.blob_path(&blob_hash);
|
||||
|
||||
// Generate/get thumbnail
|
||||
match state
|
||||
.core
|
||||
.thumbnail_service
|
||||
.get_thumbnail(&object_id, thumb_size.into(), &blob_path)
|
||||
.await
|
||||
{
|
||||
Ok(data) => {
|
||||
let etag = format!("\"thumb-{}-{:?}\"", object_id, thumb_size);
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "image/webp")
|
||||
.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()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(Body::from("Failed to generate thumbnail"))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
use axum::{
|
||||
body::{self, Body},
|
||||
http::{Request, StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
use quick_xml::{
|
||||
Reader, Writer,
|
||||
events::{BytesEnd, BytesStart, Event},
|
||||
};
|
||||
use std::collections::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::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::dtos::search_dto::SearchCriteriaDto;
|
||||
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
use crate::application::ports::inbound::{FolderUseCase, SearchUseCase};
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
use crate::interfaces::nextcloud::webdav_handler::{
|
||||
format_oc_id, nc_href, resolve_file_id, resolve_folder_id, write_file_response,
|
||||
write_folder_response,
|
||||
};
|
||||
|
||||
/// Handle WebDAV REPORT and SEARCH methods for Nextcloud compatibility.
|
||||
///
|
||||
/// Dispatches based on the XML body:
|
||||
/// - `oc:filter-files` -- list favorited items (REPORT)
|
||||
/// - `d:searchrequest` -- search files by name (SEARCH)
|
||||
pub async fn handle_nc_report(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
user: &CurrentUser,
|
||||
_subpath: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let body_bytes = body::to_bytes(req.into_body(), 64 * 1024)
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?;
|
||||
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
if body_str.contains("filter-files") {
|
||||
handle_filter_files(state, &body_str, user).await
|
||||
} else if body_str.contains("searchrequest") {
|
||||
handle_search(state, &body_str, user).await
|
||||
} else {
|
||||
// Unknown REPORT type -- return empty multistatus.
|
||||
Ok(empty_multistatus())
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────── Favorites filter (oc:filter-files) ────────────────────
|
||||
|
||||
async fn handle_filter_files(
|
||||
state: Arc<AppState>,
|
||||
_body: &str,
|
||||
user: &CurrentUser,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let fav_svc = match state.favorites_service.as_ref() {
|
||||
Some(svc) => svc,
|
||||
None => return Ok(empty_multistatus()),
|
||||
};
|
||||
|
||||
let favorites = fav_svc
|
||||
.get_favorites(&user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to get favorites: {}", e)))?;
|
||||
|
||||
if favorites.is_empty() {
|
||||
return Ok(empty_multistatus());
|
||||
}
|
||||
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
let nc = state.nextcloud.as_ref();
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
|
||||
// All items in this response are favorites.
|
||||
let favorite_ids: HashSet<String> = favorites.iter().map(|f| f.item_id.clone()).collect();
|
||||
|
||||
let home_prefix = format!("My Folder - {}/", user.username);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut xml = Writer::new(&mut buf);
|
||||
|
||||
write_multistatus_start(&mut xml)?;
|
||||
|
||||
for fav in &favorites {
|
||||
match fav.item_type.as_str() {
|
||||
"file" => {
|
||||
let file = match file_service.get_file(&fav.item_id).await {
|
||||
Ok(f) => f,
|
||||
Err(_) => continue, // Deleted or inaccessible -- skip.
|
||||
};
|
||||
let subpath = strip_home_prefix(&file.path, &home_prefix);
|
||||
let href = nc_href(&user.username, subpath);
|
||||
let fid = resolve_file_id(file_id_svc, &file.id).await;
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
&file,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
"folder" => {
|
||||
let folder = match folder_service.get_folder(&fav.item_id).await {
|
||||
Ok(f) => f,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let subpath = strip_home_prefix(&folder.path, &home_prefix);
|
||||
let href = format!("{}/", nc_href(&user.username, subpath));
|
||||
let fid = resolve_folder_id(file_id_svc, &folder.id).await;
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
&folder,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(buf))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// ──────────────────── Search (d:searchrequest) ────────────────────
|
||||
|
||||
async fn handle_search(
|
||||
state: Arc<AppState>,
|
||||
body: &str,
|
||||
user: &CurrentUser,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let search_svc = match state.applications.search_service.as_ref() {
|
||||
Some(svc) => svc,
|
||||
None => return Ok(empty_multistatus()),
|
||||
};
|
||||
|
||||
let term = parse_literal(body).unwrap_or_default();
|
||||
if term.is_empty() {
|
||||
return Ok(empty_multistatus());
|
||||
}
|
||||
|
||||
let nresults = parse_nresults(body).unwrap_or(100);
|
||||
|
||||
// Resolve folder scope from <d:href> inside <d:scope>.
|
||||
let folder_id = resolve_scope_folder(&state, body, &user.username).await;
|
||||
|
||||
let criteria = SearchCriteriaDto {
|
||||
name_contains: Some(term),
|
||||
recursive: true,
|
||||
limit: nresults,
|
||||
folder_id,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let results = search_svc
|
||||
.search(criteria, &user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Search failed: {}", e)))?;
|
||||
|
||||
let nc = state.nextcloud.as_ref();
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
let home_prefix = format!("My Folder - {}/", user.username);
|
||||
|
||||
// No favorite checking for search results -- pass an empty set.
|
||||
let favorite_ids: HashSet<String> = HashSet::new();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut xml = Writer::new(&mut buf);
|
||||
|
||||
write_multistatus_start(&mut xml)?;
|
||||
|
||||
// Files.
|
||||
for fr in &results.files {
|
||||
let file = file_dto_from_search(fr);
|
||||
let subpath = strip_home_prefix(&file.path, &home_prefix);
|
||||
let href = nc_href(&user.username, subpath);
|
||||
let fid = resolve_file_id(file_id_svc, &file.id).await;
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
&file,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
|
||||
// Folders.
|
||||
for sr in &results.folders {
|
||||
let folder = folder_dto_from_search(sr);
|
||||
let subpath = strip_home_prefix(&folder.path, &home_prefix);
|
||||
let href = format!("{}/", nc_href(&user.username, subpath));
|
||||
let fid = resolve_folder_id(file_id_svc, &folder.id).await;
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
&folder,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(buf))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// ──────────────────── DTO conversions ────────────────────
|
||||
|
||||
/// Build a `FileDto` from a search file result.
|
||||
fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileResultDto) -> FileDto {
|
||||
FileDto {
|
||||
id: fr.id.clone(),
|
||||
name: fr.name.clone(),
|
||||
path: fr.path.clone(),
|
||||
size: fr.size,
|
||||
mime_type: fr.mime_type.clone().into(),
|
||||
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(),
|
||||
size_formatted: format_file_size(fr.size),
|
||||
owner_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `FolderDto` from a search folder result.
|
||||
fn folder_dto_from_search(
|
||||
sr: &crate::application::dtos::search_dto::SearchFolderResultDto,
|
||||
) -> FolderDto {
|
||||
FolderDto {
|
||||
id: sr.id.clone(),
|
||||
name: sr.name.clone(),
|
||||
path: sr.path.clone(),
|
||||
parent_id: sr.parent_id.clone(),
|
||||
owner_id: None,
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────── XML helpers ────────────────────
|
||||
|
||||
/// Write the opening `<d:multistatus>` element with namespace declarations.
|
||||
fn write_multistatus_start<W: std::io::Write>(xml: &mut Writer<W>) -> Result<(), AppError> {
|
||||
let mut ms = BytesStart::new("d:multistatus");
|
||||
ms.push_attribute(("xmlns:d", "DAV:"));
|
||||
ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns"));
|
||||
ms.push_attribute(("xmlns:nc", "http://nextcloud.org/ns"));
|
||||
xml.write_event(Event::Start(ms))
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build an empty 207 Multi-Status response.
|
||||
fn empty_multistatus() -> Response<Body> {
|
||||
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||
</d:multistatus>"#;
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(xml))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ──────────────────── XML parsing helpers ────────────────────
|
||||
|
||||
/// Extract the search term from `<d:literal>%term%</d:literal>` using quick_xml.
|
||||
fn parse_literal(body: &str) -> Option<String> {
|
||||
let text = xml_extract_text(body, b"literal")?;
|
||||
// Strip SQL-style % wildcards.
|
||||
let term = text.trim_matches('%').trim();
|
||||
if term.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(term.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the result limit from `<d:nresults>100</d:nresults>` using quick_xml.
|
||||
fn parse_nresults(body: &str) -> Option<usize> {
|
||||
let text = xml_extract_text(body, b"nresults")?;
|
||||
text.trim().parse::<usize>().ok()
|
||||
}
|
||||
|
||||
/// Extract the scope href from `<d:href>` inside `<d:scope>` using quick_xml.
|
||||
fn parse_scope_href(body: &str) -> Option<String> {
|
||||
let mut reader = Reader::from_str(body);
|
||||
let mut inside_scope = false;
|
||||
let mut inside_href = false;
|
||||
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(ref e)) => {
|
||||
let local = e.local_name();
|
||||
if local.as_ref() == b"scope" {
|
||||
inside_scope = true;
|
||||
} else if inside_scope && local.as_ref() == b"href" {
|
||||
inside_href = true;
|
||||
}
|
||||
}
|
||||
Ok(Event::Text(ref e)) if inside_href => {
|
||||
let text = e.decode().ok()?;
|
||||
let href = text.trim();
|
||||
if href.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(href.to_string());
|
||||
}
|
||||
Ok(Event::End(ref e)) => {
|
||||
let local = e.local_name();
|
||||
if local.as_ref() == b"scope" {
|
||||
inside_scope = false;
|
||||
} else if local.as_ref() == b"href" {
|
||||
inside_href = false;
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Generic helper: extract text content from the first element matching a local name.
|
||||
fn xml_extract_text(body: &str, local_name: &[u8]) -> Option<String> {
|
||||
let mut reader = Reader::from_str(body);
|
||||
let mut inside = false;
|
||||
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(ref e)) if e.local_name().as_ref() == local_name => {
|
||||
inside = true;
|
||||
}
|
||||
Ok(Event::Text(ref e)) if inside => {
|
||||
return e.decode().ok().map(|s| s.to_string());
|
||||
}
|
||||
Ok(Event::End(ref e)) if e.local_name().as_ref() == local_name => {
|
||||
inside = false;
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve a scope href (e.g. `/files/username/Documents`) to a folder ID.
|
||||
async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> Option<String> {
|
||||
let href = parse_scope_href(body)?;
|
||||
|
||||
// The href is typically `/files/{user}/subpath` or `/remote.php/dav/files/{user}/subpath`.
|
||||
let subpath = extract_subpath_from_scope(&href, username)?;
|
||||
if subpath.is_empty() {
|
||||
// Root scope -- no folder_id filter needed.
|
||||
return None;
|
||||
}
|
||||
|
||||
let internal_path =
|
||||
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(username, &subpath)
|
||||
.ok()?;
|
||||
|
||||
let folder_service = &state.applications.folder_service;
|
||||
folder_service
|
||||
.get_folder_by_path(&internal_path)
|
||||
.await
|
||||
.ok()
|
||||
.map(|f| f.id)
|
||||
}
|
||||
|
||||
/// Extract the subpath portion from a scope href.
|
||||
///
|
||||
/// Handles both short form `/files/{user}/sub` and full
|
||||
/// `/remote.php/dav/files/{user}/sub`.
|
||||
fn extract_subpath_from_scope(href: &str, username: &str) -> Option<String> {
|
||||
let patterns = [
|
||||
format!("/remote.php/dav/files/{}/", username),
|
||||
format!("/files/{}/", username),
|
||||
format!("/remote.php/dav/files/{}", username),
|
||||
format!("/files/{}", username),
|
||||
];
|
||||
|
||||
for pat in &patterns {
|
||||
if let Some(rest) = href.strip_prefix(pat.as_str()) {
|
||||
return Some(rest.trim_matches('/').to_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)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::{Path, State},
|
||||
http::{Request, StatusCode},
|
||||
middleware,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{any, delete, get, post},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
use crate::interfaces::middleware::rate_limit::{RateLimiter, rate_limit_login};
|
||||
use crate::interfaces::nextcloud::avatar_handler;
|
||||
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::status_handler;
|
||||
use crate::interfaces::nextcloud::trashbin_handler;
|
||||
use crate::interfaces::nextcloud::uploads_handler;
|
||||
use crate::interfaces::nextcloud::webdav_handler;
|
||||
|
||||
/// Build Nextcloud routes with a pre-built `Arc<AppState>` for the middleware layer.
|
||||
///
|
||||
/// This is the preferred entry point — pass the real state so the Basic Auth
|
||||
/// middleware can look up app passwords from the database.
|
||||
pub fn nextcloud_routes_with_state(state: Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
// Rate limiter for NC login submit (reuses auth config values)
|
||||
let nc_login_limiter = {
|
||||
let rl = &state.core.config.auth.rate_limit;
|
||||
Arc::new(RateLimiter::new(
|
||||
rl.login_max_requests,
|
||||
rl.login_window_secs,
|
||||
100_000,
|
||||
))
|
||||
};
|
||||
|
||||
// Public routes — no auth required.
|
||||
let public = Router::new()
|
||||
.route("/status.php", get(status_handler::handle_status))
|
||||
.route(
|
||||
"/index.php/login/v2",
|
||||
post(login_v2_handler::handle_login_initiate),
|
||||
)
|
||||
.route(
|
||||
"/login/v2/flow/{token}",
|
||||
get(login_v2_handler::handle_login_page)
|
||||
.post(login_v2_handler::handle_login_submit)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
nc_login_limiter,
|
||||
rate_limit_login,
|
||||
)),
|
||||
)
|
||||
// OIDC initiation from Nextcloud login page
|
||||
.route(
|
||||
"/login/v2/flow/{token}/oidc",
|
||||
get(login_v2_handler::handle_login_oidc),
|
||||
)
|
||||
.route(
|
||||
"/index.php/login/v2/poll",
|
||||
post(login_v2_handler::handle_login_poll),
|
||||
)
|
||||
.route("/login/v2/poll", post(login_v2_handler::handle_login_poll))
|
||||
// Capabilities are public — iOS app fetches them before having credentials.
|
||||
.route(
|
||||
"/ocs/v1.php/cloud/capabilities",
|
||||
get(ocs_handler::handle_capabilities_v1),
|
||||
)
|
||||
.route(
|
||||
"/ocs/v2.php/cloud/capabilities",
|
||||
get(ocs_handler::handle_capabilities_v2),
|
||||
);
|
||||
|
||||
// Protected routes — require Basic Auth via app passwords.
|
||||
let protected = Router::new()
|
||||
.route("/ocs/v2.php/cloud/user", get(ocs_handler::handle_user_info))
|
||||
.route(
|
||||
"/ocs/v1.php/cloud/users/{userid}",
|
||||
get(ocs_handler::handle_user_provisioning_v1),
|
||||
)
|
||||
.route(
|
||||
"/ocs/v2.php/cloud/users/{userid}",
|
||||
get(ocs_handler::handle_user_provisioning_v2),
|
||||
)
|
||||
.route(
|
||||
"/ocs/v2.php/core/apppassword",
|
||||
delete(ocs_handler::handle_revoke_apppassword),
|
||||
)
|
||||
.route(
|
||||
"/ocs/v2.php/apps/notifications/api/v2/notifications",
|
||||
get(ocs_handler::handle_notifications_list),
|
||||
)
|
||||
.route(
|
||||
"/ocs/v2.php/apps/notifications/api/v2/push",
|
||||
post(ocs_handler::handle_notifications_push),
|
||||
)
|
||||
.route(
|
||||
"/ocs/v2.php/apps/files_sharing/api/v1/sharees",
|
||||
get(ocs_handler::handle_sharees_search),
|
||||
)
|
||||
// Unified Search
|
||||
.route(
|
||||
"/ocs/v2.php/search/providers",
|
||||
get(ocs_handler::handle_search_providers),
|
||||
)
|
||||
.route(
|
||||
"/ocs/v2.php/search/providers/{provider_id}/search",
|
||||
get(ocs_handler::handle_search),
|
||||
)
|
||||
.route(
|
||||
"/index.php/core/preview",
|
||||
get(preview_handler::handle_preview),
|
||||
)
|
||||
.route(
|
||||
"/index.php/avatar/{user}/{size}",
|
||||
get(avatar_handler::handle_avatar),
|
||||
)
|
||||
.route(
|
||||
"/remote.php/dav/files/{user}/{*subpath}",
|
||||
any(handle_dav_files),
|
||||
)
|
||||
.route("/remote.php/dav/files/{user}/", any(handle_dav_files_root))
|
||||
.route("/remote.php/dav/files/{user}", any(handle_dav_files_root))
|
||||
.route(
|
||||
"/remote.php/dav/uploads/{user}/{upload_id}/{*rest}",
|
||||
any(handle_dav_uploads),
|
||||
)
|
||||
.route(
|
||||
"/remote.php/dav/uploads/{user}/{upload_id}",
|
||||
any(handle_dav_uploads_root),
|
||||
)
|
||||
// Trashbin WebDAV
|
||||
.route(
|
||||
"/remote.php/dav/trashbin/{user}/{*subpath}",
|
||||
any(handle_dav_trashbin),
|
||||
)
|
||||
.route(
|
||||
"/remote.php/dav/trashbin/{user}/",
|
||||
any(handle_dav_trashbin_root),
|
||||
)
|
||||
.route(
|
||||
"/remote.php/dav/trashbin/{user}",
|
||||
any(handle_dav_trashbin_root),
|
||||
)
|
||||
.route("/remote.php/webdav/{*subpath}", any(handle_legacy_webdav))
|
||||
.route("/remote.php/webdav/", any(handle_legacy_webdav_root))
|
||||
.route("/remote.php/webdav", any(handle_legacy_webdav_root))
|
||||
.layer(middleware::from_fn_with_state(state, basic_auth_middleware));
|
||||
|
||||
Router::new().merge(public).merge(protected)
|
||||
}
|
||||
|
||||
// ──────────────── Handler glue ────────────────
|
||||
|
||||
/// Reject requests where the URL `{user}` doesn't match the authenticated user.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn verify_url_user(url_user: &str, auth_user: &CurrentUser) -> Result<(), Response> {
|
||||
if url_user != auth_user.username {
|
||||
Err(StatusCode::FORBIDDEN.into_response())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_dav_files(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((url_user, subpath)): Path<(String, String)>,
|
||||
user_ext: CurrentUser,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
verify_url_user(&url_user, &user_ext)?;
|
||||
webdav_handler::handle_nc_webdav(state, req, user_ext, subpath)
|
||||
.await
|
||||
.map_err(|e| e.into_response())
|
||||
}
|
||||
|
||||
async fn handle_dav_files_root(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(url_user): Path<String>,
|
||||
user_ext: CurrentUser,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
verify_url_user(&url_user, &user_ext)?;
|
||||
webdav_handler::handle_nc_webdav(state, req, user_ext, String::new())
|
||||
.await
|
||||
.map_err(|e| e.into_response())
|
||||
}
|
||||
|
||||
async fn handle_dav_uploads(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((url_user, upload_id, rest)): Path<(String, String, String)>,
|
||||
user_ext: CurrentUser,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
verify_url_user(&url_user, &user_ext)?;
|
||||
uploads_handler::handle_nc_uploads(state, req, user_ext, upload_id, rest)
|
||||
.await
|
||||
.map_err(|e| e.into_response())
|
||||
}
|
||||
|
||||
async fn handle_dav_uploads_root(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((url_user, upload_id)): Path<(String, String)>,
|
||||
user_ext: CurrentUser,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
verify_url_user(&url_user, &user_ext)?;
|
||||
uploads_handler::handle_nc_uploads(state, req, user_ext, upload_id, String::new())
|
||||
.await
|
||||
.map_err(|e| e.into_response())
|
||||
}
|
||||
|
||||
/// Legacy /remote.php/webdav/* — redirect to /remote.php/dav/files/{user}/*
|
||||
async fn handle_legacy_webdav(Path(subpath): Path<String>, user_ext: CurrentUser) -> Response {
|
||||
let location = format!("/remote.php/dav/files/{}/{}", user_ext.username, subpath);
|
||||
Response::builder()
|
||||
.status(StatusCode::MOVED_PERMANENTLY)
|
||||
.header("location", location)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn handle_legacy_webdav_root(user_ext: CurrentUser) -> Response {
|
||||
let location = format!("/remote.php/dav/files/{}/", user_ext.username);
|
||||
Response::builder()
|
||||
.status(StatusCode::MOVED_PERMANENTLY)
|
||||
.header("location", location)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn handle_dav_trashbin(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((url_user, subpath)): Path<(String, String)>,
|
||||
user_ext: CurrentUser,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
verify_url_user(&url_user, &user_ext)?;
|
||||
trashbin_handler::handle_nc_trashbin(state, req, user_ext, subpath)
|
||||
.await
|
||||
.map_err(|e| e.into_response())
|
||||
}
|
||||
|
||||
async fn handle_dav_trashbin_root(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(url_user): Path<String>,
|
||||
user_ext: CurrentUser,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response, Response> {
|
||||
verify_url_user(&url_user, &user_ext)?;
|
||||
trashbin_handler::handle_nc_trashbin(state, req, user_ext, String::new())
|
||||
.await
|
||||
.map_err(|e| e.into_response())
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{HeaderName, Request, StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
use quick_xml::{
|
||||
Writer,
|
||||
events::{BytesEnd, BytesStart, Event},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
use crate::interfaces::nextcloud::webdav_handler::{
|
||||
format_oc_id, resolve_file_id, resolve_folder_id, write_text_element,
|
||||
};
|
||||
|
||||
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
|
||||
|
||||
/// Dispatch Nextcloud WebDAV trashbin request to the appropriate handler.
|
||||
///
|
||||
/// `subpath` is everything after `/remote.php/dav/trashbin/{user}/`.
|
||||
pub async fn handle_nc_trashbin(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
user: CurrentUser,
|
||||
subpath: String,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let method = req.method().clone();
|
||||
let subpath_trimmed = subpath.trim_matches('/');
|
||||
|
||||
match method.as_str() {
|
||||
"OPTIONS" => handle_options(),
|
||||
"PROPFIND" if subpath_trimmed == "trash" || subpath_trimmed.is_empty() => {
|
||||
handle_propfind(state, &user).await
|
||||
}
|
||||
"MOVE" if subpath_trimmed.starts_with("trash/") => {
|
||||
handle_restore(state, &user, subpath_trimmed).await
|
||||
}
|
||||
"DELETE" if subpath_trimmed == "trash" || subpath_trimmed.is_empty() => {
|
||||
handle_empty_trash(state, &user).await
|
||||
}
|
||||
"DELETE" if subpath_trimmed.starts_with("trash/") => {
|
||||
handle_delete_permanent(state, &user, subpath_trimmed).await
|
||||
}
|
||||
_ => Ok(Response::builder()
|
||||
.status(StatusCode::METHOD_NOT_ALLOWED)
|
||||
.body(Body::empty())
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────── OPTIONS ────────────────────
|
||||
|
||||
fn handle_options() -> Result<Response<Body>, AppError> {
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(HEADER_DAV, "1, 2, 3")
|
||||
.header(header::ALLOW, "OPTIONS, PROPFIND, MOVE, DELETE")
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// ──────────────────── PROPFIND (list trash) ────────────────────
|
||||
|
||||
async fn handle_propfind(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let trash_svc = state
|
||||
.trash_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Trash service not available"))?;
|
||||
|
||||
let items = trash_svc
|
||||
.get_trash_items(&user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list trash: {}", e)))?;
|
||||
|
||||
let nc = state.nextcloud.as_ref();
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
write_trashbin_multistatus(&mut buf, &items, &user.username, file_id_svc)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(buf))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// ──────────────────── MOVE (restore) ────────────────────
|
||||
|
||||
async fn handle_restore(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
subpath: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let id = extract_trash_id(subpath)?;
|
||||
|
||||
let trash_svc = state
|
||||
.trash_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Trash service not available"))?;
|
||||
|
||||
trash_svc
|
||||
.restore_item(&id, &user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to restore item: {}", e)))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// ──────────────────── DELETE (empty trash) ────────────────────
|
||||
|
||||
async fn handle_empty_trash(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let trash_svc = state
|
||||
.trash_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Trash service not available"))?;
|
||||
|
||||
trash_svc
|
||||
.empty_trash(&user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to empty trash: {}", e)))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// ──────────────────── DELETE (single item) ────────────────────
|
||||
|
||||
async fn handle_delete_permanent(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
subpath: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let id = extract_trash_id(subpath)?;
|
||||
|
||||
let trash_svc = state
|
||||
.trash_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Trash service not available"))?;
|
||||
|
||||
trash_svc
|
||||
.delete_permanently(&id, &user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to permanently delete item: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// ────────────── Helpers ──────────────
|
||||
|
||||
/// Extract the item ID from a trashbin subpath like `trash/{id}`.
|
||||
fn extract_trash_id(subpath: &str) -> Result<String, AppError> {
|
||||
// subpath is already trimmed, e.g. "trash/some-uuid"
|
||||
subpath
|
||||
.strip_prefix("trash/")
|
||||
.map(|s| s.trim_matches('/').to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| AppError::bad_request("Missing trash item ID in path"))
|
||||
}
|
||||
|
||||
/// Infer MIME content type from filename extension.
|
||||
fn mime_from_name(name: &str) -> String {
|
||||
mime_guess::from_path(name)
|
||||
.first_or_octet_stream()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Strip the "My Folder - {username}/" prefix from an original path to produce
|
||||
/// the Nextcloud-relative original location.
|
||||
fn strip_home_prefix<'a>(original_path: &'a str, username: &str) -> &'a str {
|
||||
let prefix = format!("My Folder - {}/", username);
|
||||
original_path.strip_prefix(&prefix).unwrap_or(original_path)
|
||||
}
|
||||
|
||||
// ────────────── Trashbin PROPFIND XML Generation ──────────────
|
||||
|
||||
use crate::application::dtos::trash_dto::TrashedItemDto;
|
||||
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
|
||||
|
||||
/// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin.
|
||||
async fn write_trashbin_multistatus<W: std::io::Write>(
|
||||
writer: W,
|
||||
items: &[TrashedItemDto],
|
||||
username: &str,
|
||||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
) -> Result<(), String> {
|
||||
let mut xml = Writer::new(writer);
|
||||
|
||||
// Root element with all required namespaces.
|
||||
let mut ms = BytesStart::new("d:multistatus");
|
||||
ms.push_attribute(("xmlns:d", "DAV:"));
|
||||
ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns"));
|
||||
ms.push_attribute(("xmlns:nc", "http://nextcloud.org/ns"));
|
||||
xml.write_event(Event::Start(ms))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Root container entry for the trash collection itself.
|
||||
write_trash_root_response(&mut xml, username)?;
|
||||
|
||||
// Individual trashed items.
|
||||
for item in items {
|
||||
write_trash_item_response(&mut xml, item, username, file_id_svc).await?;
|
||||
}
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the root collection response entry for the trash folder.
|
||||
fn write_trash_root_response<W: std::io::Write>(
|
||||
xml: &mut Writer<W>,
|
||||
username: &str,
|
||||
) -> Result<(), String> {
|
||||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let href = format!("/remote.php/dav/trashbin/{}/trash/", username);
|
||||
write_text_element(xml, "d:href", &href)?;
|
||||
|
||||
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
xml.write_event(Event::Start(BytesStart::new("d:prop")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// resourcetype = collection
|
||||
xml.write_event(Event::Start(BytesStart::new("d:resourcetype")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
xml.write_event(Event::Empty(BytesStart::new("d:collection")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
xml.write_event(Event::End(BytesEnd::new("d:resourcetype")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:prop")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
write_text_element(xml, "d:status", "HTTP/1.1 200 OK")?;
|
||||
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a single trashed item as a `<d:response>` element.
|
||||
async fn write_trash_item_response<W: std::io::Write>(
|
||||
xml: &mut Writer<W>,
|
||||
item: &TrashedItemDto,
|
||||
username: &str,
|
||||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
) -> Result<(), String> {
|
||||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// href
|
||||
let href = format!("/remote.php/dav/trashbin/{}/trash/{}", username, item.id);
|
||||
write_text_element(xml, "d:href", &href)?;
|
||||
|
||||
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
xml.write_event(Event::Start(BytesStart::new("d:prop")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// d:displayname
|
||||
write_text_element(xml, "d:displayname", &item.name)?;
|
||||
|
||||
// d:getlastmodified
|
||||
write_text_element(xml, "d:getlastmodified", &item.trashed_at.to_rfc2822())?;
|
||||
|
||||
// d:getetag
|
||||
write_text_element(xml, "d:getetag", &format!("\"{}\"", item.original_id))?;
|
||||
|
||||
// d:resourcetype
|
||||
if item.item_type == "folder" {
|
||||
xml.write_event(Event::Start(BytesStart::new("d:resourcetype")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
xml.write_event(Event::Empty(BytesStart::new("d:collection")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
xml.write_event(Event::End(BytesEnd::new("d:resourcetype")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
xml.write_event(Event::Empty(BytesStart::new("d:resourcetype")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// d:getcontenttype
|
||||
let content_type = if item.item_type == "folder" {
|
||||
"httpd/unix-directory".to_string()
|
||||
} else {
|
||||
mime_from_name(&item.name)
|
||||
};
|
||||
write_text_element(xml, "d:getcontenttype", &content_type)?;
|
||||
|
||||
// d:getcontentlength
|
||||
write_text_element(xml, "d:getcontentlength", "0")?;
|
||||
|
||||
// oc:fileid and oc:id — resolve numeric ID via file_id service
|
||||
let file_id = if item.item_type == "folder" {
|
||||
resolve_folder_id(file_id_svc, &item.original_id).await
|
||||
} else {
|
||||
resolve_file_id(file_id_svc, &item.original_id).await
|
||||
};
|
||||
if let Some(id) = file_id {
|
||||
write_text_element(xml, "oc:fileid", &id.to_string())?;
|
||||
let oc_id = format_oc_id(id, file_id_svc);
|
||||
write_text_element(xml, "oc:id", &oc_id)?;
|
||||
}
|
||||
|
||||
// nc:trashbin-filename
|
||||
write_text_element(xml, "nc:trashbin-filename", &item.name)?;
|
||||
|
||||
// nc:trashbin-original-location
|
||||
let original_location = strip_home_prefix(&item.original_path, username);
|
||||
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(),
|
||||
)?;
|
||||
|
||||
// oc:permissions — empty in trash
|
||||
write_text_element(xml, "oc:permissions", "")?;
|
||||
|
||||
// oc:size
|
||||
write_text_element(xml, "oc:size", "0")?;
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:prop")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
write_text_element(xml, "d:status", "HTTP/1.1 200 OK")?;
|
||||
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use axum::{
|
||||
body::{self, Body},
|
||||
http::{Request, StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
|
||||
use crate::common::di::AppState;
|
||||
use crate::common::mime_detect::{filename_from_path, refine_content_type_from_file};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
|
||||
/// Dispatch Nextcloud chunked upload WebDAV requests.
|
||||
///
|
||||
/// Routes:
|
||||
/// MKCOL /remote.php/dav/uploads/{user}/{upload_id} → create session
|
||||
/// PUT /remote.php/dav/uploads/{user}/{upload_id}/{chunk} → store chunk
|
||||
/// MOVE /remote.php/dav/uploads/{user}/{upload_id}/.file → assemble
|
||||
/// DELETE /remote.php/dav/uploads/{user}/{upload_id} → abort
|
||||
pub async fn handle_nc_uploads(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
user: CurrentUser,
|
||||
upload_id: String,
|
||||
rest: String, // chunk name or ".file" or empty
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let method = req.method().clone();
|
||||
match method.as_str() {
|
||||
"MKCOL" => handle_mkcol(state, &user, &upload_id).await,
|
||||
"PUT" => handle_put_chunk(state, req, &user, &upload_id, &rest).await,
|
||||
"MOVE" => handle_assemble(state, req, &user, &upload_id).await,
|
||||
"DELETE" => handle_abort(state, &user, &upload_id).await,
|
||||
_ => Ok(Response::builder()
|
||||
.status(StatusCode::METHOD_NOT_ALLOWED)
|
||||
.body(Body::empty())
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
/// MKCOL — create upload session directory.
|
||||
async fn handle_mkcol(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
upload_id: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let nc = state
|
||||
.nextcloud
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Nextcloud services unavailable"))?;
|
||||
|
||||
nc.chunked_uploads
|
||||
.create_session(&user.username, upload_id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to create session: {}", e)))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// PUT — store a chunk.
|
||||
async fn handle_put_chunk(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
user: &CurrentUser,
|
||||
upload_id: &str,
|
||||
chunk_name: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let nc = state
|
||||
.nextcloud
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Nextcloud services unavailable"))?;
|
||||
|
||||
let chunk_name = chunk_name.trim_matches('/');
|
||||
if chunk_name.is_empty() {
|
||||
return Err(AppError::bad_request("Missing chunk name"));
|
||||
}
|
||||
|
||||
let max_upload = state.core.config.storage.max_upload_size;
|
||||
let body_bytes = body::to_bytes(req.into_body(), max_upload)
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("Failed to read chunk body: {}", e)))?;
|
||||
|
||||
nc.chunked_uploads
|
||||
.store_chunk(&user.username, upload_id, chunk_name, &body_bytes)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to store chunk: {}", e)))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// MOVE — assemble chunks into final file.
|
||||
///
|
||||
/// The Destination header contains the final file path in the DAV files namespace.
|
||||
async fn handle_assemble(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
user: &CurrentUser,
|
||||
upload_id: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let nc = state
|
||||
.nextcloud
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Nextcloud services unavailable"))?;
|
||||
|
||||
// Parse Destination header to determine final file path.
|
||||
let destination = req
|
||||
.headers()
|
||||
.get("destination")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| AppError::bad_request("Missing Destination header"))?
|
||||
.to_string();
|
||||
|
||||
let dest_subpath = extract_files_subpath(&destination, &user.username)
|
||||
.ok_or_else(|| AppError::bad_request("Invalid Destination URL"))?;
|
||||
|
||||
// Assemble chunks into a temp file (no full-file buffering in RAM).
|
||||
let (temp_path, size) = nc
|
||||
.chunked_uploads
|
||||
.assemble(&user.username, upload_id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to assemble chunks: {}", e)))?;
|
||||
|
||||
// Write assembled file to storage via the upload service.
|
||||
let upload_service = &state.applications.file_upload_service;
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
|
||||
let internal_path = format!(
|
||||
"My Folder - {}/{}",
|
||||
user.username,
|
||||
dest_subpath.trim_matches('/')
|
||||
);
|
||||
|
||||
// Detect content type via magic bytes + extension fallback.
|
||||
let filename = filename_from_path(&dest_subpath);
|
||||
let content_type =
|
||||
refine_content_type_from_file(&temp_path, filename, "application/octet-stream").await;
|
||||
|
||||
// Check if file exists (update vs create).
|
||||
let existing = file_service.get_file_by_path(&internal_path).await;
|
||||
|
||||
if existing.is_ok() {
|
||||
upload_service
|
||||
.update_file_streaming(&internal_path, &temp_path, size, &content_type, None)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
|
||||
} else {
|
||||
// For new files we still need to read the temp file since create_file takes &[u8].
|
||||
let assembled = tokio::fs::read(&temp_path).await.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to read assembled file: {}", e))
|
||||
})?;
|
||||
|
||||
let (parent_sub, filename) = match dest_subpath.rsplit_once('/') {
|
||||
Some((p, n)) => (p, n),
|
||||
None => ("", dest_subpath.as_str()),
|
||||
};
|
||||
let parent_internal = format!(
|
||||
"My Folder - {}/{}",
|
||||
user.username,
|
||||
parent_sub.trim_matches('/')
|
||||
);
|
||||
let parent_internal = parent_internal.trim_end_matches('/');
|
||||
|
||||
upload_service
|
||||
.create_file(parent_internal, filename, &assembled, &content_type)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?;
|
||||
}
|
||||
|
||||
// Clean up temp file (session cleanup below removes the directory anyway).
|
||||
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||
|
||||
// Cleanup session.
|
||||
let _ = nc.chunked_uploads.cleanup(&user.username, upload_id).await;
|
||||
|
||||
// Return etag if we can fetch the file.
|
||||
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.header(header::ETAG, format!("\"{}\"", file.id))
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// DELETE — abort an upload session.
|
||||
async fn handle_abort(
|
||||
state: Arc<AppState>,
|
||||
user: &CurrentUser,
|
||||
upload_id: &str,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let nc = state
|
||||
.nextcloud
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Nextcloud services unavailable"))?;
|
||||
|
||||
nc.chunked_uploads
|
||||
.cleanup(&user.username, upload_id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to abort upload: {}", e)))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn extract_files_subpath(dest: &str, username: &str) -> Option<String> {
|
||||
let prefix = format!("/remote.php/dav/files/{}/", username);
|
||||
let path = if dest.starts_with("http://") || dest.starts_with("https://") {
|
||||
let after_scheme = dest.split_once("://")?.1;
|
||||
let path_start = after_scheme.find('/').unwrap_or(after_scheme.len());
|
||||
&after_scheme[path_start..]
|
||||
} else {
|
||||
dest
|
||||
};
|
||||
let decoded = urlencoding::decode(path).ok()?;
|
||||
let decoded = decoded.trim_end_matches('/');
|
||||
decoded
|
||||
.strip_prefix(prefix.trim_end_matches('/'))
|
||||
.map(|s| s.trim_start_matches('/').to_string())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+20
-12
@@ -1,3 +1,5 @@
|
||||
#![allow(async_fn_in_trait)]
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
@@ -161,6 +163,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
None
|
||||
};
|
||||
|
||||
// Build Nextcloud routes if enabled
|
||||
let nextcloud_router = if config.nextcloud.enabled {
|
||||
use oxicloud::interfaces::nextcloud::routes::nextcloud_routes_with_state;
|
||||
Some(nextcloud_routes_with_state(app_state.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Apply auth middleware to protected API routes when auth is enabled
|
||||
if config.features.enable_auth {
|
||||
// SECURITY: if auth is required, auth_service MUST be present at this
|
||||
@@ -177,7 +187,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
auth_protected_routes, auth_public_routes, login_route, refresh_route, register_route,
|
||||
setup_route,
|
||||
};
|
||||
use oxicloud::interfaces::api::handlers::app_password_handler;
|
||||
use oxicloud::interfaces::api::handlers::device_auth_handler;
|
||||
use oxicloud::interfaces::middleware::auth::auth_middleware;
|
||||
use oxicloud::interfaces::middleware::csrf::csrf_middleware;
|
||||
@@ -262,15 +271,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
))
|
||||
.with_state(app_state.clone());
|
||||
|
||||
// App Password management endpoints (protected — require JWT)
|
||||
let app_password_protected = app_password_handler::app_password_routes()
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
auth_middleware,
|
||||
))
|
||||
.with_state(app_state.clone());
|
||||
|
||||
// Protected API routes — require valid JWT token
|
||||
let protected_api = api_routes
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
@@ -308,8 +308,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.nest("/api/auth/device", device_public)
|
||||
// Device Auth Grant protected endpoints (verify + device management)
|
||||
.nest("/api/auth/device", device_protected)
|
||||
// App Password management endpoints (create, list, revoke)
|
||||
.nest("/api/auth", app_password_protected)
|
||||
// Public API routes (share access, i18n) — no auth required
|
||||
.nest("/api", public_api_routes)
|
||||
// All other API routes are protected by auth middleware
|
||||
@@ -323,6 +321,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.merge(web_routes)
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
// Mount Nextcloud routes (uses its own Basic Auth middleware)
|
||||
if let Some(nc_router) = nextcloud_router {
|
||||
app = app.merge(nc_router.with_state(app_state.clone()));
|
||||
}
|
||||
|
||||
// Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware)
|
||||
if let Some((wopi_protocol, wopi_api)) = wopi_routes {
|
||||
let wopi_api_protected = wopi_api
|
||||
@@ -350,6 +353,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.merge(web_routes)
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
// Mount Nextcloud routes
|
||||
if let Some(nc_router) = nextcloud_router {
|
||||
app = app.merge(nc_router.with_state(app_state.clone()));
|
||||
}
|
||||
|
||||
// Mount WOPI routes (no auth middleware when auth is disabled)
|
||||
if let Some((wopi_protocol, wopi_api)) = wopi_routes {
|
||||
app = app.nest("/wopi", wopi_protocol).nest("/api/wopi", wopi_api);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
*{box-sizing:border-box;margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif}
|
||||
body{background:#f5f7fa;color:#1e293b;min-height:100vh;display:flex;flex-direction:column}
|
||||
body{background:#f5f7fa;color:#1e293b;min-height:100vh;height:auto;display:flex;flex-direction:column;overflow:auto}
|
||||
|
||||
.link-reset-flex{text-decoration:none;color:inherit;display:flex;align-items:center;gap:14px}
|
||||
.width-zero{width:0%}
|
||||
@@ -116,6 +116,51 @@ body{background:#f5f7fa;color:#1e293b;min-height:100vh;display:flex;flex-directi
|
||||
#auth-error a{display:inline-flex;align-items:center;gap:6px;padding:10px 24px;background:linear-gradient(135deg,#ff5e3a,#ff2d55);color:#fff;text-decoration:none;border-radius:10px;font-weight:600;font-size:14px;box-shadow:0 3px 12px rgba(255,94,58,.3);transition:all .2s}
|
||||
#auth-error a:hover{transform:translateY(-1px);box-shadow:0 5px 18px rgba(255,94,58,.4)}
|
||||
|
||||
/* ── App Passwords ── */
|
||||
.app-pw-desc{font-size:13px;color:#64748b;margin-bottom:16px;line-height:1.5}
|
||||
.app-pw-create{display:flex;gap:10px;margin-bottom:16px}
|
||||
.app-pw-create input{
|
||||
flex:1;padding:10px 14px;border:2px solid #e2e8f0;border-radius:10px;font-size:14px;
|
||||
background:#f8fafc;transition:all .2s;font-family:inherit;color:#1e293b;
|
||||
}
|
||||
.app-pw-create input:focus{outline:none;border-color:#ff5e3a;background:#fff;box-shadow:0 0 0 3px rgba(255,94,58,.1)}
|
||||
.app-pw-created{background:#ecfdf5;border:1px solid #a7f3d0;border-radius:12px;padding:16px;margin-bottom:16px}
|
||||
.app-pw-created-label{font-size:13px;color:#065f46;margin-bottom:8px;font-weight:500}
|
||||
.app-pw-created-value{display:flex;align-items:center;gap:10px;margin-bottom:6px}
|
||||
.app-pw-created-value code{
|
||||
font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;
|
||||
font-size:16px;font-weight:700;color:#065f46;letter-spacing:1px;
|
||||
background:#d1fae5;padding:8px 14px;border-radius:8px;flex:1;word-break:break-all;
|
||||
}
|
||||
.btn-copy{
|
||||
padding:8px 12px;border:none;border-radius:8px;background:#059669;color:#fff;
|
||||
cursor:pointer;font-size:14px;transition:all .15s;flex-shrink:0;
|
||||
}
|
||||
.btn-copy:hover{background:#047857}
|
||||
.app-pw-created small{font-size:12px;color:#047857}
|
||||
.app-pw-table{width:100%;border-collapse:collapse;font-size:14px}
|
||||
.app-pw-table thead th{text-align:left;font-size:11.5px;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;font-weight:700;padding:8px 12px;border-bottom:1px solid #e2e8f0}
|
||||
.app-pw-table tbody td{padding:10px 12px;border-bottom:1px solid #f1f5f9;color:#334155}
|
||||
.app-pw-table tbody tr:last-child td{border-bottom:none}
|
||||
.btn-danger-sm{
|
||||
padding:6px 10px;border:none;border-radius:8px;background:#fef2f2;color:#dc2626;
|
||||
cursor:pointer;font-size:13px;transition:all .15s;
|
||||
}
|
||||
.btn-danger-sm:hover{background:#fee2e2;color:#b91c1c}
|
||||
.app-pw-empty{text-align:center;color:#94a3b8;font-size:14px;padding:24px 0}
|
||||
.app-pw-auto-section{margin-top:20px;border-top:1px solid #e2e8f0;padding-top:16px}
|
||||
.app-pw-auto-toggle{
|
||||
display:flex;align-items:center;gap:8px;background:none;border:none;cursor:pointer;
|
||||
font-size:14px;font-weight:600;color:#64748b;padding:0;transition:color .15s;width:100%;
|
||||
}
|
||||
.app-pw-auto-toggle:hover{color:#334155}
|
||||
.app-pw-auto-toggle i{font-size:11px;transition:transform .15s;width:12px}
|
||||
.app-pw-auto-count{
|
||||
font-size:11px;font-weight:700;background:#e2e8f0;color:#64748b;
|
||||
padding:2px 8px;border-radius:10px;margin-left:auto;
|
||||
}
|
||||
.app-pw-auto-desc{font-size:12px;color:#94a3b8;margin:12px 0 8px;line-height:1.4}
|
||||
|
||||
/* ── Dark Mode ── */
|
||||
[data-theme="dark"] body{background:#0f172a;color:#e2e8f0}
|
||||
[data-theme="dark"] ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15)}
|
||||
@@ -146,3 +191,20 @@ body{background:#f5f7fa;color:#1e293b;min-height:100vh;display:flex;flex-directi
|
||||
[data-theme="dark"] #auth-error h2{color:#fca5a5}
|
||||
[data-theme="dark"] #auth-error p{color:#94a3b8}
|
||||
[data-theme="dark"] #loading{color:#64748b}
|
||||
[data-theme="dark"] .app-pw-desc{color:#94a3b8}
|
||||
[data-theme="dark"] .app-pw-create input{background:#0f172a;border-color:#334155;color:#e2e8f0}
|
||||
[data-theme="dark"] .app-pw-create input:focus{border-color:#ff5e3a;background:#0f172a;box-shadow:0 0 0 3px rgba(255,94,58,.15)}
|
||||
[data-theme="dark"] .app-pw-created{background:#052e16;border-color:#065f46}
|
||||
[data-theme="dark"] .app-pw-created-label{color:#86efac}
|
||||
[data-theme="dark"] .app-pw-created-value code{background:#064e3b;color:#86efac}
|
||||
[data-theme="dark"] .app-pw-created small{color:#6ee7b7}
|
||||
[data-theme="dark"] .app-pw-table thead th{color:#64748b;border-bottom-color:#334155}
|
||||
[data-theme="dark"] .app-pw-table tbody td{color:#e2e8f0;border-bottom-color:#1e293b}
|
||||
[data-theme="dark"] .btn-danger-sm{background:#3b1111;color:#fca5a5}
|
||||
[data-theme="dark"] .btn-danger-sm:hover{background:#501111;color:#fecaca}
|
||||
[data-theme="dark"] .app-pw-empty{color:#64748b}
|
||||
[data-theme="dark"] .app-pw-auto-section{border-top-color:#334155}
|
||||
[data-theme="dark"] .app-pw-auto-toggle{color:#94a3b8}
|
||||
[data-theme="dark"] .app-pw-auto-toggle:hover{color:#e2e8f0}
|
||||
[data-theme="dark"] .app-pw-auto-count{background:#334155;color:#94a3b8}
|
||||
[data-theme="dark"] .app-pw-auto-desc{color:#64748b}
|
||||
|
||||
+6
-2
@@ -753,11 +753,15 @@ const ui = {
|
||||
document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } }));
|
||||
}
|
||||
// WOPI editor intercept: open Office documents in the WOPI editor
|
||||
if (window.wopiEditor && await window.wopiEditor.canEdit(file.name)) {
|
||||
// But NOT image files - those should be previewed in the inline viewer
|
||||
const ext = (file.name || '').split('.').pop().toLowerCase();
|
||||
const imageExts = ['jpg','jpeg','png','gif','svg','webp','bmp','ico','heic','heif','avif','tiff'];
|
||||
const isImage = (file.mime_type && file.mime_type.startsWith('image/')) || imageExts.includes(ext);
|
||||
if (!isImage && window.wopiEditor && await window.wopiEditor.canEdit(file.name)) {
|
||||
window.wopiEditor.openInModal(file.id, file.name, 'edit');
|
||||
return;
|
||||
}
|
||||
if (self.isViewableFile(file)) {
|
||||
if (self.isViewableFile(file) || isImage) {
|
||||
if (window.inlineViewer) window.inlineViewer.openFile(file);
|
||||
else window.fileOps.downloadFile(file.id, file.name);
|
||||
} else {
|
||||
|
||||
@@ -17,7 +17,7 @@ function getAvailableLanguages() {
|
||||
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
|
||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
||||
{ code: 'pt', name: 'Português', flag: '🇧🇷' },
|
||||
{ code: 'it', name: 'Italiano', flag: '🇮🇹' }
|
||||
{ code: 'it', name: 'Italiano', flag: '🇮🇹' },
|
||||
{ code: 'nl', name: 'Nederlands', flag: '🇳🇱' }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ const contextMenus = {
|
||||
if (!wopiEdit || !wopiEditTab) return;
|
||||
|
||||
const targetFile = window.app && window.app.contextMenuTargetFile;
|
||||
// Don't show WOPI editor for image files - they should use inline preview
|
||||
const isImage = targetFile && targetFile.mime_type && targetFile.mime_type.startsWith('image/');
|
||||
const show = targetFile &&
|
||||
!isImage &&
|
||||
window.wopiEditor &&
|
||||
await window.wopiEditor.canEdit(targetFile.name);
|
||||
|
||||
|
||||
@@ -92,29 +92,34 @@ class InlineViewer {
|
||||
console.log('Opening file:', file);
|
||||
|
||||
// WOPI editor intercept: open Office documents in the WOPI editor
|
||||
if (window.wopiEditor && await window.wopiEditor.canEdit(file.name)) {
|
||||
// But NOT image files - those should be previewed in the inline viewer
|
||||
// Detect images by mime type OR extension (uploads via WebDAV may lack correct mime)
|
||||
const ext = (file.name || '').split('.').pop().toLowerCase();
|
||||
const imageExts = ['jpg','jpeg','png','gif','svg','webp','bmp','ico','heic','heif','avif','tiff'];
|
||||
const isImage = (file.mime_type && file.mime_type.startsWith('image/')) || imageExts.includes(ext);
|
||||
if (!isImage && window.wopiEditor && await window.wopiEditor.canEdit(file.name)) {
|
||||
window.wopiEditor.openInModal(file.id, file.name, 'edit');
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentFile = file;
|
||||
|
||||
|
||||
// Get container
|
||||
const modal = document.getElementById('inline-viewer-modal');
|
||||
const container = modal.querySelector('.inline-viewer-container');
|
||||
const title = modal.querySelector('.inline-viewer-title');
|
||||
|
||||
|
||||
// Clear container
|
||||
container.innerHTML = '';
|
||||
|
||||
|
||||
// Set title
|
||||
title.textContent = file.name;
|
||||
|
||||
|
||||
// Set controls visibility
|
||||
const controls = modal.querySelector('.inline-viewer-controls');
|
||||
|
||||
|
||||
// Show viewer based on file type
|
||||
if (file.mime_type && file.mime_type.startsWith('image/')) {
|
||||
if (isImage) {
|
||||
// Show zoom controls
|
||||
controls.style.display = 'flex';
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ async function init() {
|
||||
document.getElementById('password-section').style.display = 'none';
|
||||
}
|
||||
|
||||
loadAppPasswords();
|
||||
|
||||
try {
|
||||
const oidcResp = await fetch(API + '/auth/oidc/providers', { credentials: 'same-origin' });
|
||||
if (oidcResp.ok) {
|
||||
@@ -134,6 +136,150 @@ async function changePassword(e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── App Passwords ──
|
||||
|
||||
const AUTO_LABELS = ['Nextcloud', 'Nextcloud (OIDC)'];
|
||||
|
||||
function isAutoPassword(pw) {
|
||||
return AUTO_LABELS.includes(pw.label);
|
||||
}
|
||||
|
||||
function renderPwRow(pw) {
|
||||
const tr = document.createElement('tr');
|
||||
const label = document.createElement('td');
|
||||
label.textContent = pw.label;
|
||||
const created = document.createElement('td');
|
||||
created.textContent = new Date(pw.created_at).toLocaleDateString();
|
||||
const lastUsed = document.createElement('td');
|
||||
lastUsed.textContent = pw.last_used_at ? timeAgo(pw.last_used_at) : 'Never';
|
||||
const actions = document.createElement('td');
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-danger-sm';
|
||||
btn.innerHTML = '<i class="fas fa-trash"></i>';
|
||||
btn.title = 'Revoke';
|
||||
btn.onclick = function () { revokeAppPassword(pw.id, pw.label); };
|
||||
actions.appendChild(btn);
|
||||
tr.append(label, created, lastUsed, actions);
|
||||
return tr;
|
||||
}
|
||||
|
||||
async function loadAppPasswords() {
|
||||
try {
|
||||
const resp = await fetch(API + '/auth/app-passwords', { headers: headers() });
|
||||
if (!resp.ok) {
|
||||
document.getElementById('app-passwords-section').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const passwords = await resp.json();
|
||||
const userPws = passwords.filter(function (pw) { return !isAutoPassword(pw); });
|
||||
const autoPws = passwords.filter(isAutoPassword);
|
||||
|
||||
// User-created passwords
|
||||
const tbody = document.getElementById('app-pw-tbody');
|
||||
const table = document.getElementById('app-pw-table');
|
||||
const empty = document.getElementById('app-pw-empty');
|
||||
tbody.innerHTML = '';
|
||||
if (userPws.length === 0) {
|
||||
table.style.display = 'none';
|
||||
empty.style.display = 'block';
|
||||
} else {
|
||||
table.style.display = '';
|
||||
empty.style.display = 'none';
|
||||
for (const pw of userPws) tbody.appendChild(renderPwRow(pw));
|
||||
}
|
||||
|
||||
// Auto-generated (client session) passwords
|
||||
const autoSection = document.getElementById('app-pw-auto-section');
|
||||
if (autoPws.length === 0) {
|
||||
autoSection.style.display = 'none';
|
||||
} else {
|
||||
autoSection.style.display = '';
|
||||
document.getElementById('app-pw-auto-count').textContent = autoPws.length;
|
||||
const autoTbody = document.getElementById('app-pw-auto-tbody');
|
||||
autoTbody.innerHTML = '';
|
||||
for (const pw of autoPws) autoTbody.appendChild(renderPwRow(pw));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load app passwords', e);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoPasswords() {
|
||||
const body = document.getElementById('app-pw-auto-body');
|
||||
const chevron = document.getElementById('app-pw-auto-chevron');
|
||||
const open = body.style.display === 'none';
|
||||
body.style.display = open ? '' : 'none';
|
||||
chevron.className = open ? 'fas fa-chevron-down' : 'fas fa-chevron-right';
|
||||
}
|
||||
|
||||
async function createAppPassword() {
|
||||
const labelInput = document.getElementById('app-pw-label');
|
||||
const label = labelInput.value.trim();
|
||||
const statusEl = document.getElementById('app-pw-status');
|
||||
const btn = document.getElementById('app-pw-generate');
|
||||
|
||||
if (!label) {
|
||||
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Please enter a label</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating…';
|
||||
statusEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const resp = await fetch(API + '/auth/app-passwords', {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ label: label })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + (err.message || 'Failed to create app password') + '</div>';
|
||||
return;
|
||||
}
|
||||
const result = await resp.json();
|
||||
document.getElementById('app-pw-created-label').textContent = result.label;
|
||||
document.getElementById('app-pw-created-password').textContent = result.password;
|
||||
document.getElementById('app-pw-created').style.display = 'block';
|
||||
labelInput.value = '';
|
||||
loadAppPasswords();
|
||||
} catch (err) {
|
||||
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + err.message + '</div>';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-plus"></i> Generate';
|
||||
}
|
||||
}
|
||||
|
||||
function copyAppPassword() {
|
||||
const pw = document.getElementById('app-pw-created-password').textContent;
|
||||
navigator.clipboard.writeText(pw).then(function () {
|
||||
const btn = document.querySelector('.btn-copy');
|
||||
btn.innerHTML = '<i class="fas fa-check"></i>';
|
||||
setTimeout(function () { btn.innerHTML = '<i class="fas fa-copy"></i>'; }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeAppPassword(id, label) {
|
||||
if (!confirm('Revoke app password "' + label + '"? Clients using this password will stop working.')) return;
|
||||
try {
|
||||
const resp = await fetch(API + '/auth/app-passwords/' + encodeURIComponent(id), {
|
||||
method: 'DELETE',
|
||||
headers: headers()
|
||||
});
|
||||
if (resp.ok || resp.status === 204) {
|
||||
document.getElementById('app-pw-created').style.display = 'none';
|
||||
loadAppPasswords();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
alert(err.message || 'Failed to revoke app password');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
/* Wire up form handler (replaces inline onsubmit) */
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error - OxiCloud</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="auth-panel">
|
||||
<div class="auth-logo">
|
||||
<div class="auth-logo-icon">
|
||||
<svg viewBox="0 0 500 500">
|
||||
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="auth-logo-text">OxiCloud</div>
|
||||
</div>
|
||||
|
||||
<h2 class="auth-title" id="error-title">Error</h2>
|
||||
<div class="auth-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span id="error-message">An error occurred. Please try again.</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<button type="button" class="auth-button" id="error-action">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Read error type from URL query parameter
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const errorType = params.get('type') || 'generic';
|
||||
|
||||
const errorTitle = document.getElementById('error-title');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
const errorAction = document.getElementById('error-action');
|
||||
|
||||
switch(errorType) {
|
||||
case 'invalid-credentials':
|
||||
errorTitle.textContent = 'Login Failed';
|
||||
errorMessage.textContent = 'Invalid username or password. Please check your credentials and try again.';
|
||||
errorAction.textContent = 'Try Again';
|
||||
errorAction.onclick = () => history.back();
|
||||
break;
|
||||
case 'session-expired':
|
||||
errorTitle.textContent = 'Session Expired';
|
||||
errorMessage.textContent = 'Your session has expired. Please try again.';
|
||||
errorAction.textContent = 'Close Window';
|
||||
errorAction.onclick = () => window.close();
|
||||
break;
|
||||
case 'not-found':
|
||||
errorTitle.textContent = 'Not Found';
|
||||
errorMessage.textContent = 'The requested page was not found.';
|
||||
errorAction.textContent = 'Close Window';
|
||||
errorAction.onclick = () => window.close();
|
||||
break;
|
||||
default:
|
||||
errorTitle.textContent = 'Error';
|
||||
errorMessage.textContent = 'An unexpected error occurred. Please try again.';
|
||||
errorAction.textContent = 'Close Window';
|
||||
errorAction.onclick = () => window.close();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Grant Access - OxiCloud</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="auth-panel">
|
||||
<div class="auth-logo">
|
||||
<div class="auth-logo-icon">
|
||||
<svg viewBox="0 0 500 500">
|
||||
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="auth-logo-text">OxiCloud</div>
|
||||
</div>
|
||||
|
||||
<h2 class="auth-title">Grant Access</h2>
|
||||
<p style="margin-bottom: 20px; color: #6b7280; font-size: 14px;">
|
||||
A Nextcloud client is requesting access to your account.
|
||||
</p>
|
||||
|
||||
<form class="auth-form" method="POST" id="login-flow-form">
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="user">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="user"
|
||||
name="user"
|
||||
class="auth-input"
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="auth-input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-button" id="password-submit">Grant Access</button>
|
||||
</form>
|
||||
|
||||
<!-- OIDC/SSO login — shown only when OIDC is enabled -->
|
||||
<div id="oidc-section" style="display: none;">
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin: 16px 0;">
|
||||
<hr style="flex: 1; border: none; border-top: 1px solid #e5e7eb;">
|
||||
<span style="color: #9ca3af; font-size: 13px;">or</span>
|
||||
<hr style="flex: 1; border: none; border-top: 1px solid #e5e7eb;">
|
||||
</div>
|
||||
<button type="button" id="oidc-button" class="auth-button" style="background: #4f46e5;">
|
||||
Sign in with SSO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Extract token from URL path and set form action
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const token = pathParts[pathParts.length - 1];
|
||||
// Validate token is hex-only to prevent injection
|
||||
if (!/^[0-9a-fA-F]+$/.test(token)) {
|
||||
document.body.innerHTML = '<p>Invalid session token.</p>';
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
document.getElementById('login-flow-form').action = `/login/v2/flow/${token}`;
|
||||
|
||||
// Check if OIDC is available and configure SSO button
|
||||
(async function() {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/oidc/providers');
|
||||
if (!resp.ok) return;
|
||||
const info = await resp.json();
|
||||
if (!info.enabled) return;
|
||||
|
||||
// Show OIDC section
|
||||
const section = document.getElementById('oidc-section');
|
||||
section.style.display = 'block';
|
||||
|
||||
// Update button text with provider name
|
||||
const btn = document.getElementById('oidc-button');
|
||||
btn.textContent = `Sign in with ${info.provider_name || 'SSO'}`;
|
||||
|
||||
// If password login is disabled, hide the password form
|
||||
if (!info.password_login_enabled) {
|
||||
document.getElementById('login-flow-form').style.display = 'none';
|
||||
}
|
||||
|
||||
// SSO button redirects to the OIDC flow for this NC token
|
||||
btn.addEventListener('click', () => {
|
||||
window.location.href = `/login/v2/flow/${token}/oidc`;
|
||||
});
|
||||
} catch(e) {
|
||||
// OIDC not available — silently keep password-only mode
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Access Granted - OxiCloud</title>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="auth-panel">
|
||||
<div class="auth-logo">
|
||||
<div class="auth-logo-icon">
|
||||
<svg viewBox="0 0 500 500">
|
||||
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="auth-logo-text">OxiCloud</div>
|
||||
</div>
|
||||
|
||||
<h2 class="auth-title">Access Granted</h2>
|
||||
<div class="auth-success">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<span>You have successfully granted access to your account.</span>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 20px; color: #6b7280; font-size: 14px;">
|
||||
You can now close this window and return to your Nextcloud app.
|
||||
</p>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<button type="button" class="auth-button" onclick="window.close()">Close Window</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-close after 3 seconds
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -94,6 +94,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-card" id="app-passwords-section">
|
||||
<h2><i class="fas fa-key"></i> App Passwords</h2>
|
||||
<p class="app-pw-desc">Generate passwords for WebDAV, CalDAV, and CardDAV clients. Each password is shown only once.</p>
|
||||
|
||||
<div class="app-pw-create">
|
||||
<input type="text" id="app-pw-label" placeholder="Label (e.g. Thunderbird, macOS)" maxlength="128">
|
||||
<button class="btn btn-primary" id="app-pw-generate" onclick="createAppPassword()"><i class="fas fa-plus"></i> Generate</button>
|
||||
</div>
|
||||
|
||||
<div id="app-pw-created" class="app-pw-created" style="display:none">
|
||||
<div class="app-pw-created-label">New password for <strong id="app-pw-created-label"></strong>:</div>
|
||||
<div class="app-pw-created-value">
|
||||
<code id="app-pw-created-password"></code>
|
||||
<button class="btn btn-copy" onclick="copyAppPassword()" title="Copy to clipboard"><i class="fas fa-copy"></i></button>
|
||||
</div>
|
||||
<small>Copy this password now. You won't be able to see it again.</small>
|
||||
</div>
|
||||
|
||||
<div id="app-pw-status"></div>
|
||||
|
||||
<table class="app-pw-table" id="app-pw-table">
|
||||
<thead>
|
||||
<tr><th>Label</th><th>Created</th><th>Last Used</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="app-pw-tbody"></tbody>
|
||||
</table>
|
||||
<div id="app-pw-empty" class="app-pw-empty" style="display:none">No app passwords yet.</div>
|
||||
|
||||
<div id="app-pw-auto-section" class="app-pw-auto-section" style="display:none">
|
||||
<button class="app-pw-auto-toggle" id="app-pw-auto-toggle" onclick="toggleAutoPasswords()">
|
||||
<i class="fas fa-chevron-right" id="app-pw-auto-chevron"></i>
|
||||
<span>Client sessions</span>
|
||||
<span class="app-pw-auto-count" id="app-pw-auto-count">0</span>
|
||||
</button>
|
||||
<div id="app-pw-auto-body" style="display:none">
|
||||
<p class="app-pw-auto-desc">Auto-generated when you connect a Nextcloud-compatible client.</p>
|
||||
<table class="app-pw-table" id="app-pw-auto-table">
|
||||
<thead>
|
||||
<tr><th>Client</th><th>Created</th><th>Last Used</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="app-pw-auto-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-card" id="password-section">
|
||||
<h2><i class="fas fa-key"></i> Change Password</h2>
|
||||
<form id="password-form">
|
||||
|
||||
Reference in New Issue
Block a user