feat: prevent duplicate TFE submissions with logging and user feedback

- Add DuplicateThesisException (typed, carries existing thesis metadata)
- Add Database::findDuplicateThesis(): matches on year + author + normalised
  title (exact, prefix, Levenshtein ≤10% of longer string)
- ThesisCreateController::submit() runs duplicate check before any DB write
  and throws DuplicateThesisException on match
- AppLogger::logDuplicate() writes status=duplicate entries to the JSON-lines
  log for audit purposes
- App::flash/consumeFlash extended to support 'warning' flash type
- admin/actions/formulaire.php: catches DuplicateThesisException, logs it,
  flashes an HTML warning toast with a clickable link to the existing thesis,
  and repopulates the form fields
- partage/index.php: same catch block; surfaces a plain-text flash-warning
  banner on the student form with identifier, title, and year of the match;
  form is repopulated via session
- toast.php: renders toast--warning variant
- admin.css: .toast--warning + link colour rules
- form.css: .flash-warning style for the partage form
This commit is contained in:
Pontoporeia
2026-05-04 16:29:31 +02:00
parent 0a05f3911c
commit a2cba6d3c0
35 changed files with 1726 additions and 1302 deletions

View File

@@ -7,7 +7,8 @@
* field values: 'smtp_host', 'smtp_port', 'smtp_encryption',
* 'smtp_username', 'smtp_password', or null (unknown)
*/
class SmtpProbeException extends \RuntimeException {
class SmtpProbeException extends \RuntimeException
{
public function __construct(
string $message,
public readonly ?string $field = null
@@ -25,7 +26,8 @@ class SmtpProbeException extends \RuntimeException {
* 421 / 450 / 451 — transient failures (try again later)
* 530 / 535 — authentication failure
*/
class SmtpSendException extends \RuntimeException {
class SmtpSendException extends \RuntimeException
{
public function __construct(
string $message,
public readonly int $smtpCode = 0,
@@ -51,8 +53,8 @@ class SmtpSendException extends \RuntimeException {
* 3. Send via native SMTP socket (STARTTLS / SSL / plain).
* 4. Probe credentials without sending any message (for validation on save).
*/
class SmtpRelay {
class SmtpRelay
{
// -----------------------------------------------------------------------
// DB operations
// -----------------------------------------------------------------------
@@ -62,10 +64,11 @@ class SmtpRelay {
*
* @return array{host:string,port:int,encryption:string,username:string,password:string,from_email:string,from_name:string,notify_email:string}
*/
public static function getSettings(Database $db): array {
public static function getSettings(Database $db): array
{
$stmt = $db->getPDO()->query(
"SELECT host, port, encryption, username, password, from_email, from_name, notify_email
FROM v_smtp_active LIMIT 1"
'SELECT host, port, encryption, username, password, from_email, from_name, notify_email
FROM v_smtp_active LIMIT 1'
);
$row = $stmt->fetch();
@@ -98,7 +101,8 @@ class SmtpRelay {
* @param array $data Keys: host, port, encryption, username, password,
* from_email, from_name. Missing keys are left unchanged.
*/
public static function updateSettings(Database $db, array $data): void {
public static function updateSettings(Database $db, array $data): void
{
$current = self::getSettings($db);
$merged = array_merge($current, $data);
@@ -107,7 +111,7 @@ class SmtpRelay {
? $merged['encryption'] : 'tls';
$stmt = $db->getPDO()->prepare(
"UPDATE smtp_settings
'UPDATE smtp_settings
SET host = :host,
port = :port,
encryption = :encryption,
@@ -117,7 +121,7 @@ class SmtpRelay {
from_name = :from_name,
notify_email = :notify_email,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1"
WHERE id = 1'
);
$stmt->execute([
@@ -135,7 +139,8 @@ class SmtpRelay {
/**
* Check whether the SMTP relay is fully configured.
*/
public static function isConfigured(Database $db): bool {
public static function isConfigured(Database $db): bool
{
$s = self::getSettings($db);
return $s['host'] !== '' && $s['username'] !== '' && $s['from_email'] !== '';
}
@@ -186,7 +191,8 @@ class SmtpRelay {
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
$errno = 0; $errstr = '';
$errno = 0;
$errstr = '';
$ctx = stream_context_create([
'ssl' => [
'verify_peer' => true,
@@ -196,8 +202,12 @@ class SmtpRelay {
],
]);
$sock = @stream_socket_client(
"{$connectHost}:{$port}", $errno, $errstr, $timeout,
STREAM_CLIENT_CONNECT, $ctx
"{$connectHost}:{$port}",
$errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
$ctx
);
if ($sock === false) {
$isNameFail = (
@@ -234,9 +244,13 @@ class SmtpRelay {
$buf = '';
while (($line = fgets($sock, 512)) !== false) {
$buf .= $line;
if (isset($line[3]) && $line[3] === ' ') break;
if (isset($line[3]) && $line[3] === ' ') {
break;
}
$meta = stream_get_meta_data($sock);
if ($meta['timed_out']) break;
if ($meta['timed_out']) {
break;
}
}
return $buf;
};
@@ -251,12 +265,12 @@ class SmtpRelay {
$meta = stream_get_meta_data($sock);
if ($meta['timed_out']) {
throw new SmtpProbeException(
"Délai dépassé en attendant la salutation SMTP — hôte ou port incorrect ?",
'Délai dépassé en attendant la salutation SMTP — hôte ou port incorrect ?',
'smtp_port'
);
}
throw new SmtpProbeException(
"Réponse inattendue du serveur : " . json_encode(trim($greeting)),
'Réponse inattendue du serveur : ' . json_encode(trim($greeting)),
'smtp_host'
);
}
@@ -265,7 +279,9 @@ class SmtpRelay {
$caps = [];
foreach (explode("\n", $resp) as $line) {
$line = rtrim($line);
if (strlen($line) > 4) $caps[] = strtoupper(substr($line, 4));
if (strlen($line) > 4) {
$caps[] = strtoupper(substr($line, 4));
}
}
return $caps;
};
@@ -281,7 +297,7 @@ class SmtpRelay {
$stResp = $send('STARTTLS');
if (strncmp($stResp, '220', 3) !== 0) {
throw new SmtpProbeException(
"Le serveur ne supporte pas STARTTLS — essayez SSL (port 465) ou \"Aucun\".",
'Le serveur ne supporte pas STARTTLS — essayez SSL (port 465) ou "Aucun".',
'smtp_encryption'
);
}
@@ -291,7 +307,7 @@ class SmtpRelay {
stream_context_set_option($sock, 'ssl', 'cafile', self::caBundlePath());
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new SmtpProbeException(
"Échec de la négociation TLS — certificat invalide ou port incorrect ?",
'Échec de la négociation TLS — certificat invalide ou port incorrect ?',
'smtp_encryption'
);
}
@@ -303,7 +319,10 @@ class SmtpRelay {
if ($s['username'] !== '') {
$authLine = '';
foreach ($caps as $cap) {
if (strncmp($cap, 'AUTH', 4) === 0) { $authLine = $cap; break; }
if (strncmp($cap, 'AUTH', 4) === 0) {
$authLine = $cap;
break;
}
}
$mechanisms = preg_split('/[\s=]+/', $authLine);
@@ -315,7 +334,7 @@ class SmtpRelay {
$code = substr(trim($resp), 0, 3);
$field = ($code === '535') ? 'smtp_password' : 'smtp_username';
throw new SmtpProbeException(
"Authentification refusée : " . trim($resp),
'Authentification refusée : ' . trim($resp),
$field
);
}
@@ -338,7 +357,7 @@ class SmtpRelay {
$r3 = $send(base64_encode($s['password']));
if (strncmp($r3, '235', 3) !== 0) {
throw new SmtpProbeException(
"Mot de passe refusé : " . trim($r3),
'Mot de passe refusé : ' . trim($r3),
'smtp_password'
);
}
@@ -382,7 +401,7 @@ class SmtpRelay {
$boundary = 'xamxam_' . bin2hex(random_bytes(8));
$date = date('r');
$fromHdr = ($s['from_name'] ?? '') !== ''
? "=?UTF-8?B?" . base64_encode($s['from_name']) . "?= <{$s['from_email']}>"
? '=?UTF-8?B?' . base64_encode($s['from_name']) . "?= <{$s['from_email']}>"
: $s['from_email'];
$subjectHdr = '=?UTF-8?B?' . base64_encode($subject) . '?=';
@@ -456,7 +475,8 @@ class SmtpRelay {
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
$errno = 0; $errstr = '';
$errno = 0;
$errstr = '';
$ctx = stream_context_create([
'ssl' => [
'verify_peer' => true,
@@ -466,8 +486,12 @@ class SmtpRelay {
],
]);
$sock = @stream_socket_client(
"{$connectHost}:{$port}", $errno, $errstr, $timeout,
STREAM_CLIENT_CONNECT, $ctx
"{$connectHost}:{$port}",
$errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
$ctx
);
if ($sock === false) {
throw new \RuntimeException("SMTP connect failed ({$connectHost}:{$port}): {$errstr} [{$errno}]");
@@ -478,9 +502,13 @@ class SmtpRelay {
$buf = '';
while (($line = fgets($sock, 512)) !== false) {
$buf .= $line;
if (isset($line[3]) && $line[3] === ' ') break;
if (isset($line[3]) && $line[3] === ' ') {
break;
}
$meta = stream_get_meta_data($sock);
if ($meta['timed_out']) break;
if ($meta['timed_out']) {
break;
}
}
return $buf;
};
@@ -512,7 +540,9 @@ class SmtpRelay {
$caps = [];
foreach (explode("\n", $resp) as $line) {
$line = rtrim($line);
if (strlen($line) > 4) $caps[] = strtoupper(substr($line, 4));
if (strlen($line) > 4) {
$caps[] = strtoupper(substr($line, 4));
}
}
return $caps;
};
@@ -539,7 +569,10 @@ class SmtpRelay {
if ($s['username'] !== '') {
$authLine = '';
foreach ($caps as $cap) {
if (strncmp($cap, 'AUTH', 4) === 0) { $authLine = $cap; break; }
if (strncmp($cap, 'AUTH', 4) === 0) {
$authLine = $cap;
break;
}
}
$mechanisms = preg_split('/[\s=]+/', $authLine);
if (in_array('PLAIN', $mechanisms, true)) {
@@ -613,12 +646,15 @@ class SmtpRelay {
'/usr/local/share/certs/ca-root-nss.crt',
];
foreach ($candidates as $path) {
if (file_exists($path)) return $path;
if (file_exists($path)) {
return $path;
}
}
return null; // PHP will fall back to its compiled-in bundle
}
private static function htmlToPlain(string $html): string {
private static function htmlToPlain(string $html): string
{
$text = strip_tags($html);
$text = preg_replace('/\n{3,}/', "\n\n", $text);
return trim($text);