Raiju/admin/dashboard/scripts/system.php
2026-10-25 06:05:42 +03:00

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));
}