406 lines
25 KiB
PHP
406 lines
25 KiB
PHP
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
|
|
// Security check state is shared by the startup check, the warning banner and
|
|
// the system tab. var: switchTab('system') can run before this fragment executes.
|
|
var securityCheckRequest = null;
|
|
var securityStatus = null;
|
|
|
|
function runSecurityCheck(force) {
|
|
if (securityCheckRequest && !force) return securityCheckRequest;
|
|
const form = new FormData();
|
|
form.set('action', 'security-check');
|
|
form.set('csrf_token', CSRF_TOKEN);
|
|
form.set('scheme', window.location.protocol === 'https:' ? 'https' : 'http');
|
|
if (force) form.set('force', '1');
|
|
const request = fetch('api.php', { method: 'POST', body: form })
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (!result.success) throw new Error(result.message || t('system.load_failed'));
|
|
return result.data;
|
|
})
|
|
.finally(() => { if (securityCheckRequest === request) securityCheckRequest = null; })
|
|
.then(data => { applySecurityStatus(data); return data; });
|
|
securityCheckRequest = request;
|
|
if (securityStatus) applySecurityStatus(securityStatus);
|
|
return request;
|
|
}
|
|
|
|
function applySecurityStatus(security) {
|
|
if (!security || !Array.isArray(security.items)) return;
|
|
securityStatus = security;
|
|
const folders = security.items.find(item => item.id === 'folders') || {};
|
|
const exposed = folders.state === 'critical' ? (folders.exposed || []) : [];
|
|
const banner = document.getElementById('securityExposureWarning');
|
|
if (banner) {
|
|
const data = exposed.some(path => path === 'content/' || path === 'backups/');
|
|
const text = t('security_check.banner_text', { paths: exposed.join(', ') }) + (data ? ' ' + t('security_check.banner_data') : '');
|
|
const body = document.getElementById('securityExposureText');
|
|
// role="alert" announces changes; do not repeat an unchanged warning.
|
|
if (body.textContent !== text) body.textContent = text;
|
|
banner.hidden = !exposed.length;
|
|
}
|
|
updateSystemBadge();
|
|
const panel = document.getElementById('systemSecurityPanel');
|
|
if (panel) panel.replaceWith(renderSecurityPanel(security));
|
|
}
|
|
|
|
function securityItemText(item) {
|
|
let key = item.id + '_' + item.state;
|
|
if (item.state === 'skipped') key = (item.id === 'folders' && item.canRun ? 'folders_' : '') + 'skipped_' + (item.reason || 'local');
|
|
else if (item.state === 'pending') key = securityCheckRequest || item.running ? 'running' : 'pending';
|
|
else if (item.state === 'unknown') key = item.reason ? 'unknown_reason' : 'unknown';
|
|
else if (item.id === 'http') key = 'http_' + item.mode;
|
|
else if (item.id === 'php' && item.state === 'ok' && !item.until) key = 'php_current';
|
|
const reason = item.reason ? t('security_check.reason_' + item.reason, { url: item.url || '', detail: item.detail || '' }) : '';
|
|
return t('security_check.' + key, {
|
|
paths: (item.exposed || []).join(', '), ip: item.ip || '', version: item.version || '', reason: reason,
|
|
until: item.until ? rjFormatDate(item.until, 'date') : ''
|
|
});
|
|
}
|
|
|
|
function renderSecurityDetails(item) {
|
|
const blocks = [];
|
|
const paragraph = (key, params) => {
|
|
const text = document.createElement('p'); text.textContent = t(key, params); blocks.push(text);
|
|
};
|
|
// Only media trash folders public: a warning with its own explanation.
|
|
if (['critical', 'warning', 'note'].includes(item.state)) paragraph('security_check.' + item.id + '_fix' + (item.id === 'folders' && item.state === 'warning' ? '_trash' : ''));
|
|
if (item.id === 'folders' && item.state === 'unknown' && item.url) paragraph('security_check.folders_manual', { url: item.url + '/content/settings.json' });
|
|
if (item.id === 'folders' && item.probes && item.probes.length) {
|
|
const list = document.createElement('ul'); list.className = 'system-check__probes';
|
|
for (const probe of item.probes) {
|
|
const entry = document.createElement('li'); const path = document.createElement('code');
|
|
path.textContent = probe.path;
|
|
entry.append(path, ' ' + t('security_check.probe_' + (probe.status ? probe.state : 'skipped'), { status: probe.status }));
|
|
list.append(entry);
|
|
}
|
|
blocks.push(list);
|
|
}
|
|
if (item.rules) {
|
|
const rules = document.createElement('div'); rules.className = 'system-check__rules';
|
|
const pre = document.createElement('pre'); const code = document.createElement('code');
|
|
code.textContent = item.rules; pre.append(code);
|
|
const copy = document.createElement('button'); copy.type = 'button'; copy.className = 'btn btn-secondary btn-sm';
|
|
copy.textContent = t('security_check.copy');
|
|
copy.onclick = () => {
|
|
const done = () => { copy.textContent = t('security_check.copied'); setTimeout(() => { copy.textContent = t('security_check.copy'); }, 1500); };
|
|
// Without the clipboard API (plain http://) or permission, select the rules for manual copying.
|
|
const select = () => {
|
|
const range = document.createRange(); range.selectNodeContents(code);
|
|
const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range);
|
|
if (document.execCommand('copy')) done();
|
|
};
|
|
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(item.rules).then(done, select);
|
|
else select();
|
|
};
|
|
rules.append(pre, copy); blocks.push(rules);
|
|
paragraph('security_check.folders_hestia', { domain: window.location.hostname });
|
|
paragraph('security_check.folders_plesk');
|
|
paragraph('security_check.folders_htaccess');
|
|
}
|
|
if (!blocks.length) return null;
|
|
const details = document.createElement('details'); details.className = 'system-check__details';
|
|
details.open = item.state === 'critical';
|
|
const summary = document.createElement('summary');
|
|
summary.textContent = t(['critical', 'warning', 'note'].includes(item.state) ? 'security_check.how_to_fix' : 'system.details');
|
|
details.append(summary, ...blocks);
|
|
return details;
|
|
}
|
|
|
|
function renderSecurityPanel(security) {
|
|
const section = document.createElement('section');
|
|
section.className = 'dashboard-panel system-security'; section.id = 'systemSecurityPanel';
|
|
const header = document.createElement('div'); header.className = 'dashboard-panel-header';
|
|
const heading = document.createElement('h3'); heading.textContent = t('security_check.title');
|
|
header.append(heading);
|
|
const folders = security.items.find(item => item.id === 'folders') || {};
|
|
const actions = document.createElement('div'); actions.className = 'system-security__actions';
|
|
if (folders.checkedAt) {
|
|
const checked = document.createElement('span');
|
|
checked.textContent = t('security_check.checked_at', { date: rjFormatDate(folders.checkedAt, 'datetime') });
|
|
actions.append(checked);
|
|
}
|
|
if (folders.canRun) {
|
|
const busy = !!securityCheckRequest || !!folders.running;
|
|
const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-secondary btn-sm';
|
|
button.textContent = t(busy ? 'security_check.running' : 'security_check.run'); button.disabled = busy;
|
|
button.onclick = () => runSecurityCheck(true).catch(error => {
|
|
showToast(error.message, 'error');
|
|
if (securityStatus) applySecurityStatus(securityStatus);
|
|
});
|
|
actions.append(button);
|
|
}
|
|
header.append(actions);
|
|
section.append(header);
|
|
for (const item of security.items) {
|
|
const check = document.createElement('div'); check.className = 'system-check system-check--' + item.state;
|
|
const row = document.createElement('div'); row.className = 'system-status-row';
|
|
const name = document.createElement('strong'); name.textContent = t('security_check.' + item.id);
|
|
const text = document.createElement('span'); text.className = 'system-check__state'; text.textContent = securityItemText(item);
|
|
row.append(name, text); check.append(row);
|
|
const details = renderSecurityDetails(item);
|
|
if (details) check.append(details);
|
|
section.append(check);
|
|
}
|
|
return section;
|
|
}
|
|
|
|
// Search engine check (staging block forgotten at go-live). Same lifecycle as the security check.
|
|
var seoCheckRequest = null;
|
|
var seoStatus = null;
|
|
|
|
function updateSystemBadge() {
|
|
const badge = document.getElementById('systemBadge');
|
|
if (!badge) return;
|
|
const count = [securityStatus, seoStatus].reduce((sum, status) => sum + (status
|
|
? status.items.filter(item => item.state === 'critical' || item.state === 'warning').length : 0), 0);
|
|
badge.textContent = String(count);
|
|
badge.title = t('system.badge', { count: count });
|
|
badge.classList.toggle('mail-badge--hidden', count === 0);
|
|
}
|
|
|
|
function runSeoCheck(force) {
|
|
if (seoCheckRequest && !force) return seoCheckRequest;
|
|
const form = new FormData();
|
|
form.set('action', 'seo-check');
|
|
form.set('csrf_token', CSRF_TOKEN);
|
|
form.set('scheme', window.location.protocol === 'https:' ? 'https' : 'http');
|
|
if (force) form.set('force', '1');
|
|
const request = fetch('api.php', { method: 'POST', body: form })
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (!result.success) throw new Error(result.message || t('system.load_failed'));
|
|
return result.data;
|
|
})
|
|
.finally(() => { if (seoCheckRequest === request) seoCheckRequest = null; })
|
|
.then(data => { applySeoStatus(data); return data; });
|
|
seoCheckRequest = request;
|
|
if (seoStatus) applySeoStatus(seoStatus);
|
|
return request;
|
|
}
|
|
|
|
// One click on the banner or in the panel: only this setting changes, nothing else.
|
|
async function allowSeoIndexing(button) {
|
|
button.disabled = true;
|
|
const form = new FormData();
|
|
form.set('action', 'save-settings');
|
|
form.set('csrf_token', CSRF_TOKEN);
|
|
form.set('settings', JSON.stringify({ seo: { noindexSite: false } }));
|
|
try {
|
|
// Saving settings needs the current revision; loading them records it (js/revision-client.js).
|
|
await fetch('api.php?action=load-settings');
|
|
const result = await (await fetch('api.php', { method: 'POST', body: form })).json();
|
|
if (!result.success) throw new Error(result.message);
|
|
currentSettings = result.data;
|
|
showToast(t('seo_check.allowed'), 'success');
|
|
await runSeoCheck(true);
|
|
} catch (error) {
|
|
showToast(error.message, 'error');
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
function applySeoStatus(seo) {
|
|
if (!seo || !Array.isArray(seo.items)) return;
|
|
seoStatus = seo;
|
|
const banner = document.getElementById('seoBlockedWarning');
|
|
if (banner) {
|
|
const where = [];
|
|
for (const item of seo.items) {
|
|
if (item.state !== 'warning') continue;
|
|
if (item.reason === 'blocked') where.push('robots.txt');
|
|
if (item.reason === 'noindex') for (const place of item.where || []) where.push(t('seo_check.where_' + place));
|
|
}
|
|
const text = t(seo.noindexSite ? 'seo_check.banner_text_setting' : 'seo_check.banner_text_other', { where: where.join(', ') });
|
|
const body = document.getElementById('seoBlockedText');
|
|
if (body.textContent !== text) body.textContent = text;
|
|
document.getElementById('seoBlockedAllow').hidden = !seo.noindexSite;
|
|
banner.hidden = !seo.blocked;
|
|
}
|
|
updateSystemBadge();
|
|
const panel = document.getElementById('systemSeoPanel');
|
|
if (panel) panel.replaceWith(renderSeoPanel(seo));
|
|
}
|
|
|
|
function seoItemText(item) {
|
|
let key = item.id + '_' + (item.reason || item.state);
|
|
if (item.state === 'skipped') key = 'skipped_' + (item.reason || 'local');
|
|
else if (item.state === 'pending') key = seoCheckRequest || item.running ? 'running' : 'pending';
|
|
else if (item.state === 'unknown') key = 'unknown_' + (item.reason || 'unreachable');
|
|
return t('seo_check.' + key, {
|
|
url: item.url || '', detail: item.detail || '', count: item.count || 0, total: item.total || 0,
|
|
where: (item.where || []).map(place => t('seo_check.where_' + place)).join(', ')
|
|
});
|
|
}
|
|
|
|
function renderSeoDetails(item) {
|
|
if (!['warning', 'note', 'unknown'].includes(item.state)) return null;
|
|
const blocks = [];
|
|
const paragraph = (text) => { const p = document.createElement('p'); p.textContent = text; blocks.push(p); };
|
|
if (item.url) {
|
|
const p = document.createElement('p'); const code = document.createElement('code'); code.textContent = item.url;
|
|
p.append(t('seo_check.url') + ' ', code); blocks.push(p);
|
|
}
|
|
if (item.state !== 'unknown') paragraph(t('seo_check.expected') + ' ' + t('seo_check.' + item.id + '_expected'));
|
|
if (item.state === 'unknown') paragraph(t('seo_check.unknown_fix', { url: item.url || '' }));
|
|
else if (item.fix) paragraph(t('seo_check.' + item.id + '_fix_' + item.fix, { url: item.url || '', detail: item.detail || '' }));
|
|
if (item.source === 'setting') {
|
|
const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-secondary btn-sm';
|
|
button.textContent = t('seo_check.allow'); button.onclick = () => allowSeoIndexing(button);
|
|
blocks.push(button);
|
|
}
|
|
const details = document.createElement('details'); details.className = 'system-check__details';
|
|
details.open = item.state === 'warning';
|
|
const summary = document.createElement('summary'); summary.textContent = t('security_check.how_to_fix');
|
|
details.append(summary, ...blocks);
|
|
return details;
|
|
}
|
|
|
|
function renderSeoPanel(seo) {
|
|
const section = document.createElement('section');
|
|
section.className = 'dashboard-panel system-security'; section.id = 'systemSeoPanel';
|
|
const header = document.createElement('div'); header.className = 'dashboard-panel-header';
|
|
const heading = document.createElement('h3'); heading.textContent = t('seo_check.title');
|
|
header.append(heading);
|
|
const actions = document.createElement('div'); actions.className = 'system-security__actions';
|
|
if (seo.checkedAt) {
|
|
const checked = document.createElement('span');
|
|
checked.textContent = t('security_check.checked_at', { date: rjFormatDate(seo.checkedAt, 'datetime') });
|
|
actions.append(checked);
|
|
}
|
|
if (seo.canRun) {
|
|
const busy = !!seoCheckRequest || !!seo.running;
|
|
const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-secondary btn-sm';
|
|
button.textContent = t(busy ? 'security_check.running' : 'security_check.run'); button.disabled = busy;
|
|
button.onclick = () => runSeoCheck(true).catch(error => {
|
|
showToast(error.message, 'error');
|
|
if (seoStatus) applySeoStatus(seoStatus);
|
|
});
|
|
actions.append(button);
|
|
}
|
|
header.append(actions);
|
|
section.append(header);
|
|
for (const item of seo.items) {
|
|
const check = document.createElement('div'); check.className = 'system-check system-check--' + item.state;
|
|
const row = document.createElement('div'); row.className = 'system-status-row';
|
|
const name = document.createElement('strong'); name.textContent = t('seo_check.' + item.id);
|
|
const text = document.createElement('span'); text.className = 'system-check__state'; text.textContent = seoItemText(item);
|
|
row.append(name, text); check.append(row);
|
|
const details = renderSeoDetails(item);
|
|
if (details) check.append(details);
|
|
section.append(check);
|
|
}
|
|
return section;
|
|
}
|
|
|
|
async function loadSystemStatus() {
|
|
const target = document.getElementById('systemStatusBody');
|
|
if (!target) return;
|
|
target.textContent = t('system.loading');
|
|
const row = (label, value) => {
|
|
const item = document.createElement('div'); item.className = 'system-status-row';
|
|
const name = document.createElement('strong'); name.textContent = label;
|
|
const text = document.createElement('span'); text.textContent = value;
|
|
item.append(name, text); return item;
|
|
};
|
|
const group = title => {
|
|
const section = document.createElement('section'); section.className = 'dashboard-panel';
|
|
const heading = document.createElement('h3'); heading.textContent = title;
|
|
section.append(heading); target.append(section); return section;
|
|
};
|
|
try {
|
|
const scheme = window.location.protocol === 'https:' ? 'https' : 'http';
|
|
const response = await fetch('api.php?action=system-status&scheme=' + scheme);
|
|
const result = await response.json();
|
|
if (!result.success) throw new Error(result.message);
|
|
target.replaceChildren();
|
|
const data = result.data;
|
|
if (data.security) {
|
|
const placeholder = document.createElement('section'); placeholder.id = 'systemSecurityPanel';
|
|
target.append(placeholder);
|
|
applySecurityStatus(data.security);
|
|
// Refresh an outdated or missing probe in the background; local hosts only on request.
|
|
const folders = data.security.items.find(item => item.id === 'folders') || {};
|
|
if (folders.canRun && folders.stale && !data.security.local) {
|
|
runSecurityCheck(false).catch(() => { if (securityStatus) applySecurityStatus(securityStatus); });
|
|
}
|
|
}
|
|
if (data.seo) {
|
|
const placeholder = document.createElement('section'); placeholder.id = 'systemSeoPanel';
|
|
target.append(placeholder);
|
|
applySeoStatus(data.seo);
|
|
if (data.seo.canRun && data.seo.stale && !data.seo.local) {
|
|
runSeoCheck(false).catch(() => { if (seoStatus) applySeoStatus(seoStatus); });
|
|
}
|
|
}
|
|
const rjMegabytes = bytes => { const mb = bytes / 1048576; return mb >= 1 && Math.abs(mb - Math.round(mb)) < 0.05 ? String(Math.round(mb)) : String(Math.round(mb * 10) / 10); };
|
|
const runtime = group(t('system.runtime'));
|
|
runtime.append(row('PHP', data.php));
|
|
for (const ext of data.extensions) runtime.append(row(ext.name, t(ext.available ? 'system.available' : 'system.missing')));
|
|
if (data.agent) {
|
|
const agent = group(t('system.agent'));
|
|
agent.append(row(t('system.agent_state'), t(data.agent.enabled ? 'system.agent_on' : 'system.agent_off')));
|
|
agent.append(row(t('system.agent_tools'), t(data.agent.toolCalling ? 'system.available' : 'system.missing')));
|
|
agent.append(row(t('system.agent_gd'), t(data.agent.gd ? 'system.available' : 'system.missing')));
|
|
agent.append(row(t('system.agent_webp'), t(data.agent.webp ? 'system.available' : 'system.missing')));
|
|
agent.append(row(t('system.agent_research'), t(data.agent.research ? 'system.configured' : 'system.not_configured')));
|
|
agent.append(row(t('system.agent_stock'), t(data.agent.stock ? 'system.configured' : 'system.not_configured')));
|
|
agent.append(row('content/ai-agent/', t(data.agent.storageWritable ? 'system.writable' : 'system.not_writable')));
|
|
const note = document.createElement('p'); note.textContent = t('system.agent_storage_note'); agent.append(note);
|
|
}
|
|
if (data.uploads) {
|
|
const up = data.uploads;
|
|
const uploads = group(t('system.uploads'));
|
|
uploads.append(row(t('system.upload_server_limit'), up.serverMaxBytes ? rjMegabytes(up.serverMaxBytes) + ' MB (upload_max_filesize ' + up.uploadMaxFilesize + ', post_max_size ' + up.postMaxSize + ')' : t('system.upload_unlimited')));
|
|
uploads.append(row(t('system.upload_image_limit'), rjMegabytes(up.imageMaxBytes) + ' MB' + (up.serverMaxBytes && up.serverMaxBytes < up.imageMaxBytes ? ' · ' + t('system.upload_server_bound', { max: rjMegabytes(up.serverMaxBytes) }) : '')));
|
|
uploads.append(row(t('system.upload_web_version'), t(!up.webVersion ? 'system.upload_off' : (up.webp ? 'system.upload_on' : 'system.upload_no_webp'))));
|
|
uploads.append(row(t('system.upload_memory'), up.memoryLimit));
|
|
const uploadNote = document.createElement('p'); uploadNote.textContent = t('system.upload_note'); uploads.append(uploadNote);
|
|
}
|
|
const storage = group(t('system.storage'));
|
|
for (const path of data.paths) storage.append(row(path.name, t(path.writable ? 'system.writable' : 'system.not_writable')));
|
|
storage.append(row(t('system.last_backup'), data.lastBackup ? rjFormatDate(data.lastBackup, 'datetime') : t('system.no_backup')));
|
|
const jobs = group(t('system.failed_jobs'));
|
|
if (!data.failedJobs.length) jobs.append(row(t('system.state'), t('system.no_failed_jobs')));
|
|
for (const job of data.failedJobs) {
|
|
const details = document.createElement('details'); const summary = document.createElement('summary');
|
|
summary.textContent = job.id; const text = document.createElement('p'); text.textContent = job.error;
|
|
details.append(summary, text); jobs.append(details);
|
|
}
|
|
const requests = group(t('system.requests'));
|
|
const hint = document.createElement('p'); hint.textContent = t('system.resolve_hint'); requests.append(hint);
|
|
if (!data.requests.length) requests.append(row(t('system.state'), t('system.no_requests')));
|
|
for (const entry of data.requests) {
|
|
const article = document.createElement('article'); article.className = 'system-request';
|
|
article.append(row(entry.provider, formatAiCents(entry.reservedCents)));
|
|
const details = document.createElement('details'); const summary = document.createElement('summary'); summary.textContent = t('system.details');
|
|
details.append(summary, row(t('system.request_id'), entry.id), row(t('system.provider_tasks'), entry.tasks.join(', ') || '—'));
|
|
article.append(details);
|
|
const actions = document.createElement('div'); actions.className = 'system-request-actions';
|
|
for (const resolution of ['charged', 'released']) {
|
|
const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-secondary btn-sm';
|
|
button.textContent = t('system.resolve_' + resolution); button.disabled = !entry.resolvable;
|
|
button.onclick = async () => {
|
|
button.disabled = true;
|
|
const form = new FormData(); form.set('action', 'ai-resolve-request'); form.set('csrf_token', CSRF_TOKEN);
|
|
form.set('request_id', entry.id); form.set('resolution', resolution);
|
|
try {
|
|
const result = await (await fetch('api.php', { method: 'POST', body: form })).json();
|
|
if (!result.success) throw new Error(result.message);
|
|
await loadSystemStatus();
|
|
} catch (error) { showToast(error.message, 'error'); button.disabled = false; }
|
|
};
|
|
actions.append(button);
|
|
}
|
|
article.append(actions); requests.append(article);
|
|
}
|
|
} catch (_) { target.textContent = t('system.load_failed'); }
|
|
}
|
|
|
|
// Administrators: check security in the background once the dashboard has loaded.
|
|
// The server reuses a recent result and never delays the dashboard itself.
|
|
if (VALID_DASHBOARD_TABS.indexOf('system') !== -1) {
|
|
window.addEventListener('load', () => setTimeout(() => { runSecurityCheck(false).catch(() => {}); }, 1000));
|
|
window.addEventListener('load', () => setTimeout(() => { runSeoCheck(false).catch(() => {}); }, 1500));
|
|
}
|