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
+146
View File
@@ -65,6 +65,8 @@ async function init() {
document.getElementById('password-section').style.display = 'none';
}
loadAppPasswords();
try {
const oidcResp = await fetch(API + '/auth/oidc/providers', { credentials: 'same-origin' });
if (oidcResp.ok) {
@@ -134,6 +136,150 @@ async function changePassword(e) {
return false;
}
// ── App Passwords ──
const AUTO_LABELS = ['Nextcloud', 'Nextcloud (OIDC)'];
function isAutoPassword(pw) {
return AUTO_LABELS.includes(pw.label);
}
function renderPwRow(pw) {
const tr = document.createElement('tr');
const label = document.createElement('td');
label.textContent = pw.label;
const created = document.createElement('td');
created.textContent = new Date(pw.created_at).toLocaleDateString();
const lastUsed = document.createElement('td');
lastUsed.textContent = pw.last_used_at ? timeAgo(pw.last_used_at) : 'Never';
const actions = document.createElement('td');
const btn = document.createElement('button');
btn.className = 'btn btn-danger-sm';
btn.innerHTML = '<i class="fas fa-trash"></i>';
btn.title = 'Revoke';
btn.onclick = function () { revokeAppPassword(pw.id, pw.label); };
actions.appendChild(btn);
tr.append(label, created, lastUsed, actions);
return tr;
}
async function loadAppPasswords() {
try {
const resp = await fetch(API + '/auth/app-passwords', { headers: headers() });
if (!resp.ok) {
document.getElementById('app-passwords-section').style.display = 'none';
return;
}
const passwords = await resp.json();
const userPws = passwords.filter(function (pw) { return !isAutoPassword(pw); });
const autoPws = passwords.filter(isAutoPassword);
// User-created passwords
const tbody = document.getElementById('app-pw-tbody');
const table = document.getElementById('app-pw-table');
const empty = document.getElementById('app-pw-empty');
tbody.innerHTML = '';
if (userPws.length === 0) {
table.style.display = 'none';
empty.style.display = 'block';
} else {
table.style.display = '';
empty.style.display = 'none';
for (const pw of userPws) tbody.appendChild(renderPwRow(pw));
}
// Auto-generated (client session) passwords
const autoSection = document.getElementById('app-pw-auto-section');
if (autoPws.length === 0) {
autoSection.style.display = 'none';
} else {
autoSection.style.display = '';
document.getElementById('app-pw-auto-count').textContent = autoPws.length;
const autoTbody = document.getElementById('app-pw-auto-tbody');
autoTbody.innerHTML = '';
for (const pw of autoPws) autoTbody.appendChild(renderPwRow(pw));
}
} catch (e) {
console.error('Failed to load app passwords', e);
}
}
function toggleAutoPasswords() {
const body = document.getElementById('app-pw-auto-body');
const chevron = document.getElementById('app-pw-auto-chevron');
const open = body.style.display === 'none';
body.style.display = open ? '' : 'none';
chevron.className = open ? 'fas fa-chevron-down' : 'fas fa-chevron-right';
}
async function createAppPassword() {
const labelInput = document.getElementById('app-pw-label');
const label = labelInput.value.trim();
const statusEl = document.getElementById('app-pw-status');
const btn = document.getElementById('app-pw-generate');
if (!label) {
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> Please enter a label</div>';
return;
}
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating…';
statusEl.innerHTML = '';
try {
const resp = await fetch(API + '/auth/app-passwords', {
method: 'POST',
headers: headers(),
body: JSON.stringify({ label: label })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + (err.message || 'Failed to create app password') + '</div>';
return;
}
const result = await resp.json();
document.getElementById('app-pw-created-label').textContent = result.label;
document.getElementById('app-pw-created-password').textContent = result.password;
document.getElementById('app-pw-created').style.display = 'block';
labelInput.value = '';
loadAppPasswords();
} catch (err) {
statusEl.innerHTML = '<div class="alert alert-error"><i class="fas fa-exclamation-circle"></i> ' + err.message + '</div>';
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-plus"></i> Generate';
}
}
function copyAppPassword() {
const pw = document.getElementById('app-pw-created-password').textContent;
navigator.clipboard.writeText(pw).then(function () {
const btn = document.querySelector('.btn-copy');
btn.innerHTML = '<i class="fas fa-check"></i>';
setTimeout(function () { btn.innerHTML = '<i class="fas fa-copy"></i>'; }, 1500);
});
}
async function revokeAppPassword(id, label) {
if (!confirm('Revoke app password "' + label + '"? Clients using this password will stop working.')) return;
try {
const resp = await fetch(API + '/auth/app-passwords/' + encodeURIComponent(id), {
method: 'DELETE',
headers: headers()
});
if (resp.ok || resp.status === 204) {
document.getElementById('app-pw-created').style.display = 'none';
loadAppPasswords();
} else {
const err = await resp.json().catch(() => ({}));
alert(err.message || 'Failed to revoke app password');
}
} catch (err) {
alert('Network error: ' + err.message);
}
}
init();
/* Wire up form handler (replaces inline onsubmit) */