release(v1.8.0): feat(onlyoffice): first-class ONLYOFFICE integration (view/edit), admin UI, API, CSP helpers

Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately.
This commit is contained in:
Ryan
2025-11-03 16:39:48 -05:00
committed by GitHub
parent 77a94ecd85
commit d00db803c3
15 changed files with 1240 additions and 105 deletions

View File

@@ -491,6 +491,7 @@ export function openAdminPanel() {
{ id: "headerSettings", label: t("header_settings") },
{ id: "loginOptions", label: t("login_options") },
{ id: "webdav", label: "WebDAV Access" },
{ id: "onlyoffice", label: "ONLYOFFICE" },
{ id: "upload", label: t("shared_max_upload_size_bytes_title") },
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
{ id: "shareLinks", label: t("manage_shared_links") },
@@ -514,7 +515,7 @@ export function openAdminPanel() {
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
["userManagement", "headerSettings", "loginOptions", "webdav", "upload", "oidc", "shareLinks", "sponsor"]
["userManagement", "headerSettings", "loginOptions", "webdav", "onlyoffice", "upload", "oidc", "shareLinks", "sponsor"]
.forEach(id => {
document.getElementById(id + "Header")
.addEventListener("click", () => toggleSection(id));
@@ -574,6 +575,268 @@ export function openAdminPanel() {
</div>
`;
// ONLYOFFICE Content
const hasOOSecret = !!(config.onlyoffice && config.onlyoffice.hasJwtSecret);
window.__HAS_OO_SECRET = hasOOSecret;
document.getElementById("onlyofficeContent").innerHTML = `
<div class="form-group">
<input type="checkbox" id="ooEnabled" />
<label for="ooEnabled">Enable ONLYOFFICE integration</label>
</div>
<div class="form-group">
<label for="ooDocsOrigin">Document Server Origin:</label>
<input type="url" id="ooDocsOrigin" class="form-control" placeholder="e.g. http://192.168.1.61" />
<small class="text-muted">Must be reachable by your browser (for API.js) and by FileRise (for callbacks). Avoid “localhost”.</small>
</div>
${renderMaskedInput({ id: "ooJwtSecret", label: "JWT Secret", hasValue: hasOOSecret, isSecret: true })}
`;
wireReplaceButtons(document.getElementById("onlyofficeContent"));
// --- Test ONLYOFFICE block ---
const testBox = document.createElement("div");
testBox.className = "card";
testBox.style.marginTop = "12px";
testBox.innerHTML = `
<div class="card-body">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
<strong>Test ONLYOFFICE connection</strong>
<button type="button" id="ooTestBtn" class="btn btn-sm btn-primary">Run tests</button>
<span id="ooTestSpinner" style="display:none;">⏳</span>
</div>
<ul id="ooTestResults" class="list-unstyled" style="margin:0;"></ul>
<small class="text-muted">These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding.</small>
</div>
`;
document.getElementById("onlyofficeContent").appendChild(testBox);
// Util: tiny UI helpers for results
function ooRow(label, status, detail = "") {
const li = document.createElement("li");
li.style.margin = "6px 0";
const icon = status === "ok" ? "✅" : status === "warn" ? "⚠️" : "❌";
li.innerHTML = `<span style="min-width:1.2em;display:inline-block">${icon}</span> <strong>${label}</strong>${detail ? ` — <span>${detail}</span>` : ""}`;
return li;
}
function ooClear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
// Probes that dont explode your state
async function ooProbeScript(docsOrigin) {
return new Promise(resolve => {
const src = docsOrigin.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js?probe=' + Date.now();
const s = document.createElement('script');
s.id = 'ooProbeScript';
s.async = true;
s.src = src;
s.onload = () => { resolve({ ok: true }); setTimeout(() => s.remove(), 0); };
s.onerror = () => { resolve({ ok: false }); setTimeout(() => s.remove(), 0); };
document.head.appendChild(s);
});
}
async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
return new Promise(resolve => {
const f = document.createElement('iframe');
f.id = 'ooProbeFrame';
f.src = docsOrigin;
f.style.display = 'none';
let t = setTimeout(() => { cleanup(); resolve({ ok: false, timeout: true }); }, timeoutMs);
function cleanup() { try { f.remove(); } catch { } clearTimeout(t); }
f.onload = () => { cleanup(); resolve({ ok: true }); };
f.onerror = () => { cleanup(); resolve({ ok: false }); };
document.body.appendChild(f);
});
}
// Main test runner
async function runOnlyOfficeTests() {
const spinner = document.getElementById('ooTestSpinner');
const out = document.getElementById('ooTestResults');
const docsOrigin = (document.getElementById('ooDocsOrigin')?.value || '').trim();
spinner.style.display = 'inline';
ooClear(out);
// 1) FileRise status
let statusOk = false, statusJson = null;
try {
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
statusJson = await r.json().catch(() => ({}));
if (r.ok) {
if (statusJson.enabled) {
out.appendChild(ooRow('FileRise status', 'ok', 'Enabled and ready'));
statusOk = true;
} else {
// Disabled usually means missing secret or origin; well dig deeper below.
out.appendChild(ooRow('FileRise status', 'warn', 'Disabled — check JWT Secret and Document Server Origin'));
}
} else {
out.appendChild(ooRow('FileRise status', 'fail', `HTTP ${r.status}`));
}
} catch (e) {
out.appendChild(ooRow('FileRise status', 'fail', (e && e.message) || 'Network error'));
}
// 2) Secret presence (fresh read)
try {
const cfg = await fetch('/api/admin/getConfig.php', { credentials: 'include', cache: 'no-store' }).then(r => r.json());
const hasSecret = !!(cfg.onlyoffice && cfg.onlyoffice.hasJwtSecret);
out.appendChild(ooRow('JWT secret saved', hasSecret ? 'ok' : 'fail', hasSecret ? 'Present' : 'Missing'));
} catch {
out.appendChild(ooRow('JWT secret saved', 'warn', 'Could not verify'));
}
// 3) Callback reachable (basic ping)
try {
const r = await fetch('/api/onlyoffice/callback.php?ping=1', { credentials: 'include', cache: 'no-store' });
if (r.ok) out.appendChild(ooRow('Callback endpoint', 'ok', 'Reachable'));
else out.appendChild(ooRow('Callback endpoint', 'fail', `HTTP ${r.status}`));
} catch {
out.appendChild(ooRow('Callback endpoint', 'fail', 'Network error'));
}
// Early sanity on origin
if (!/^https?:\/\//i.test(docsOrigin)) {
out.appendChild(ooRow('Document Server Origin', 'fail', 'Enter a valid http(s) origin (e.g., https://docs.example.com)'));
spinner.style.display = 'none';
return;
}
// 4a) Can browser load api.js (also surfaces CSP script-src issues)
const sRes = await ooProbeScript(docsOrigin);
out.appendChild(ooRow('Load api.js', sRes.ok ? 'ok' : 'fail', sRes.ok ? 'Loaded' : 'Blocked (check CSP script-src and origin)'));
// 4b) Can browser embed DS in an iframe (CSP frame-src)
const fRes = await ooProbeFrame(docsOrigin);
out.appendChild(ooRow('Embed DS iframe', fRes.ok ? 'ok' : 'fail', fRes.ok ? 'Allowed' : 'Blocked (check CSP frame-src)'));
// Optional tip if we see common red flags
if (!statusOk || !sRes.ok || !fRes.ok) {
const tip = document.createElement('li');
tip.style.marginTop = '8px';
tip.innerHTML = "💡 <em>Tip:</em> Use the CSP helper above to include your Document Server in <code>script-src</code>, <code>connect-src</code>, and <code>frame-src</code>.";
out.appendChild(tip);
}
spinner.style.display = 'none';
}
// Wire the button
document.getElementById('ooTestBtn')?.addEventListener('click', runOnlyOfficeTests);
// Append CSP help box
// --- CSP help box (replace your whole block with this) ---
const ooSec = document.getElementById("onlyofficeContent");
const cspHelp = document.createElement("div");
cspHelp.className = "alert alert-info";
cspHelp.style.marginTop = "12px";
cspHelp.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<strong>Content-Security-Policy help</strong>
<button type="button" id="copyOoCsp" class="btn btn-sm btn-outline-secondary">Copy</button>
<button type="button" id="selectOoCsp" class="btn btn-sm btn-outline-secondary">Select</button>
</div>
<div class="form-text" style="margin-bottom:8px;">
Add/replace this line in <code>public/.htaccess</code> (Apache). It allows loading ONLYOFFICE's <code>api.js</code>,
embedding the editor iframe, and letting the script make XHR to your Document Server.
</div>
<pre id="ooCspSnippet" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7;"></pre>
<div class="form-text" style="margin-top:8px;">
If you terminate SSL or set CSP at a reverse proxy (e.g. Nginx), update it there instead.
Also note: if your site is <code>https://</code>, your ONLYOFFICE server must be <code>https://</code> too,
otherwise the browser will block it as mixed content.
</div>
<details style="margin-top:8px;">
<summary>Nginx equivalent</summary>
<pre id="ooCspSnippetNginx" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7; margin-top:6px;"></pre>
</details>
`;
ooSec.appendChild(cspHelp);
const INLINE_SHA = "sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=";
function buildCspApache(originRaw) {
const o = (originRaw || "https://your-onlyoffice-server.example.com").replace(/\/+$/, '');
const api = `${o}/web-apps/apps/api/documents/api.js`;
return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`;
}
function buildCspNginx(originRaw) {
const o = (originRaw || "https://your-onlyoffice-server.example.com").replace(/\/+$/, '');
const api = `${o}/web-apps/apps/api/documents/api.js`;
return `add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}" always;`;
}
const ooDocsInput = document.getElementById("ooDocsOrigin");
const cspPre = document.getElementById("ooCspSnippet");
const cspPreNgx = document.getElementById("ooCspSnippetNginx");
function refreshCsp() {
const val = (ooDocsInput?.value || "").trim();
cspPre.textContent = buildCspApache(val);
cspPreNgx.textContent = buildCspNginx(val);
}
ooDocsInput?.addEventListener("input", refreshCsp);
refreshCsp();
// ---- Copy helpers (with robust fallback) ----
async function copyToClipboard(text) {
// Best path: async clipboard API in a secure context (https/localhost)
if (navigator.clipboard && window.isSecureContext) {
try { await navigator.clipboard.writeText(text); return true; }
catch (_) { /* fall through */ }
}
// Fallback for http or blocked clipboard: hidden textarea + execCommand
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy'); // deprecated but still widely supported
document.body.removeChild(ta);
return ok;
} catch (_) {
return false;
}
}
function selectElementContents(el) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
document.getElementById("copyOoCsp")?.addEventListener("click", async () => {
const txt = (cspPre.textContent || "").trim();
const ok = await copyToClipboard(txt);
if (ok) {
showToast("CSP line copied.");
} else {
// Auto-select so the user can Ctrl/Cmd+C as a last resort
try { selectElementContents(cspPre); } catch { }
const reason = window.isSecureContext ? "" : " (page is not HTTPS or localhost)";
showToast("Copy failed" + reason + ". Press Ctrl/Cmd+C to copy.");
}
});
document.getElementById("selectOoCsp")?.addEventListener("click", () => {
try { selectElementContents(cspPre); showToast("Selected — press Ctrl/Cmd+C"); }
catch { /* ignore */ }
});
document.getElementById("ooEnabled").checked = !!(config.onlyoffice && config.onlyoffice.enabled);
document.getElementById("ooDocsOrigin").value = (config.onlyoffice && config.onlyoffice.docsOrigin) ? config.onlyoffice.docsOrigin : "";
const hasId = !!(config.oidc && config.oidc.hasClientId);
const hasSecret = !!(config.oidc && config.oidc.hasClientSecret);
@@ -696,10 +959,24 @@ export function openAdminPanel() {
document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User";
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
// remember lock for handleSave
window.__OO_LOCKED = !!(config.onlyoffice && config.onlyoffice.lockedByPhp);
if (window.__OO_LOCKED) {
const sec = document.getElementById("onlyofficeContent");
sec.querySelectorAll("input,button").forEach(el => el.disabled = true);
const note = document.createElement("div");
note.className = "form-text";
note.style.marginTop = "6px";
note.textContent = "Managed by config.php — edit ONLYOFFICE_* constants there.";
sec.appendChild(note);
}
captureInitialAdminConfig();
} else {
mdl.style.display = "flex";
const hasId = !!(config.oidc && config.oidc.hasClientId);
const hasSecret = !!(config.oidc && config.oidc.hasClientSecret);
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
@@ -713,6 +990,10 @@ export function openAdminPanel() {
if (!hasId) idEl.value = window.currentOIDCConfig?.clientId || "";
if (!hasSecret) secEl.value = window.currentOIDCConfig?.clientSecret || "";
wireReplaceButtons(document.getElementById("oidcContent"));
document.getElementById("ooEnabled").checked = !!(config.onlyoffice && config.onlyoffice.enabled);
document.getElementById("ooDocsOrigin").value = (config.onlyoffice && config.onlyoffice.docsOrigin) ? config.onlyoffice.docsOrigin : "";
const ooCont = document.getElementById("onlyofficeContent");
if (ooCont) wireReplaceButtons(ooCont);
document.getElementById("oidcClientSecret").value = window.currentOIDCConfig?.clientSecret || "";
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || "";
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || '';
@@ -752,6 +1033,30 @@ function handleSave() {
payload.oidc.clientSecret = scEl.value.trim();
}
const ooSecretEl = document.getElementById("ooJwtSecret");
payload.onlyoffice = {
enabled: document.getElementById("ooEnabled").checked,
docsOrigin: document.getElementById("ooDocsOrigin").value.trim()
};
if (ooSecretEl?.dataset.replace === '1' && ooSecretEl.value.trim() !== '') {
payload.onlyoffice.jwtSecret = ooSecretEl.value.trim();
}
// ---- ONLYOFFICE payload ----
if (!window.__OO_LOCKED) {
const ooSecretVal = (document.getElementById("ooJwtSecret")?.value || "").trim();
payload.onlyoffice = {
enabled: document.getElementById("ooEnabled").checked,
docsOrigin: document.getElementById("ooDocsOrigin").value.trim()
};
// If user typed a secret (non-empty), send it (server keeps it if non-empty)
if (ooSecretVal !== "") {
payload.onlyoffice.jwtSecret = ooSecretVal;
}
}
fetch('/api/admin/updateConfig.php', {
method: 'POST',
credentials: 'include',