mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user