init: projet complet copymail pour Raspberry Pi
Stack: Nginx + PHP 8.2 + MariaDB + Postfix + Dovecot + Samba - install.sh: script installation automatisé pour RPi OS Bookworm - Interface web: visionneuse mails + admin (comptes, domaine, dashboard) - Chaque compte mail génère automatiquement un share Samba - Processeur cron: extraction pièces jointes via zbateson/mail-mime-parser - Zéro injection SQL (PDO + prepared statements) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
198
web/admin/accounts.php
Normal file
198
web/admin/accounts.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/layout.php';
|
||||
|
||||
auth_check();
|
||||
|
||||
$error = '';
|
||||
|
||||
// Création d'un compte
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
||||
|
||||
if ($_POST['action'] === 'create') {
|
||||
$username = preg_replace('/[^a-z0-9._-]/', '', strtolower(trim($_POST['username'] ?? '')));
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
$display = trim($_POST['display_name'] ?? '');
|
||||
$domain = cfg('domain');
|
||||
|
||||
if (strlen($username) < 2) {
|
||||
$error = 'Nom d\'utilisateur invalide (min 2 caractères, a-z0-9._-)';
|
||||
} elseif (strlen($password) < 6) {
|
||||
$error = 'Mot de passe trop court (min 6 caractères).';
|
||||
} else {
|
||||
$email = "{$username}@{$domain}";
|
||||
$smbPath = "/srv/copymail/{$username}";
|
||||
|
||||
$st = db()->prepare('SELECT id FROM accounts WHERE username = ? OR email = ?');
|
||||
$st->execute([$username, $email]);
|
||||
if ($st->fetch()) {
|
||||
$error = "Le compte « {$username} » existe déjà.";
|
||||
} else {
|
||||
db()->prepare(
|
||||
'INSERT INTO accounts (username, email, display_name, smb_path) VALUES (?, ?, ?, ?)'
|
||||
)->execute([$username, $email, $display, $smbPath]);
|
||||
|
||||
// Crée le dossier, l'utilisateur Dovecot et le share Samba
|
||||
$cmd = sprintf(
|
||||
'/opt/copymail/scripts/create_account.sh %s %s %s 2>&1',
|
||||
escapeshellarg($username),
|
||||
escapeshellarg($password),
|
||||
escapeshellarg($domain)
|
||||
);
|
||||
shell_exec($cmd);
|
||||
|
||||
flash('success', "Compte <strong>{$email}</strong> créé. Share SMB : <code>\\\\serveur\\{$username}</code>");
|
||||
header('Location: /admin/accounts.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($_POST['action'] === 'delete') {
|
||||
$id = (int)$_POST['account_id'];
|
||||
$st = db()->prepare('SELECT username FROM accounts WHERE id = ?');
|
||||
$st->execute([$id]);
|
||||
$row = $st->fetch();
|
||||
if ($row) {
|
||||
$cmd = sprintf('/opt/copymail/scripts/delete_account.sh %s 2>&1', escapeshellarg($row['username']));
|
||||
shell_exec($cmd);
|
||||
db()->prepare('DELETE FROM accounts WHERE id = ?')->execute([$id]);
|
||||
flash('success', "Compte « {$row['username']} » supprimé.");
|
||||
}
|
||||
header('Location: /admin/accounts.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_POST['action'] === 'toggle') {
|
||||
$id = (int)$_POST['account_id'];
|
||||
db()->prepare('UPDATE accounts SET active = 1 - active WHERE id = ?')->execute([$id]);
|
||||
header('Location: /admin/accounts.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$accounts = db()->query(
|
||||
'SELECT a.*, COUNT(sf.id) as mail_count FROM accounts a
|
||||
LEFT JOIN source_file sf ON sf.account_id = a.id
|
||||
GROUP BY a.id ORDER BY a.created_at DESC'
|
||||
)->fetchAll();
|
||||
|
||||
$domain = cfg('domain');
|
||||
|
||||
html_head('Comptes');
|
||||
html_navbar('accounts');
|
||||
?>
|
||||
<div class="container-fluid">
|
||||
<?php flash_render(); ?>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- Formulaire création -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold"><i class="bi bi-person-plus-fill me-2"></i>Nouveau compte</div>
|
||||
<div class="card-body">
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger py-2 small"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nom d'utilisateur</label>
|
||||
<div class="input-group">
|
||||
<input type="text" name="username" class="form-control" placeholder="copycaisse" pattern="[a-zA-Z0-9._-]+" required>
|
||||
<span class="input-group-text text-muted small">@<?= htmlspecialchars($domain) ?></span>
|
||||
</div>
|
||||
<div class="form-text">Lettres, chiffres, . _ -</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Mot de passe IMAP/SMB</label>
|
||||
<input type="password" name="password" class="form-control" required minlength="6">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nom affiché <span class="text-muted">(optionnel)</span></label>
|
||||
<input type="text" name="display_name" class="form-control" placeholder="Copy Caisse">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-plus-circle me-1"></i>Créer le compte
|
||||
</button>
|
||||
</form>
|
||||
<hr class="my-3">
|
||||
<div class="small text-muted">
|
||||
<i class="bi bi-info-circle me-1"></i>La création génère automatiquement :
|
||||
<ul class="mt-1 mb-0">
|
||||
<li>Boîte mail <code>username@<?= htmlspecialchars($domain) ?></code></li>
|
||||
<li>Dossier <code>/srv/copymail/username/</code></li>
|
||||
<li>Share SMB <code>\\serveur\username</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des comptes -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-people-fill me-2"></i>Comptes existants</span>
|
||||
<span class="badge bg-secondary"><?= count($accounts) ?></span>
|
||||
</div>
|
||||
<?php if (empty($accounts)): ?>
|
||||
<div class="card-body text-muted">Aucun compte créé.</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Compte</th>
|
||||
<th>Email</th>
|
||||
<th>Share SMB</th>
|
||||
<th class="text-center">Mails</th>
|
||||
<th class="text-center">Statut</th>
|
||||
<th class="text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($accounts as $acc): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><?= htmlspecialchars($acc['username']) ?></strong>
|
||||
<?php if ($acc['display_name']): ?>
|
||||
<br><small class="text-muted"><?= htmlspecialchars($acc['display_name']) ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="small"><?= htmlspecialchars($acc['email']) ?></td>
|
||||
<td class="small font-monospace">\\serveur\<?= htmlspecialchars($acc['username']) ?></td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-light text-dark"><?= $acc['mail_count'] ?></span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="action" value="toggle">
|
||||
<input type="hidden" name="account_id" value="<?= $acc['id'] ?>">
|
||||
<button class="badge border-0 <?= $acc['active'] ? 'bg-success' : 'bg-secondary' ?>" title="Cliquer pour basculer">
|
||||
<?= $acc['active'] ? 'actif' : 'inactif' ?>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<form method="post" onsubmit="return confirm('Supprimer le compte <?= htmlspecialchars($acc['username']) ?> et tous ses fichiers ?')">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="account_id" value="<?= $acc['id'] ?>">
|
||||
<button class="btn btn-sm btn-outline-danger" title="Supprimer">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php html_foot(); ?>
|
||||
96
web/admin/domain.php
Normal file
96
web/admin/domain.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/layout.php';
|
||||
|
||||
auth_check();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$fields = ['domain', 'hostname', 'smtp_port', 'submission_port', 'imap_port', 'imaps_port', 'smb_workgroup'];
|
||||
foreach ($fields as $f) {
|
||||
$val = trim($_POST[$f] ?? '');
|
||||
if ($val !== '') cfg_set($f, $val);
|
||||
}
|
||||
// Régénère les fichiers de config et recharge les services
|
||||
$result = shell_exec('/opt/copymail/scripts/apply_config.sh 2>&1');
|
||||
flash('success', 'Configuration sauvegardée et services rechargés.');
|
||||
header('Location: /admin/domain.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$fields = ['domain', 'hostname', 'smtp_port', 'submission_port', 'imap_port', 'imaps_port', 'smb_workgroup'];
|
||||
$vals = [];
|
||||
foreach ($fields as $f) $vals[$f] = cfg($f);
|
||||
|
||||
html_head('Configuration');
|
||||
html_navbar('domain');
|
||||
?>
|
||||
<div class="container" style="max-width:700px">
|
||||
<?php flash_render(); ?>
|
||||
<h5 class="mb-4"><i class="bi bi-gear-fill me-2"></i>Configuration du serveur</h5>
|
||||
|
||||
<form method="post">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header fw-semibold"><i class="bi bi-globe me-2"></i>Réseau & Domaine</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Nom de domaine mail</label>
|
||||
<input type="text" name="domain" class="form-control" value="<?= htmlspecialchars($vals['domain']) ?>" placeholder="mail.mondomaine.fr" required>
|
||||
<div class="form-text">Ex: <code>mail.mondomaine.fr</code></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Hostname RPi</label>
|
||||
<input type="text" name="hostname" class="form-control" value="<?= htmlspecialchars($vals['hostname']) ?>" placeholder="raspberrypi">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header fw-semibold"><i class="bi bi-envelope-at me-2"></i>Ports mail</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">SMTP (réception)</label>
|
||||
<input type="number" name="smtp_port" class="form-control" value="<?= htmlspecialchars($vals['smtp_port']) ?>" min="1" max="65535">
|
||||
<div class="form-text">Défaut: 25</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Submission</label>
|
||||
<input type="number" name="submission_port" class="form-control" value="<?= htmlspecialchars($vals['submission_port']) ?>" min="1" max="65535">
|
||||
<div class="form-text">Défaut: 587</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">IMAP</label>
|
||||
<input type="number" name="imap_port" class="form-control" value="<?= htmlspecialchars($vals['imap_port']) ?>" min="1" max="65535">
|
||||
<div class="form-text">Défaut: 143</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">IMAPS</label>
|
||||
<input type="number" name="imaps_port" class="form-control" value="<?= htmlspecialchars($vals['imaps_port']) ?>" min="1" max="65535">
|
||||
<div class="form-text">Défaut: 993</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header fw-semibold"><i class="bi bi-folder-symlink me-2"></i>Samba (SMB)</div>
|
||||
<div class="card-body">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Workgroup</label>
|
||||
<input type="text" name="smb_workgroup" class="form-control" value="<?= htmlspecialchars($vals['smb_workgroup']) ?>" placeholder="WORKGROUP">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-floppy-fill me-1"></i>Sauvegarder & Appliquer
|
||||
</button>
|
||||
<a href="/admin/index.php" class="btn btn-outline-secondary">Annuler</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php html_foot(); ?>
|
||||
116
web/admin/index.php
Normal file
116
web/admin/index.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/layout.php';
|
||||
|
||||
auth_check();
|
||||
|
||||
$services = [
|
||||
'postfix' => ['Postfix (SMTP)', 'bi-envelope-at'],
|
||||
'dovecot' => ['Dovecot (IMAP)', 'bi-inbox'],
|
||||
'smbd' => ['Samba (SMB)', 'bi-folder-symlink'],
|
||||
'nginx' => ['Nginx (Web)', 'bi-globe'],
|
||||
'mariadb' => ['MariaDB', 'bi-database'],
|
||||
];
|
||||
|
||||
$statuses = [];
|
||||
foreach ($services as $svc => [$label, $icon]) {
|
||||
$out = shell_exec("systemctl is-active " . escapeshellarg($svc) . " 2>/dev/null");
|
||||
$statuses[$svc] = ['label' => $label, 'icon' => $icon, 'active' => trim($out) === 'active'];
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'accounts' => (int)db()->query('SELECT COUNT(*) FROM accounts WHERE active=1')->fetchColumn(),
|
||||
'emails' => (int)db()->query('SELECT COUNT(*) FROM source_file')->fetchColumn(),
|
||||
'files' => (int)db()->query('SELECT COUNT(*) FROM files')->fetchColumn(),
|
||||
];
|
||||
|
||||
$domain = cfg('domain');
|
||||
$last5 = db()->query(
|
||||
'SELECT sf.title, sf.sender, sf.date_processing, a.username
|
||||
FROM source_file sf LEFT JOIN accounts a ON sf.account_id = a.id
|
||||
ORDER BY sf.date_processing DESC LIMIT 5'
|
||||
)->fetchAll();
|
||||
|
||||
html_head('Tableau de bord');
|
||||
html_navbar('status');
|
||||
?>
|
||||
<div class="container-fluid">
|
||||
<h5 class="mb-4"><i class="bi bi-activity me-2"></i>Tableau de bord</h5>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm text-center">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold text-primary"><?= $stats['accounts'] ?></div>
|
||||
<div class="text-muted small">Comptes actifs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm text-center">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold text-success"><?= $stats['emails'] ?></div>
|
||||
<div class="text-muted small">Emails traités</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card border-0 shadow-sm text-center">
|
||||
<div class="card-body">
|
||||
<div class="display-6 fw-bold text-info"><?= $stats['files'] ?></div>
|
||||
<div class="text-muted small">Fichiers extraits</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-server me-2"></i>État des services</span>
|
||||
<small class="text-muted">Domaine : <strong><?= htmlspecialchars($domain) ?></strong></small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<?php foreach ($statuses as $svc => $s): ?>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center p-2 rounded border <?= $s['active'] ? 'border-success bg-success bg-opacity-10' : 'border-danger bg-danger bg-opacity-10' ?>">
|
||||
<i class="bi <?= $s['icon'] ?> fs-4 me-3 <?= $s['active'] ? 'text-success' : 'text-danger' ?>"></i>
|
||||
<div>
|
||||
<div class="fw-semibold"><?= $s['label'] ?></div>
|
||||
<span class="badge <?= $s['active'] ? 'bg-success' : 'bg-danger' ?>"><?= $s['active'] ? 'actif' : 'arrêté' ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Derniers emails -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="bi bi-clock-history me-2"></i>Derniers courriers reçus</div>
|
||||
<?php if (empty($last5)): ?>
|
||||
<div class="card-body text-muted">Aucun courrier traité.</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light"><tr><th>Date</th><th>Compte</th><th>Sujet</th><th>Expéditeur</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($last5 as $r): ?>
|
||||
<tr>
|
||||
<td class="small text-muted"><?= date('d/m H:i', strtotime($r['date_processing'])) ?></td>
|
||||
<td><span class="badge bg-primary"><?= htmlspecialchars($r['username'] ?? '?') ?></span></td>
|
||||
<td><?= htmlspecialchars($r['title'] ?? '—') ?></td>
|
||||
<td class="small text-muted"><?= htmlspecialchars($r['sender'] ?? '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php html_foot(); ?>
|
||||
32
web/assets/css/style.css
Normal file
32
web/assets/css/style.css
Normal file
@@ -0,0 +1,32 @@
|
||||
body {
|
||||
background-color: #f4f6fb;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-size: .82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-monospace {
|
||||
font-size: .85em;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.container-fluid {
|
||||
padding: 0 .75rem;
|
||||
}
|
||||
}
|
||||
7
web/assets/js/app.js
Normal file
7
web/assets/js/app.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Auto-dismiss alerts after 4s
|
||||
document.querySelectorAll('.alert-dismissible').forEach(el => {
|
||||
setTimeout(() => {
|
||||
const btn = el.querySelector('.btn-close');
|
||||
if (btn) btn.click();
|
||||
}, 4000);
|
||||
});
|
||||
39
web/includes/auth.php
Normal file
39
web/includes/auth.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
function auth_start(): void {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_name('copymail_sess');
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
function auth_check(): void {
|
||||
auth_start();
|
||||
if (empty($_SESSION['admin_id'])) {
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
function auth_login(string $username, string $password): bool {
|
||||
$st = db()->prepare('SELECT id, password FROM admin_users WHERE username = ? LIMIT 1');
|
||||
$st->execute([$username]);
|
||||
$row = $st->fetch();
|
||||
if ($row && password_verify($password, $row['password'])) {
|
||||
$_SESSION['admin_id'] = $row['id'];
|
||||
$_SESSION['admin_user'] = $username;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function auth_logout(): void {
|
||||
auth_start();
|
||||
session_destroy();
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
function current_user(): string {
|
||||
return $_SESSION['admin_user'] ?? '';
|
||||
}
|
||||
31
web/includes/db.php
Normal file
31
web/includes/db.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
function db(): PDO {
|
||||
static $pdo = null;
|
||||
if ($pdo !== null) return $pdo;
|
||||
|
||||
$cfg = require __DIR__ . '/../../config.php';
|
||||
$dsn = "mysql:host={$cfg['db_host']};dbname={$cfg['db_name']};charset=utf8mb4";
|
||||
$pdo = new PDO($dsn, $cfg['db_user'], $cfg['db_pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function cfg(string $key, string $default = ''): string {
|
||||
static $cache = [];
|
||||
if (!isset($cache[$key])) {
|
||||
$st = db()->prepare('SELECT value FROM config WHERE `key` = ?');
|
||||
$st->execute([$key]);
|
||||
$row = $st->fetch();
|
||||
$cache[$key] = $row ? $row['value'] : $default;
|
||||
}
|
||||
return $cache[$key];
|
||||
}
|
||||
|
||||
function cfg_set(string $key, string $value): void {
|
||||
db()->prepare('INSERT INTO config (`key`, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = ?')
|
||||
->execute([$key, $value, $value]);
|
||||
}
|
||||
65
web/includes/layout.php
Normal file
65
web/includes/layout.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
function html_head(string $title): void {
|
||||
$domain = cfg('domain', 'copymail');
|
||||
echo <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{$title} — CopyMail</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
HTML;
|
||||
}
|
||||
|
||||
function html_navbar(string $active = ''): void {
|
||||
$user = current_user();
|
||||
$pages = [
|
||||
'mail' => ['/', 'bi-envelope-fill', 'Courriers'],
|
||||
'accounts' => ['/admin/accounts.php', 'bi-people-fill', 'Comptes'],
|
||||
'domain' => ['/admin/domain.php', 'bi-gear-fill', 'Configuration'],
|
||||
'status' => ['/admin/index.php', 'bi-activity', 'Tableau de bord'],
|
||||
];
|
||||
echo '<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">';
|
||||
echo '<div class="container-fluid">';
|
||||
echo '<a class="navbar-brand fw-bold" href="/"><i class="bi bi-envelope-arrow-down-fill me-2"></i>CopyMail</a>';
|
||||
echo '<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav"><span class="navbar-toggler-icon"></span></button>';
|
||||
echo '<div class="collapse navbar-collapse" id="nav"><ul class="navbar-nav me-auto">';
|
||||
foreach ($pages as $key => [$href, $icon, $label]) {
|
||||
$cls = $key === $active ? 'nav-link active' : 'nav-link';
|
||||
echo "<li class=\"nav-item\"><a class=\"{$cls}\" href=\"{$href}\"><i class=\"bi {$icon} me-1\"></i>{$label}</a></li>";
|
||||
}
|
||||
echo '</ul>';
|
||||
echo "<span class=\"navbar-text me-3\"><i class=\"bi bi-person-circle me-1\"></i>{$user}</span>";
|
||||
echo '<a class="btn btn-outline-light btn-sm" href="/logout.php"><i class="bi bi-box-arrow-right me-1"></i>Déconnexion</a>';
|
||||
echo '</div></div></nav>';
|
||||
}
|
||||
|
||||
function html_foot(): void {
|
||||
echo <<<HTML
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
function flash(string $type, string $msg): void {
|
||||
$_SESSION['flash'] = ['type' => $type, 'msg' => $msg];
|
||||
}
|
||||
|
||||
function flash_render(): void {
|
||||
if (!empty($_SESSION['flash'])) {
|
||||
$f = $_SESSION['flash'];
|
||||
unset($_SESSION['flash']);
|
||||
$icon = $f['type'] === 'success' ? 'check-circle-fill' : 'exclamation-triangle-fill';
|
||||
echo "<div class=\"alert alert-{$f['type']} d-flex align-items-center alert-dismissible fade show\" role=\"alert\">";
|
||||
echo "<i class=\"bi bi-{$icon} me-2\"></i>{$f['msg']}";
|
||||
echo '<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>';
|
||||
}
|
||||
}
|
||||
122
web/index.php
Normal file
122
web/index.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/db.php';
|
||||
require_once __DIR__ . '/includes/auth.php';
|
||||
require_once __DIR__ . '/includes/layout.php';
|
||||
|
||||
auth_check();
|
||||
|
||||
$message = '';
|
||||
|
||||
// Suppression d'un email traité
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_source'])) {
|
||||
$id = (int)$_POST['delete_source'];
|
||||
$st = db()->prepare('SELECT sf.title, CONCAT(sf.file_name) as fname, GROUP_CONCAT(f.path) as fpaths
|
||||
FROM source_file sf LEFT JOIN files f ON sf.id = f.source_file_id
|
||||
WHERE sf.id = ? GROUP BY sf.id');
|
||||
$st->execute([$id]);
|
||||
$row = $st->fetch();
|
||||
if ($row) {
|
||||
if ($row['fpaths']) {
|
||||
foreach (explode(',', $row['fpaths']) as $path) {
|
||||
if (file_exists($path)) unlink($path);
|
||||
}
|
||||
}
|
||||
db()->prepare('DELETE FROM source_file WHERE id = ?')->execute([$id]);
|
||||
flash('success', "Entrée « " . htmlspecialchars($row['title']) . " » supprimée.");
|
||||
}
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
|
||||
$rows = db()->query(
|
||||
'SELECT sf.id, sf.title, sf.sender, sf.date_processing, a.username,
|
||||
GROUP_CONCAT(f.id, ":", f.file_name, ":", f.path ORDER BY f.id SEPARATOR "|") as files
|
||||
FROM source_file sf
|
||||
LEFT JOIN accounts a ON sf.account_id = a.id
|
||||
LEFT JOIN files f ON sf.id = f.source_file_id
|
||||
GROUP BY sf.id
|
||||
ORDER BY sf.date_processing DESC'
|
||||
)->fetchAll();
|
||||
|
||||
html_head('Courriers');
|
||||
html_navbar('mail');
|
||||
?>
|
||||
<div class="container-fluid">
|
||||
<?php flash_render(); ?>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-envelope-fill me-2"></i>Courriers traités</h5>
|
||||
<span class="badge bg-secondary"><?= count($rows) ?> entrée(s)</span>
|
||||
</div>
|
||||
|
||||
<?php if (empty($rows)): ?>
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>Aucun courrier traité pour l'instant.
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Compte</th>
|
||||
<th>Sujet</th>
|
||||
<th>Expéditeur</th>
|
||||
<th>Pièces jointes</th>
|
||||
<th class="text-center">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $r): ?>
|
||||
<tr>
|
||||
<td class="text-nowrap text-muted small">
|
||||
<?= htmlspecialchars(date('d/m/Y H:i', strtotime($r['date_processing']))) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($r['username']): ?>
|
||||
<span class="badge bg-primary"><?= htmlspecialchars($r['username']) ?></span>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($r['title'] ?? '(sans sujet)') ?></td>
|
||||
<td class="small text-muted"><?= htmlspecialchars($r['sender'] ?? '') ?></td>
|
||||
<td>
|
||||
<?php if ($r['files']): ?>
|
||||
<?php foreach (explode('|', $r['files']) as $f): ?>
|
||||
<?php [$fid, $fname, $fpath] = array_pad(explode(':', $f, 3), 3, ''); ?>
|
||||
<?php if (file_exists($fpath)): ?>
|
||||
<a href="/mail/download.php?id=<?= (int)$fid ?>" class="badge bg-info text-decoration-none me-1">
|
||||
<i class="bi bi-download me-1"></i><?= htmlspecialchars(substr($fname, 0, 20)) ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-secondary me-1" title="Fichier introuvable">
|
||||
<?= htmlspecialchars(substr($fname, 0, 20)) ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<span class="text-muted small">Aucune pièce jointe</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<form method="post" onsubmit="return confirm('Supprimer cette entrée ?')">
|
||||
<input type="hidden" name="delete_source" value="<?= $r['id'] ?>">
|
||||
<button class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
setTimeout(() => location.reload(), 30000);
|
||||
</script>
|
||||
<?php html_foot(); ?>
|
||||
52
web/login.php
Normal file
52
web/login.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/db.php';
|
||||
require_once __DIR__ . '/includes/auth.php';
|
||||
require_once __DIR__ . '/includes/layout.php';
|
||||
|
||||
auth_start();
|
||||
|
||||
if (!empty($_SESSION['admin_id'])) {
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
if (auth_login($username, $password)) {
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
$error = 'Identifiants incorrects.';
|
||||
}
|
||||
|
||||
html_head('Connexion');
|
||||
?>
|
||||
<div class="min-vh-100 d-flex align-items-center justify-content-center bg-light">
|
||||
<div class="card shadow" style="width:360px">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-envelope-arrow-down-fill text-primary" style="font-size:2.5rem"></i>
|
||||
<h4 class="mt-2 fw-bold">CopyMail</h4>
|
||||
</div>
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger py-2"><i class="bi bi-exclamation-triangle-fill me-1"></i><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nom d'utilisateur</label>
|
||||
<input type="text" name="username" class="form-control" autofocus required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Mot de passe</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i>Connexion
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php html_foot(); ?>
|
||||
4
web/logout.php
Normal file
4
web/logout.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/db.php';
|
||||
require_once __DIR__ . '/includes/auth.php';
|
||||
auth_logout();
|
||||
33
web/mail/download.php
Normal file
33
web/mail/download.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/db.php';
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
auth_check();
|
||||
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if (!$id) { http_response_code(400); exit('bad request'); }
|
||||
|
||||
$st = db()->prepare('SELECT file_name, path FROM files WHERE id = ?');
|
||||
$st->execute([$id]);
|
||||
$row = $st->fetch();
|
||||
|
||||
if (!$row || !file_exists($row['path'])) {
|
||||
http_response_code(404);
|
||||
exit('Fichier introuvable.');
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($row['file_name'], PATHINFO_EXTENSION));
|
||||
$mime = match($ext) {
|
||||
'pdf' => 'application/pdf',
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'csv' => 'text/csv',
|
||||
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Disposition: attachment; filename="' . addslashes($row['file_name']) . '"');
|
||||
header('Content-Length: ' . filesize($row['path']));
|
||||
readfile($row['path']);
|
||||
Reference in New Issue
Block a user