// public/js/adminOnlyOffice.js import { t } from './i18n.js?v={{APP_QVER}}'; import { showToast } from './domUtils.js?v={{APP_QVER}}'; /** * Translate with fallback */ const tf = (key, fallback) => { const v = t(key); return (v && v !== key) ? v : fallback; }; /** * Local masked-input renderer (copied from adminPanel.js style) */ function renderMaskedInput({ id, label, hasValue, isSecret = false }) { const type = isSecret ? 'password' : 'text'; const disabled = hasValue ? 'disabled data-replace="0" placeholder="•••••• (saved)"' : 'data-replace="1"'; const replaceBtn = hasValue ? `` : ''; const note = hasValue ? `Saved — leave blank to keep` : ''; return `
${replaceBtn}
${note}
`; } /** * Local "Replace" wiring (copied from adminPanel.js style, but scoped) */ function wireReplaceButtons(scope = document) { scope.querySelectorAll('[data-replace-for]').forEach(btn => { if (btn.__wired) return; btn.__wired = true; btn.addEventListener('click', () => { const id = btn.getAttribute('data-replace-for'); const inp = scope.querySelector('#' + id); if (!inp) return; inp.disabled = false; inp.dataset.replace = '1'; inp.placeholder = ''; inp.value = ''; btn.textContent = 'Keep saved value'; btn.removeAttribute('data-replace-for'); btn.addEventListener('click', () => { /* no-op after first toggle */ }, { once: true }); }, { once: true }); }); } /** * Trusted origin helper (mirror of your inline logic) */ function getTrustedDocsOrigin(raw) { try { const u = new URL(String(raw || '').trim()); if (!/^https?:$/.test(u.protocol)) return null; // only http/https if (u.username || u.password) return null; // no creds in URL return u.origin; } catch { return null; } } function buildOnlyOfficeApiUrl(origin) { const u = new URL('/web-apps/apps/api/documents/api.js', origin); u.searchParams.set('probe', String(Date.now())); return u.toString(); } /** * Lightweight JSON helper for this module */ async function safeJsonLocal(res) { const txt = await res.text(); let body = null; try { body = txt ? JSON.parse(txt) : null; } catch { /* ignore */ } if (!res.ok) { const msg = (body && (body.error || body.message)) || (txt && txt.trim()) || `HTTP ${res.status}`; const err = new Error(msg); err.status = res.status; throw err; } return body ?? {}; } /** * Script probe for api.js (mirrors old ooProbeScript) */ async function ooProbeScript(docsOrigin) { return new Promise(resolve => { const base = getTrustedDocsOrigin(docsOrigin); if (!base) { resolve({ ok: false }); return; } const src = buildOnlyOfficeApiUrl(base); const s = document.createElement('script'); s.id = 'ooProbeScript'; s.async = true; s.src = src; const nonce = document.querySelector('meta[name="csp-nonce"]')?.content; if (nonce) s.setAttribute('nonce', nonce); const cleanup = () => { try { s.remove(); } catch { /* ignore */ } }; s.onload = () => { cleanup(); resolve({ ok: true }); }; s.onerror = () => { cleanup(); resolve({ ok: false }); }; // origin is validated, path is fixed => safe document.head.appendChild(s); }); } /** * Iframe probe for DS (mirrors old ooProbeFrame) */ async function ooProbeFrame(docsOrigin, timeoutMs = 4000) { return new Promise(resolve => { const base = getTrustedDocsOrigin(docsOrigin); if (!base) { resolve({ ok: false }); return; } const f = document.createElement('iframe'); f.id = 'ooProbeFrame'; f.src = base; f.style.display = 'none'; const cleanup = () => { try { f.remove(); } catch { /* ignore */ } }; const t = setTimeout(() => { cleanup(); resolve({ ok: false, timeout: true }); }, timeoutMs); f.onload = () => { clearTimeout(t); cleanup(); resolve({ ok: true }); }; f.onerror = () => { clearTimeout(t); cleanup(); resolve({ ok: false }); }; // src constrained to validated http/https origin document.body.appendChild(f); }); } /** * Copy helpers (same behavior you had before) */ async function copyToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text); return true; } catch { // fall through } } 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'); 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); } /** * Builds the ONLYOFFICE test card and wires Run tests button */ function attachOnlyOfficeTests(container) { const testBox = document.createElement('div'); testBox.className = 'card'; testBox.style.marginTop = '12px'; testBox.innerHTML = `
Test ONLYOFFICE connection
These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding.
`; container.appendChild(testBox); const spinner = testBox.querySelector('#ooTestSpinner'); const out = testBox.querySelector('#ooTestResults'); function ooRow(label, status, detail = '') { const li = document.createElement('li'); li.style.margin = '6px 0'; const icon = status === 'ok' ? '✅' : status === 'warn' ? '⚠️' : '❌'; li.innerHTML = `${icon}` + ` ${label}` + (detail ? ` — ${detail}` : ''); return li; } function ooClear() { while (out.firstChild) out.removeChild(out.firstChild); } async function runOnlyOfficeTests() { const docsOrigin = (document.getElementById('ooDocsOrigin')?.value || '').trim(); spinner.style.display = 'inline'; ooClear(); // 1) FileRise status let statusOk = false; try { const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' }); const statusJson = await r.json().catch(() => ({})); if (r.ok) { if (statusJson.enabled) { out.appendChild(ooRow('FileRise status', 'ok', 'Enabled and ready')); statusOk = true; } else { 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 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')); } // Basic 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) api.js 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) iframe const fRes = await ooProbeFrame(docsOrigin); out.appendChild( ooRow( 'Embed DS iframe', fRes.ok ? 'ok' : 'fail', fRes.ok ? 'Allowed' : 'Blocked (check CSP frame-src)' ) ); if (!statusOk || !sRes.ok || !fRes.ok) { const tip = document.createElement('li'); tip.style.marginTop = '8px'; tip.innerHTML = '💡 Tip: Use the CSP helper below to include your Document Server in ' + 'script-src, connect-src, and frame-src.'; out.appendChild(tip); } spinner.style.display = 'none'; } testBox.querySelector('#ooTestBtn')?.addEventListener('click', runOnlyOfficeTests); } /** * CSP helper card (Apache + Nginx snippets) */ function attachOnlyOfficeCspHelper(container) { const cspHelp = document.createElement('div'); cspHelp.className = 'alert alert-info'; cspHelp.style.marginTop = '12px'; cspHelp.innerHTML = `
Content-Security-Policy help
Add/replace this line in public/.htaccess (Apache). It allows loading ONLYOFFICE's api.js, embedding the editor iframe, and letting the script make XHR to your Document Server.

    
If you terminate SSL or set CSP at a reverse proxy (e.g. Nginx), update it there instead. Also note: if your site is https://, your ONLYOFFICE server must be https:// too, otherwise the browser will block it as mixed content.
Nginx equivalent

    
`; container.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 raw = (ooDocsInput?.value || '').trim(); const base = getTrustedDocsOrigin(raw) || raw; cspPre.textContent = buildCspApache(base); cspPreNgx.textContent = buildCspNginx(base); } ooDocsInput?.addEventListener('input', refreshCsp); refreshCsp(); document.getElementById('copyOoCsp')?.addEventListener('click', async () => { const txt = (cspPre.textContent || '').trim(); const ok = await copyToClipboard(txt); if (ok) { showToast('CSP line copied.'); } else { try { selectElementContents(cspPre); } catch { /* ignore */ } 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 */ } }); } /** * Public: build + wire ONLYOFFICE admin section */ export function initOnlyOfficeUI({ config }) { const sec = document.getElementById('onlyofficeContent'); if (!sec) return; const onlyCfg = config.onlyoffice || {}; const hasOOSecret = !!onlyCfg.hasJwtSecret; window.__HAS_OO_SECRET = hasOOSecret; // Base content sec.innerHTML = `
Must be reachable by your browser (for api.js) and by FileRise (for callbacks). Avoid “localhost”.
${renderMaskedInput({ id: 'ooJwtSecret', label: 'JWT Secret', hasValue: hasOOSecret, isSecret: true })} `; wireReplaceButtons(sec); // Tests + CSP helper attachOnlyOfficeTests(sec); attachOnlyOfficeCspHelper(sec); // Initial values const enabled = !!onlyCfg.enabled; const docsOrigin = onlyCfg.docsOrigin || ''; const enabledEl = document.getElementById('ooEnabled'); const originEl = document.getElementById('ooDocsOrigin'); if (enabledEl) enabledEl.checked = enabled; if (originEl) originEl.value = docsOrigin; // Locking (managed in config.php) const locked = !!onlyCfg.lockedByPhp; window.__OO_LOCKED = locked; if (locked) { 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); } } /** * Public: inject ONLYOFFICE settings into payload (used in handleSave) */ export function collectOnlyOfficeSettingsForSave(payload) { const ooEnabledEl = document.getElementById('ooEnabled'); const ooDocsOriginEl = document.getElementById('ooDocsOrigin'); const ooSecretEl = document.getElementById('ooJwtSecret'); const onlyoffice = { enabled: !!(ooEnabledEl && ooEnabledEl.checked), docsOrigin: (ooDocsOriginEl && ooDocsOriginEl.value.trim()) || '' }; if (!window.__OO_LOCKED && ooSecretEl) { const val = ooSecretEl.value.trim(); const hasSaved = !!window.__HAS_OO_SECRET; const shouldReplace = ooSecretEl.dataset.replace === '1' || !hasSaved; if (shouldReplace && val !== '') { onlyoffice.jwtSecret = val; } } payload.onlyoffice = onlyoffice; return payload; }