feat(nextcloud): add Nextcloud-compatible API layer
Implement a complete Nextcloud client compatibility layer so that Nextcloud desktop/mobile sync clients can connect to OxiCloud. Key additions: - Login Flow v2 (device auth) with OIDC bridge support - WebDAV handler compatible with Nextcloud clients (PROPFIND, GET, PUT, DELETE, MKCOL, MOVE, COPY, HEAD, PROPPATCH) - OCS API endpoints (user info, capabilities, notifications stubs, sharees, unified search) - Basic Auth middleware with app password verification, account lockout integration, and blake3-keyed auth cache - App password management: create, list, revoke via both native API (JWT-authenticated profile page) and Nextcloud OCS endpoints - Nextcloud file ID mapping (oc:fileid) with persistent DB storage - Chunked upload support (Nextcloud v2 chunking protocol) - Trashbin WebDAV interface - Avatar (SVG placeholder) and preview (redirect) handlers - User profile page with app password management UI - URL user validation on all DAV routes (403 on mismatch) - Database schema for app_passwords and nextcloud_object_ids tables All services are behind a `nextcloud.enabled` config flag and cleanly separated under src/interfaces/nextcloud/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+15
@@ -55,6 +55,7 @@ npm-debug.log
|
|||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
logs.txt
|
||||||
|
|
||||||
# Storage data (user files, blobs — never commit)
|
# Storage data (user files, blobs — never commit)
|
||||||
storage/
|
storage/
|
||||||
@@ -73,3 +74,17 @@ storage/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
nohup.out
|
nohup.out
|
||||||
|
|
||||||
|
# Agent planning docs (live on 'planning' branch)
|
||||||
|
docs/plans/
|
||||||
|
.planning/
|
||||||
|
|
||||||
|
# Local dev compose (not in upstream)
|
||||||
|
docker-compose.dev.yml
|
||||||
|
|
||||||
|
# Test scripts with hardcoded credentials
|
||||||
|
test-nextcloud-*.sh
|
||||||
|
|
||||||
|
# Claude Code artifacts
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
Generated
+7
@@ -1844,6 +1844,7 @@ dependencies = [
|
|||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3270,6 +3271,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ async-compression = { version = "0.4", features = ["tokio", "gzip"] }
|
|||||||
async_zip = { version = "0.0.18", features = ["tokio", "deflate"] }
|
async_zip = { version = "0.0.18", features = ["tokio", "deflate"] }
|
||||||
dashmap = "6"
|
dashmap = "6"
|
||||||
socket2 = { version = "0.6.2", features = ["all"] }
|
socket2 = { version = "0.6.2", features = ["all"] }
|
||||||
|
urlencoding = "2.1.3"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ $$ LANGUAGE plpgsql IMMUTABLE;
|
|||||||
CREATE INDEX IF NOT EXISTS idx_sessions_active ON auth.sessions(user_id, revoked)
|
CREATE INDEX IF NOT EXISTS idx_sessions_active ON auth.sessions(user_id, revoked)
|
||||||
WHERE NOT revoked AND auth.is_session_active(expires_at);
|
WHERE NOT revoked AND auth.is_session_active(expires_at);
|
||||||
|
|
||||||
|
|
||||||
-- File ownership tracking
|
-- File ownership tracking
|
||||||
CREATE TABLE IF NOT EXISTS auth.user_files (
|
CREATE TABLE IF NOT EXISTS auth.user_files (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -468,6 +469,16 @@ CREATE INDEX IF NOT EXISTS idx_folders_path ON storage.folders (path text_patter
|
|||||||
CREATE INDEX IF NOT EXISTS idx_folders_name_trgm
|
CREATE INDEX IF NOT EXISTS idx_folders_name_trgm
|
||||||
ON storage.folders USING gin (name gin_trgm_ops);
|
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 ──
|
-- ── ltree trigger: compute path & lpath on INSERT or UPDATE of name/parent_id ──
|
||||||
CREATE OR REPLACE FUNCTION storage.compute_folder_path()
|
CREATE OR REPLACE FUNCTION storage.compute_folder_path()
|
||||||
RETURNS trigger AS $$
|
RETURNS trigger AS $$
|
||||||
|
|||||||
@@ -120,6 +120,62 @@ pub enum LockType {
|
|||||||
Write,
|
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
|
/// WebDAV adapter for converting between XML and domain objects
|
||||||
pub struct WebDavAdapter;
|
pub struct WebDavAdapter;
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
// Shared display helpers for DTOs.
|
//! Shared display helpers for DTOs.
|
||||||
//
|
//!
|
||||||
// These functions centralise the mime→icon / mime→category / size→human-string
|
//! These functions centralise the mime→icon / mime→category / size→human-string
|
||||||
// logic so that every API response carries pre-computed display fields and the
|
//! logic so that every API response carries pre-computed display fields and the
|
||||||
// frontend does **not** need to duplicate these mappings.
|
//! frontend does **not** need to duplicate these mappings.
|
||||||
//
|
//!
|
||||||
// The approach is: try MIME first (specific matches beat prefix matches),
|
//! The approach is: try MIME first (specific matches beat prefix matches),
|
||||||
// then fall back to the file extension when the MIME is generic
|
//! then fall back to the file extension when the MIME is generic
|
||||||
// (`application/octet-stream` or empty).
|
//! (`application/octet-stream` or empty).
|
||||||
|
|
||||||
// ─── Private: extract lowercase extension from a filename ────────────
|
// ─── Private: extract lowercase extension from a filename ────────────
|
||||||
|
|
||||||
fn ext_of(name: &str) -> Option<&str> {
|
fn ext_of(name: &str) -> Option<&str> {
|
||||||
let name = name.rsplit('/').next().unwrap_or(name); // strip path
|
let name = name.rsplit('/').next().unwrap_or(name); // strip path
|
||||||
let after_dot = name.rsplit('.').next()?;
|
let after_dot = name.rsplit('.').next()?;
|
||||||
|
|||||||
@@ -87,6 +87,30 @@ pub struct CurrentUser {
|
|||||||
pub role: String,
|
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
|
// OIDC DTOs
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ pub trait UserStoragePort: Send + Sync + 'static {
|
|||||||
/// Lists users with pagination
|
/// Lists users with pagination
|
||||||
async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError>;
|
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")
|
/// Lists users by role (e.g., "admin" or "user")
|
||||||
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError>;
|
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.
|
/// Update the `last_used_at` timestamp after a successful authentication.
|
||||||
async fn touch_last_used(&self, id: &str) -> Result<(), DomainError>;
|
async fn touch_last_used(&self, id: &str) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Deactivate (soft-delete) an app password.
|
/// Get active app passwords for a user filtered by token prefix (first 8 chars).
|
||||||
async fn revoke(&self, id: &str) -> Result<(), DomainError>;
|
/// 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).
|
/// Hard-delete expired/revoked app passwords (cleanup).
|
||||||
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
||||||
|
|||||||
@@ -152,6 +152,12 @@ pub trait DedupPort: Send + Sync + 'static {
|
|||||||
/// Calculate BLAKE3 hash of a file (streaming).
|
/// Calculate BLAKE3 hash of a file (streaming).
|
||||||
async fn hash_file(&self, path: &Path) -> Result<String, DomainError>;
|
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.
|
/// Get deduplication statistics.
|
||||||
async fn get_stats(&self) -> DedupStatsDto;
|
async fn get_stats(&self) -> DedupStatsDto;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::application::dtos::favorites_dto::{BatchFavoritesResult, FavoriteItemDto};
|
use crate::application::dtos::favorites_dto::{BatchFavoritesResult, FavoriteItemDto};
|
||||||
use crate::common::errors::Result;
|
use crate::common::errors::Result;
|
||||||
|
|
||||||
@@ -27,6 +29,14 @@ pub trait FavoritesUseCase: Send + Sync {
|
|||||||
user_id: &str,
|
user_id: &str,
|
||||||
items: &[(String, String)],
|
items: &[(String, String)],
|
||||||
) -> Result<BatchFavoritesResult>;
|
) -> 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.
|
/// Insert multiple items in a single transaction.
|
||||||
/// Returns the number of rows actually inserted (ignoring duplicates).
|
/// Returns the number of rows actually inserted (ignoring duplicates).
|
||||||
async fn add_favorites_batch(&self, user_id: &str, items: &[(String, String)]) -> Result<u64>;
|
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>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ pub trait FileReadPort: Send + Sync + 'static {
|
|||||||
/// Gets the parent folder ID from a path (WebDAV).
|
/// Gets the parent folder ID from a path (WebDAV).
|
||||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
|
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).
|
/// Gets the content-addressable blob hash for a file (O(1) DB lookup).
|
||||||
///
|
///
|
||||||
/// Returns the BLAKE3 hash stored in `storage.files.blob_hash`.
|
/// Returns the BLAKE3 hash stored in `storage.files.blob_hash`.
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ use crate::application::dtos::app_password_dto::*;
|
|||||||
use crate::application::ports::auth_ports::{
|
use crate::application::ports::auth_ports::{
|
||||||
AppPasswordStoragePort, PasswordHasherPort, UserStoragePort,
|
AppPasswordStoragePort, PasswordHasherPort, UserStoragePort,
|
||||||
};
|
};
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::{DomainError, ErrorKind};
|
||||||
use crate::domain::entities::app_password::AppPassword;
|
use crate::domain::entities::app_password::AppPassword;
|
||||||
use crate::infrastructure::repositories::pg::AppPasswordPgRepository;
|
use crate::infrastructure::repositories::pg::AppPasswordPgRepository;
|
||||||
use crate::infrastructure::repositories::pg::UserPgRepository;
|
use crate::infrastructure::repositories::pg::UserPgRepository;
|
||||||
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use moka::future::Cache;
|
use moka::future::Cache;
|
||||||
|
use rand_core::RngCore;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration as StdDuration;
|
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).
|
/// Prefix for all app password tokens (makes them easily identifiable).
|
||||||
const TOKEN_PREFIX: &str = "oxicloud-";
|
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.
|
/// TTL for cached Basic Auth verification results.
|
||||||
/// Balances performance (avoids repeated Argon2id + DB queries) with security
|
/// Balances performance (avoids repeated Argon2id + DB queries) with security
|
||||||
/// (limits the window during which a revoked app password remains usable).
|
/// (limits the window during which a revoked app password remains usable).
|
||||||
@@ -231,13 +237,16 @@ impl AppPasswordService {
|
|||||||
user_id: &str,
|
user_id: &str,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<AppPasswordRevokeResponseDto, DomainError> {
|
) -> 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?;
|
let ap = self.repo.get_by_id(id).await?;
|
||||||
if ap.user_id != user_id {
|
if ap.user_id != user_id {
|
||||||
return Err(DomainError::unauthorized(
|
return Err(DomainError::unauthorized(
|
||||||
"You can only revoke your own app passwords",
|
"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
|
// Invalidate all cached auth entries for this user so the
|
||||||
// revocation is effective immediately.
|
// revocation is effective immediately.
|
||||||
@@ -262,23 +271,19 @@ impl AppPasswordService {
|
|||||||
///
|
///
|
||||||
/// Returns `(user_id, username, email, role)` on success.
|
/// 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`
|
/// Successful verifications are cached for `BASIC_AUTH_CACHE_TTL_SECS`
|
||||||
/// (default 30 s) keyed by `blake3(username:password)`. This avoids
|
/// keyed by `blake3(username:password)`. Failed verifications are
|
||||||
/// the expensive Argon2id computation **and** the three PostgreSQL
|
/// **never** cached, preserving the full Argon2id cost as a brute-force
|
||||||
/// round-trips on every repeated DAV request from the same client.
|
/// deterrent.
|
||||||
///
|
|
||||||
/// Failed verifications are **never** cached, preserving the full
|
|
||||||
/// Argon2id cost as a brute-force deterrent.
|
|
||||||
pub async fn verify_basic_auth(
|
pub async fn verify_basic_auth(
|
||||||
&self,
|
&self,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<(String, String, String, String), DomainError> {
|
) -> Result<(String, String, String, String), DomainError> {
|
||||||
// ── 1. Compute cache key = blake3("username:password") ────────
|
// ── 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] =
|
let cache_key: [u8; 32] =
|
||||||
blake3::hash(format!("{}:{}", username, password).as_bytes()).into();
|
blake3::hash(format!("{}:{}", username, password).as_bytes()).into();
|
||||||
|
|
||||||
@@ -288,30 +293,57 @@ impl AppPasswordService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Cache miss → full verification ────────────────────────
|
// ── 3. Cache miss → full verification ────────────────────────
|
||||||
// Look up user by username
|
|
||||||
let user = self
|
let user = self
|
||||||
.user_repo
|
.user_repo
|
||||||
.get_user_by_username(username)
|
.get_user_by_username(username)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| DomainError::unauthorized("Invalid username or app password"))?;
|
.map_err(|_| DomainError::unauthorized("Invalid username or app password"))?;
|
||||||
|
|
||||||
// Get all active app passwords for this user
|
if !user.is_active() {
|
||||||
let app_passwords = self.repo.get_active_by_user_id(user.id()).await?;
|
|
||||||
|
|
||||||
if app_passwords.is_empty() {
|
|
||||||
return Err(DomainError::unauthorized(
|
return Err(DomainError::unauthorized(
|
||||||
"Invalid username or app password",
|
"Invalid username or app password",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try each app password hash (Argon2id — CPU-intensive)
|
// Determine the password form and prefix for DB lookup.
|
||||||
for ap in &app_passwords {
|
// 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
|
if let Ok(true) = self
|
||||||
.hasher
|
.hasher
|
||||||
.verify_password(password, &ap.password_hash)
|
.verify_password(&verify_password, &ap.password_hash)
|
||||||
.await
|
.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 _ = self.repo.touch_last_used(&ap.id).await;
|
||||||
|
|
||||||
let result = CachedBasicAuthResult {
|
let result = CachedBasicAuthResult {
|
||||||
@@ -321,17 +353,198 @@ impl AppPasswordService {
|
|||||||
role: user.role().to_string(),
|
role: user.role().to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 4. Cache the successful result ────────────────────
|
|
||||||
self.auth_cache.insert(cache_key, result.clone()).await;
|
self.auth_cache.insert(cache_key, result.clone()).await;
|
||||||
|
|
||||||
return Ok((result.user_id, result.username, result.email, result.role));
|
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(
|
Err(DomainError::unauthorized(
|
||||||
"Invalid username or app password",
|
"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::sync::RwLock;
|
||||||
use std::time::Duration;
|
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)
|
/// Tracks a pending OIDC authorization flow (CSRF + PKCE + nonce)
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct PendingOidcFlow {
|
struct PendingOidcFlow {
|
||||||
pkce_verifier: String,
|
pkce_verifier: String,
|
||||||
nonce: 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
|
/// 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(
|
pub async fn refresh_token(
|
||||||
&self,
|
&self,
|
||||||
dto: RefreshTokenDto,
|
dto: RefreshTokenDto,
|
||||||
@@ -605,6 +668,11 @@ impl AuthApplicationService {
|
|||||||
Ok(users.into_iter().map(UserDto::from).collect())
|
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
|
// Admin User Management Methods
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -856,6 +924,7 @@ impl AuthApplicationService {
|
|||||||
PendingOidcFlow {
|
PendingOidcFlow {
|
||||||
pkce_verifier,
|
pkce_verifier,
|
||||||
nonce: nonce.clone(),
|
nonce: nonce.clone(),
|
||||||
|
nc_flow_token: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -872,11 +941,77 @@ impl AuthApplicationService {
|
|||||||
Ok(authorize_url)
|
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,
|
/// Handle the OIDC callback: validate CSRF state, exchange code with PKCE,
|
||||||
/// validate ID token nonce, find or create user (JIT provisioning),
|
/// validate ID token nonce, find or create user (JIT provisioning),
|
||||||
/// issue internal tokens, and return a one-time exchange code.
|
/// 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)
|
// (entry is auto-expired by moka TTL — remove returns None if expired)
|
||||||
let flow = self.pending_oidc_flows.remove(state).ok_or_else(|| {
|
let flow = self.pending_oidc_flows.remove(state).ok_or_else(|| {
|
||||||
tracing::warn!("OIDC callback with invalid/expired state token");
|
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.",
|
"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
|
// Clone the Arc and config out of the RwLock so we don't hold the lock across await points
|
||||||
let (oidc, oidc_config) = {
|
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)
|
// 6. Issue internal tokens (same as regular login)
|
||||||
let access_token = self.token_service.generate_access_token(&user)?;
|
let access_token = self.token_service.generate_access_token(&user)?;
|
||||||
let refresh_token = self.token_service.generate_refresh_token();
|
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");
|
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.
|
/// Exchange a one-time code for the authentication tokens.
|
||||||
|
|||||||
@@ -1042,18 +1042,22 @@ impl BatchOperationService {
|
|||||||
#[cfg(integration_tests)]
|
#[cfg(integration_tests)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::common::stubs::{StubFileManagementUseCase, StubFileRetrievalUseCase};
|
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||||
|
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
|
||||||
|
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_generic_batch_operation() {
|
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(
|
let batch_service = BatchOperationService::new(
|
||||||
Arc::new(StubFileRetrievalUseCase),
|
Arc::new(FileRetrievalService::new(file_read_repo)),
|
||||||
Arc::new(StubFileManagementUseCase),
|
Arc::new(FileManagementService::new(file_write_repo)),
|
||||||
Arc::new(FolderService::new(Arc::new(
|
Arc::new(FolderService::new(folder_repo)),
|
||||||
crate::common::stubs::StubFolderStoragePort,
|
|
||||||
))),
|
|
||||||
AppConfig::default(),
|
AppConfig::default(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -262,6 +262,10 @@ impl DeviceAuthService {
|
|||||||
let refresh_token = dc.refresh_token().unwrap_or_default().to_string();
|
let refresh_token = dc.refresh_token().unwrap_or_default().to_string();
|
||||||
let scope = dc.scopes().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 {
|
Ok(DeviceTokenSuccessDto {
|
||||||
access_token,
|
access_token,
|
||||||
token_type: "Bearer".to_string(),
|
token_type: "Bearer".to_string(),
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
use crate::application::dtos::favorites_dto::{
|
use crate::application::dtos::favorites_dto::{
|
||||||
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto,
|
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto,
|
||||||
};
|
};
|
||||||
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
||||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||||
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
||||||
use std::sync::Arc;
|
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
/// Implementation of the FavoritesUseCase for managing user favorites.
|
/// Implementation of the FavoritesUseCase for managing user favorites.
|
||||||
///
|
///
|
||||||
@@ -142,4 +145,12 @@ impl FavoritesUseCase for FavoritesService {
|
|||||||
favorites,
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,9 +182,11 @@ impl FileUploadUseCase for FileUploadService {
|
|||||||
content: &[u8],
|
content: &[u8],
|
||||||
content_type: &str,
|
content_type: &str,
|
||||||
) -> Result<FileDto, DomainError> {
|
) -> Result<FileDto, DomainError> {
|
||||||
|
// Look up the folder ID by folder path
|
||||||
let parent_id = if !parent_path.is_empty() {
|
let parent_id = if !parent_path.is_empty() {
|
||||||
if let Some(file_read) = &self.file_read {
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ pub mod file_upload_service;
|
|||||||
pub mod file_use_case_factory;
|
pub mod file_use_case_factory;
|
||||||
pub mod folder_service;
|
pub mod folder_service;
|
||||||
pub mod i18n_application_service;
|
pub mod i18n_application_service;
|
||||||
|
pub mod nextcloud_file_id_service;
|
||||||
|
pub mod nextcloud_login_flow_service;
|
||||||
pub mod recent_service;
|
pub mod recent_service;
|
||||||
pub mod search_service;
|
pub mod search_service;
|
||||||
pub mod share_service;
|
pub mod share_service;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -436,10 +436,267 @@ mod tests {
|
|||||||
use crate::application::dtos::share_dto::SharePermissionsDto;
|
use crate::application::dtos::share_dto::SharePermissionsDto;
|
||||||
use crate::application::ports::auth_ports::PasswordHasherPort;
|
use crate::application::ports::auth_ports::PasswordHasherPort;
|
||||||
use crate::application::ports::share_ports::ShareStoragePort;
|
use crate::application::ports::share_ports::ShareStoragePort;
|
||||||
|
use crate::application::ports::storage_ports::FileReadPort;
|
||||||
use crate::common::config::AppConfig;
|
use crate::common::config::AppConfig;
|
||||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||||
use std::collections::HashMap;
|
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) -> Result<ShareDto, DomainError> {
|
||||||
|
let share = self
|
||||||
|
.share_repository
|
||||||
|
.find_share_by_id(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,
|
||||||
|
) -> Result<Vec<ShareDto>, DomainError> {
|
||||||
|
let shares = self
|
||||||
|
.share_repository
|
||||||
|
.find_shares_by_item(item_id, item_type)
|
||||||
|
.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,
|
||||||
|
dto: UpdateShareDto,
|
||||||
|
) -> Result<ShareDto, DomainError> {
|
||||||
|
let mut share = self
|
||||||
|
.share_repository
|
||||||
|
.find_share_by_id(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) -> Result<(), DomainError> {
|
||||||
|
self.share_repository
|
||||||
|
.delete_share(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<bool, 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) => self.password_hasher.verify_password(password, hash).await,
|
||||||
|
None => Ok(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
struct MockPasswordHasher;
|
||||||
|
|
||||||
@@ -519,6 +776,10 @@ mod tests {
|
|||||||
unimplemented!()
|
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> {
|
async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> {
|
||||||
Ok(String::new())
|
Ok(String::new())
|
||||||
}
|
}
|
||||||
@@ -831,7 +1092,7 @@ mod tests {
|
|||||||
let password_hasher = Arc::new(MockPasswordHasher);
|
let password_hasher = Arc::new(MockPasswordHasher);
|
||||||
|
|
||||||
let service =
|
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
|
// Test creating a file share
|
||||||
let dto = CreateShareDto {
|
let dto = CreateShareDto {
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ use std::pin::Pin;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::application::dtos::trash_dto::TrashedItemDto;
|
||||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||||
use crate::application::services::trash_service::TrashService;
|
use crate::application::ports::trash_ports::TrashUseCase;
|
||||||
use crate::common::errors::{DomainError, Result};
|
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||||
use crate::domain::entities::file::File;
|
use crate::domain::entities::file::File;
|
||||||
use crate::domain::entities::folder::Folder;
|
use crate::domain::entities::folder::Folder;
|
||||||
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
|
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
|
||||||
@@ -17,6 +18,301 @@ use crate::domain::repositories::folder_repository::FolderRepository;
|
|||||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||||
use crate::domain::services::path_service::StoragePath;
|
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.
|
||||||
|
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,
|
||||||
|
{
|
||||||
|
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 {
|
||||||
|
if !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 {
|
||||||
|
if !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 {
|
||||||
|
if !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 {
|
||||||
|
if !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
|
// Mock repositories for testing
|
||||||
struct MockTrashRepository {
|
struct MockTrashRepository {
|
||||||
trash_items: Mutex<HashMap<Uuid, TrashedItem>>,
|
trash_items: Mutex<HashMap<Uuid, TrashedItem>>,
|
||||||
@@ -180,6 +476,13 @@ impl FileReadPort for MockFileRepository {
|
|||||||
unimplemented!()
|
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> {
|
async fn get_blob_hash(&self, _file_id: &str) -> std::result::Result<String, DomainError> {
|
||||||
Ok(String::new())
|
Ok(String::new())
|
||||||
}
|
}
|
||||||
@@ -518,10 +821,10 @@ mod tests {
|
|||||||
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
||||||
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
||||||
|
|
||||||
let service = TrashService::new(
|
let service = TrashServiceForTest::new(
|
||||||
trash_repo.clone(),
|
trash_repo.clone(),
|
||||||
file_repo.clone() as Arc<FileBlobReadRepository>,
|
file_repo.clone(),
|
||||||
file_repo.clone() as Arc<FileBlobWriteRepository>,
|
file_repo.clone(),
|
||||||
folder_repo.clone(),
|
folder_repo.clone(),
|
||||||
30, // 30 days retention
|
30, // 30 days retention
|
||||||
);
|
);
|
||||||
@@ -592,10 +895,10 @@ mod tests {
|
|||||||
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
||||||
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
||||||
|
|
||||||
let service = TrashService::new(
|
let service = TrashServiceForTest::new(
|
||||||
trash_repo.clone(),
|
trash_repo.clone(),
|
||||||
file_repo.clone() as Arc<FileBlobReadRepository>,
|
file_repo.clone(),
|
||||||
file_repo.clone() as Arc<FileBlobWriteRepository>,
|
file_repo.clone(),
|
||||||
folder_repo.clone(),
|
folder_repo.clone(),
|
||||||
30, // 30 days retention
|
30, // 30 days retention
|
||||||
);
|
);
|
||||||
@@ -657,10 +960,10 @@ mod tests {
|
|||||||
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
||||||
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
||||||
|
|
||||||
let service = TrashService::new(
|
let service = TrashServiceForTest::new(
|
||||||
trash_repo.clone(),
|
trash_repo.clone(),
|
||||||
file_repo.clone() as Arc<FileBlobReadRepository>,
|
file_repo.clone(),
|
||||||
file_repo.clone() as Arc<FileBlobWriteRepository>,
|
file_repo.clone(),
|
||||||
folder_repo.clone(),
|
folder_repo.clone(),
|
||||||
30, // 30 days retention
|
30, // 30 days retention
|
||||||
);
|
);
|
||||||
@@ -727,10 +1030,10 @@ mod tests {
|
|||||||
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
||||||
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
||||||
|
|
||||||
let service = TrashService::new(
|
let service = TrashServiceForTest::new(
|
||||||
trash_repo.clone(),
|
trash_repo.clone(),
|
||||||
file_repo.clone() as Arc<FileBlobReadRepository>,
|
file_repo.clone(),
|
||||||
file_repo.clone() as Arc<FileBlobWriteRepository>,
|
file_repo.clone(),
|
||||||
folder_repo.clone(),
|
folder_repo.clone(),
|
||||||
30, // 30 days retention
|
30, // 30 days retention
|
||||||
);
|
);
|
||||||
@@ -796,10 +1099,10 @@ mod tests {
|
|||||||
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
let file_repo = Arc::new(MockFileRepository::new(trashed_files));
|
||||||
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
let folder_repo = Arc::new(MockFolderRepository::new(trashed_folders));
|
||||||
|
|
||||||
let service = TrashService::new(
|
let service = TrashServiceForTest::new(
|
||||||
trash_repo.clone(),
|
trash_repo.clone(),
|
||||||
file_repo.clone() as Arc<FileBlobReadRepository>,
|
file_repo.clone(),
|
||||||
file_repo.clone() as Arc<FileBlobWriteRepository>,
|
file_repo.clone(),
|
||||||
folder_repo.clone(),
|
folder_repo.clone(),
|
||||||
30, // 30 days retention
|
30, // 30 days retention
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)
|
/// Feature configuration (feature flags)
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FeaturesConfig {
|
pub struct FeaturesConfig {
|
||||||
@@ -488,6 +521,8 @@ pub struct AppConfig {
|
|||||||
pub oidc: OidcConfig,
|
pub oidc: OidcConfig,
|
||||||
/// WOPI configuration
|
/// WOPI configuration
|
||||||
pub wopi: WopiConfig,
|
pub wopi: WopiConfig,
|
||||||
|
/// Nextcloud compatibility configuration
|
||||||
|
pub nextcloud: NextcloudConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -507,6 +542,7 @@ impl Default for AppConfig {
|
|||||||
features: FeaturesConfig::default(),
|
features: FeaturesConfig::default(),
|
||||||
oidc: OidcConfig::default(),
|
oidc: OidcConfig::default(),
|
||||||
wopi: WopiConfig::default(),
|
wopi: WopiConfig::default(),
|
||||||
|
nextcloud: NextcloudConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -797,6 +833,30 @@ impl AppConfig {
|
|||||||
tracing::info!("WOPI secret not set, falling back to JWT secret");
|
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
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+77
-25
@@ -11,6 +11,8 @@ use crate::application::ports::file_ports::FileUseCaseFactory;
|
|||||||
use crate::application::services::favorites_service::FavoritesService;
|
use crate::application::services::favorites_service::FavoritesService;
|
||||||
use crate::application::services::folder_service::FolderService;
|
use crate::application::services::folder_service::FolderService;
|
||||||
use crate::application::services::i18n_application_service::I18nApplicationService;
|
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::recent_service::RecentService;
|
||||||
use crate::application::services::search_service::SearchService;
|
use crate::application::services::search_service::SearchService;
|
||||||
use crate::application::services::share_service::ShareService;
|
use crate::application::services::share_service::ShareService;
|
||||||
@@ -28,6 +30,7 @@ use crate::infrastructure::services::file_content_cache::{
|
|||||||
FileContentCache, FileContentCacheConfig,
|
FileContentCache, FileContentCacheConfig,
|
||||||
};
|
};
|
||||||
use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService;
|
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::path_service::PathService;
|
||||||
use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService;
|
use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService;
|
||||||
|
|
||||||
@@ -463,6 +466,7 @@ impl AppServiceFactory {
|
|||||||
let recent_service: Option<Arc<RecentService>>;
|
let recent_service: Option<Arc<RecentService>>;
|
||||||
let storage_usage_service: Option<Arc<StorageUsageService>>;
|
let storage_usage_service: Option<Arc<StorageUsageService>>;
|
||||||
let mut auth_services: Option<crate::common::di::AuthServices> = None;
|
let mut auth_services: Option<crate::common::di::AuthServices> = None;
|
||||||
|
let mut nextcloud_services: Option<NextcloudServices> = None;
|
||||||
|
|
||||||
{
|
{
|
||||||
let favs = self.create_favorites_service(&pool);
|
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
|
// 7. Preload translations
|
||||||
self.preload_translations(&apps.i18n_service).await;
|
self.preload_translations(&apps.i18n_service).await;
|
||||||
|
|
||||||
@@ -528,6 +592,7 @@ impl AppServiceFactory {
|
|||||||
db_pool: Some(pool.clone()),
|
db_pool: Some(pool.clone()),
|
||||||
maintenance_pool: Some(maintenance_pool),
|
maintenance_pool: Some(maintenance_pool),
|
||||||
auth_service: auth_services,
|
auth_service: auth_services,
|
||||||
|
nextcloud: nextcloud_services,
|
||||||
admin_settings_service: None,
|
admin_settings_service: None,
|
||||||
trash_service,
|
trash_service,
|
||||||
share_service,
|
share_service,
|
||||||
@@ -642,31 +707,8 @@ impl AppServiceFactory {
|
|||||||
tracing::info!("Device Authorization Grant (RFC 8628) service initialized");
|
tracing::info!("Device Authorization Grant (RFC 8628) service initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9d. Wire App Password service
|
// 9d. Wire App Password service (reuse shared instance)
|
||||||
{
|
app_state.app_password_service = shared_app_pw_svc.clone();
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9e. Wire PathResolver for single-query WebDAV path resolution
|
// 9e. Wire PathResolver for single-query WebDAV path resolution
|
||||||
@@ -816,6 +858,15 @@ pub struct AuthServices {
|
|||||||
Arc<crate::infrastructure::services::login_lockout_service::LoginLockoutService>,
|
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
|
/// Global application state for dependency injection
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -826,6 +877,7 @@ pub struct AppState {
|
|||||||
/// Isolated pool for background / batch operations.
|
/// Isolated pool for background / batch operations.
|
||||||
pub maintenance_pool: Option<Arc<PgPool>>,
|
pub maintenance_pool: Option<Arc<PgPool>>,
|
||||||
pub auth_service: Option<AuthServices>,
|
pub auth_service: Option<AuthServices>,
|
||||||
|
pub nextcloud: Option<NextcloudServices>,
|
||||||
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
|
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
|
||||||
pub trash_service: Option<Arc<TrashService>>,
|
pub trash_service: Option<Arc<TrashService>>,
|
||||||
pub share_service: Option<Arc<ShareService>>,
|
pub share_service: Option<Arc<ShareService>>,
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ impl FileReadPort for StubFileReadPort {
|
|||||||
Ok("root".to_string())
|
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> {
|
async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> {
|
||||||
Ok(String::new())
|
Ok(String::new())
|
||||||
}
|
}
|
||||||
@@ -773,6 +777,10 @@ impl DedupPort for StubDedupPort {
|
|||||||
Ok(String::new())
|
Ok(String::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn blob_path(&self, hash: &str) -> PathBuf {
|
||||||
|
PathBuf::from(format!("stub_blob_{}.blob", hash))
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_stats(&self) -> DedupStatsDto {
|
async fn get_stats(&self) -> DedupStatsDto {
|
||||||
DedupStatsDto::default()
|
DedupStatsDto::default()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ pub trait UserRepository: Send + Sync + 'static {
|
|||||||
/// Lists users with pagination
|
/// Lists users with pagination
|
||||||
async fn list_users(&self, limit: i64, offset: i64) -> UserRepositoryResult<Vec<User>>;
|
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
|
/// Activates or deactivates a user
|
||||||
async fn set_user_active_status(&self, user_id: &str, active: bool)
|
async fn set_user_active_status(&self, user_id: &str, active: bool)
|
||||||
-> UserRepositoryResult<()>;
|
-> UserRepositoryResult<()>;
|
||||||
|
|||||||
@@ -103,6 +103,34 @@ impl AppPasswordStoragePort for AppPasswordPgRepository {
|
|||||||
Ok(rows.into_iter().map(|r| r.into()).collect())
|
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> {
|
async fn touch_last_used(&self, id: &str) -> Result<(), DomainError> {
|
||||||
sqlx::query("UPDATE auth.app_passwords SET last_used_at = NOW() WHERE id = $1")
|
sqlx::query("UPDATE auth.app_passwords SET last_used_at = NOW() WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -112,9 +140,12 @@ impl AppPasswordStoragePort for AppPasswordPgRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn revoke(&self, id: &str) -> Result<(), DomainError> {
|
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")
|
let result = sqlx::query(
|
||||||
|
"UPDATE auth.app_passwords SET active = FALSE WHERE id = $1 AND user_id = $2",
|
||||||
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
.execute(self.pool())
|
.execute(self.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("revoke: {e}")))?;
|
.map_err(|e| DomainError::internal_error("AppPasswordPg", format!("revoke: {e}")))?;
|
||||||
@@ -125,6 +156,19 @@ impl AppPasswordStoragePort for AppPasswordPgRepository {
|
|||||||
Ok(())
|
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> {
|
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -247,4 +248,37 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
|
|||||||
|
|
||||||
Ok(total_inserted)
|
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.
|
/// Build a `StoragePath` from the materialized folder path + file name.
|
||||||
fn make_file_path(folder_path: Option<&str>, file_name: &str) -> StoragePath {
|
fn make_file_path(folder_path: Option<&str>, file_name: &str) -> StoragePath {
|
||||||
match folder_path {
|
match folder_path {
|
||||||
@@ -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>(
|
sqlx::query_scalar::<_, String>(
|
||||||
"SELECT id::text FROM storage.folders WHERE path = $1 AND NOT is_trashed",
|
"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())
|
.fetch_optional(self.pool.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("parent lookup: {e}")))?
|
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("folder lookup: {e}")))?
|
||||||
.ok_or_else(|| DomainError::not_found("Folder", format!("parent for path: {path}")))
|
.ok_or_else(|| DomainError::not_found("Folder", format!("path: {folder_path}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direct SQL lookup using materialized folder paths.
|
/// Direct SQL lookup using materialized folder paths.
|
||||||
@@ -1042,13 +1071,9 @@ mod tests {
|
|||||||
/// Only the moka `hash_cache` is exercised — no SQL is executed.
|
/// Only the moka `hash_cache` is exercised — no SQL is executed.
|
||||||
fn make_repo() -> FileBlobReadRepository {
|
fn make_repo() -> FileBlobReadRepository {
|
||||||
let _folder_repo = Arc::new(FolderDbRepository::new_stub());
|
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(DedupService::new_stub());
|
||||||
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.
|
|
||||||
FileBlobReadRepository {
|
FileBlobReadRepository {
|
||||||
pool: Arc::new(
|
pool: Arc::new(
|
||||||
// Use an intentionally invalid URL; tests never reach PG.
|
|
||||||
sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
|
sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
|
||||||
.max_connections(1)
|
.max_connections(1)
|
||||||
.connect_lazy("postgres://invalid:5432/none")
|
.connect_lazy("postgres://invalid:5432/none")
|
||||||
@@ -1135,7 +1160,7 @@ mod tests {
|
|||||||
.connect_lazy("postgres://invalid:5432/none")
|
.connect_lazy("postgres://invalid:5432/none")
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
),
|
),
|
||||||
dedup: Arc::new(StubDedupPort),
|
dedup: Arc::new(DedupService::new_stub()),
|
||||||
hash_cache: Cache::builder()
|
hash_cache: Cache::builder()
|
||||||
.max_capacity(2) // only 2 entries
|
.max_capacity(2) // only 2 entries
|
||||||
.build(),
|
.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.
|
/// Build a `StoragePath` from the materialized folder path + file name.
|
||||||
fn make_file_path(folder_path: Option<&str>, file_name: &str) -> StoragePath {
|
fn make_file_path(folder_path: Option<&str>, file_name: &str) -> StoragePath {
|
||||||
match folder_path {
|
match folder_path {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod contact_persistence_dto;
|
|||||||
mod contact_pg_repository;
|
mod contact_pg_repository;
|
||||||
mod device_code_pg_repository;
|
mod device_code_pg_repository;
|
||||||
mod favorites_pg_repository;
|
mod favorites_pg_repository;
|
||||||
|
mod nextcloud_object_id_repository;
|
||||||
mod recent_items_pg_repository;
|
mod recent_items_pg_repository;
|
||||||
mod session_pg_repository;
|
mod session_pg_repository;
|
||||||
mod settings_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_read_repository::FileBlobReadRepository;
|
||||||
pub use file_blob_write_repository::FileBlobWriteRepository;
|
pub use file_blob_write_repository::FileBlobWriteRepository;
|
||||||
pub use folder_db_repository::FolderDbRepository;
|
pub use folder_db_repository::FolderDbRepository;
|
||||||
|
pub use nextcloud_object_id_repository::NextcloudObjectIdRepository;
|
||||||
pub use recent_items_pg_repository::RecentItemsPgRepository;
|
pub use recent_items_pg_repository::RecentItemsPgRepository;
|
||||||
pub use session_pg_repository::SessionPgRepository;
|
pub use session_pg_repository::SessionPgRepository;
|
||||||
pub use settings_pg_repository::SettingsPgRepository;
|
pub use settings_pg_repository::SettingsPgRepository;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,19 @@ impl SharePgRepository {
|
|||||||
Self { db_pool }
|
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.
|
/// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity.
|
||||||
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
|
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
|
||||||
let id: String = row
|
let id: String = row
|
||||||
|
|||||||
@@ -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.
|
/// Convert a trash_items view row into a TrashedItem entity.
|
||||||
fn row_to_trashed_item(
|
fn row_to_trashed_item(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -369,6 +369,57 @@ impl UserRepository for UserPgRepository {
|
|||||||
Ok(users)
|
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
|
/// Activates or deactivates a user
|
||||||
async fn set_user_active_status(
|
async fn set_user_active_status(
|
||||||
&self,
|
&self,
|
||||||
@@ -664,6 +715,12 @@ impl UserStoragePort for UserPgRepository {
|
|||||||
.map_err(DomainError::from)
|
.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> {
|
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError> {
|
||||||
UserRepository::list_users_by_role(self, role)
|
UserRepository::list_users_by_role(self, role)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -110,6 +110,23 @@ impl DedupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a stub instance for testing — never hits PG or the filesystem.
|
||||||
|
#[cfg(test)]
|
||||||
|
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).
|
/// Initialize the service (create blob directories on the filesystem).
|
||||||
pub async fn initialize(&self) -> Result<(), DomainError> {
|
pub async fn initialize(&self) -> Result<(), DomainError> {
|
||||||
// Create directories
|
// Create directories
|
||||||
@@ -903,6 +920,10 @@ impl DedupPort for DedupService {
|
|||||||
.map_err(DomainError::from)
|
.map_err(DomainError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn blob_path(&self, hash: &str) -> PathBuf {
|
||||||
|
self.blob_path(hash)
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_stats(&self) -> DedupStatsDto {
|
async fn get_stats(&self) -> DedupStatsDto {
|
||||||
self.get_stats().await
|
self.get_stats().await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod file_system_i18n_service;
|
|||||||
pub mod image_transcode_service;
|
pub mod image_transcode_service;
|
||||||
pub mod jwt_service;
|
pub mod jwt_service;
|
||||||
pub mod login_lockout_service;
|
pub mod login_lockout_service;
|
||||||
|
pub mod nextcloud_chunked_upload_service;
|
||||||
pub mod oidc_service;
|
pub mod oidc_service;
|
||||||
pub mod password_hasher;
|
pub mod password_hasher;
|
||||||
pub mod path_resolver_service;
|
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,16 +1,19 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
extract::{Json, Query, State},
|
extract::{Json, Path, Query, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode, header},
|
||||||
response::{IntoResponse, Redirect, Response},
|
response::{IntoResponse, Redirect, Response},
|
||||||
routing::{get, post, put},
|
routing::{delete, get, post, put},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::application::dtos::user_dto::{
|
use crate::application::dtos::user_dto::{
|
||||||
ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto,
|
AppPasswordCreatedDto, AppPasswordDto, ChangePasswordDto, CreateAppPasswordDto, LoginDto,
|
||||||
RefreshTokenDto, RegisterDto, SetupAdminDto,
|
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::common::di::AppState;
|
||||||
use crate::interfaces::api::cookie_auth;
|
use crate::interfaces::api::cookie_auth;
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
@@ -34,6 +37,11 @@ pub fn auth_protected_routes() -> Router<Arc<AppState>> {
|
|||||||
.route("/me", get(get_current_user))
|
.route("/me", get(get_current_user))
|
||||||
.route("/change-password", put(change_password))
|
.route("/change-password", put(change_password))
|
||||||
.route("/logout", post(logout))
|
.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
|
/// Rate-limited auth routes — split out so main.rs can apply per-endpoint
|
||||||
@@ -522,6 +530,140 @@ async fn get_system_status(
|
|||||||
Ok((StatusCode::OK, Json(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
|
// OIDC Handlers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -602,7 +744,7 @@ async fn oidc_callback(
|
|||||||
tracing::info!("OIDC callback received with code");
|
tracing::info!("OIDC callback received with code");
|
||||||
|
|
||||||
// Exchange code, validate state/nonce/PKCE, authenticate user
|
// Exchange code, validate state/nonce/PKCE, authenticate user
|
||||||
let exchange_code = auth_app
|
let result = auth_app
|
||||||
.oidc_callback(&query.code, &query.state)
|
.oidc_callback(&query.code, &query.state)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -610,15 +752,59 @@ async fn oidc_callback(
|
|||||||
AppError::from(e)
|
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 config = auth_app.oidc_config().unwrap();
|
||||||
let frontend_url = config.frontend_url.trim_end_matches('/');
|
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");
|
tracing::info!("OIDC login successful, redirecting with exchange code");
|
||||||
|
|
||||||
Ok(Redirect::temporary(&redirect_url))
|
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
|
/// POST /api/auth/oidc/exchange — Exchange one-time code for auth tokens
|
||||||
/// Request body: { "code": "<one_time_code>" }
|
/// Request body: { "code": "<one_time_code>" }
|
||||||
|
|||||||
@@ -335,10 +335,10 @@ impl FileHandler {
|
|||||||
.into_response();
|
.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
|
match thumbnail_service
|
||||||
.get_thumbnail(&id, thumb_size.into(), &file_path)
|
.get_thumbnail(&id, thumb_size.into(), &blob_path)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
|
|||||||
@@ -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
|
// Implement FromRequestParts for CurrentUserId — lightweight extractor for user_id only
|
||||||
impl<S> FromRequestParts<S> for CurrentUserId
|
impl<S> FromRequestParts<S> for CurrentUserId
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
|
pub mod nextcloud;
|
||||||
pub mod web;
|
pub mod web;
|
||||||
|
|
||||||
pub use api::create_api_routes;
|
pub use api::create_api_routes;
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{StatusCode, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::common::di::AppState;
|
||||||
|
|
||||||
|
/// GET /index.php/avatar/{user}/{size}
|
||||||
|
///
|
||||||
|
/// Returns an SVG avatar with the user's initials on a colored background.
|
||||||
|
pub async fn handle_avatar(
|
||||||
|
State(_state): State<Arc<AppState>>,
|
||||||
|
Path((username, size)): Path<(String, u32)>,
|
||||||
|
) -> Response {
|
||||||
|
let size = size.clamp(16, 1024);
|
||||||
|
let initials = extract_initials(&username);
|
||||||
|
let color = pick_color(&username);
|
||||||
|
let font_size = (size as f32 * 0.45) as u32;
|
||||||
|
|
||||||
|
let safe_initials = xml_escape(&initials);
|
||||||
|
|
||||||
|
let svg = format!(
|
||||||
|
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{s}" height="{s}" viewBox="0 0 {s} {s}">
|
||||||
|
<rect width="{s}" height="{s}" rx="{r}" fill="{c}"/>
|
||||||
|
<text x="50%" y="50%" dy="0.36em" fill="#fff" font-family="-apple-system,BlinkMacSystemFont,sans-serif" font-size="{fs}" font-weight="600" text-anchor="middle">{i}</text>
|
||||||
|
</svg>"##,
|
||||||
|
s = size,
|
||||||
|
r = size / 2,
|
||||||
|
c = color,
|
||||||
|
fs = font_size,
|
||||||
|
i = safe_initials,
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "image/svg+xml"),
|
||||||
|
(header::CACHE_CONTROL, "public, max-age=86400, immutable"),
|
||||||
|
(
|
||||||
|
header::CONTENT_SECURITY_POLICY,
|
||||||
|
"default-src 'none'; style-src 'unsafe-inline'",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
svg,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape XML special characters to prevent XSS in SVG output.
|
||||||
|
fn xml_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_initials(username: &str) -> String {
|
||||||
|
let parts: Vec<&str> = username.split_whitespace().collect();
|
||||||
|
match parts.len() {
|
||||||
|
0 => "?".to_string(),
|
||||||
|
1 => parts[0]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.unwrap_or('?')
|
||||||
|
.to_uppercase()
|
||||||
|
.to_string(),
|
||||||
|
_ => {
|
||||||
|
let first = parts[0].chars().next().unwrap_or('?');
|
||||||
|
let last = parts[parts.len() - 1].chars().next().unwrap_or('?');
|
||||||
|
format!("{}{}", first.to_uppercase(), last.to_uppercase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_color(username: &str) -> &'static str {
|
||||||
|
const PALETTE: [&str; 10] = [
|
||||||
|
"#0082c9", "#e9322d", "#2d8a0f", "#c37200", "#6c2d9e", "#007a87", "#b02e7c", "#465a64",
|
||||||
|
"#a65d00", "#3b5998",
|
||||||
|
];
|
||||||
|
let hash: u32 = username
|
||||||
|
.bytes()
|
||||||
|
.fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32));
|
||||||
|
PALETTE[(hash as usize) % PALETTE.len()]
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Request, State},
|
||||||
|
http::{HeaderMap, StatusCode, header},
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use base64::Engine;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::common::di::AppState;
|
||||||
|
use crate::interfaces::middleware::auth::CurrentUser;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum NextcloudAuthError {
|
||||||
|
#[error("Unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
#[error("Nextcloud services unavailable")]
|
||||||
|
ServiceUnavailable,
|
||||||
|
#[error("Internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for NextcloudAuthError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
match self {
|
||||||
|
NextcloudAuthError::Unauthorized => (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
[(header::WWW_AUTHENTICATE, "Basic realm=\"OxiCloud\"")],
|
||||||
|
"Unauthorized",
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
NextcloudAuthError::ServiceUnavailable => {
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, "Nextcloud unavailable").into_response()
|
||||||
|
}
|
||||||
|
NextcloudAuthError::Internal(_) => {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn basic_auth_middleware(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, NextcloudAuthError> {
|
||||||
|
tracing::debug!("[NC] {} {}", request.method(), request.uri());
|
||||||
|
|
||||||
|
let auth_header = headers
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
tracing::warn!(
|
||||||
|
"[NC] 401 no auth header: {} {}",
|
||||||
|
request.method(),
|
||||||
|
request.uri()
|
||||||
|
);
|
||||||
|
NextcloudAuthError::Unauthorized
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (username, password) =
|
||||||
|
parse_basic_auth(auth_header).ok_or(NextcloudAuthError::Unauthorized)?;
|
||||||
|
|
||||||
|
// Check account lockout before attempting password verification (saves CPU)
|
||||||
|
if let Some(auth_svc) = state.auth_service.as_ref() {
|
||||||
|
if let Err(secs) = auth_svc.login_lockout.check(&username) {
|
||||||
|
tracing::warn!(
|
||||||
|
username = %username,
|
||||||
|
lockout_remaining_secs = secs,
|
||||||
|
"[NC] Account locked — too many failed attempts"
|
||||||
|
);
|
||||||
|
return Err(NextcloudAuthError::Unauthorized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextcloud = state
|
||||||
|
.nextcloud
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(NextcloudAuthError::ServiceUnavailable)?;
|
||||||
|
|
||||||
|
match nextcloud
|
||||||
|
.app_passwords
|
||||||
|
.verify_basic_auth(&username, &password)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((user_id, uname, email, role)) => {
|
||||||
|
// Reset lockout counter on success
|
||||||
|
if let Some(auth_svc) = state.auth_service.as_ref() {
|
||||||
|
auth_svc.login_lockout.record_success(&username);
|
||||||
|
}
|
||||||
|
request.extensions_mut().insert(CurrentUser {
|
||||||
|
id: user_id,
|
||||||
|
username: uname,
|
||||||
|
email,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
Ok(next.run(request).await)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Record failed attempt for lockout tracking
|
||||||
|
if let Some(auth_svc) = state.auth_service.as_ref() {
|
||||||
|
auth_svc.login_lockout.record_failure(&username);
|
||||||
|
}
|
||||||
|
Err(NextcloudAuthError::Unauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `Basic` Authorization header into `(username, password)`.
|
||||||
|
pub fn parse_basic_auth(header_value: &str) -> Option<(String, String)> {
|
||||||
|
let mut parts = header_value.splitn(2, ' ');
|
||||||
|
let scheme = parts.next()?.trim();
|
||||||
|
let encoded = parts.next()?.trim();
|
||||||
|
|
||||||
|
if !scheme.eq_ignore_ascii_case("Basic") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(encoded)
|
||||||
|
.ok()?;
|
||||||
|
let decoded = String::from_utf8(decoded).ok()?;
|
||||||
|
let (user, pass) = decoded.split_once(':')?;
|
||||||
|
|
||||||
|
Some((user.to_string(), pass.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_valid_basic_auth() {
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode("alice:secret123");
|
||||||
|
let header = format!("Basic {}", encoded);
|
||||||
|
let (user, pass) = parse_basic_auth(&header).expect("should parse");
|
||||||
|
assert_eq!(user, "alice");
|
||||||
|
assert_eq!(pass, "secret123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_basic_auth_with_colon_in_password() {
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode("user:pass:with:colons");
|
||||||
|
let header = format!("Basic {}", encoded);
|
||||||
|
let (user, pass) = parse_basic_auth(&header).expect("should parse");
|
||||||
|
assert_eq!(user, "user");
|
||||||
|
assert_eq!(pass, "pass:with:colons");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_basic_auth_bearer_scheme_rejected() {
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode("user:pass");
|
||||||
|
let header = format!("Bearer {}", encoded);
|
||||||
|
assert!(parse_basic_auth(&header).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_basic_auth_missing_colon() {
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode("nocolon");
|
||||||
|
let header = format!("Basic {}", encoded);
|
||||||
|
assert!(parse_basic_auth(&header).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_basic_auth_invalid_base64() {
|
||||||
|
assert!(parse_basic_auth("Basic not-valid-base64!!!").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_basic_auth_case_insensitive_scheme() {
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode("user:pass");
|
||||||
|
let header = format!("BASIC {}", encoded);
|
||||||
|
let result = parse_basic_auth(&header);
|
||||||
|
assert!(result.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::{HeaderMap, StatusCode, header},
|
||||||
|
response::{Html, IntoResponse, Json, Response},
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::common::di::AppState;
|
||||||
|
use crate::common::errors::DomainError;
|
||||||
|
|
||||||
|
/// Serve an HTML page with a Content-Security-Policy header as defense-in-depth.
|
||||||
|
fn html_with_csp(html: &'static str) -> Response {
|
||||||
|
(
|
||||||
|
[(
|
||||||
|
header::CONTENT_SECURITY_POLICY,
|
||||||
|
"default-src 'none'; script-src 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; form-action 'self'",
|
||||||
|
)],
|
||||||
|
Html(html),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_login_initiate(State(state): State<Arc<AppState>>) -> Response {
|
||||||
|
let nextcloud = match state.nextcloud.as_ref() {
|
||||||
|
Some(nextcloud) => nextcloud,
|
||||||
|
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let base_url = state.core.config.base_url();
|
||||||
|
let flow = match nextcloud.login_flow.initiate(&base_url) {
|
||||||
|
Ok(flow) => flow,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!("Login Flow v2: too many pending flows, rejecting");
|
||||||
|
return StatusCode::TOO_MANY_REQUESTS.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
base_url = %base_url,
|
||||||
|
login_url = %flow.login_url,
|
||||||
|
poll_endpoint = %flow.poll_endpoint,
|
||||||
|
"Login Flow v2 initiated"
|
||||||
|
);
|
||||||
|
|
||||||
|
Json(json!({
|
||||||
|
"poll": {
|
||||||
|
"token": flow.poll_token,
|
||||||
|
"endpoint": flow.poll_endpoint,
|
||||||
|
},
|
||||||
|
"login": flow.login_url,
|
||||||
|
}))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_login_poll(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<HashMap<String, String>>,
|
||||||
|
body: String,
|
||||||
|
) -> Response {
|
||||||
|
let nextcloud = match state.nextcloud.as_ref() {
|
||||||
|
Some(nextcloud) => nextcloud,
|
||||||
|
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_type = headers
|
||||||
|
.get("content-type")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("(none)");
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
body = %body,
|
||||||
|
content_type = %content_type,
|
||||||
|
query_has_token = query.contains_key("token"),
|
||||||
|
"Login Flow v2 poll request"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to extract token from multiple sources:
|
||||||
|
// 1. Form-encoded body (token=xxx)
|
||||||
|
// 2. JSON body ({"token": "xxx"})
|
||||||
|
// 3. Query parameter (?token=xxx)
|
||||||
|
let token = parse_form_value(&body, "token")
|
||||||
|
.or_else(|| {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&body)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.get("token")?.as_str().map(String::from))
|
||||||
|
})
|
||||||
|
.or_else(|| query.get("token").cloned());
|
||||||
|
|
||||||
|
let token = match token {
|
||||||
|
Some(token) => token,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
body = %body,
|
||||||
|
content_type = %content_type,
|
||||||
|
"Login Flow v2 poll: could not extract token from body, JSON, or query"
|
||||||
|
);
|
||||||
|
return StatusCode::BAD_REQUEST.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match nextcloud.login_flow.poll(&token) {
|
||||||
|
Some(result) => {
|
||||||
|
tracing::info!(
|
||||||
|
login_name = %result.login_name,
|
||||||
|
server = %result.server,
|
||||||
|
"Login Flow v2 poll: returning completed credentials"
|
||||||
|
);
|
||||||
|
Json(json!({
|
||||||
|
"server": result.server,
|
||||||
|
"loginName": result.login_name,
|
||||||
|
"appPassword": result.app_password,
|
||||||
|
}))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::debug!("Login Flow v2 poll: not yet completed");
|
||||||
|
StatusCode::NOT_FOUND.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_login_page(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
let nextcloud = match state.nextcloud.as_ref() {
|
||||||
|
Some(nextcloud) => nextcloud,
|
||||||
|
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !nextcloud.login_flow.flow_exists(&token) {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
html_with_csp(include_str!("../../../static/nextcloud-login.html"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_login_submit(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
body: String,
|
||||||
|
) -> Response {
|
||||||
|
let nextcloud = match state.nextcloud.as_ref() {
|
||||||
|
Some(nextcloud) => nextcloud,
|
||||||
|
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let params = parse_form(&body);
|
||||||
|
let username = match params.get("user") {
|
||||||
|
Some(value) if !value.is_empty() => value,
|
||||||
|
_ => return StatusCode::BAD_REQUEST.into_response(),
|
||||||
|
};
|
||||||
|
let password = match params.get("password") {
|
||||||
|
Some(value) if !value.is_empty() => value,
|
||||||
|
_ => return StatusCode::BAD_REQUEST.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth = match state.auth_service.as_ref() {
|
||||||
|
Some(auth) => auth,
|
||||||
|
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_user = match auth
|
||||||
|
.auth_application_service
|
||||||
|
.verify_credentials(username, password)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(e) => return login_failed_response(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app_password = match nextcloud
|
||||||
|
.app_passwords
|
||||||
|
.create_nc(¤t_user.id, "Nextcloud")
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((_id, password)) => password,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, user = %current_user.username, "Login Flow v2: failed to create app password");
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let base_url = state.core.config.base_url();
|
||||||
|
let completed =
|
||||||
|
nextcloud
|
||||||
|
.login_flow
|
||||||
|
.complete(&token, ¤t_user.username, &base_url, &app_password);
|
||||||
|
|
||||||
|
if completed {
|
||||||
|
tracing::info!(
|
||||||
|
user = %current_user.username,
|
||||||
|
base_url = %base_url,
|
||||||
|
"Login Flow v2: flow completed successfully"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::error!(
|
||||||
|
user = %current_user.username,
|
||||||
|
"Login Flow v2: complete() returned false — flow token not found"
|
||||||
|
);
|
||||||
|
return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
html_with_csp(include_str!("../../../static/nextcloud-success.html"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /login/v2/flow/{token}/oidc — Start an OIDC authorization flow that is
|
||||||
|
/// tied to a Nextcloud Login Flow v2 session. After successful IdP
|
||||||
|
/// authentication the regular `/api/auth/oidc/callback` endpoint will detect
|
||||||
|
/// the NC flow token and complete the Nextcloud login instead of issuing
|
||||||
|
/// internal JWTs.
|
||||||
|
pub async fn handle_login_oidc(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
// Verify Nextcloud services are configured
|
||||||
|
let nextcloud = match state.nextcloud.as_ref() {
|
||||||
|
Some(nc) => nc,
|
||||||
|
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify the NC login flow token exists
|
||||||
|
if !nextcloud.login_flow.flow_exists(&token) {
|
||||||
|
return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify auth + OIDC are configured and enabled
|
||||||
|
let auth = match state.auth_service.as_ref() {
|
||||||
|
Some(auth) => auth,
|
||||||
|
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !auth.auth_application_service.oidc_enabled() {
|
||||||
|
tracing::warn!("OIDC login requested on NC login page but OIDC is not enabled");
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare an OIDC authorize flow that carries the NC flow token
|
||||||
|
match auth
|
||||||
|
.auth_application_service
|
||||||
|
.prepare_oidc_authorize_for_nextcloud(&token)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(authorize_url) => {
|
||||||
|
tracing::info!("OIDC authorize redirect for Nextcloud Login Flow v2");
|
||||||
|
axum::response::Redirect::temporary(&authorize_url).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "Failed to prepare OIDC authorize for NC login");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login_failed_response(_err: DomainError) -> Response {
|
||||||
|
axum::response::Redirect::to("/nextcloud-error.html?type=invalid-credentials").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_form(body: &str) -> HashMap<String, String> {
|
||||||
|
body.split('&')
|
||||||
|
.filter_map(|pair| {
|
||||||
|
let (key, value) = pair.split_once('=')?;
|
||||||
|
let key = urlencoding::decode(key).ok()?.to_string();
|
||||||
|
let value = urlencoding::decode(value).ok()?.to_string();
|
||||||
|
Some((key, value))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_form_value(body: &str, key: &str) -> Option<String> {
|
||||||
|
parse_form(body).remove(key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
pub mod avatar_handler;
|
||||||
|
pub mod basic_auth_middleware;
|
||||||
|
pub mod login_v2_handler;
|
||||||
|
pub mod ocs_handler;
|
||||||
|
pub mod preview_handler;
|
||||||
|
pub mod report_handler;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod status_handler;
|
||||||
|
pub mod trashbin_handler;
|
||||||
|
pub mod uploads_handler;
|
||||||
|
pub mod webdav_handler;
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::application::dtos::search_dto::SearchCriteriaDto;
|
||||||
|
use crate::application::ports::inbound::SearchUseCase;
|
||||||
|
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||||
|
use crate::common::di::AppState;
|
||||||
|
use crate::interfaces::middleware::auth::CurrentUser;
|
||||||
|
|
||||||
|
/// Build an OCS success response with the given statuscode and data.
|
||||||
|
fn ocs_ok(statuscode: u16, data: serde_json::Value) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"ocs": {
|
||||||
|
"meta": { "status": "ok", "statuscode": statuscode, "message": "OK" },
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an OCS error response.
|
||||||
|
fn ocs_err(statuscode: u16, message: &str) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"ocs": {
|
||||||
|
"meta": { "status": "failure", "statuscode": statuscode, "message": message },
|
||||||
|
"data": {},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_capabilities_v1(State(state): State<Arc<AppState>>) -> Response {
|
||||||
|
let payload = capabilities_payload(&state, 1);
|
||||||
|
tracing::info!("[NC] capabilities v1 requested, returning payload");
|
||||||
|
Json(payload).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_capabilities_v2(State(state): State<Arc<AppState>>) -> Response {
|
||||||
|
let payload = capabilities_payload(&state, 2);
|
||||||
|
tracing::info!("[NC] capabilities v2 requested, returning payload");
|
||||||
|
Json(payload).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_user_info(State(state): State<Arc<AppState>>, user: CurrentUser) -> Response {
|
||||||
|
let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
|
||||||
|
Some(service) => match service.get_user_storage_info(&user.id).await {
|
||||||
|
Ok((used, total)) => (used, total),
|
||||||
|
Err(_) => (0, 0),
|
||||||
|
},
|
||||||
|
None => (0, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let free = quota.1.saturating_sub(quota.0);
|
||||||
|
let relative = if quota.1 > 0 {
|
||||||
|
(quota.0 as f64 / quota.1 as f64) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
Json(json!({
|
||||||
|
"ocs": {
|
||||||
|
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
|
||||||
|
"data": {
|
||||||
|
"enabled": true,
|
||||||
|
"id": user.username,
|
||||||
|
"display-name": user.username,
|
||||||
|
"displayname": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"quota": {
|
||||||
|
"used": quota.0,
|
||||||
|
"total": quota.1,
|
||||||
|
"free": free,
|
||||||
|
"relative": relative
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /ocs/v1.php/cloud/users/{userid}
|
||||||
|
pub async fn handle_user_provisioning_v1(
|
||||||
|
state: State<Arc<AppState>>,
|
||||||
|
path: Path<String>,
|
||||||
|
user: CurrentUser,
|
||||||
|
) -> Response {
|
||||||
|
user_provisioning_response(state, path, user, 1).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /ocs/v2.php/cloud/users/{userid}
|
||||||
|
pub async fn handle_user_provisioning_v2(
|
||||||
|
state: State<Arc<AppState>>,
|
||||||
|
path: Path<String>,
|
||||||
|
user: CurrentUser,
|
||||||
|
) -> Response {
|
||||||
|
user_provisioning_response(state, path, user, 2).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns user details in Nextcloud OCS provisioning API format.
|
||||||
|
/// Used by the Nextcloud mobile app to fetch the user profile screen.
|
||||||
|
async fn user_provisioning_response(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(userid): Path<String>,
|
||||||
|
user: CurrentUser,
|
||||||
|
ocs_version: u8,
|
||||||
|
) -> Response {
|
||||||
|
let statuscode = if ocs_version == 1 { 100 } else { 200 };
|
||||||
|
|
||||||
|
// Only allow users to view their own profile, unless they are admin.
|
||||||
|
if user.username != userid && user.role != "admin" {
|
||||||
|
return Json(ocs_err(403, "Insufficient privileges")).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_service = match state.auth_service.as_ref() {
|
||||||
|
Some(svc) => &svc.auth_application_service,
|
||||||
|
None => {
|
||||||
|
return Json(ocs_err(997, "Authentication not configured")).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_dto = match auth_service.get_user_by_username(&userid).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_) => {
|
||||||
|
return Json(ocs_err(404, "User not found")).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine groups based on role
|
||||||
|
let groups = if user_dto.role == "admin" {
|
||||||
|
vec!["admin", "users"]
|
||||||
|
} else {
|
||||||
|
vec!["users"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine backend based on auth provider
|
||||||
|
let backend = if user_dto.auth_provider.to_lowercase().contains("oidc") {
|
||||||
|
"OIDC"
|
||||||
|
} else {
|
||||||
|
"Database"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert last_login_at to JS milliseconds
|
||||||
|
let last_login = user_dto
|
||||||
|
.last_login_at
|
||||||
|
.map(|dt| dt.timestamp() * 1000)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Fetch quota from storage usage service
|
||||||
|
let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
|
||||||
|
Some(service) => match service.get_user_storage_info(&user_dto.id).await {
|
||||||
|
Ok((used, total)) => (used, total),
|
||||||
|
Err(_) => (0, 0),
|
||||||
|
},
|
||||||
|
None => (0, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let free = quota.1.saturating_sub(quota.0);
|
||||||
|
let relative = if quota.1 > 0 {
|
||||||
|
(quota.0 as f64 / quota.1 as f64) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
Json(json!({
|
||||||
|
"ocs": {
|
||||||
|
"meta": { "status": "ok", "statuscode": statuscode, "message": "OK" },
|
||||||
|
"data": {
|
||||||
|
"enabled": user_dto.active,
|
||||||
|
"id": user_dto.username,
|
||||||
|
"display-name": user_dto.username,
|
||||||
|
"displayname": user_dto.username,
|
||||||
|
"email": user_dto.email,
|
||||||
|
"phone": "",
|
||||||
|
"address": "",
|
||||||
|
"website": "",
|
||||||
|
"twitter": "",
|
||||||
|
"groups": groups,
|
||||||
|
"language": "en",
|
||||||
|
"locale": "en_US",
|
||||||
|
"backend": backend,
|
||||||
|
"lastLogin": last_login,
|
||||||
|
"quota": {
|
||||||
|
"used": quota.0,
|
||||||
|
"total": quota.1,
|
||||||
|
"free": free,
|
||||||
|
"relative": relative
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_revoke_apppassword(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
user: CurrentUser,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
let nextcloud = match state.nextcloud.as_ref() {
|
||||||
|
Some(nextcloud) => nextcloud,
|
||||||
|
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app_password = match extract_basic_password(&headers) {
|
||||||
|
Some(password) => password,
|
||||||
|
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = nextcloud
|
||||||
|
.app_passwords
|
||||||
|
.revoke_by_password(&user.id, &app_password)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("Failed to revoke app password for {}: {}", user.id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(ocs_ok(200, json!({}))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_notifications_list() -> Response {
|
||||||
|
Json(ocs_ok(200, json!([]))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_notifications_push() -> Response {
|
||||||
|
Json(ocs_ok(200, json!({}))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /ocs/v2.php/apps/files_sharing/api/v1/sharees?search={query}&itemType={type}
|
||||||
|
///
|
||||||
|
/// Returns matching users for the sharing autocomplete UI.
|
||||||
|
/// Even though sharing is disabled, the Nextcloud mobile app still calls
|
||||||
|
/// this endpoint and expects a well-formed OCS response rather than a 404.
|
||||||
|
pub async fn handle_sharees_search(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
user: CurrentUser,
|
||||||
|
axum::extract::Query(params): axum::extract::Query<ShareeSearchParams>,
|
||||||
|
) -> Response {
|
||||||
|
let search = params.search.unwrap_or_default();
|
||||||
|
if search.is_empty() {
|
||||||
|
return sharees_response(vec![]).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_service = match state.auth_service.as_ref() {
|
||||||
|
Some(svc) => &svc.auth_application_service,
|
||||||
|
None => return sharees_response(vec![]).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// SQL-level ILIKE search with limit — avoids loading all users into memory.
|
||||||
|
let users = auth_service
|
||||||
|
.search_users(&search, 26)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let matches: Vec<serde_json::Value> = users
|
||||||
|
.into_iter()
|
||||||
|
.filter(|u| u.username != user.username) // Don't suggest self
|
||||||
|
.take(25)
|
||||||
|
.map(|u| {
|
||||||
|
json!({
|
||||||
|
"label": u.username,
|
||||||
|
"value": {
|
||||||
|
"shareType": 0,
|
||||||
|
"shareWith": u.username
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
sharees_response(matches).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct ShareeSearchParams {
|
||||||
|
search: Option<String>,
|
||||||
|
#[serde(rename = "itemType")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
item_type: Option<String>,
|
||||||
|
#[serde(rename = "perPage")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
per_page: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sharees_response(users: Vec<serde_json::Value>) -> Json<serde_json::Value> {
|
||||||
|
Json(json!({
|
||||||
|
"ocs": {
|
||||||
|
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
|
||||||
|
"data": {
|
||||||
|
"exact": { "users": [], "groups": [], "remotes": [] },
|
||||||
|
"users": users,
|
||||||
|
"groups": [],
|
||||||
|
"remotes": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /ocs/v2.php/search/providers
|
||||||
|
///
|
||||||
|
/// Returns the list of available Unified Search providers.
|
||||||
|
/// We only expose the "files" provider.
|
||||||
|
pub async fn handle_search_providers() -> Response {
|
||||||
|
Json(json!({
|
||||||
|
"ocs": {
|
||||||
|
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "files",
|
||||||
|
"appId": "files",
|
||||||
|
"name": "Files",
|
||||||
|
"icon": "/apps/files/img/app.svg",
|
||||||
|
"order": 5,
|
||||||
|
"filters": {},
|
||||||
|
"isPaginated": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /ocs/v2.php/search/providers/{provider_id}/search?term=…&limit=…&cursor=…
|
||||||
|
///
|
||||||
|
/// Executes a Unified Search query against the given provider.
|
||||||
|
/// Only the "files" provider is implemented; all others return empty results.
|
||||||
|
pub async fn handle_search(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(provider_id): Path<String>,
|
||||||
|
axum::extract::Query(params): axum::extract::Query<UnifiedSearchParams>,
|
||||||
|
user: CurrentUser,
|
||||||
|
) -> Response {
|
||||||
|
// Only the "files" provider is supported
|
||||||
|
if provider_id != "files" {
|
||||||
|
return empty_search_response().into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let search_service = match state.applications.search_service.as_ref() {
|
||||||
|
Some(svc) => svc,
|
||||||
|
None => return empty_search_response().into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let term = params.term.unwrap_or_default();
|
||||||
|
if term.is_empty() {
|
||||||
|
return empty_search_response().into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let criteria = SearchCriteriaDto {
|
||||||
|
name_contains: Some(term),
|
||||||
|
recursive: true,
|
||||||
|
limit: params.limit.unwrap_or(25),
|
||||||
|
..SearchCriteriaDto::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let results = match search_service.search(criteria, &user.id).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return empty_search_response().into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_id_svc = state.nextcloud.as_ref().map(|n| &n.file_ids);
|
||||||
|
|
||||||
|
let mut entries: Vec<serde_json::Value> = Vec::new();
|
||||||
|
|
||||||
|
// Map file results
|
||||||
|
for file in &results.files {
|
||||||
|
let display_path = file
|
||||||
|
.path
|
||||||
|
.strip_prefix(&format!("My Folder - {}/", user.username))
|
||||||
|
.unwrap_or(&file.path);
|
||||||
|
let display_path = format!("/{}", display_path);
|
||||||
|
|
||||||
|
let numeric_id = if let Some(svc) = file_id_svc {
|
||||||
|
svc.get_or_create_file_id(&file.id).await.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let thumbnail_url = match numeric_id {
|
||||||
|
Some(nid) => format!("/index.php/core/preview?fileId={}&x=32&y=32", nid),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
let resource_url = match numeric_id {
|
||||||
|
Some(nid) => format!("/f/{}", nid),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.push(json!({
|
||||||
|
"thumbnailUrl": thumbnail_url,
|
||||||
|
"title": file.name,
|
||||||
|
"subline": display_path,
|
||||||
|
"resourceUrl": resource_url,
|
||||||
|
"icon": "",
|
||||||
|
"rounded": false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map folder results
|
||||||
|
for folder in &results.folders {
|
||||||
|
let display_path = folder
|
||||||
|
.path
|
||||||
|
.strip_prefix(&format!("My Folder - {}/", user.username))
|
||||||
|
.unwrap_or(&folder.path);
|
||||||
|
let display_path = format!("/{}", display_path);
|
||||||
|
|
||||||
|
entries.push(json!({
|
||||||
|
"thumbnailUrl": "",
|
||||||
|
"title": folder.name,
|
||||||
|
"subline": display_path,
|
||||||
|
"resourceUrl": "",
|
||||||
|
"icon": "/apps/files/img/folder.svg",
|
||||||
|
"rounded": false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(json!({
|
||||||
|
"ocs": {
|
||||||
|
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
|
||||||
|
"data": {
|
||||||
|
"name": "Files",
|
||||||
|
"isPaginated": false,
|
||||||
|
"entries": entries,
|
||||||
|
"cursor": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct UnifiedSearchParams {
|
||||||
|
term: Option<String>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_search_response() -> Json<serde_json::Value> {
|
||||||
|
Json(json!({
|
||||||
|
"ocs": {
|
||||||
|
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
|
||||||
|
"data": {
|
||||||
|
"name": "Files",
|
||||||
|
"isPaginated": false,
|
||||||
|
"entries": [],
|
||||||
|
"cursor": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities_payload(state: &AppState, ocs_version: u8) -> serde_json::Value {
|
||||||
|
let statuscode = if ocs_version == 1 { 100 } else { 200 };
|
||||||
|
let base_url = state.core.config.base_url();
|
||||||
|
let (nc_major, nc_minor, nc_micro) = state.core.config.nextcloud.emulated_version;
|
||||||
|
let nc_version_str = state.core.config.nextcloud.version_string();
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"ocs": {
|
||||||
|
"meta": {
|
||||||
|
"status": "ok",
|
||||||
|
"statuscode": statuscode,
|
||||||
|
"message": "OK"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"version": {
|
||||||
|
"major": nc_major,
|
||||||
|
"minor": nc_minor,
|
||||||
|
"micro": nc_micro,
|
||||||
|
"string": nc_version_str,
|
||||||
|
"edition": "",
|
||||||
|
"extendedSupport": false
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"core": {
|
||||||
|
"pollinterval": 60,
|
||||||
|
"webdav-root": "remote.php/dav",
|
||||||
|
"reference-api": false,
|
||||||
|
"reference-regex": ""
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"bigfilechunking": true,
|
||||||
|
"favorites": true,
|
||||||
|
"undelete": true,
|
||||||
|
"versioning": false
|
||||||
|
},
|
||||||
|
"dav": {
|
||||||
|
"chunking": "1.0"
|
||||||
|
},
|
||||||
|
"checksums": {
|
||||||
|
"preferredUploadType": "SHA1",
|
||||||
|
"supportedTypes": ["SHA1", "MD5"]
|
||||||
|
},
|
||||||
|
"files_sharing": {
|
||||||
|
"api_enabled": false,
|
||||||
|
"public": { "enabled": false },
|
||||||
|
"user": { "send_mail": false },
|
||||||
|
"resharing": false
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"ocs-endpoints": ["list", "get", "delete", "delete-all"]
|
||||||
|
},
|
||||||
|
"theming": {
|
||||||
|
"name": "OxiCloud",
|
||||||
|
"url": base_url,
|
||||||
|
"logo": format!("{}/logo.png", base_url),
|
||||||
|
"color": "#0082c9",
|
||||||
|
"color-text": "#ffffff",
|
||||||
|
"color-element": "#0082c9",
|
||||||
|
"color-element-bright": "#0082c9",
|
||||||
|
"color-element-dark": "#0082c9",
|
||||||
|
"background": "#0082c9",
|
||||||
|
"background-plain": true,
|
||||||
|
"background-default": true,
|
||||||
|
"logoheader": format!("{}/logo.png", base_url),
|
||||||
|
"favicon": format!("{}/favicon.ico", base_url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_basic_password(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
|
let value = headers
|
||||||
|
.get(axum::http::header::AUTHORIZATION)?
|
||||||
|
.to_str()
|
||||||
|
.ok()?;
|
||||||
|
super::basic_auth_middleware::parse_basic_auth(value).map(|(_, pass)| pass)
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//! Nextcloud-compatible preview/thumbnail endpoint.
|
||||||
|
//!
|
||||||
|
//! Maps Nextcloud preview requests to OxiCloud's thumbnail service.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{Query, State},
|
||||||
|
http::{StatusCode, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||||
|
use crate::application::ports::storage_ports::FileReadPort;
|
||||||
|
use crate::application::ports::thumbnail_ports::{ThumbnailPort, ThumbnailSize};
|
||||||
|
use crate::common::di::AppState;
|
||||||
|
use crate::interfaces::middleware::auth::CurrentUser;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PreviewParams {
|
||||||
|
#[serde(rename = "fileId")]
|
||||||
|
file_id: String,
|
||||||
|
x: Option<u32>,
|
||||||
|
y: Option<u32>,
|
||||||
|
#[serde(rename = "forceIcon")]
|
||||||
|
force_icon: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle Nextcloud preview requests.
|
||||||
|
///
|
||||||
|
/// Maps:
|
||||||
|
/// - `/index.php/core/preview?fileId=X` to thumbnail generation
|
||||||
|
/// - Size selection based on request dimensions and forceIcon param
|
||||||
|
pub async fn handle_preview(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Query(params): Query<PreviewParams>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Parse the Nextcloud file ID (numeric) to get the OxiCloud UUID
|
||||||
|
let nc_file_id: i64 = match params.file_id.parse() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::BAD_REQUEST)
|
||||||
|
.body(Body::from("Invalid file ID"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Look up the OxiCloud file UUID from the Nextcloud ID
|
||||||
|
let object_id = match state.nextcloud.as_ref() {
|
||||||
|
Some(nc) => match nc.file_ids.get_oxicloud_id(nc_file_id).await {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(Body::from("File not found"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
.body(Body::from("Nextcloud integration not configured"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get file details
|
||||||
|
let file = match state
|
||||||
|
.applications
|
||||||
|
.file_retrieval_service
|
||||||
|
.get_file(&object_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(_) => {
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(Body::from("File not found"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify the authenticated user owns this file
|
||||||
|
if file.owner_id.as_deref() != Some(&user.id) {
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(Body::from("File not found"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine thumbnail size based on request params
|
||||||
|
let thumb_size = if params.force_icon == Some(1) {
|
||||||
|
ThumbnailSize::Icon
|
||||||
|
} else {
|
||||||
|
// Map requested dimensions to our thumbnail sizes
|
||||||
|
let max_dim = params.x.unwrap_or(400).max(params.y.unwrap_or(400));
|
||||||
|
if max_dim <= 150 {
|
||||||
|
ThumbnailSize::Icon
|
||||||
|
} else if max_dim <= 400 {
|
||||||
|
ThumbnailSize::Preview
|
||||||
|
} else {
|
||||||
|
ThumbnailSize::Large
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if file is an image
|
||||||
|
if !state
|
||||||
|
.core
|
||||||
|
.thumbnail_service
|
||||||
|
.is_supported_image(&file.mime_type)
|
||||||
|
{
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(Body::from("Preview not available for this file type"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the physical blob path (content-addressable storage)
|
||||||
|
let blob_hash = match state
|
||||||
|
.repositories
|
||||||
|
.file_read_repository
|
||||||
|
.get_blob_hash(&object_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(hash) => hash,
|
||||||
|
Err(_) => {
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(Body::from("File blob not found"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let blob_path = state.core.dedup_service.blob_path(&blob_hash);
|
||||||
|
|
||||||
|
// Generate/get thumbnail
|
||||||
|
match state
|
||||||
|
.core
|
||||||
|
.thumbnail_service
|
||||||
|
.get_thumbnail(&object_id, thumb_size.into(), &blob_path)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(data) => {
|
||||||
|
let etag = format!("\"thumb-{}-{:?}\"", object_id, thumb_size);
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, "image/webp")
|
||||||
|
.header(header::CONTENT_LENGTH, data.len())
|
||||||
|
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||||
|
.header(header::ETAG, etag)
|
||||||
|
.body(Body::from(data))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("Thumbnail generation failed for {}: {}", object_id, err);
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
.body(Body::from("Failed to generate thumbnail"))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
use axum::{
|
||||||
|
body::{self, Body},
|
||||||
|
http::{Request, StatusCode, header},
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use quick_xml::{
|
||||||
|
Reader, Writer,
|
||||||
|
events::{BytesEnd, BytesStart, Event},
|
||||||
|
};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::application::dtos::display_helpers::{
|
||||||
|
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||||
|
};
|
||||||
|
use crate::application::dtos::file_dto::FileDto;
|
||||||
|
use crate::application::dtos::folder_dto::FolderDto;
|
||||||
|
use crate::application::dtos::search_dto::SearchCriteriaDto;
|
||||||
|
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
||||||
|
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||||
|
use crate::application::ports::inbound::{FolderUseCase, SearchUseCase};
|
||||||
|
use crate::common::di::AppState;
|
||||||
|
use crate::interfaces::errors::AppError;
|
||||||
|
use crate::interfaces::middleware::auth::CurrentUser;
|
||||||
|
use crate::interfaces::nextcloud::webdav_handler::{
|
||||||
|
format_oc_id, nc_href, resolve_file_id, resolve_folder_id, write_file_response,
|
||||||
|
write_folder_response,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Handle WebDAV REPORT and SEARCH methods for Nextcloud compatibility.
|
||||||
|
///
|
||||||
|
/// Dispatches based on the XML body:
|
||||||
|
/// - `oc:filter-files` -- list favorited items (REPORT)
|
||||||
|
/// - `d:searchrequest` -- search files by name (SEARCH)
|
||||||
|
pub async fn handle_nc_report(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
req: Request<Body>,
|
||||||
|
user: &CurrentUser,
|
||||||
|
_subpath: &str,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let body_bytes = body::to_bytes(req.into_body(), 64 * 1024)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?;
|
||||||
|
|
||||||
|
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||||
|
|
||||||
|
if body_str.contains("filter-files") {
|
||||||
|
handle_filter_files(state, &body_str, user).await
|
||||||
|
} else if body_str.contains("searchrequest") {
|
||||||
|
handle_search(state, &body_str, user).await
|
||||||
|
} else {
|
||||||
|
// Unknown REPORT type -- return empty multistatus.
|
||||||
|
Ok(empty_multistatus())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────── Favorites filter (oc:filter-files) ────────────────────
|
||||||
|
|
||||||
|
async fn handle_filter_files(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
_body: &str,
|
||||||
|
user: &CurrentUser,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let fav_svc = match state.favorites_service.as_ref() {
|
||||||
|
Some(svc) => svc,
|
||||||
|
None => return Ok(empty_multistatus()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let favorites = fav_svc
|
||||||
|
.get_favorites(&user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::internal_error(format!("Failed to get favorites: {}", e)))?;
|
||||||
|
|
||||||
|
if favorites.is_empty() {
|
||||||
|
return Ok(empty_multistatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_service = &state.applications.file_retrieval_service;
|
||||||
|
let folder_service = &state.applications.folder_service;
|
||||||
|
let nc = state.nextcloud.as_ref();
|
||||||
|
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||||
|
|
||||||
|
// All items in this response are favorites.
|
||||||
|
let favorite_ids: HashSet<String> = favorites.iter().map(|f| f.item_id.clone()).collect();
|
||||||
|
|
||||||
|
let home_prefix = format!("My Folder - {}/", user.username);
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut xml = Writer::new(&mut buf);
|
||||||
|
|
||||||
|
write_multistatus_start(&mut xml)?;
|
||||||
|
|
||||||
|
for fav in &favorites {
|
||||||
|
match fav.item_type.as_str() {
|
||||||
|
"file" => {
|
||||||
|
let file = match file_service.get_file(&fav.item_id).await {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => continue, // Deleted or inaccessible -- skip.
|
||||||
|
};
|
||||||
|
let subpath = strip_home_prefix(&file.path, &home_prefix);
|
||||||
|
let href = nc_href(&user.username, subpath);
|
||||||
|
let fid = resolve_file_id(file_id_svc, &file.id).await;
|
||||||
|
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||||
|
write_file_response(
|
||||||
|
&mut xml,
|
||||||
|
&file,
|
||||||
|
&href,
|
||||||
|
fid,
|
||||||
|
oc_id.as_deref(),
|
||||||
|
&user.username,
|
||||||
|
&favorite_ids,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
|
}
|
||||||
|
"folder" => {
|
||||||
|
let folder = match folder_service.get_folder(&fav.item_id).await {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let subpath = strip_home_prefix(&folder.path, &home_prefix);
|
||||||
|
let href = format!("{}/", nc_href(&user.username, subpath));
|
||||||
|
let fid = resolve_folder_id(file_id_svc, &folder.id).await;
|
||||||
|
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||||
|
write_folder_response(
|
||||||
|
&mut xml,
|
||||||
|
&folder,
|
||||||
|
&href,
|
||||||
|
fid,
|
||||||
|
oc_id.as_deref(),
|
||||||
|
&user.username,
|
||||||
|
&favorite_ids,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||||
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::MULTI_STATUS)
|
||||||
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||||
|
.body(Body::from(buf))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────── Search (d:searchrequest) ────────────────────
|
||||||
|
|
||||||
|
async fn handle_search(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
body: &str,
|
||||||
|
user: &CurrentUser,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let search_svc = match state.applications.search_service.as_ref() {
|
||||||
|
Some(svc) => svc,
|
||||||
|
None => return Ok(empty_multistatus()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let term = parse_literal(body).unwrap_or_default();
|
||||||
|
if term.is_empty() {
|
||||||
|
return Ok(empty_multistatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
let nresults = parse_nresults(body).unwrap_or(100);
|
||||||
|
|
||||||
|
// Resolve folder scope from <d:href> inside <d:scope>.
|
||||||
|
let folder_id = resolve_scope_folder(&state, body, &user.username).await;
|
||||||
|
|
||||||
|
let criteria = SearchCriteriaDto {
|
||||||
|
name_contains: Some(term),
|
||||||
|
recursive: true,
|
||||||
|
limit: nresults,
|
||||||
|
folder_id,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let results = search_svc
|
||||||
|
.search(criteria, &user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::internal_error(format!("Search failed: {}", e)))?;
|
||||||
|
|
||||||
|
let nc = state.nextcloud.as_ref();
|
||||||
|
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||||
|
let home_prefix = format!("My Folder - {}/", user.username);
|
||||||
|
|
||||||
|
// No favorite checking for search results -- pass an empty set.
|
||||||
|
let favorite_ids: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut xml = Writer::new(&mut buf);
|
||||||
|
|
||||||
|
write_multistatus_start(&mut xml)?;
|
||||||
|
|
||||||
|
// Files.
|
||||||
|
for fr in &results.files {
|
||||||
|
let file = file_dto_from_search(fr);
|
||||||
|
let subpath = strip_home_prefix(&file.path, &home_prefix);
|
||||||
|
let href = nc_href(&user.username, subpath);
|
||||||
|
let fid = resolve_file_id(file_id_svc, &file.id).await;
|
||||||
|
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||||
|
write_file_response(
|
||||||
|
&mut xml,
|
||||||
|
&file,
|
||||||
|
&href,
|
||||||
|
fid,
|
||||||
|
oc_id.as_deref(),
|
||||||
|
&user.username,
|
||||||
|
&favorite_ids,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folders.
|
||||||
|
for sr in &results.folders {
|
||||||
|
let folder = folder_dto_from_search(sr);
|
||||||
|
let subpath = strip_home_prefix(&folder.path, &home_prefix);
|
||||||
|
let href = format!("{}/", nc_href(&user.username, subpath));
|
||||||
|
let fid = resolve_folder_id(file_id_svc, &folder.id).await;
|
||||||
|
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||||
|
write_folder_response(
|
||||||
|
&mut xml,
|
||||||
|
&folder,
|
||||||
|
&href,
|
||||||
|
fid,
|
||||||
|
oc_id.as_deref(),
|
||||||
|
&user.username,
|
||||||
|
&favorite_ids,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||||
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::MULTI_STATUS)
|
||||||
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||||
|
.body(Body::from(buf))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────── DTO conversions ────────────────────
|
||||||
|
|
||||||
|
/// Build a `FileDto` from a search file result.
|
||||||
|
fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileResultDto) -> FileDto {
|
||||||
|
FileDto {
|
||||||
|
id: fr.id.clone(),
|
||||||
|
name: fr.name.clone(),
|
||||||
|
path: fr.path.clone(),
|
||||||
|
size: fr.size,
|
||||||
|
mime_type: fr.mime_type.clone().into(),
|
||||||
|
folder_id: fr.folder_id.clone(),
|
||||||
|
created_at: fr.created_at,
|
||||||
|
modified_at: fr.modified_at,
|
||||||
|
icon_class: icon_class_for(&fr.name, &fr.mime_type).to_string().into(),
|
||||||
|
icon_special_class: icon_special_class_for(&fr.name, &fr.mime_type)
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
category: category_for(&fr.name, &fr.mime_type).to_string().into(),
|
||||||
|
size_formatted: format_file_size(fr.size),
|
||||||
|
owner_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `FolderDto` from a search folder result.
|
||||||
|
fn folder_dto_from_search(
|
||||||
|
sr: &crate::application::dtos::search_dto::SearchFolderResultDto,
|
||||||
|
) -> FolderDto {
|
||||||
|
FolderDto {
|
||||||
|
id: sr.id.clone(),
|
||||||
|
name: sr.name.clone(),
|
||||||
|
path: sr.path.clone(),
|
||||||
|
parent_id: sr.parent_id.clone(),
|
||||||
|
owner_id: None,
|
||||||
|
created_at: sr.created_at,
|
||||||
|
modified_at: sr.modified_at,
|
||||||
|
is_root: sr.is_root,
|
||||||
|
icon_class: Arc::from("fas fa-folder"),
|
||||||
|
icon_special_class: Arc::from("folder-icon"),
|
||||||
|
category: Arc::from("Folder"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────── XML helpers ────────────────────
|
||||||
|
|
||||||
|
/// Write the opening `<d:multistatus>` element with namespace declarations.
|
||||||
|
fn write_multistatus_start<W: std::io::Write>(xml: &mut Writer<W>) -> Result<(), AppError> {
|
||||||
|
let mut ms = BytesStart::new("d:multistatus");
|
||||||
|
ms.push_attribute(("xmlns:d", "DAV:"));
|
||||||
|
ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns"));
|
||||||
|
ms.push_attribute(("xmlns:nc", "http://nextcloud.org/ns"));
|
||||||
|
xml.write_event(Event::Start(ms))
|
||||||
|
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an empty 207 Multi-Status response.
|
||||||
|
fn empty_multistatus() -> Response<Body> {
|
||||||
|
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
</d:multistatus>"#;
|
||||||
|
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::MULTI_STATUS)
|
||||||
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||||
|
.body(Body::from(xml))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────── XML parsing helpers ────────────────────
|
||||||
|
|
||||||
|
/// Extract the search term from `<d:literal>%term%</d:literal>` using quick_xml.
|
||||||
|
fn parse_literal(body: &str) -> Option<String> {
|
||||||
|
let text = xml_extract_text(body, b"literal")?;
|
||||||
|
// Strip SQL-style % wildcards.
|
||||||
|
let term = text.trim_matches('%').trim();
|
||||||
|
if term.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(term.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the result limit from `<d:nresults>100</d:nresults>` using quick_xml.
|
||||||
|
fn parse_nresults(body: &str) -> Option<usize> {
|
||||||
|
let text = xml_extract_text(body, b"nresults")?;
|
||||||
|
text.trim().parse::<usize>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the scope href from `<d:href>` inside `<d:scope>` using quick_xml.
|
||||||
|
fn parse_scope_href(body: &str) -> Option<String> {
|
||||||
|
let mut reader = Reader::from_str(body);
|
||||||
|
let mut inside_scope = false;
|
||||||
|
let mut inside_href = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match reader.read_event() {
|
||||||
|
Ok(Event::Start(ref e)) => {
|
||||||
|
let local = e.local_name();
|
||||||
|
if local.as_ref() == b"scope" {
|
||||||
|
inside_scope = true;
|
||||||
|
} else if inside_scope && local.as_ref() == b"href" {
|
||||||
|
inside_href = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Text(ref e)) if inside_href => {
|
||||||
|
let text = e.decode().ok()?;
|
||||||
|
let href = text.trim();
|
||||||
|
if href.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some(href.to_string());
|
||||||
|
}
|
||||||
|
Ok(Event::End(ref e)) => {
|
||||||
|
let local = e.local_name();
|
||||||
|
if local.as_ref() == b"scope" {
|
||||||
|
inside_scope = false;
|
||||||
|
} else if local.as_ref() == b"href" {
|
||||||
|
inside_href = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Eof) => break,
|
||||||
|
Err(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic helper: extract text content from the first element matching a local name.
|
||||||
|
fn xml_extract_text(body: &str, local_name: &[u8]) -> Option<String> {
|
||||||
|
let mut reader = Reader::from_str(body);
|
||||||
|
let mut inside = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match reader.read_event() {
|
||||||
|
Ok(Event::Start(ref e)) if e.local_name().as_ref() == local_name => {
|
||||||
|
inside = true;
|
||||||
|
}
|
||||||
|
Ok(Event::Text(ref e)) if inside => {
|
||||||
|
return e.decode().ok().map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
Ok(Event::End(ref e)) if e.local_name().as_ref() == local_name => {
|
||||||
|
inside = false;
|
||||||
|
}
|
||||||
|
Ok(Event::Eof) => break,
|
||||||
|
Err(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a scope href (e.g. `/files/username/Documents`) to a folder ID.
|
||||||
|
async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> Option<String> {
|
||||||
|
let href = parse_scope_href(body)?;
|
||||||
|
|
||||||
|
// The href is typically `/files/{user}/subpath` or `/remote.php/dav/files/{user}/subpath`.
|
||||||
|
let subpath = extract_subpath_from_scope(&href, username)?;
|
||||||
|
if subpath.is_empty() {
|
||||||
|
// Root scope -- no folder_id filter needed.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let internal_path =
|
||||||
|
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(username, &subpath)
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let folder_service = &state.applications.folder_service;
|
||||||
|
folder_service
|
||||||
|
.get_folder_by_path(&internal_path)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|f| f.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the subpath portion from a scope href.
|
||||||
|
///
|
||||||
|
/// Handles both short form `/files/{user}/sub` and full
|
||||||
|
/// `/remote.php/dav/files/{user}/sub`.
|
||||||
|
fn extract_subpath_from_scope(href: &str, username: &str) -> Option<String> {
|
||||||
|
let patterns = [
|
||||||
|
format!("/remote.php/dav/files/{}/", username),
|
||||||
|
format!("/files/{}/", username),
|
||||||
|
format!("/remote.php/dav/files/{}", username),
|
||||||
|
format!("/files/{}", username),
|
||||||
|
];
|
||||||
|
|
||||||
|
for pat in &patterns {
|
||||||
|
if let Some(rest) = href.strip_prefix(pat.as_str()) {
|
||||||
|
return Some(rest.trim_matches('/').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the `My Folder - {username}/` prefix to get the DAV subpath.
|
||||||
|
fn strip_home_prefix<'a>(path: &'a str, prefix: &str) -> &'a str {
|
||||||
|
path.strip_prefix(prefix).unwrap_or(path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
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.
|
||||||
|
fn verify_url_user(url_user: &str, auth_user: &CurrentUser) -> Result<(), Response> {
|
||||||
|
if url_user != auth_user.username {
|
||||||
|
Err(StatusCode::FORBIDDEN.into_response())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_dav_files(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path((url_user, subpath)): Path<(String, String)>,
|
||||||
|
user_ext: CurrentUser,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Result<Response, Response> {
|
||||||
|
verify_url_user(&url_user, &user_ext)?;
|
||||||
|
webdav_handler::handle_nc_webdav(state, req, user_ext, subpath)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_dav_files_root(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(url_user): Path<String>,
|
||||||
|
user_ext: CurrentUser,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Result<Response, Response> {
|
||||||
|
verify_url_user(&url_user, &user_ext)?;
|
||||||
|
webdav_handler::handle_nc_webdav(state, req, user_ext, String::new())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_dav_uploads(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path((url_user, upload_id, rest)): Path<(String, String, String)>,
|
||||||
|
user_ext: CurrentUser,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Result<Response, Response> {
|
||||||
|
verify_url_user(&url_user, &user_ext)?;
|
||||||
|
uploads_handler::handle_nc_uploads(state, req, user_ext, upload_id, rest)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_dav_uploads_root(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path((url_user, upload_id)): Path<(String, String)>,
|
||||||
|
user_ext: CurrentUser,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Result<Response, Response> {
|
||||||
|
verify_url_user(&url_user, &user_ext)?;
|
||||||
|
uploads_handler::handle_nc_uploads(state, req, user_ext, upload_id, String::new())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy /remote.php/webdav/* — redirect to /remote.php/dav/files/{user}/*
|
||||||
|
async fn handle_legacy_webdav(Path(subpath): Path<String>, user_ext: CurrentUser) -> Response {
|
||||||
|
let location = format!("/remote.php/dav/files/{}/{}", user_ext.username, subpath);
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::MOVED_PERMANENTLY)
|
||||||
|
.header("location", location)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_legacy_webdav_root(user_ext: CurrentUser) -> Response {
|
||||||
|
let location = format!("/remote.php/dav/files/{}/", user_ext.username);
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::MOVED_PERMANENTLY)
|
||||||
|
.header("location", location)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_dav_trashbin(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path((url_user, subpath)): Path<(String, String)>,
|
||||||
|
user_ext: CurrentUser,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Result<Response, Response> {
|
||||||
|
verify_url_user(&url_user, &user_ext)?;
|
||||||
|
trashbin_handler::handle_nc_trashbin(state, req, user_ext, subpath)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_dav_trashbin_root(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(url_user): Path<String>,
|
||||||
|
user_ext: CurrentUser,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Result<Response, Response> {
|
||||||
|
verify_url_user(&url_user, &user_ext)?;
|
||||||
|
trashbin_handler::handle_nc_trashbin(state, req, user_ext, String::new())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.into_response())
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::common::di::AppState;
|
||||||
|
|
||||||
|
pub async fn handle_status(State(state): State<Arc<AppState>>) -> Response {
|
||||||
|
let (major, minor, patch) = state.core.config.nextcloud.emulated_version;
|
||||||
|
let version_string = state.core.config.nextcloud.version_string();
|
||||||
|
Json(json!({
|
||||||
|
"installed": true,
|
||||||
|
"maintenance": false,
|
||||||
|
"needsDbUpgrade": false,
|
||||||
|
"version": format!("{}.{}.{}.1", major, minor, patch),
|
||||||
|
"versionstring": version_string,
|
||||||
|
"productname": "OxiCloud",
|
||||||
|
"edition": ""
|
||||||
|
}))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{HeaderName, Request, StatusCode, header},
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use quick_xml::{
|
||||||
|
Writer,
|
||||||
|
events::{BytesEnd, BytesStart, Event},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::application::ports::trash_ports::TrashUseCase;
|
||||||
|
use crate::common::di::AppState;
|
||||||
|
use crate::interfaces::errors::AppError;
|
||||||
|
use crate::interfaces::middleware::auth::CurrentUser;
|
||||||
|
use crate::interfaces::nextcloud::webdav_handler::{
|
||||||
|
format_oc_id, resolve_file_id, resolve_folder_id, write_text_element,
|
||||||
|
};
|
||||||
|
|
||||||
|
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
|
||||||
|
|
||||||
|
/// Dispatch Nextcloud WebDAV trashbin request to the appropriate handler.
|
||||||
|
///
|
||||||
|
/// `subpath` is everything after `/remote.php/dav/trashbin/{user}/`.
|
||||||
|
pub async fn handle_nc_trashbin(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
req: Request<Body>,
|
||||||
|
user: CurrentUser,
|
||||||
|
subpath: String,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let method = req.method().clone();
|
||||||
|
let subpath_trimmed = subpath.trim_matches('/');
|
||||||
|
|
||||||
|
match method.as_str() {
|
||||||
|
"OPTIONS" => handle_options(),
|
||||||
|
"PROPFIND" if subpath_trimmed == "trash" || subpath_trimmed.is_empty() => {
|
||||||
|
handle_propfind(state, &user).await
|
||||||
|
}
|
||||||
|
"MOVE" if subpath_trimmed.starts_with("trash/") => {
|
||||||
|
handle_restore(state, &user, subpath_trimmed).await
|
||||||
|
}
|
||||||
|
"DELETE" if subpath_trimmed == "trash" || subpath_trimmed.is_empty() => {
|
||||||
|
handle_empty_trash(state, &user).await
|
||||||
|
}
|
||||||
|
"DELETE" if subpath_trimmed.starts_with("trash/") => {
|
||||||
|
handle_delete_permanent(state, &user, subpath_trimmed).await
|
||||||
|
}
|
||||||
|
_ => Ok(Response::builder()
|
||||||
|
.status(StatusCode::METHOD_NOT_ALLOWED)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────── OPTIONS ────────────────────
|
||||||
|
|
||||||
|
fn handle_options() -> Result<Response<Body>, AppError> {
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(HEADER_DAV, "1, 2, 3")
|
||||||
|
.header(header::ALLOW, "OPTIONS, PROPFIND, MOVE, DELETE")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────── PROPFIND (list trash) ────────────────────
|
||||||
|
|
||||||
|
async fn handle_propfind(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
user: &CurrentUser,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let trash_svc = state
|
||||||
|
.trash_service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::internal_error("Trash service not available"))?;
|
||||||
|
|
||||||
|
let items = trash_svc
|
||||||
|
.get_trash_items(&user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::internal_error(format!("Failed to list trash: {}", e)))?;
|
||||||
|
|
||||||
|
let nc = state.nextcloud.as_ref();
|
||||||
|
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
write_trashbin_multistatus(&mut buf, &items, &user.username, file_id_svc)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::MULTI_STATUS)
|
||||||
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||||
|
.body(Body::from(buf))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────── MOVE (restore) ────────────────────
|
||||||
|
|
||||||
|
async fn handle_restore(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
user: &CurrentUser,
|
||||||
|
subpath: &str,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let id = extract_trash_id(subpath)?;
|
||||||
|
|
||||||
|
let trash_svc = state
|
||||||
|
.trash_service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::internal_error("Trash service not available"))?;
|
||||||
|
|
||||||
|
trash_svc
|
||||||
|
.restore_item(&id, &user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::internal_error(format!("Failed to restore item: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::CREATED)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────── DELETE (empty trash) ────────────────────
|
||||||
|
|
||||||
|
async fn handle_empty_trash(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
user: &CurrentUser,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let trash_svc = state
|
||||||
|
.trash_service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::internal_error("Trash service not available"))?;
|
||||||
|
|
||||||
|
trash_svc
|
||||||
|
.empty_trash(&user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::internal_error(format!("Failed to empty trash: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::NO_CONTENT)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────── DELETE (single item) ────────────────────
|
||||||
|
|
||||||
|
async fn handle_delete_permanent(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
user: &CurrentUser,
|
||||||
|
subpath: &str,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let id = extract_trash_id(subpath)?;
|
||||||
|
|
||||||
|
let trash_svc = state
|
||||||
|
.trash_service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::internal_error("Trash service not available"))?;
|
||||||
|
|
||||||
|
trash_svc
|
||||||
|
.delete_permanently(&id, &user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::internal_error(format!("Failed to permanently delete item: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::NO_CONTENT)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────── Helpers ──────────────
|
||||||
|
|
||||||
|
/// Extract the item ID from a trashbin subpath like `trash/{id}`.
|
||||||
|
fn extract_trash_id(subpath: &str) -> Result<String, AppError> {
|
||||||
|
// subpath is already trimmed, e.g. "trash/some-uuid"
|
||||||
|
subpath
|
||||||
|
.strip_prefix("trash/")
|
||||||
|
.map(|s| s.trim_matches('/').to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| AppError::bad_request("Missing trash item ID in path"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Infer MIME content type from filename extension.
|
||||||
|
fn mime_from_name(name: &str) -> String {
|
||||||
|
mime_guess::from_path(name)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the "My Folder - {username}/" prefix from an original path to produce
|
||||||
|
/// the Nextcloud-relative original location.
|
||||||
|
fn strip_home_prefix<'a>(original_path: &'a str, username: &str) -> &'a str {
|
||||||
|
let prefix = format!("My Folder - {}/", username);
|
||||||
|
original_path.strip_prefix(&prefix).unwrap_or(original_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────── Trashbin PROPFIND XML Generation ──────────────
|
||||||
|
|
||||||
|
use crate::application::dtos::trash_dto::TrashedItemDto;
|
||||||
|
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
|
||||||
|
|
||||||
|
/// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin.
|
||||||
|
async fn write_trashbin_multistatus<W: std::io::Write>(
|
||||||
|
writer: W,
|
||||||
|
items: &[TrashedItemDto],
|
||||||
|
username: &str,
|
||||||
|
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut xml = Writer::new(writer);
|
||||||
|
|
||||||
|
// Root element with all required namespaces.
|
||||||
|
let mut ms = BytesStart::new("d:multistatus");
|
||||||
|
ms.push_attribute(("xmlns:d", "DAV:"));
|
||||||
|
ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns"));
|
||||||
|
ms.push_attribute(("xmlns:nc", "http://nextcloud.org/ns"));
|
||||||
|
xml.write_event(Event::Start(ms))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Root container entry for the trash collection itself.
|
||||||
|
write_trash_root_response(&mut xml, username)?;
|
||||||
|
|
||||||
|
// Individual trashed items.
|
||||||
|
for item in items {
|
||||||
|
write_trash_item_response(&mut xml, item, username, file_id_svc).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the root collection response entry for the trash folder.
|
||||||
|
fn write_trash_root_response<W: std::io::Write>(
|
||||||
|
xml: &mut Writer<W>,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let href = format!("/remote.php/dav/trashbin/{}/trash/", username);
|
||||||
|
write_text_element(xml, "d:href", &href)?;
|
||||||
|
|
||||||
|
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
xml.write_event(Event::Start(BytesStart::new("d:prop")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// resourcetype = collection
|
||||||
|
xml.write_event(Event::Start(BytesStart::new("d:resourcetype")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
xml.write_event(Event::Empty(BytesStart::new("d:collection")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
xml.write_event(Event::End(BytesEnd::new("d:resourcetype")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
xml.write_event(Event::End(BytesEnd::new("d:prop")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
write_text_element(xml, "d:status", "HTTP/1.1 200 OK")?;
|
||||||
|
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a single trashed item as a `<d:response>` element.
|
||||||
|
async fn write_trash_item_response<W: std::io::Write>(
|
||||||
|
xml: &mut Writer<W>,
|
||||||
|
item: &TrashedItemDto,
|
||||||
|
username: &str,
|
||||||
|
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// href
|
||||||
|
let href = format!("/remote.php/dav/trashbin/{}/trash/{}", username, item.id);
|
||||||
|
write_text_element(xml, "d:href", &href)?;
|
||||||
|
|
||||||
|
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
xml.write_event(Event::Start(BytesStart::new("d:prop")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// d:displayname
|
||||||
|
write_text_element(xml, "d:displayname", &item.name)?;
|
||||||
|
|
||||||
|
// d:getlastmodified
|
||||||
|
write_text_element(xml, "d:getlastmodified", &item.trashed_at.to_rfc2822())?;
|
||||||
|
|
||||||
|
// d:getetag
|
||||||
|
write_text_element(xml, "d:getetag", &format!("\"{}\"", item.original_id))?;
|
||||||
|
|
||||||
|
// d:resourcetype
|
||||||
|
if item.item_type == "folder" {
|
||||||
|
xml.write_event(Event::Start(BytesStart::new("d:resourcetype")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
xml.write_event(Event::Empty(BytesStart::new("d:collection")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
xml.write_event(Event::End(BytesEnd::new("d:resourcetype")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
xml.write_event(Event::Empty(BytesStart::new("d:resourcetype")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// d:getcontenttype
|
||||||
|
let content_type = if item.item_type == "folder" {
|
||||||
|
"httpd/unix-directory".to_string()
|
||||||
|
} else {
|
||||||
|
mime_from_name(&item.name)
|
||||||
|
};
|
||||||
|
write_text_element(xml, "d:getcontenttype", &content_type)?;
|
||||||
|
|
||||||
|
// d:getcontentlength
|
||||||
|
write_text_element(xml, "d:getcontentlength", "0")?;
|
||||||
|
|
||||||
|
// oc:fileid and oc:id — resolve numeric ID via file_id service
|
||||||
|
let file_id = if item.item_type == "folder" {
|
||||||
|
resolve_folder_id(file_id_svc, &item.original_id).await
|
||||||
|
} else {
|
||||||
|
resolve_file_id(file_id_svc, &item.original_id).await
|
||||||
|
};
|
||||||
|
if let Some(id) = file_id {
|
||||||
|
write_text_element(xml, "oc:fileid", &id.to_string())?;
|
||||||
|
let oc_id = format_oc_id(id, file_id_svc);
|
||||||
|
write_text_element(xml, "oc:id", &oc_id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// nc:trashbin-filename
|
||||||
|
write_text_element(xml, "nc:trashbin-filename", &item.name)?;
|
||||||
|
|
||||||
|
// nc:trashbin-original-location
|
||||||
|
let original_location = strip_home_prefix(&item.original_path, username);
|
||||||
|
write_text_element(xml, "nc:trashbin-original-location", original_location)?;
|
||||||
|
|
||||||
|
// nc:trashbin-deletion-time
|
||||||
|
write_text_element(
|
||||||
|
xml,
|
||||||
|
"nc:trashbin-deletion-time",
|
||||||
|
&item.trashed_at.timestamp().to_string(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// oc:permissions — empty in trash
|
||||||
|
write_text_element(xml, "oc:permissions", "")?;
|
||||||
|
|
||||||
|
// oc:size
|
||||||
|
write_text_element(xml, "oc:size", "0")?;
|
||||||
|
|
||||||
|
xml.write_event(Event::End(BytesEnd::new("d:prop")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
write_text_element(xml, "d:status", "HTTP/1.1 200 OK")?;
|
||||||
|
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
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::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 from file extension.
|
||||||
|
let content_type = mime_guess::from_path(&dest_subpath)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// 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
+18
@@ -161,6 +161,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
None
|
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
|
// Apply auth middleware to protected API routes when auth is enabled
|
||||||
if config.features.enable_auth {
|
if config.features.enable_auth {
|
||||||
// SECURITY: if auth is required, auth_service MUST be present at this
|
// SECURITY: if auth is required, auth_service MUST be present at this
|
||||||
@@ -323,6 +331,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.merge(web_routes)
|
.merge(web_routes)
|
||||||
.layer(TraceLayer::new_for_http());
|
.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)
|
// Mount WOPI routes (protocol routes use own token auth, API routes behind auth middleware)
|
||||||
if let Some((wopi_protocol, wopi_api)) = wopi_routes {
|
if let Some((wopi_protocol, wopi_api)) = wopi_routes {
|
||||||
let wopi_api_protected = wopi_api
|
let wopi_api_protected = wopi_api
|
||||||
@@ -350,6 +363,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.merge(web_routes)
|
.merge(web_routes)
|
||||||
.layer(TraceLayer::new_for_http());
|
.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)
|
// Mount WOPI routes (no auth middleware when auth is disabled)
|
||||||
if let Some((wopi_protocol, wopi_api)) = wopi_routes {
|
if let Some((wopi_protocol, wopi_api)) = wopi_routes {
|
||||||
app = app.nest("/wopi", wopi_protocol).nest("/api/wopi", wopi_api);
|
app = app.nest("/wopi", wopi_protocol).nest("/api/wopi", wopi_api);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
*{box-sizing:border-box;margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif}
|
*{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}
|
.link-reset-flex{text-decoration:none;color:inherit;display:flex;align-items:center;gap:14px}
|
||||||
.width-zero{width:0%}
|
.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{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)}
|
#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 ── */
|
/* ── Dark Mode ── */
|
||||||
[data-theme="dark"] body{background:#0f172a;color:#e2e8f0}
|
[data-theme="dark"] body{background:#0f172a;color:#e2e8f0}
|
||||||
[data-theme="dark"] ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15)}
|
[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 h2{color:#fca5a5}
|
||||||
[data-theme="dark"] #auth-error p{color:#94a3b8}
|
[data-theme="dark"] #auth-error p{color:#94a3b8}
|
||||||
[data-theme="dark"] #loading{color:#64748b}
|
[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}
|
||||||
|
|||||||
+3
-1
@@ -753,7 +753,9 @@ const ui = {
|
|||||||
document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } }));
|
document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } }));
|
||||||
}
|
}
|
||||||
// WOPI editor intercept: open Office documents in the WOPI editor
|
// 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 isImage = file.mime_type && file.mime_type.startsWith('image/');
|
||||||
|
if (!isImage && window.wopiEditor && await window.wopiEditor.canEdit(file.name)) {
|
||||||
window.wopiEditor.openInModal(file.id, file.name, 'edit');
|
window.wopiEditor.openInModal(file.id, file.name, 'edit');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ const contextMenus = {
|
|||||||
if (!wopiEdit || !wopiEditTab) return;
|
if (!wopiEdit || !wopiEditTab) return;
|
||||||
|
|
||||||
const targetFile = window.app && window.app.contextMenuTargetFile;
|
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 &&
|
const show = targetFile &&
|
||||||
|
!isImage &&
|
||||||
window.wopiEditor &&
|
window.wopiEditor &&
|
||||||
await window.wopiEditor.canEdit(targetFile.name);
|
await window.wopiEditor.canEdit(targetFile.name);
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ class InlineViewer {
|
|||||||
console.log('Opening file:', file);
|
console.log('Opening file:', file);
|
||||||
|
|
||||||
// WOPI editor intercept: open Office documents in the WOPI editor
|
// 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 isImage = file.mime_type && file.mime_type.startsWith('image/');
|
||||||
|
if (!isImage && window.wopiEditor && await window.wopiEditor.canEdit(file.name)) {
|
||||||
window.wopiEditor.openInModal(file.id, file.name, 'edit');
|
window.wopiEditor.openInModal(file.id, file.name, 'edit');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ async function init() {
|
|||||||
document.getElementById('password-section').style.display = 'none';
|
document.getElementById('password-section').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadAppPasswords();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const oidcResp = await fetch(API + '/auth/oidc/providers', { credentials: 'same-origin' });
|
const oidcResp = await fetch(API + '/auth/oidc/providers', { credentials: 'same-origin' });
|
||||||
if (oidcResp.ok) {
|
if (oidcResp.ok) {
|
||||||
@@ -134,6 +136,150 @@ async function changePassword(e) {
|
|||||||
return false;
|
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();
|
init();
|
||||||
|
|
||||||
/* Wire up form handler (replaces inline onsubmit) */
|
/* Wire up form handler (replaces inline onsubmit) */
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Error - OxiCloud</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/css/views/auth.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="auth-panel">
|
||||||
|
<div class="auth-logo">
|
||||||
|
<div class="auth-logo-icon">
|
||||||
|
<svg viewBox="0 0 500 500">
|
||||||
|
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="auth-logo-text">OxiCloud</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="auth-title" id="error-title">Error</h2>
|
||||||
|
<div class="auth-error">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
<span id="error-message">An error occurred. Please try again.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
<button type="button" class="auth-button" id="error-action">Try Again</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Read error type from URL query parameter
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const errorType = params.get('type') || 'generic';
|
||||||
|
|
||||||
|
const errorTitle = document.getElementById('error-title');
|
||||||
|
const errorMessage = document.getElementById('error-message');
|
||||||
|
const errorAction = document.getElementById('error-action');
|
||||||
|
|
||||||
|
switch(errorType) {
|
||||||
|
case 'invalid-credentials':
|
||||||
|
errorTitle.textContent = 'Login Failed';
|
||||||
|
errorMessage.textContent = 'Invalid username or password. Please check your credentials and try again.';
|
||||||
|
errorAction.textContent = 'Try Again';
|
||||||
|
errorAction.onclick = () => history.back();
|
||||||
|
break;
|
||||||
|
case 'session-expired':
|
||||||
|
errorTitle.textContent = 'Session Expired';
|
||||||
|
errorMessage.textContent = 'Your session has expired. Please try again.';
|
||||||
|
errorAction.textContent = 'Close Window';
|
||||||
|
errorAction.onclick = () => window.close();
|
||||||
|
break;
|
||||||
|
case 'not-found':
|
||||||
|
errorTitle.textContent = 'Not Found';
|
||||||
|
errorMessage.textContent = 'The requested page was not found.';
|
||||||
|
errorAction.textContent = 'Close Window';
|
||||||
|
errorAction.onclick = () => window.close();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorTitle.textContent = 'Error';
|
||||||
|
errorMessage.textContent = 'An unexpected error occurred. Please try again.';
|
||||||
|
errorAction.textContent = 'Close Window';
|
||||||
|
errorAction.onclick = () => window.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Grant Access - OxiCloud</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/css/views/auth.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="auth-panel">
|
||||||
|
<div class="auth-logo">
|
||||||
|
<div class="auth-logo-icon">
|
||||||
|
<svg viewBox="0 0 500 500">
|
||||||
|
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="auth-logo-text">OxiCloud</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="auth-title">Grant Access</h2>
|
||||||
|
<p style="margin-bottom: 20px; color: #6b7280; font-size: 14px;">
|
||||||
|
A Nextcloud client is requesting access to your account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="auth-form" method="POST" id="login-flow-form">
|
||||||
|
<div class="auth-input-group">
|
||||||
|
<label class="auth-label" for="user">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="user"
|
||||||
|
name="user"
|
||||||
|
class="auth-input"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-input-group">
|
||||||
|
<label class="auth-label" for="password">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
class="auth-input"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="auth-button" id="password-submit">Grant Access</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- OIDC/SSO login — shown only when OIDC is enabled -->
|
||||||
|
<div id="oidc-section" style="display: none;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px; margin: 16px 0;">
|
||||||
|
<hr style="flex: 1; border: none; border-top: 1px solid #e5e7eb;">
|
||||||
|
<span style="color: #9ca3af; font-size: 13px;">or</span>
|
||||||
|
<hr style="flex: 1; border: none; border-top: 1px solid #e5e7eb;">
|
||||||
|
</div>
|
||||||
|
<button type="button" id="oidc-button" class="auth-button" style="background: #4f46e5;">
|
||||||
|
Sign in with SSO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Extract token from URL path and set form action
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
const token = pathParts[pathParts.length - 1];
|
||||||
|
// Validate token is hex-only to prevent injection
|
||||||
|
if (!/^[0-9a-fA-F]+$/.test(token)) {
|
||||||
|
document.body.innerHTML = '<p>Invalid session token.</p>';
|
||||||
|
throw new Error('Invalid token format');
|
||||||
|
}
|
||||||
|
document.getElementById('login-flow-form').action = `/login/v2/flow/${token}`;
|
||||||
|
|
||||||
|
// Check if OIDC is available and configure SSO button
|
||||||
|
(async function() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/oidc/providers');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const info = await resp.json();
|
||||||
|
if (!info.enabled) return;
|
||||||
|
|
||||||
|
// Show OIDC section
|
||||||
|
const section = document.getElementById('oidc-section');
|
||||||
|
section.style.display = 'block';
|
||||||
|
|
||||||
|
// Update button text with provider name
|
||||||
|
const btn = document.getElementById('oidc-button');
|
||||||
|
btn.textContent = `Sign in with ${info.provider_name || 'SSO'}`;
|
||||||
|
|
||||||
|
// If password login is disabled, hide the password form
|
||||||
|
if (!info.password_login_enabled) {
|
||||||
|
document.getElementById('login-flow-form').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSO button redirects to the OIDC flow for this NC token
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
window.location.href = `/login/v2/flow/${token}/oidc`;
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
// OIDC not available — silently keep password-only mode
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Access Granted - OxiCloud</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/css/views/auth.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="auth-panel">
|
||||||
|
<div class="auth-logo">
|
||||||
|
<div class="auth-logo-icon">
|
||||||
|
<svg viewBox="0 0 500 500">
|
||||||
|
<path d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="auth-logo-text">OxiCloud</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="auth-title">Access Granted</h2>
|
||||||
|
<div class="auth-success">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<span>You have successfully granted access to your account.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px; color: #6b7280; font-size: 14px;">
|
||||||
|
You can now close this window and return to your Nextcloud app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
<button type="button" class="auth-button" onclick="window.close()">Close Window</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Auto-close after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
window.close();
|
||||||
|
}, 3000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -94,6 +94,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="profile-card" id="password-section">
|
||||||
<h2><i class="fas fa-key"></i> Change Password</h2>
|
<h2><i class="fas fa-key"></i> Change Password</h2>
|
||||||
<form id="password-form">
|
<form id="password-form">
|
||||||
|
|||||||
Reference in New Issue
Block a user