Merge pull request #160 from zjean/feature/nextcloud-compatible-api

Feature/nextcloud compatible api
This commit is contained in:
Dionisio Pozo
2026-03-05 21:51:20 +01:00
committed by GitHub
91 changed files with 7428 additions and 420 deletions
+15
View File
@@ -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
View File
@@ -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"
+2
View File
@@ -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)'] }
+14
View File
@@ -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 $$
@@ -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;
+9 -10
View File
@@ -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()?;
+24
View File
@@ -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
// ============================================================================
+16 -2
View File
@@ -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.
///
+6
View 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;
+18
View File
@@ -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>>;
}
+7 -2
View File
@@ -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())
}
}
+1 -5
View File
@@ -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>;
+5 -2
View File
@@ -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
+237 -24
View File
@@ -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.
+25 -12
View File
@@ -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 {
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.into()));
}
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(),
+13 -2
View File
@@ -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
+5 -1
View File
@@ -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)
// ═══════════════════════════════════════════════════════════════════════════
+3 -1
View File
@@ -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());
}
}
+292 -14
View File
@@ -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 {
+6 -2
View File
@@ -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),
+328 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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>>,
+127 -5
View File
@@ -9,10 +9,16 @@
//! 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
@@ -62,11 +68,12 @@ pub async fn refine_content_type_from_file(
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)
// 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!(
@@ -83,3 +90,118 @@ pub async fn refine_content_type_from_file(
}
}
}
#[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
View File
@@ -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()
}
+10 -4
View File
@@ -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,9 +140,12 @@ 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")
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}")))?;
@@ -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>,
+6 -1
View File
@@ -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,7 +808,14 @@ 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)
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))
}
@@ -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};
+1
View File
@@ -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();
}
}
+1 -1
View File
@@ -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;
@@ -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,10 +8,7 @@ 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,
)
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());
+199 -11
View File
@@ -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,15 +754,59 @@ async fn oidc_callback(
AppError::from(e)
})?;
// Redirect to frontend with one-time exchange code (NOT raw tokens)
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,);
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"))?;
let (_id, app_password) = nextcloud
.app_passwords
.create_nc(&user_id, "Nextcloud (OIDC)")
.await
.map_err(|e| {
tracing::error!(error = %e, user = %username, "OIDC+NC: failed to create app password");
AppError::from(e)
})?;
let base_url = state.core.config.base_url();
let completed =
nextcloud
.login_flow
.complete(&nc_flow_token, &username, &base_url, &app_password);
if completed {
tracing::info!(
user = %username,
"OIDC login completed Nextcloud Login Flow v2 successfully"
);
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
/// Request body: { "code": "<one_time_code>" }
+40 -10
View File
@@ -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()
}
}
}
}
+2 -6
View File
@@ -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();
}
+22 -21
View File
@@ -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.
+16 -13
View File
@@ -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(),
}
}
+8 -16
View File
@@ -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();
+15 -15
View File
@@ -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,11 +1229,12 @@ 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 {
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()))
.await
@@ -1328,11 +1331,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 {
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()))
.await
@@ -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";
+16
View File
@@ -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
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
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(&current_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, &current_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)
}
+11
View File
@@ -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;
+531
View File
@@ -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)
}
+164
View File
@@ -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()
}
}
}
+447
View File
@@ -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)
}
+256
View File
@@ -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(())
}
+235
View File
@@ -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
View File
@@ -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);
+63 -1
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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: '🇳🇱' }
];
}
+3
View File
@@ -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);
+7 -2
View File
@@ -92,7 +92,12 @@ 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;
}
@@ -114,7 +119,7 @@ class InlineViewer {
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';
+146
View File
@@ -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) */
+70
View File
@@ -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>
+114
View File
@@ -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>
+45
View File
@@ -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>
+46
View File
@@ -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">