Files
xamxam/app/templates/admin/parametres.php
Pontoporeia ae66c2baad Integrate Monolog: replace four logging systems with single PSR-3 factory
- Add monolog/monolog dependency (^3.10)  
- Create app/Logger.php central factory with channels: app, admin, error, audit
- Each channel gets RotatingFileHandler (30-day retention) with pass-through LineFormatter
  preserving existing JSON format contracts
- Rewrite AppLogger as thin facade delegating to Logger::get('app')
- Rewrite ErrorHandler::log() to delegate to Logger::get('error')
- Rewrite AdminLogger file output to delegate to Logger::get('admin'), keep DB writes
- Add Monolog file shadow to Audit via Logger::get('audit') (Option A per monolog-plan)
- Log level controlled by LOG_LEVEL env var (defaults: DEBUG in cli-server, WARNING otherwise)
- Graceful NullHandler fallback when log directory is not writable
- Update SystemController LOG_FILES: remove php_error, add app/admin/error/audit
- JSON app logs parsed to readable one-liners in the log viewer
- Remove nginx config tab (parametres + fragment + template + css)
- Friendly empty-state message when app log files don't exist yet (notYet)
- PHP tail fallback when exec() unavailable
- All 228 PHPUnit tests pass, no call sites changed
2026-05-20 12:28:31 +02:00

