511 lines
16 KiB
JavaScript
511 lines
16 KiB
JavaScript
// 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
|
|
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
|
|
: '';
|
|
const note = hasValue
|
|
? `<small class="text-success" style="margin-left:4px;">Saved — leave blank to keep</small>`
|
|
: '';
|
|
|
|
return `
|
|
<div class="form-group">
|
|
<label for="${id}">${label}:</label>
|
|
<div style="display:flex; gap:8px; align-items:center;">
|
|
<input type="${type}" id="${id}" class="form-control" ${disabled} />
|
|
${replaceBtn}
|
|
</div>
|
|
${note}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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 = `
|
|
<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>
|
|
`;
|
|
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 =
|
|
`<span style="min-width:1.2em;display:inline-block">${icon}</span>` +
|
|
` <strong>${label}</strong>` +
|
|
(detail ? ` — <span>${detail}</span>` : '');
|
|
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 =
|
|
'💡 <em>Tip:</em> Use the CSP helper below 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';
|
|
}
|
|
|
|
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 = `
|
|
<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>
|
|
`;
|
|
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 = `
|
|
<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. https://docs.example.com" />
|
|
<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(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;
|
|
} |