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
@@ -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());
}
}