518 lines
28 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<main id="main-content" class="admin-main--toc">
<?php include APP_ROOT . '/templates/admin/partials/admin-toc.php'; ?>
<article>
<h1>Paramètres</h1>
<!-- ══════════════════════════════════════════════════════════════
MAINTENANCE
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-maintenance-title">
<h2 id="settings-maintenance-title">Maintenance</h2>
<table class="param-access-table">
<caption>Visibilité des pages selon le mode</caption>
<thead>
<tr>
<th scope="col">Page</th>
<th scope="col">Normal</th>
<th scope="col">Maintenance</th>
</tr>
</thead>
<tbody>
<tr>
<td>Accueil, recherche, répertoire, TFE, à propos, licence</td>
<td class="param-access-yes">✓ Visible</td>
<td class="param-access-no">✗ 503</td>
</tr>
<tr>
<td>Formulaire étudiant (partage)</td>
<td class="param-access-yes">✓ Visible</td>
<td class="param-access-yes">✓ Visible</td>
</tr>
<tr>
<td>Administration</td>
<td class="param-access-yes">✓ Visible</td>
<td class="param-access-yes">✓ Visible</td>
</tr>
</tbody>
</table>
<div class="param-maintenance-row">
<?php if ($maintenanceOn): ?>
<p>
<strong>⚠ Mode maintenance activé</strong> — le site public est inaccessible.
</p>
<p class="param-note">
Le formulaire étudiant (partage) et l'administration restent accessibles.
</p>
<form method="post" action="actions/maintenance.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="disable_maintenance">
<input type="hidden" name="redirect" value="/admin/parametres.php">
<button type="submit" class="btn btn--secondary">Désactiver la maintenance</button>
</form>
<?php else: ?>
<p>Site public : <strong>en ligne</strong></p>
<form method="post" action="actions/maintenance.php" id="enable-maintenance-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="enable_maintenance">
<input type="hidden" name="redirect" value="/admin/parametres.php">
<button type="button" class="btn btn--warning"
onclick="document.getElementById('enable-maintenance-dialog').showModal()">
Activer la maintenance
</button>
</form>
<?php endif; ?>
</div>
<h3 style="margin-top:var(--space-l);margin-bottom:var(--space-xs);font-size:var(--step-0)">Export de la base de données</h3>
<p style="margin-bottom:var(--space-s)">
Télécharger une copie complète de la base de données SQLite
(<code>xamxam.db</code>) pour sauvegarde manuelle.
</p>
<form method="get" action="/admin/actions/export.php" class="param-form">
<input type="hidden" name="db" value="1">
<button type="submit" class="btn btn--primary">Télécharger la base de données</button>
</form>
</section>
<!-- ══════════════════════════════════════════════════════════════
RELAY SMTP
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-smtp-title">
<h2 id="settings-smtp-title">Emails</h2>
<p>
Configuration du serveur SMTP pour l'envoi d'e-mails
(notifications, partage de TFE, etc.).
</p>
<div class="param-smtp-status">
<?php if ($smtpConfigured): ?>
<span class="param-badge-ok">✓ Configuré</span>
<span><?= htmlspecialchars($smtpSettings['host']) ?>:<?= (int)$smtpSettings['port'] ?> (<?= htmlspecialchars($smtpSettings['encryption']) ?>)</span>
<?php else: ?>
<span class="param-badge-warn">✗ Non configuré</span>
<?php endif; ?>
</div>
<?php
// Inline helper: emit aria-invalid + error <small> when this field is the culprit
$smtpFieldErr = function(string $id) use ($smtpErrorField): string {
return $smtpErrorField === $id ? ' aria-invalid="true"' : '';
};
$smtpFieldMsg = function(string $id, string $msg) use ($smtpErrorField): string {
return $smtpErrorField === $id
? '<small class="param-field-error" id="' . $id . '-error">' . htmlspecialchars($msg) . '</small>'
: '';
};
// Human-readable hints per field (brief — the full message is in the toast)
$smtpHints = [
'smtp_host' => 'Vérifiez ladresse du serveur SMTP.',
'smtp_port' => 'Vérifiez le numéro de port.',
'smtp_encryption' => 'Vérifiez le mode de chiffrement.',
'smtp_username' => 'Vérifiez le nom dutilisateur.',
'smtp_password' => 'Mot de passe incorrect.',
];
?>
<form method="post" action="actions/settings.php" class="param-form"
<?= $smtpErrorField ? 'data-smtp-error-field="' . htmlspecialchars($smtpErrorField) . '"' : '' ?>>
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="smtp">
<fieldset class="param-grid">
<legend>Paramètres email</legend>
<div>
<label for="smtp_host">Hôte SMTP</label>
<input type="text" id="smtp_host" name="smtp_host"
value="<?= htmlspecialchars($smtpSettings['host']) ?>"
placeholder="smtp.example.com"
<?= $smtpFieldErr('smtp_host') ?>
<?= $smtpErrorField === 'smtp_host' ? 'aria-describedby="smtp_host-error"' : '' ?>>
<?= $smtpFieldMsg('smtp_host', $smtpHints['smtp_host']) ?>
</div>
<div>
<label for="smtp_port">Port</label>
<input type="number" id="smtp_port" name="smtp_port"
value="<?= (int)$smtpSettings['port'] ?>"
min="1" max="65535"
<?= $smtpFieldErr('smtp_port') ?>
<?= $smtpErrorField === 'smtp_port' ? 'aria-describedby="smtp_port-error"' : '' ?>>
<?= $smtpFieldMsg('smtp_port', $smtpHints['smtp_port']) ?>
</div>
<div>
<label for="smtp_encryption">Chiffrement</label>
<select id="smtp_encryption" name="smtp_encryption"
<?= $smtpFieldErr('smtp_encryption') ?>
<?= $smtpErrorField === 'smtp_encryption' ? 'aria-describedby="smtp_encryption-error"' : '' ?>>
<option value="tls" <?= $smtpSettings['encryption'] === 'tls' ? 'selected' : '' ?>>TLS (STARTTLS)</option>
<option value="ssl" <?= $smtpSettings['encryption'] === 'ssl' ? 'selected' : '' ?>>SSL (SMTPS)</option>
<option value="none" <?= $smtpSettings['encryption'] === 'none' ? 'selected' : '' ?>>Aucun</option>
</select>
<?= $smtpFieldMsg('smtp_encryption', $smtpHints['smtp_encryption']) ?>
</div>
<div>
<label for="smtp_username">Adresse e-mail</label>
<input type="email" id="smtp_username" name="smtp_username"
value="<?= htmlspecialchars($smtpSettings['username']) ?>"
placeholder="xamxam@erg.be"
<?= $smtpFieldErr('smtp_username') ?>
<?= $smtpErrorField === 'smtp_username' ? 'aria-describedby="smtp_username-error"' : '' ?>>
<small>Adresse utilisée pour l'authentification SMTP et comme expéditeur.</small>
<?= $smtpFieldMsg('smtp_username', $smtpHints['smtp_username']) ?>
</div>
<div>
<label for="smtp_password">Mot de passe</label>
<input type="password" id="smtp_password" name="smtp_password"
value=""
autocomplete="new-password"
placeholder="Laissez vide pour ne pas modifier"
<?= $smtpFieldErr('smtp_password') ?>
<?= $smtpErrorField === 'smtp_password' ? 'aria-describedby="smtp_password-error"' : '' ?>>
<?= $smtpFieldMsg('smtp_password', $smtpHints['smtp_password']) ?>
</div>
<div>
<label for="smtp_from_name">Nom d'expéditeur</label>
<input type="text" id="smtp_from_name" name="smtp_from_name"
value="<?= htmlspecialchars($smtpSettings['from_name']) ?>">
</div>
<div>
<label for="smtp_notify_email">Adresse de notification admin</label>
<input type="email" id="smtp_notify_email" name="smtp_notify_email"
value="<?= htmlspecialchars($smtpSettings['notify_email'] ?? '') ?>"
placeholder="admin@example.com">
<small>Reçoit les notifications (demandes d'accès, etc.). Si vide, utilise l'adresse d'expédition.</small>
</div>
</fieldset>
<button type="submit" class="btn btn--primary">Enregistrer</button>
</form>
<!-- Test d'envoi -->
<fieldset class="param-smtp-test">
<legend>Tester l'envoi d'un e-mail</legend>
<p>Envoie un e-mail de test via le relay SMTP configuré ci-dessus.</p>
<?php if (!$smtpConfigured): ?>
<p class="param-note">⚠ Configurez le relay SMTP avant de pouvoir tester l'envoi.</p>
<?php else: ?>
<form method="post" action="actions/smtp-test.php" class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<div class="param-smtp-test-row">
<div>
<label for="smtp_test_email">Adresse de destination</label>
<input type="email" id="smtp_test_email" name="test_email"
placeholder="test@example.com" required>
</div>
<button type="submit" class="btn btn--primary">Envoyer le test</button>
</div>
</form>
<?php endif; ?>
</fieldset>
</section>
<!-- ══════════════════════════════════════════════════════════════
PEERTUBE
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-peertube-title">
<h2 id="settings-peertube-title">PeerTube</h2>
<p>
Intégration avec une instance PeerTube pour l'hébergement des vidéos et fichiers audio.
Les fichiers sont uploadés via l'API PeerTube et intégrés comme lecteurs embarqués sur la page du TFE.
</p>
<div class="param-smtp-status">
<?php if ($peerTubeConfigured): ?>
<span class="param-badge-ok">✓ Configuré</span>
<span><?= htmlspecialchars($peerTubeSettings['instance_url']) ?></span>
<?php else: ?>
<span class="param-badge-warn">✗ Non configuré</span>
<?php endif; ?>
</div>
<form method="post" action="actions/settings.php" class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="peertube">
<fieldset>
<legend>Activation</legend>
<label class="param-checkbox">
<input type="checkbox" name="peertube_upload_enabled" value="1"
<?= $peerTubeEnabled ? 'checked' : '' ?>>
<span>
<strong>Activer l'upload PeerTube</strong><br>
<small>Lorsqu'activé, les fichiers vidéo et audio soumis via le formulaire sont uploadés
sur l'instance PeerTube plutôt que stockés localement sur le serveur.</small>
</span>
</label>
</fieldset>
<fieldset class="param-grid">
<legend>Paramètres Peertube</legend>
<p class="param-note">
L'authentification PeerTube utilise les mêmes identifiants que le
<strong>relay SMTP</strong> configuré ci-dessus.
</p>
<div>
<label for="peertube_instance_url">URL de l'instance PeerTube</label>
<input type="url" id="peertube_instance_url" name="peertube_instance_url"
value="<?= htmlspecialchars($peerTubeSettings['instance_url']) ?>"
placeholder="https://peertube.example.com">
<small>Sans slash final. Ex : <code>https://peertube.erg.be</code></small>
</div>
<div>
<label for="peertube_channel_name">Nom de la chaîne</label>
<input type="text" id="peertube_channel_name" name="peertube_channel_name"
value="<?= htmlspecialchars($peerTubeSettings['channel_name'] ?? '') ?>"
placeholder="xamxam_erg.be_channel@videos.erg.be">
<small>Identifiant complet de la chaîne (handle), ex : <code>nom_de_chaîne@hôte</code>.</small>
</div>
<div>
<label for="peertube_privacy">Visibilité des vidéos</label>
<select id="peertube_privacy" name="peertube_privacy">
<option value="1" <?= (int)$peerTubeSettings['privacy'] === 1 ? 'selected' : '' ?>>Publique</option>
<option value="2" <?= (int)$peerTubeSettings['privacy'] === 2 ? 'selected' : '' ?>>Non listée</option>
<option value="3" <?= (int)$peerTubeSettings['privacy'] === 3 ? 'selected' : '' ?>>Privée</option>
</select>
</div>
</fieldset>
<button type="submit" class="btn btn--primary">Enregistrer</button>
<button type="button" class="btn btn--secondary" id="peertube-test-btn"
hx-post="/admin/actions/peertube-test.php"
hx-target="#peertube-test-result"
hx-include="closest form"
hx-indicator="#peertube-test-spinner">
Tester la connexion
</button>
<span id="peertube-test-spinner" class="htmx-indicator" style="display:none;margin-left:var(--space-xs);">⏳</span>
<div id="peertube-test-result" style="margin-top:var(--space-xs);"></div>
</form>
</section>
<!-- ══════════════════════════════════════════════════════════════
COMPTE ADMINISTRATEUR
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-account-title">
<h2 id="settings-account-title">Compte administrateur</h2>
<dl class="param-account-status">
<div>
<dt>Authentification PHP</dt>
<dd><?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Active'; $badgeWarnLabel = 'Non configurée'; include APP_ROOT . '/templates/partials/status-badge.php'; ?></dd>
</div>
<div>
<dt>Stockage du hash</dt>
<dd>
<code>site_settings (DB)</code>
<?php $badgeType = 'ok'; $badgeValue = $hasPassword; $badgeOkLabel = 'Présent'; $badgeWarnLabel = 'Absent'; include APP_ROOT . '/templates/partials/status-badge.php'; ?>
</dd>
</div>
</dl>
<?php if (!$hasPassword): ?>
<p class="param-note">
Aucun mot de passe PHP configuré. Le formulaire ci-dessous stockera
un hash bcrypt dans la base de données.
</p>
<?php endif; ?>
<form method="post" action="/admin/actions/account.php" class="param-form" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="redirect" value="/admin/parametres.php">
<?php if ($hasPassword): ?>
<div>
<label for="current_password">Mot de passe actuel</label>
<input type="password" id="current_password"
name="current_password" required autocomplete="current-password">
</div>
<?php endif; ?>
<div>
<label for="new_password">Nouveau mot de passe</label>
<input type="password" id="new_password"
name="new_password" required autocomplete="new-password"
minlength="12">
<small>Minimum 12 caractères.</small>
</div>
<div>
<label for="confirm_password">Confirmer le mot de passe</label>
<input type="password" id="confirm_password"
name="confirm_password" required autocomplete="new-password">
</div>
<button type="submit" class="btn btn--primary">
<?= $hasPassword ? 'Mettre à jour le mot de passe' : 'Définir le mot de passe' ?>
</button>
</form>
<!-- Danger zone: remove credentials -->
<?php if ($hasPassword): ?>
<fieldset class="param-danger-zone">
<legend>Supprimer la configuration du mot de passe PHP</legend>
<p>
Supprime le hash de la base de données. L'accès admin
dépendra uniquement de l'authentification nginx Basic Auth si elle est configurée.
</p>
<?php /* TODO: replace this browser confirm() with a proper <dialog> modal like the other confirmations */ ?>
<form method="post" action="/admin/actions/account.php"
onsubmit="return confirm('Supprimer le mot de passe PHP ? L\'accès admin ne sera protégé que par nginx Basic Auth.')">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="remove_credentials">
<input type="hidden" name="redirect" value="/admin/parametres.php">
<input type="hidden" name="current_password_remove" value="">
<button type="submit" class="btn btn--danger">Supprimer le mot de passe</button>
</form>
</fieldset>
<?php endif; ?>
</section>
<!-- ══════════════════════════════════════════════════════════════
SYSTÈME
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-system-title">
<h2 id="settings-system-title">Système</h2>
<p class="sys-refresh-note">
Affiché le <?= date('d/m/Y à H:i:s') ?> —
<a href="?tab=<?= htmlspecialchars($activeTab) ?>&amp;n=<?= $selectedN ?>">Rafraîchir</a> —
<a href="?tab=<?= htmlspecialchars($activeTab) ?>&amp;n=<?= $selectedN ?>&amp;refresh=1">Forcer actualisation</a>
</p>
<div class="sys-status-header">
<h3 class="srv-section-title srv-section-title--compact">Statut
<?php if ($statusCached && $statusCacheAge !== null): ?>
<span class="sys-cache-badge sys-cache-badge--hit" title="Données en cache">
⚡ Cache — il y a <?= $statusCacheAge ?>s
</span>
<?php else: ?>
<span class="sys-cache-badge sys-cache-badge--miss" title="Données fraîches">
⟳ Actualisé
</span>
<?php endif; ?>
</h3>
<button id="sys-status-toggle" class="sys-status-toggle"
aria-expanded="<?= $statusInitiallyCollapsed ? 'false' : 'true' ?>" aria-controls="sys-status-body"
type="button"
onclick="var b=document.getElementById('sys-status-body');var c=b.hidden;b.hidden=!c;this.setAttribute('aria-expanded',c);this.textContent=c?'▲ Réduire':'▼ Développer';document.cookie='sys_collapsed='+(!c)+';path=/;max-age=31536000';return false">
<?= $statusInitiallyCollapsed ? '▼ Développer' : '▲ Réduire' ?>
</button>
</div>
<div id="sys-status-body"<?= $statusInitiallyCollapsed ? ' hidden' : '' ?>>
<div class="srv-grid">
<?php foreach ($checks as $check): ?>
<?php $st = $check['status'] ?? 'unknown'; ?>
<div class="srv-card">
<div class="srv-card__header">
<span class="srv-card__name"><?= htmlspecialchars($check['label']) ?></span>
<span class="<?= SystemController::statusClass($st) ?>"><?= SystemController::statusLabel($st) ?></span>
</div>
<?php if (!empty($check['detail'])): ?>
<div class="srv-card__detail"><?= htmlspecialchars($check['detail']) ?></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<div class="sys-status-meta">
<div>
<h4 class="srv-section-title srv-section-title--sub">Environnement PHP</h4>
<div class="php-grid php-grid--flush">
<?php foreach ($phpInfo as $key => $val): ?>
<div class="php-item">
<div class="php-item__key"><?= htmlspecialchars($key) ?></div>
<div class="php-item__val"><?= htmlspecialchars($val) ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<div>
<h4 class="srv-section-title srv-section-title--sub">Espace disque</h4>
<div class="disk-bar-wrap">
<div class="disk-bar" style="--disk-pct:<?= $diskPct ?>%;--disk-color:<?= $diskColor ?>"></div>
</div>
<div class="disk-stats">
<span><?= SystemController::humanBytes($diskUsed) ?> utilisé (<?= $diskPct ?>%)</span>
<span><?= SystemController::humanBytes($diskFree) ?> libre / <?= SystemController::humanBytes($diskTotal) ?></span>
</div>
</div>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════════
JOURNAUX
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-logs-title">
<h2 id="settings-logs-title">Journaux</h2>
<nav class="sys-tabs" aria-label="Journaux et configuration">
<?php foreach (SystemController::LOG_FILES as $key => $def): ?>
<a href="?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
class="sys-tab <?= $activeTab === $key ? 'active' : '' ?>"
hx-get="/admin/system-fragment.php?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
hx-target="#sys-tab-panel"
hx-push-url="?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
data-tab="<?= htmlspecialchars($key) ?>"
<?= $activeTab === $key ? 'aria-current="page"' : '' ?>>
<?= htmlspecialchars($def['label']) ?>
</a>
<?php endforeach; ?>
</nav>
<div id="sys-tab-panel">
<?php include APP_ROOT . '/templates/admin/partials/system-log-panel.php'; ?>
</div>
</section>
</article>
</main>
<script>
// Focus the SMTP field that caused the probe error
(function () {
var form = document.querySelector('form[data-smtp-error-field]');
if (!form) return;
var fieldId = form.getAttribute('data-smtp-error-field');
var el = fieldId ? document.getElementById(fieldId) : null;
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.focus();
}());
</script>
<script src="/assets/js/app/admin-logs.js"></script>
<!-- Enable maintenance confirm -->
<dialog id="enable-maintenance-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="enable-maint-title">
<div class="admin-dialog__header">
<h2 id="enable-maint-title">Activer la maintenance</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="this.closest('dialog').close()">&#x2715;</button>
</div>
<div class="admin-dialog__alert">
<p>Mettre le site en maintenance ? Les visiteurs verront une page 503.</p>
</div>
<div class="admin-dialog__footer">
<button type="button" class="btn btn--warning"
onclick="this.closest('dialog').close(); document.getElementById('enable-maintenance-form').submit()">
Activer
</button>
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div>
</dialog>