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
+63 -1
View File
@@ -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}
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}
.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: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 ── */
[data-theme="dark"] body{background:#0f172a;color:#e2e8f0}
[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 p{color:#94a3b8}
[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
View File
@@ -753,7 +753,9 @@ const ui = {
document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } }));
}
// 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');
return;
}
+3
View File
@@ -24,7 +24,10 @@ const contextMenus = {
if (!wopiEdit || !wopiEditTab) return;
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 &&
!isImage &&
window.wopiEditor &&
await window.wopiEditor.canEdit(targetFile.name);
+3 -1
View File
@@ -92,7 +92,9 @@ class InlineViewer {
console.log('Opening file:', file);
// 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');
return;
}
+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) */
+70
View File
@@ -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>
+114
View File
@@ -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>
+45
View File
@@ -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>
+46
View File
@@ -94,6 +94,52 @@
</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">
<h2><i class="fas fa-key"></i> Change Password</h2>
<form id="password-form">