feat: téléversement mobile par QR code — alternative AirDrop pour clients en magasin

Page publique /upload.php (mobile-first, sans auth) : le client scanne un QR code
au comptoir, choisit le poste de destination, et envoie ses documents.
Les fichiers sont déposés dans le share SMB du compte, enregistrés en BDD
(mêmes tables que les mails) et déclenchent le webhook si configuré.

Page admin /admin/qrcode.php pour générer/imprimer les QR codes par poste.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SChGCatJdbmZKCyYvTJMf
This commit is contained in:
Jules
2026-09-05 15:13:37 +02:00
parent a1badb18f2
commit 8dd0e471fd
4 changed files with 623 additions and 0 deletions

104
web/api/upload.php Normal file
View File

@@ -0,0 +1,104 @@
<?php
require_once __DIR__ . '/../includes/db.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Méthode non autorisée']);
exit;
}
$accountId = (int)($_POST['account_id'] ?? 0);
if (!$accountId) {
http_response_code(400);
echo json_encode(['error' => 'Compte non spécifié']);
exit;
}
$st = db()->prepare('SELECT id, username, smb_path, webhook_url FROM accounts WHERE id = ? AND active = 1');
$st->execute([$accountId]);
$account = $st->fetch();
if (!$account) {
http_response_code(404);
echo json_encode(['error' => 'Compte introuvable ou inactif']);
exit;
}
if (empty($_FILES['files']) || !is_array($_FILES['files']['name'])) {
http_response_code(400);
echo json_encode(['error' => 'Aucun fichier envoyé']);
exit;
}
$destDir = $account['smb_path'];
if (!is_dir($destDir)) mkdir($destDir, 0750, true);
$st = db()->prepare('INSERT INTO source_file (account_id, title, sender, file_name) VALUES (?, ?, ?, ?)');
$st->execute([$account['id'], 'Dépôt comptoir', 'Téléversement mobile', 'upload_' . time()]);
$sourceId = (int)db()->lastInsertId();
$uploaded = 0;
$errors = [];
$fileCount = count($_FILES['files']['name']);
for ($i = 0; $i < $fileCount; $i++) {
if ($_FILES['files']['error'][$i] !== UPLOAD_ERR_OK) {
$errors[] = $_FILES['files']['name'][$i] . ' : erreur upload';
continue;
}
$origName = $_FILES['files']['name'][$i];
$tmpPath = $_FILES['files']['tmp_name'][$i];
$safeName = preg_replace('/[^a-zA-Z0-9._\- ]/', '_', $origName);
$destPath = $destDir . '/' . $safeName;
if (file_exists($destPath)) {
$ext = pathinfo($safeName, PATHINFO_EXTENSION);
$base = pathinfo($safeName, PATHINFO_FILENAME);
$destPath = $destDir . '/' . $base . '_' . time() . ($ext ? '.' . $ext : '');
$safeName = basename($destPath);
}
if (move_uploaded_file($tmpPath, $destPath)) {
chmod($destPath, 0644);
db()->prepare('INSERT INTO files (source_file_id, file_name, path) VALUES (?, ?, ?)')
->execute([$sourceId, $safeName, $destPath]);
$uploaded++;
} else {
$errors[] = $origName . ' : échec écriture';
}
}
if ($uploaded === 0) {
db()->prepare('DELETE FROM source_file WHERE id = ?')->execute([$sourceId]);
http_response_code(500);
echo json_encode(['error' => 'Aucun fichier enregistré', 'details' => $errors]);
exit;
}
if (!empty($account['webhook_url'])) {
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => json_encode([
'account' => $account['username'],
'subject' => 'Dépôt comptoir',
'from' => 'Téléversement mobile',
'source_id' => $sourceId,
'files' => $uploaded,
'received_at' => date('c'),
]),
'timeout' => 5,
'ignore_errors' => true,
]]);
@file_get_contents($account['webhook_url'], false, $ctx);
}
echo json_encode([
'success' => true,
'uploaded' => $uploaded,
'account' => $account['username'],
'errors' => $errors,
]);