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:
zjean
2026-03-04 14:02:15 +01:00
parent ecd1a8148a
commit 54eedf5483
64 changed files with 6761 additions and 126 deletions
+60
View File
@@ -435,6 +435,39 @@ impl Default for WopiConfig {
}
}
/// Nextcloud compatibility configuration
#[derive(Debug, Clone)]
pub struct NextcloudConfig {
/// Whether the Nextcloud compatibility layer is enabled
pub enabled: bool,
/// Instance ID suffix for oc:id formatting (e.g., "ocnca")
pub instance_id: String,
/// Emulated Nextcloud version (major.minor.patch).
/// Clients use this to decide which features to enable.
pub emulated_version: (u32, u32, u32),
/// Login Flow v2 token TTL in seconds (default: 600 = 10 minutes)
pub login_flow_ttl_secs: u64,
}
impl Default for NextcloudConfig {
fn default() -> Self {
Self {
enabled: false,
instance_id: "ocnca".to_string(),
emulated_version: (28, 0, 4),
login_flow_ttl_secs: 600,
}
}
}
impl NextcloudConfig {
/// Version string, e.g. "28.0.4".
pub fn version_string(&self) -> String {
let (maj, min, pat) = self.emulated_version;
format!("{}.{}.{}", maj, min, pat)
}
}
/// Feature configuration (feature flags)
#[derive(Debug, Clone)]
pub struct FeaturesConfig {
@@ -488,6 +521,8 @@ pub struct AppConfig {
pub oidc: OidcConfig,
/// WOPI configuration
pub wopi: WopiConfig,
/// Nextcloud compatibility configuration
pub nextcloud: NextcloudConfig,
}
impl Default for AppConfig {
@@ -507,6 +542,7 @@ impl Default for AppConfig {
features: FeaturesConfig::default(),
oidc: OidcConfig::default(),
wopi: WopiConfig::default(),
nextcloud: NextcloudConfig::default(),
}
}
}
@@ -797,6 +833,30 @@ impl AppConfig {
tracing::info!("WOPI secret not set, falling back to JWT secret");
}
// Nextcloud compatibility configuration
if let Ok(v) = env::var("OXICLOUD_NEXTCLOUD_ENABLED") {
config.nextcloud.enabled = v.parse::<bool>().unwrap_or(false);
}
if let Ok(v) = env::var("OXICLOUD_NEXTCLOUD_INSTANCE_ID") {
let trimmed = v.trim();
if !trimmed.is_empty() {
config.nextcloud.instance_id = trimmed.to_string();
}
}
if let Ok(v) = env::var("OXICLOUD_NEXTCLOUD_VERSION") {
// Expected format: "28.0.4"
let parts: Vec<&str> = v.trim().splitn(3, '.').collect();
if parts.len() == 3
&& let (Ok(maj), Ok(min), Ok(pat)) = (
parts[0].parse::<u32>(),
parts[1].parse::<u32>(),
parts[2].parse::<u32>(),
)
{
config.nextcloud.emulated_version = (maj, min, pat);
}
}
config
}
+77 -25
View File
@@ -11,6 +11,8 @@ use crate::application::ports::file_ports::FileUseCaseFactory;
use crate::application::services::favorites_service::FavoritesService;
use crate::application::services::folder_service::FolderService;
use crate::application::services::i18n_application_service::I18nApplicationService;
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
use crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService;
use crate::application::services::recent_service::RecentService;
use crate::application::services::search_service::SearchService;
use crate::application::services::share_service::ShareService;
@@ -28,6 +30,7 @@ use crate::infrastructure::services::file_content_cache::{
FileContentCache, FileContentCacheConfig,
};
use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService;
use crate::infrastructure::services::nextcloud_chunked_upload_service::NextcloudChunkedUploadService;
use crate::infrastructure::services::path_service::PathService;
use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService;
@@ -463,6 +466,7 @@ impl AppServiceFactory {
let recent_service: Option<Arc<RecentService>>;
let storage_usage_service: Option<Arc<StorageUsageService>>;
let mut auth_services: Option<crate::common::di::AuthServices> = None;
let mut nextcloud_services: Option<NextcloudServices> = None;
{
let favs = self.create_favorites_service(&pool);
@@ -507,6 +511,66 @@ impl AppServiceFactory {
}
}
// Shared App Password service — created once, used by both NC routes and native API
let shared_app_pw_svc: Option<Arc<AppPasswordService>> =
if self.config.nextcloud.enabled || self.config.features.enable_auth {
let app_pw_repo: Arc<AppPasswordPgRepository> =
Arc::new(AppPasswordPgRepository::new(pool.clone()));
let hasher: Arc<Argon2PasswordHasher> = Arc::new(
crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new(
self.config.auth.hash_memory_cost,
self.config.auth.hash_time_cost,
self.config.auth.hash_parallelism,
),
);
let user_repo: Arc<UserPgRepository> = Arc::new(
crate::infrastructure::repositories::pg::UserPgRepository::new(pool.clone()),
);
let svc = Arc::new(AppPasswordService::new(
app_pw_repo,
hasher,
user_repo,
self.config.base_url(),
));
tracing::info!("App Password service initialized (shared)");
Some(svc)
} else {
None
};
// Nextcloud compatibility services
if self.config.nextcloud.enabled {
if !self.config.features.enable_auth {
tracing::warn!(
"Nextcloud compatibility enabled but auth is disabled; Nextcloud routes will be unusable"
);
}
let chunk_base = self.storage_path.join(".uploads/nextcloud");
let chunked_uploads = Arc::new(NextcloudChunkedUploadService::new(chunk_base));
let file_id_repo = Arc::new(
crate::infrastructure::repositories::pg::NextcloudObjectIdRepository::new(
pool.clone(),
),
);
let file_ids = Arc::new(NextcloudFileIdService::new(
file_id_repo,
self.config.nextcloud.instance_id.clone(),
));
nextcloud_services = Some(NextcloudServices {
login_flow: Arc::new(NextcloudLoginFlowService::new(
std::time::Duration::from_secs(self.config.nextcloud.login_flow_ttl_secs),
)),
app_passwords: shared_app_pw_svc
.clone()
.expect("AppPasswordService must be available when NC is enabled"),
file_ids,
chunked_uploads,
});
}
// 7. Preload translations
self.preload_translations(&apps.i18n_service).await;
@@ -528,6 +592,7 @@ impl AppServiceFactory {
db_pool: Some(pool.clone()),
maintenance_pool: Some(maintenance_pool),
auth_service: auth_services,
nextcloud: nextcloud_services,
admin_settings_service: None,
trash_service,
share_service,
@@ -642,31 +707,8 @@ impl AppServiceFactory {
tracing::info!("Device Authorization Grant (RFC 8628) service initialized");
}
// 9d. Wire App Password service
{
let app_pw_repo: Arc<AppPasswordPgRepository> =
Arc::new(AppPasswordPgRepository::new(pool.clone()));
let hasher: Arc<Argon2PasswordHasher> = Arc::new(
crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new(
self.config.auth.hash_memory_cost,
self.config.auth.hash_time_cost,
self.config.auth.hash_parallelism,
),
);
let user_repo: Arc<UserPgRepository> = Arc::new(
crate::infrastructure::repositories::UserPgRepository::new(pool.clone()),
);
let base_url = self.config.base_url();
let app_pw_svc = Arc::new(AppPasswordService::new(
app_pw_repo,
hasher,
user_repo,
base_url,
));
app_state.app_password_service = Some(app_pw_svc);
tracing::info!("App Password service initialized");
}
// 9d. Wire App Password service (reuse shared instance)
app_state.app_password_service = shared_app_pw_svc.clone();
}
// 9e. Wire PathResolver for single-query WebDAV path resolution
@@ -816,6 +858,15 @@ pub struct AuthServices {
Arc<crate::infrastructure::services::login_lockout_service::LoginLockoutService>,
}
/// Container for Nextcloud compatibility services
#[derive(Clone)]
pub struct NextcloudServices {
pub login_flow: Arc<NextcloudLoginFlowService>,
pub app_passwords: Arc<AppPasswordService>,
pub file_ids: Arc<NextcloudFileIdService>,
pub chunked_uploads: Arc<NextcloudChunkedUploadService>,
}
/// Global application state for dependency injection
#[derive(Clone)]
pub struct AppState {
@@ -826,6 +877,7 @@ pub struct AppState {
/// Isolated pool for background / batch operations.
pub maintenance_pool: Option<Arc<PgPool>>,
pub auth_service: Option<AuthServices>,
pub nextcloud: Option<NextcloudServices>,
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
pub trash_service: Option<Arc<TrashService>>,
pub share_service: Option<Arc<ShareService>>,
+8
View File
@@ -96,6 +96,10 @@ impl FileReadPort for StubFileReadPort {
Ok("root".to_string())
}
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
Ok("stub-folder-id".to_string())
}
async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> {
Ok(String::new())
}
@@ -773,6 +777,10 @@ impl DedupPort for StubDedupPort {
Ok(String::new())
}
fn blob_path(&self, hash: &str) -> PathBuf {
PathBuf::from(format!("stub_blob_{}.blob", hash))
}
async fn get_stats(&self) -> DedupStatsDto {
DedupStatsDto::default()
}