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