From 791ee11c03d614327ee051707693b98e988a7deb Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:19:53 +0100 Subject: [PATCH 01/44] Let import_logs record user imports (0.9.0 migration section) --- .../database/migrations/migrate_0.9.0.sql | 12 +++++ installer/database/schema.sql | 2 +- tests/migration-0.9.0-patron-import.unit.php | 53 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/migration-0.9.0-patron-import.unit.php diff --git a/installer/database/migrations/migrate_0.9.0.sql b/installer/database/migrations/migrate_0.9.0.sql index a8822d5fc..7c1e26313 100644 --- a/installer/database/migrations/migrate_0.9.0.sql +++ b/installer/database/migrations/migrate_0.9.0.sql @@ -142,3 +142,15 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- ── end minor features ── + +-- ── patron-cards-import ── +-- The user CSV import writes its run into import_logs like the book imports do, +-- so the import_type enum gains a users value. The new type is computed from the +-- current COLUMN_TYPE instead of being spelled out, which keeps any value another +-- section of this file (or a future release) has already added to the enum. +SET @pci_type = (SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'import_logs' AND COLUMN_NAME = 'import_type'); +SET @pci_sql = IF(@pci_type IS NOT NULL AND LOCATE('''users''', @pci_type) = 0, CONCAT('ALTER TABLE import_logs MODIFY COLUMN import_type ', LEFT(@pci_type, CHAR_LENGTH(@pci_type) - 1), ',''users'') NOT NULL COMMENT ''Type of import'''), 'SELECT 1'); +PREPARE pci_stmt FROM @pci_sql; +EXECUTE pci_stmt; +DEALLOCATE PREPARE pci_stmt; +-- ── end patron-cards-import ── diff --git a/installer/database/schema.sql b/installer/database/schema.sql index f6ce4777a..68f94640e 100755 --- a/installer/database/schema.sql +++ b/installer/database/schema.sql @@ -336,7 +336,7 @@ CREATE TABLE `home_content` ( CREATE TABLE `import_logs` ( `id` int NOT NULL AUTO_INCREMENT, `import_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Unique identifier for this import session', - `import_type` enum('csv','librarything','marc') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Type of import', + `import_type` enum('csv','librarything','marc','users') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Type of import', `file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Original filename uploaded', `user_id` int DEFAULT NULL COMMENT 'User who initiated the import', `total_rows` int NOT NULL DEFAULT '0' COMMENT 'Total rows in CSV', diff --git a/tests/migration-0.9.0-patron-import.unit.php b/tests/migration-0.9.0-patron-import.unit.php new file mode 100644 index 000000000..50f7db6af --- /dev/null +++ b/tests/migration-0.9.0-patron-import.unit.php @@ -0,0 +1,53 @@ +query($sql); } } +}; +$checks = 0; +$check = static function (bool $ok, string $label) use (&$checks): void { + if (!$ok) { throw new RuntimeException('FAIL ' . $label); } + $checks++; echo "OK {$label}\n"; +}; +$type = static function () use ($db, $table): array { + $r = $db->query("SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}' AND COLUMN_NAME = 'import_type'"); + return $r->fetch_assoc() ?? []; +}; +try { + // The 0.8.9 shape of the column. + $db->query("CREATE TABLE {$table} (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, import_id VARCHAR(36) NOT NULL, import_type ENUM('csv','librarything') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Type of import', file_name VARCHAR(255) NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + $db->query("INSERT INTO {$table} (import_id, import_type, file_name) VALUES ('a','csv','books.csv'),('b','librarything','lt.tsv')"); + $check(!str_contains($type()['COLUMN_TYPE'] ?? '', "'users'"), 'the pre-upgrade enum has no users value'); + $run(); + $after = $type(); + $check(($after['COLUMN_TYPE'] ?? '') === "enum('csv','librarything','users')", 'the migration appends users and keeps the existing values in order'); + $check(($after['IS_NULLABLE'] ?? '') === 'NO' && ($after['COLUMN_COMMENT'] ?? '') === 'Type of import', 'NOT NULL and the comment survive the change'); + $db->query("INSERT INTO {$table} (import_id, import_type, file_name) VALUES ('c','users','patrons.csv')"); + $check($db->query("SELECT GROUP_CONCAT(CONCAT(import_id,':',import_type) ORDER BY id) FROM {$table}")->fetch_row()[0] === 'a:csv,b:librarything,c:users', 'existing rows are preserved and a users row can be stored'); + $run(); + $check(($type()['COLUMN_TYPE'] ?? '') === "enum('csv','librarything','users')", 'a second run is a no-op'); + // A stacked section that already added another value must not lose it. + $db->query("DELETE FROM {$table} WHERE import_type = 'users'"); + $db->query("ALTER TABLE {$table} MODIFY COLUMN import_type ENUM('csv','librarything','marc') NOT NULL COMMENT 'Type of import'"); + $run(); + $check(($type()['COLUMN_TYPE'] ?? '') === "enum('csv','librarything','marc','users')", 'a value added by another section is kept'); + $check(str_contains((string) file_get_contents(dirname(__DIR__) . '/installer/database/schema.sql'), "`import_type` enum('csv','librarything','users')"), 'fresh install includes the users value'); + $version = json_decode((string) file_get_contents(dirname(__DIR__) . '/version.json'), true)['version']; + $check(version_compare('0.9.0', $version, '<='), 'release includes the migration'); + echo "SUCCESS {$checks} migration checks\n"; +} finally { + $db->query("DROP TABLE IF EXISTS {$table}"); +} From 2094050ad5c2226c523f9b275fa4cb4eecf76758 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 01:23:05 +0100 Subject: [PATCH 02/44] Print library cards, take the patron photo, import users from CSV Library cards: a TCPDF PDF in CR80 size or on A4 sheets of 10 (9 for the portrait layout), with logo, name, card code as Code128 or QR, photo and expiry, and an optional back text mirrored for duplex printing. Settings live in a new "Tessere" tab (/admin/settings/cards). A card prints from the reader's page; the users list gets a selection column to print many. Patron photo: the user form takes it from the webcam (getUserMedia, canvas) or from a file and sends both to one endpoint that re-encodes a bounded, EXIF-free JPEG into storage/patron_photos, outside the web root, served only through /admin/users/{id}/photo. Deleting the photo, or the account, deletes the file. Permissions-Policy keeps camera=(self), now built in ContentSecurityPolicy; nothing else is loosened. User import: /admin/users/import uploads a CSV, previews it, maps the columns (headers recognised in five languages, custom registration fields included), then processes it in chunks. Duplicates by email or card code are skipped, never merged; passwords are never imported and an optional invitation uses PasswordSetupToken. Each run lands in the import history as type "users". The patron-type column goes through PatronTypeImport, which does nothing until the tipologie_lettore schema exists. --- app/Controllers/PatronCardsController.php | 256 ++++++++++++ app/Controllers/SettingsController.php | 16 + app/Controllers/UserImportController.php | 465 ++++++++++++++++++++++ app/Controllers/UsersController.php | 3 + app/Controllers/UtentiApiController.php | 13 +- app/Routes/web.php | 55 +++ app/Support/ContentSecurityPolicy.php | 18 + app/Support/PatronCardPdf.php | 424 ++++++++++++++++++++ app/Support/PatronCardSettings.php | 111 ++++++ app/Support/PatronPhoto.php | 189 +++++++++ app/Support/PatronTypeImport.php | 108 +++++ app/Support/Updater.php | 1 + app/Support/UserImport.php | 333 ++++++++++++++++ app/Views/admin/imports_history.php | 1 + app/Views/partials/settings-sections.php | 1 + app/Views/settings/cards-tab.php | 146 +++++++ app/Views/settings/index.php | 3 + app/Views/utenti/dettagli_utente.php | 48 +++ app/Views/utenti/import.php | 353 ++++++++++++++++ app/Views/utenti/index.php | 105 ++++- app/Views/utenti/modifica_utente.php | 158 ++++++++ public/index.php | 8 +- 22 files changed, 2800 insertions(+), 15 deletions(-) create mode 100644 app/Controllers/PatronCardsController.php create mode 100644 app/Controllers/UserImportController.php create mode 100644 app/Support/PatronCardPdf.php create mode 100644 app/Support/PatronCardSettings.php create mode 100644 app/Support/PatronPhoto.php create mode 100644 app/Support/PatronTypeImport.php create mode 100644 app/Support/UserImport.php create mode 100644 app/Views/settings/cards-tab.php create mode 100644 app/Views/utenti/import.php diff --git a/app/Controllers/PatronCardsController.php b/app/Controllers/PatronCardsController.php new file mode 100644 index 000000000..3653c342f --- /dev/null +++ b/app/Controllers/PatronCardsController.php @@ -0,0 +1,256 @@ +withStatus(403); + } + $rows = $this->loadUsers($db, [$id]); + if ($rows === []) { + return $response->withStatus(404); + } + if (!$this->canRead($rows[0])) { + return $response->withStatus(403); + } + $code = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $rows[0]['codice_tessera']) ?: (string) $id; + return $this->pdfResponse($response, $db, $rows, $request->getQueryParams(), 'tessera_' . $code . '.pdf'); + } + + public function bulkPdf(Request $request, Response $response, mysqli $db): Response + { + if (!\App\Support\Permissions::can('users.manage')) { + return $response->withStatus(403); + } + $query = $request->getQueryParams(); + $ids = []; + foreach (explode(',', is_string($query['ids'] ?? null) ? $query['ids'] : '') as $part) { + $n = (int) trim($part); + if ($n > 0) { + $ids[$n] = $n; + } + } + if ($ids === []) { + return $response->withStatus(400); + } + if (count($ids) > self::MAX_BULK) { + $ids = array_slice($ids, 0, self::MAX_BULK, true); + } + $rows = array_values(array_filter($this->loadUsers($db, array_values($ids)), fn (array $row): bool => $this->canRead($row))); + if ($rows === []) { + return $response->withStatus(404); + } + return $this->pdfResponse($response, $db, $rows, $query, 'tessere.pdf'); + } + + /** Stream the stored photo (never cached by shared caches). */ + public function photo(Request $request, Response $response, mysqli $db, int $id): Response + { + if (!\App\Support\Permissions::can('users.manage')) { + return $response->withStatus(403); + } + $rows = $this->loadUsers($db, [$id]); + if ($rows === [] || !$this->canRead($rows[0])) { + return $response->withStatus($rows === [] ? 404 : 403); + } + $path = PatronPhoto::absolutePath($rows[0]['foto_profilo_url'] ?? null); + if ($path === null) { + return $response->withStatus(404); + } + $bytes = (string) file_get_contents($path); + $response->getBody()->write($bytes); + return $response + ->withHeader('Content-Type', 'image/jpeg') + ->withHeader('Content-Length', (string) strlen($bytes)) + ->withHeader('Cache-Control', 'private, no-store') + ->withHeader('X-Content-Type-Options', 'nosniff'); + } + + /** Save a photo sent as multipart field `photo` (webcam frame or picked file). JSON reply. */ + public function uploadPhoto(Request $request, Response $response, mysqli $db, int $id): Response + { + $guard = $this->editGuard($db, $id); + if ($guard !== null) { + return $this->json($response, ['success' => false, 'error' => $guard[1]], $guard[0]); + } + $files = $request->getUploadedFiles(); + $file = $files['photo'] ?? null; + if (!$file instanceof \Psr\Http\Message\UploadedFileInterface || $file->getError() !== UPLOAD_ERR_OK) { + $tooLarge = $file instanceof \Psr\Http\Message\UploadedFileInterface + && in_array($file->getError(), [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true); + return $this->json($response, ['success' => false, 'error' => $tooLarge ? __('La foto supera la dimensione massima consentita.') : __('Nessuna foto ricevuta.')], 400); + } + $tmp = $file->getStream()->getMetadata('uri'); + if (!is_string($tmp) || !is_file($tmp)) { + return $this->json($response, ['success' => false, 'error' => __('Nessuna foto ricevuta.')], 400); + } + $error = PatronPhoto::store($db, $id, $tmp); + if ($error !== null) { + $message = match ($error) { + 'too_large' => __('La foto supera la dimensione massima consentita.'), + 'invalid_image' => __('Il file non è un\'immagine JPEG, PNG o WebP valida.'), + default => __('Impossibile salvare la foto. Riprova più tardi.'), + }; + return $this->json($response, ['success' => false, 'error' => $message], $error === 'storage' || $error === 'db' ? 500 : 422); + } + \App\Support\SecureLogger::info('Patron photo updated', ['user_id' => $id, 'by' => $_SESSION['user']['id'] ?? null]); + return $this->json($response, [ + 'success' => true, + 'url' => url('/admin/users/' . $id . '/photo') . '?v=' . substr(sha1((string) PatronPhoto::storedFor($db, $id)), 0, 10), + ]); + } + + /** Remove the photo (the reader's right to erasure). JSON for fetch(), redirect for a plain form. */ + public function deletePhoto(Request $request, Response $response, mysqli $db, int $id): Response + { + $wantsJson = str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json'); + $guard = $this->editGuard($db, $id); + if ($guard !== null) { + return $wantsJson + ? $this->json($response, ['success' => false, 'error' => $guard[1]], $guard[0]) + : $response->withStatus($guard[0]); + } + $ok = PatronPhoto::delete($db, $id); + if ($ok) { + \App\Support\SecureLogger::info('Patron photo deleted', ['user_id' => $id, 'by' => $_SESSION['user']['id'] ?? null]); + } + if ($wantsJson) { + return $this->json($response, $ok ? ['success' => true] : ['success' => false, 'error' => __('Impossibile eliminare la foto. Riprova più tardi.')], $ok ? 200 : 500); + } + $_SESSION[$ok ? 'success_message' : 'error_message'] = $ok ? __('Foto eliminata.') : __('Impossibile eliminare la foto. Riprova più tardi.'); + return $response->withHeader('Location', url('/admin/users/details/' . $id))->withStatus(302); + } + + /** + * @param list> $rows + * @param array $query + */ + private function pdfResponse(Response $response, mysqli $db, array $rows, array $query, string $filename): Response + { + $repository = new SettingsRepository($db); + $settings = PatronCardSettings::resolve($repository); + $format = is_string($query['format'] ?? null) && in_array($query['format'], PatronCardSettings::FORMATS, true) + ? $query['format'] + : $settings['format']; + + $cards = []; + foreach ($rows as $row) { + $expiry = (string) ($row['data_scadenza_tessera'] ?? ''); + $cards[] = [ + 'name' => trim((string) ($row['nome'] ?? '') . ' ' . (string) ($row['cognome'] ?? '')), + 'code' => (string) ($row['codice_tessera'] ?? ''), + 'expiry' => $expiry !== '' && function_exists('format_date') ? format_date($expiry, false, '/') : $expiry, + 'photo' => $settings['show_photo'] ? PatronPhoto::absolutePath($row['foto_profilo_url'] ?? null) : null, + ]; + } + + $appName = (string) ConfigStore::get('app.name', 'Biblioteca'); + $generator = new PatronCardPdf($settings, $appName, $this->localLogo()); + $pdf = $generator->render($cards, $format); + + $response->getBody()->write($pdf); + return $response + ->withHeader('Content-Type', 'application/pdf') + ->withHeader('Content-Disposition', 'inline; filename="' . $filename . '"') + ->withHeader('Cache-Control', 'private, no-store'); + } + + /** The configured logo as a local file (remote logos are not fetched while rendering). */ + private function localLogo(): ?string + { + $logo = Branding::logo(); + if ($logo === '' || preg_match('#^https?://#i', $logo) === 1) { + return null; + } + $path = (string) (parse_url($logo, PHP_URL_PATH) ?? ''); + $public = realpath(dirname(__DIR__, 2) . '/public'); + if ($path === '' || $public === false) { + return null; + } + $file = realpath($public . '/' . ltrim($path, '/')); + return $file !== false && str_starts_with($file, $public . DIRECTORY_SEPARATOR) ? $file : null; + } + + /** + * @param list $ids + * @return list> + */ + private function loadUsers(mysqli $db, array $ids): array + { + $ids = array_values(array_filter(array_map('intval', $ids), static fn (int $n): bool => $n > 0)); + if ($ids === []) { + return []; + } + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $db->prepare("SELECT id, nome, cognome, codice_tessera, data_scadenza_tessera, foto_profilo_url, tipo_utente, ruolo_id, accesso_digitale + FROM utenti WHERE id IN ({$placeholders}) + ORDER BY cognome, nome, id"); + if ($stmt === false) { + return []; + } + $stmt->bind_param(str_repeat('i', count($ids)), ...$ids); + $stmt->execute(); + $result = $stmt->get_result(); + $rows = []; + while ($row = $result->fetch_assoc()) { + $rows[] = $row; + } + $stmt->close(); + return $rows; + } + + /** @param array $row */ + private function canRead(array $row): bool + { + return in_array($_SESSION['user']['tipo_utente'] ?? '', ['admin', 'staff'], true) + || \App\Support\UserRoleAssignment::canEdit($row); + } + + /** @return array{0:int,1:string}|null status and message when refused */ + private function editGuard(mysqli $db, int $id): ?array + { + if (!\App\Support\Permissions::can('users.manage')) { + return [403, __('Accesso negato')]; + } + $rows = $this->loadUsers($db, [$id]); + if ($rows === []) { + return [404, __('Utente non trovato')]; + } + if (!\App\Support\UserRoleAssignment::canEdit($rows[0])) { + return [403, __('Accesso negato')]; + } + return null; + } + + /** @param array $payload */ + private function json(Response $response, array $payload, int $status = 200): Response + { + $response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return $response->withHeader('Content-Type', 'application/json')->withStatus($status); + } +} diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index e72cc04e9..d563edd88 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -68,6 +68,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons $contactSettings = $this->resolveContactSettings($repository); $privacySettings = $this->resolvePrivacySettings($repository); $labelSettings = $this->resolveLabelSettings($repository); + $cardSettings = \App\Support\PatronCardSettings::resolve($repository); $eventSettings = $this->resolveEventSettings($repository); // Catalogue header (Settings → CMS): edit the configured site language. // Historical translations stay stored for later language changes. @@ -104,6 +105,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons 'contactSettings', 'privacySettings', 'labelSettings', + 'cardSettings', 'eventSettings', 'catalogHeaderLocales', 'catalogHeaderTexts', @@ -1301,6 +1303,20 @@ public function updateLabels(Request $request, Response $response, mysqli $db): return $this->redirect($response, '/admin/settings?tab=labels'); } + /** Save the library card settings (Settings → Tessere). */ + public function updateCards(Request $request, Response $response, mysqli $db): Response + { + $data = (array) $request->getParsedBody(); + // CSRF validated by CsrfMiddleware + + $repository = new SettingsRepository($db); + $repository->ensureTables(); + \App\Support\PatronCardSettings::save($repository, \App\Support\PatronCardSettings::sanitize($data)); + + $_SESSION['success_message'] = __('Impostazioni tessere aggiornate correttamente.'); + return $this->redirect($response, '/admin/settings?tab=cards'); + } + public function toggleApi(Request $request, Response $response, mysqli $db): Response { // Exposing or withdrawing the public API changes the application's diff --git a/app/Controllers/UserImportController.php b/app/Controllers/UserImportController.php new file mode 100644 index 000000000..bedfcf467 --- /dev/null +++ b/app/Controllers/UserImportController.php @@ -0,0 +1,465 @@ +withStatus(403); + } + $patronTypes = (new PatronTypeImport($db))->available(); + ob_start(); + require __DIR__ . '/../Views/utenti/import.php'; + $content = ob_get_clean(); + ob_start(); + require __DIR__ . '/../Views/layout.php'; + $html = ob_get_clean(); + $response->getBody()->write((string) $html); + return $response; + } + + /** A sample file with every recognised column. */ + public function example(Request $request, Response $response, mysqli $db): Response + { + if (!\App\Support\Permissions::can('users.manage')) { + return $response->withStatus(403); + } + $writer = Csv::writerToString(';', "'"); + $writer->insertOne(['nome', 'cognome', 'email', 'telefono', 'indirizzo', 'data_nascita', 'codice_tessera', 'data_scadenza_tessera', 'note']); + $writer->insertOne(['Maria', 'Rossi', 'maria.rossi@example.org', '+39 333 1234567', 'Via Roma 1, Milano', '15/04/1990', 'T0001', '31/12/2027', '']); + $writer->insertOne(['Luca', 'Bianchi', 'luca.bianchi@example.org', '', '', '1985-09-02', '', '', 'Socio sostenitore']); + $response->getBody()->write("\xEF\xBB\xBF" . $writer->toString()); + return $response + ->withHeader('Content-Type', 'text/csv; charset=UTF-8') + ->withHeader('Content-Disposition', 'attachment; filename="esempio_import_utenti.csv"') + ->withHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + } + + public function upload(Request $request, Response $response, mysqli $db): Response + { + @ini_set('display_errors', '0'); + if (!\App\Support\Permissions::can('users.manage')) { + return $this->json($response, ['success' => false, 'error' => __('Accesso negato')], 403); + } + $this->discardSessionFile(); + + $file = $request->getUploadedFiles()['csv_file'] ?? null; + if (!$file instanceof \Psr\Http\Message\UploadedFileInterface || $file->getError() !== UPLOAD_ERR_OK) { + return $this->json($response, ['success' => false, 'error' => __('Nessun file caricato')], 400); + } + $name = (string) $file->getClientFilename(); + if (!preg_match('/\.(csv|txt|tsv)$/i', $name)) { + return $this->json($response, ['success' => false, 'error' => __('Il file deve avere estensione .csv')], 400); + } + if ((int) $file->getSize() > UserImport::MAX_BYTES) { + return $this->json($response, ['success' => false, 'error' => __('Il file supera la dimensione massima di 5 MB.')], 400); + } + $tmp = $file->getStream()->getMetadata('uri'); + if (!is_string($tmp) || !is_file($tmp)) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile leggere il file caricato')], 400); + } + + $handle = fopen($tmp, 'r'); + if ($handle === false) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile leggere il file caricato')], 400); + } + $sample = []; + for ($i = 0; $i < 5 && !feof($handle); $i++) { + $sample[] = fgets($handle); + } + fclose($handle); + $delimiter = UserImport::detectDelimiter($sample); + if ($delimiter === null) { + return $this->json($response, ['success' => false, 'error' => __('File CSV non valido: usa ";", "," o TAB come separatore.')], 400); + } + + $dir = dirname(__DIR__, 2) . '/writable/uploads'; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + $saved = $dir . '/user_import_' . bin2hex(random_bytes(12)) . '.csv'; + if (!@copy($tmp, $saved)) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile salvare il file CSV caricato')], 500); + } + + try { + $reader = Csv::readerFromPath($saved, $delimiter); + $headers = array_map(static fn ($h): string => trim((string) $h), (array) $reader->nth(0)); + $total = max(0, count($reader) - 1); + $preview = []; + foreach ((new Statement())->offset(1)->limit(UserImport::PREVIEW_ROWS)->process($reader) as $row) { + $preview[] = array_map(static fn ($v): string => mb_substr(trim((string) $v), 0, 120), array_values((array) $row)); + } + } catch (\Throwable $e) { + @unlink($saved); + return $this->json($response, ['success' => false, 'error' => __('File CSV vuoto o formato non valido')], 400); + } + if ($headers === [] || $total === 0) { + @unlink($saved); + return $this->json($response, ['success' => false, 'error' => __('File CSV vuoto o formato non valido')], 400); + } + + $fields = $this->targetFields($db); + $_SESSION[self::SESSION_KEY] = [ + 'file' => $saved, + 'name' => mb_substr($name, 0, 255), + 'delimiter' => $delimiter, + 'total' => $total, + 'columns' => count($headers), + 'started' => false, + ]; + + return $this->json($response, [ + 'success' => true, + 'file_name' => $name, + 'total_rows' => $total, + 'headers' => $headers, + 'preview' => $preview, + 'fields' => $fields, + 'mapping' => UserImport::guessMapping($headers, $fields), + ]); + } + + public function start(Request $request, Response $response, mysqli $db): Response + { + @ini_set('display_errors', '0'); + if (!\App\Support\Permissions::can('users.manage')) { + return $this->json($response, ['success' => false, 'error' => __('Accesso negato')], 403); + } + $state = $_SESSION[self::SESSION_KEY] ?? null; + $body = json_decode((string) $request->getBody(), true); + if (!is_array($state) || !is_array($body) || !is_file((string) ($state['file'] ?? ''))) { + return $this->json($response, ['success' => false, 'error' => __('Sessione import scaduta o dati non validi')], 400); + } + $check = UserImport::validateMapping($body['mapping'] ?? null, $this->targetFields($db), (int) $state['columns']); + if ($check['error'] !== null) { + return $this->json($response, ['success' => false, 'error' => $check['error']], 422); + } + $state['mapping'] = $check['mapping']; + $state['send_invites'] = !empty($body['send_invites']); + $state['started'] = true; + $state['created'] = 0; + $state['duplicates'] = 0; + $state['failed'] = 0; + $state['invited'] = 0; + $state['invite_failed'] = 0; + $state['errors'] = []; + $state['seen_emails'] = []; + $state['seen_codes'] = []; + $state['next'] = 0; + $_SESSION[self::SESSION_KEY] = $state; + + return $this->json($response, [ + 'success' => true, + 'total_rows' => (int) $state['total'], + 'chunk_size' => UserImport::CHUNK_SIZE, + ]); + } + + public function chunk(Request $request, Response $response, mysqli $db): Response + { + @set_time_limit(300); + @ini_set('display_errors', '0'); + if (!\App\Support\Permissions::can('users.manage')) { + return $this->json($response, ['success' => false, 'error' => __('Accesso negato')], 403); + } + $state = $_SESSION[self::SESSION_KEY] ?? null; + $body = json_decode((string) $request->getBody(), true); + if (!is_array($state) || empty($state['started']) || !is_array($body)) { + return $this->json($response, ['success' => false, 'error' => __('Sessione import scaduta o dati non validi')], 400); + } + $start = max(0, (int) ($body['start'] ?? 0)); + // Chunks are processed in order exactly once: a replayed request (a + // retry after a timeout) must not insert the same rows again. + if ($start !== (int) $state['next']) { + return $this->json($response, ['success' => false, 'error' => __('Blocco di righe non valido.')], 409); + } + $total = (int) $state['total']; + + try { + $reader = Csv::readerFromPath((string) $state['file'], (string) $state['delimiter']); + } catch (\Throwable) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile aprire il file CSV')], 500); + } + + $customDefinitions = RegistrationFields::definitions($db); + $locales = array_map('strval', array_keys(\App\Support\I18n::getAvailableLocales())); + $visible = [ + 'sesso' => RegistrationFields::isVisible('sesso'), + 'cod_fiscale' => RegistrationFields::isVisible('cod_fiscale'), + ]; + $installLocale = \App\Support\I18n::normalizeLocaleCode(\App\Support\I18n::getInstallationLocale()); + if (!in_array($installLocale, $locales, true)) { + $installLocale = 'it_IT'; + } + $patronTypes = new PatronTypeImport($db); + $notifier = $state['send_invites'] ? new NotificationService($db) : null; + + $line = $start + 2; // header is line 1 + $records = (new Statement())->offset(1 + $start)->limit(UserImport::CHUNK_SIZE)->process($reader); + $processed = 0; + foreach ($records as $row) { + $processed++; + $row = array_values((array) $row); + $label = ''; + try { + $parsed = UserImport::normalizeRow($row, $state['mapping'], $customDefinitions, $locales, $visible); + $record = $parsed['record']; + $label = trim((string) ($record['nome'] ?? '') . ' ' . (string) ($record['cognome'] ?? '')) ?: (string) ($record['email'] ?? ''); + if ($parsed['error'] !== null) { + $this->rowError($state, $line, $this->rowLabel($row, $state['mapping']), $parsed['error'], 'validation'); + continue; + } + $email = (string) $record['email']; + $code = (string) ($record['codice_tessera'] ?? ''); + if (isset($state['seen_emails'][$email]) || $this->exists($db, 'email', $email)) { + $this->duplicate($state, $line, $label, sprintf(__('Email già registrata: %s'), $email)); + continue; + } + if ($code !== '' && (isset($state['seen_codes'][mb_strtolower($code)]) || $this->exists($db, 'codice_tessera', $code))) { + $this->duplicate($state, $line, $label, sprintf(__('Codice tessera già assegnato: %s'), $code)); + continue; + } + if ($code === '') { + $record['codice_tessera'] = $this->generateCardCode($db); + } + $record['locale'] ??= $installLocale; + + $userId = $this->insertUser($db, $record); + $state['seen_emails'][$email] = true; + $state['seen_codes'][mb_strtolower((string) $record['codice_tessera'])] = true; + $state['created']++; + + if ($parsed['custom'] !== []) { + RegistrationFields::saveValues($db, $userId, $parsed['custom']); + } + if ($patronTypes->assign($userId, $parsed['tipologia']) === 'unknown') { + $state['errors'][] = ['line' => $line, 'title' => $label, 'message' => sprintf(__('Tipologia lettore sconosciuta: %s (assegnata quella predefinita)'), (string) $parsed['tipologia']), 'type' => 'warning']; + } + if ($notifier !== null) { + $token = PasswordSetupToken::issue($db, $userId); + if ($token !== null && $notifier->sendUserPasswordSetup($userId, $token)) { + $state['invited']++; + } else { + $state['invite_failed']++; + $state['errors'][] = ['line' => $line, 'title' => $label, 'message' => __('Utente importato, ma l\'invito non è stato inviato.'), 'type' => 'email']; + } + } + } catch (\Throwable $e) { + $field = $e instanceof \mysqli_sql_exception ? \App\Support\UniqueViolation::fieldFor($e) : 'other'; + if ($field === 'email' || $field === 'codice_tessera' || $field === 'cod_fiscale') { + $this->duplicate($state, $line, $label, match ($field) { + 'email' => __('Email già registrata'), + 'codice_tessera' => __('Codice tessera già assegnato'), + default => __('Codice fiscale già registrato'), + }); + } else { + SecureLogger::error('[UserImport] row failed', ['line' => $line, 'error' => $e->getMessage()]); + $this->rowError($state, $line, $label, __('Errore del database'), 'database'); + } + } finally { + $line++; + } + } + + $state['next'] = $start + $processed; + $done = $processed === 0 || $state['next'] >= $total; + $payload = [ + 'success' => true, + 'processed' => (int) $state['next'], + 'total_rows' => $total, + 'created' => (int) $state['created'], + 'duplicates' => (int) $state['duplicates'], + 'failed' => (int) $state['failed'], + 'invited' => (int) $state['invited'], + 'done' => $done, + ]; + + if ($done) { + $payload['import_id'] = $this->finish($db, $state); + $payload['invite_failed'] = (int) $state['invite_failed']; + $payload['errors'] = array_slice($state['errors'], 0, 50); + $this->discardSessionFile(); + unset($_SESSION[self::SESSION_KEY]); + SecureLogger::info('Users imported from CSV', [ + 'created' => $state['created'], + 'duplicates' => $state['duplicates'], + 'failed' => $state['failed'], + 'by' => $_SESSION['user']['id'] ?? null, + ]); + } else { + $_SESSION[self::SESSION_KEY] = $state; + } + return $this->json($response, $payload); + } + + /** Write the run into import_logs; returns its import_id ('' when logging failed). */ + private function finish(mysqli $db, array $state): string + { + try { + $logger = new ImportLogger($db, 'users', (string) $state['name'], isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null); + $logger->setStats(['imported' => (int) $state['created'], 'failed' => (int) $state['failed']]); + foreach ($state['errors'] as $error) { + $logger->addError((int) $error['line'], (string) $error['title'], (string) $error['message'], (string) $error['type']); + } + $logger->complete((int) $state['total']); + return $logger->getImportId(); + } catch (\Throwable $e) { + SecureLogger::error('[UserImport] could not write the import log: ' . $e->getMessage()); + return ''; + } + } + + /** @param array $record */ + private function insertUser(mysqli $db, array $record): int + { + // Never imported: the password is a random secret nobody knows, the + // reader sets a real one through the setup invitation. + $password = password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT); + $stato = 'attivo'; + $tipo = 'standard'; + $verified = 1; + $stmt = $db->prepare('INSERT INTO utenti ( + nome, cognome, email, telefono, password, indirizzo, cod_fiscale, data_nascita, sesso, + codice_tessera, data_scadenza_tessera, stato, tipo_utente, note_utente, locale, email_verificata + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'); + if ($stmt === false) { + throw new \RuntimeException($db->error); + } + $stmt->bind_param( + str_repeat('s', 15) . 'i', + $record['nome'], + $record['cognome'], + $record['email'], + $record['telefono'], + $password, + $record['indirizzo'], + $record['cod_fiscale'], + $record['data_nascita'], + $record['sesso'], + $record['codice_tessera'], + $record['data_scadenza_tessera'], + $stato, + $tipo, + $record['note_utente'], + $record['locale'], + $verified + ); + try { + $stmt->execute(); + return (int) $stmt->insert_id; + } finally { + $stmt->close(); + } + } + + private function exists(mysqli $db, string $column, string $value): bool + { + $sql = $column === 'email' + ? 'SELECT 1 FROM utenti WHERE LOWER(email) = LOWER(?) LIMIT 1' + : 'SELECT 1 FROM utenti WHERE codice_tessera = ? LIMIT 1'; + $stmt = $db->prepare($sql); + if ($stmt === false) { + return false; + } + $stmt->bind_param('s', $value); + $stmt->execute(); + $found = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + return $found; + } + + /** Same shape as the codes UsersController generates for new readers. */ + private function generateCardCode(mysqli $db): string + { + do { + $code = 'T' . strtoupper(substr(bin2hex(random_bytes(6)), 0, 10)); + } while ($this->exists($db, 'codice_tessera', $code)); + return $code; + } + + private function rowError(array &$state, int $line, string $label, string $message, string $type): void + { + $state['failed']++; + $state['errors'][] = ['line' => $line, 'title' => $label, 'message' => $message, 'type' => $type]; + } + + private function duplicate(array &$state, int $line, string $label, string $message): void + { + $state['duplicates']++; + $state['errors'][] = ['line' => $line, 'title' => $label, 'message' => $message, 'type' => 'duplicate']; + } + + /** + * A readable name for a row that failed validation (nome/cognome/email cells). + * + * @param list $row + * @param array $mapping + */ + private function rowLabel(array $row, array $mapping): string + { + $parts = []; + foreach (['nome', 'cognome', 'email'] as $field) { + $index = array_search($field, $mapping, true); + if ($index !== false && trim((string) ($row[$index] ?? '')) !== '') { + $parts[] = trim((string) $row[$index]); + } + } + return mb_substr(implode(' ', $parts), 0, 200); + } + + /** @return array */ + private function targetFields(mysqli $db): array + { + return UserImport::targetFields(RegistrationFields::definitions($db), (new PatronTypeImport($db))->available()); + } + + private function discardSessionFile(): void + { + $file = $_SESSION[self::SESSION_KEY]['file'] ?? null; + if (is_string($file) && str_contains($file, '/writable/uploads/user_import_') && is_file($file)) { + @unlink($file); + } + } + + /** @param array $payload */ + private function json(Response $response, array $payload, int $status = 200): Response + { + $response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return $response->withHeader('Content-Type', 'application/json')->withStatus($status); + } +} diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index 01202f669..5e44ae548 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -707,6 +707,9 @@ public function delete(Request $request, Response $response, mysqli $db, int $id return $response->withHeader('Location', url('/admin/users?error=db_error'))->withStatus(302); } + // The photo is personal data: it goes with the account. + \App\Support\PatronPhoto::deleteFile($userToDelete['foto_profilo_url'] ?? null); + // Audit logging for user deletion \App\Support\SecureLogger::info('User deleted', [ 'deleted_user_id' => $id, diff --git a/app/Controllers/UtentiApiController.php b/app/Controllers/UtentiApiController.php index 79c425f76..c3a7d8324 100644 --- a/app/Controllers/UtentiApiController.php +++ b/app/Controllers/UtentiApiController.php @@ -92,12 +92,13 @@ public function list(Request $request, Response $response, mysqli $db): Response // Map column index to database column // 0: nome/cognome, 1: email, 2: telefono, 3: tipo_utente, 4: stato + // Column 0 of the on-screen table is the selection checkbox (not orderable). $columnMap = [ - 0 => 'u.cognome', // Nome (sorts by cognome first) - 1 => 'u.email', // Email - 2 => 'u.telefono', // Telefono - 3 => 'u.tipo_utente', // Ruolo - 4 => 'u.stato', // Stato + 1 => 'u.cognome', // Nome (sorts by cognome first) + 2 => 'u.email', // Email + 3 => 'u.telefono', // Telefono + 4 => 'u.tipo_utente', // Ruolo + 5 => 'u.stato', // Stato ]; // Parse order parameter from DataTables @@ -109,7 +110,7 @@ public function list(Request $request, Response $response, mysqli $db): Response $orderColumn = $columnMap[$colIdx]; $orderDir = $dir; // Only add secondary sort for nome column - $orderExtra = ($colIdx === 0) ? ', u.nome ' . $dir : ''; + $orderExtra = ($colIdx === 1) ? ', u.nome ' . $dir : ''; } } diff --git a/app/Routes/web.php b/app/Routes/web.php index 65b12fff7..631e2c1b2 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -718,6 +718,17 @@ return $controller->updateLabels($request, $response, $db); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Library cards (patron-cards-import): the tab lives in /admin/settings like + // the labels one; the GET form of the address opens that tab. + $app->get('/admin/settings/cards', function ($request, $response) { + return $response->withHeader('Location', url('/admin/settings?tab=cards'))->withStatus(302); + })->add(new AdminAuthMiddleware()); + $app->post('/admin/settings/cards', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + $controller = new SettingsController(); + return $controller->updateCards($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/settings/sharing', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); $controller = new SettingsController(); @@ -1271,6 +1282,50 @@ return $controller->activateDirectly($request, $response, $db, $args); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Library cards, patron photo and user CSV import (patron-cards-import). + $app->get('/admin/users/{id:\d+}/card-pdf', function ($request, $response, $args) use ($app) { + $controller = new \App\Controllers\PatronCardsController(); + return $controller->cardPdf($request, $response, $app->getContainer()->get('db'), (int) $args['id']); + })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->get('/admin/users/cards-pdf', function ($request, $response) use ($app) { + $controller = new \App\Controllers\PatronCardsController(); + return $controller->bulkPdf($request, $response, $app->getContainer()->get('db')); + })->add(new \App\Middleware\RateLimitMiddleware(15, 60))->add(new AdminAuthMiddleware()); + $app->get('/admin/users/{id:\d+}/photo', function ($request, $response, $args) use ($app) { + $controller = new \App\Controllers\PatronCardsController(); + return $controller->photo($request, $response, $app->getContainer()->get('db'), (int) $args['id']); + })->add(new AdminAuthMiddleware()); + $app->post('/admin/users/{id:\d+}/photo', function ($request, $response, $args) use ($app) { + $controller = new \App\Controllers\PatronCardsController(); + return $controller->uploadPhoto($request, $response, $app->getContainer()->get('db'), (int) $args['id']); + })->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->post('/admin/users/{id:\d+}/photo/delete', function ($request, $response, $args) use ($app) { + $controller = new \App\Controllers\PatronCardsController(); + return $controller->deletePhoto($request, $response, $app->getContainer()->get('db'), (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->get('/admin/users/import', function ($request, $response) use ($app) { + $controller = new \App\Controllers\UserImportController(); + return $controller->page($request, $response, $app->getContainer()->get('db')); + })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->get('/admin/users/import/example', function ($request, $response) use ($app) { + $controller = new \App\Controllers\UserImportController(); + return $controller->example($request, $response, $app->getContainer()->get('db')); + })->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->post('/admin/users/import/upload', function ($request, $response) use ($app) { + $controller = new \App\Controllers\UserImportController(); + return $controller->upload($request, $response, $app->getContainer()->get('db')); + })->add(new CsrfMiddleware())->add(new \App\Middleware\RateLimitMiddleware(30, 60))->add(new AdminAuthMiddleware()); + $app->post('/admin/users/import/start', function ($request, $response) use ($app) { + $controller = new \App\Controllers\UserImportController(); + return $controller->start($request, $response, $app->getContainer()->get('db')); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // No RateLimitMiddleware on /chunk: same rationale as the book import (#113), + // one sequential request per chunk, bound to the session's import. + $app->post('/admin/users/import/chunk', function ($request, $response) use ($app) { + $controller = new \App\Controllers\UserImportController(); + return $controller->chunk($request, $response, $app->getContainer()->get('db')); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // CSV Export route for users $app->get('/admin/users/export/csv', function ($request, $response) use ($app) { $controller = new \App\Controllers\UsersController(); diff --git a/app/Support/ContentSecurityPolicy.php b/app/Support/ContentSecurityPolicy.php index 50d0b48f4..385a1a40b 100644 --- a/app/Support/ContentSecurityPolicy.php +++ b/app/Support/ContentSecurityPolicy.php @@ -28,6 +28,24 @@ public static function createNonce(): string return implode('-', str_split(bin2hex(random_bytes(16)), 8)); } + /** + * The Permissions-Policy header sent with every response. + * + * camera=(self): two same-origin features call getUserMedia — the copy + * barcode scanner (zxing-wasm) on the loan/return pages and the patron + * photo taken from the webcam in the user form. Denying it (camera=()) + * blocks both; no other origin (no embedded frame) gets the camera. + * geolocation and microphone stay denied: nothing in the app uses them. + * The CSP needs nothing for the photo: the live MediaStream is not a URL + * that media-src governs, the captured frame is drawn on a canvas and sent + * to the same origin (connect-src 'self'), and the stored photo is an + * ordinary same-origin image (img-src 'self'). + */ + public static function permissionsPolicy(): string + { + return 'geolocation=(), microphone=(), camera=(self)'; + } + public static function header(string $nonce, bool $upgradeInsecureRequests = false): string { if (!preg_match(self::NONCE_PATTERN, $nonce)) { diff --git a/app/Support/PatronCardPdf.php b/app/Support/PatronCardPdf.php new file mode 100644 index 000000000..5b923076a --- /dev/null +++ b/app/Support/PatronCardPdf.php @@ -0,0 +1,424 @@ +settings = $settings; + $this->title = $settings['title'] !== '' ? $settings['title'] : $appName; + $this->logoFile = ($settings['show_logo'] && $logoFile !== null && self::isUsableImage($logoFile)) ? $logoFile : null; + } + + /** + * Card size (mm) for the configured layout, as [width, height]. + * + * @return array{0: float, 1: float} + */ + public function cardSize(): array + { + return $this->settings['layout'] === 'portrait' + ? [self::CARD_H, self::CARD_W] + : [self::CARD_W, self::CARD_H]; + } + + /** + * Slots of one A4 sheet as [columns, rows, left, top] for the layout. + * + * @return array{0:int, 1:int, 2:float, 3:float} + */ + public function sheetGrid(): array + { + [$w, $h] = $this->cardSize(); + $cols = (int) floor(self::A4_W / $w); + $rows = (int) floor((self::A4_H - 20) / $h); + $cols = max(1, min($cols, 3)); + $rows = max(1, $rows); + $left = (self::A4_W - $cols * $w) / 2; + $top = (self::A4_H - $rows * $h) / 2; + return [$cols, $rows, $left, $top]; + } + + /** + * @param list $cards + * @param string $format 'single' or 'sheet' + * @return string the PDF bytes + */ + public function render(array $cards, string $format): string + { + [$w, $h] = $this->cardSize(); + $portrait = $h > $w; + $hasBack = $this->settings['back_text'] !== ''; + + if ($format === 'sheet') { + $pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8', false); + } else { + $pdf = new TCPDF($portrait ? 'P' : 'L', 'mm', [$w, $h], true, 'UTF-8', false); + } + $pdf->SetCreator($this->title); + $pdf->SetAuthor($this->title); + $pdf->SetTitle(__('Tessere della biblioteca')); + $pdf->setPrintHeader(false); + $pdf->setPrintFooter(false); + $pdf->SetMargins(0, 0, 0); + $pdf->SetAutoPageBreak(false, 0); + $pdf->setCellPaddings(0, 0, 0, 0); + + if ($format === 'sheet') { + [$cols, $rows, $left, $top] = $this->sheetGrid(); + $perSheet = $cols * $rows; + foreach (array_chunk($cards, $perSheet) as $sheet) { + $pdf->AddPage(); + foreach ($sheet as $i => $card) { + $col = $i % $cols; + $row = intdiv($i, $cols); + $x = $left + $col * $w; + $y = $top + $row * $h; + $this->cutGuide($pdf, $x, $y, $w, $h); + $this->front($pdf, $x, $y, $w, $h, $card); + } + if ($hasBack) { + $pdf->AddPage(); + foreach ($sheet as $i => $card) { + // Mirror the columns: on a long-edge duplex print the + // back of column c lands behind column (cols-1-c). + $col = $cols - 1 - ($i % $cols); + $row = intdiv($i, $cols); + $x = $left + $col * $w; + $y = $top + $row * $h; + $this->cutGuide($pdf, $x, $y, $w, $h); + $this->back($pdf, $x, $y, $w, $h); + } + } + } + } else { + foreach ($cards as $card) { + $pdf->AddPage(); + $this->front($pdf, 0, 0, $w, $h, $card); + if ($hasBack) { + $pdf->AddPage(); + $this->back($pdf, 0, 0, $w, $h); + } + } + } + + if ($cards === []) { + $pdf->AddPage(); + } + + return $pdf->Output('', 'S'); + } + + /** @param array{name:string, code:string, expiry:string, photo:?string} $card */ + private function front(TCPDF $pdf, float $x, float $y, float $w, float $h, array $card): void + { + switch ($this->settings['layout']) { + case 'minimal': + $this->frontMinimal($pdf, $x, $y, $w, $h, $card); + break; + case 'portrait': + $this->frontPortrait($pdf, $x, $y, $w, $h, $card); + break; + default: + $this->frontClassic($pdf, $x, $y, $w, $h, $card); + } + } + + /** + * Header band with logo and title; photo on the left; name, code and expiry + * on the right; Code128 across the bottom of the text column, or a QR code + * at its right. + * + * @param array{name:string, code:string, expiry:string, photo:?string} $card + */ + private function frontClassic(TCPDF $pdf, float $x, float $y, float $w, float $h, array $card): void + { + $band = 11.0; + $this->band($pdf, $x, $y, $w, $band); + + $pad = 3.0; + $top = $y + $band + 3; + $textX = $x + $pad; + if ($this->settings['show_photo']) { + $this->photo($pdf, $x + $pad, $top, 22, 28, $card); + $textX = $x + $pad + 22 + 3; + } + $textW = $x + $w - $pad - $textX; + $qr = $this->settings['code_type'] === 'qr'; + $qrSize = 20.0; + if ($qr) { + $textW -= $qrSize + 2; + } + + $this->fitText($pdf, $textX, $top, $textW, 6, $card['name'], 'B', 11); + $this->codeLine($pdf, $textX, $top + 7, $textW, $card['code'], 9); + if ($this->settings['show_expiry'] && $card['expiry'] !== '') { + $this->fitText($pdf, $textX, $top + 12, $textW, 4, __('Scadenza') . ': ' . $card['expiry'], '', 7, [80, 80, 80]); + } + + if ($qr) { + $this->qr($pdf, $x + $w - $pad - $qrSize, $top, $qrSize, $card['code']); + } else { + $this->barcode($pdf, $textX, $y + $h - $pad - 11, $textW, 11, $card['code']); + } + } + + /** + * No band and no photo: logo and title small at the top, the name large, + * the code as a wide barcode (or a QR on the right). + * + * @param array{name:string, code:string, expiry:string, photo:?string} $card + */ + private function frontMinimal(TCPDF $pdf, float $x, float $y, float $w, float $h, array $card): void + { + $pad = 4.0; + $titleX = $x + $pad; + if ($this->logoFile !== null) { + $logoW = $this->logoWidth(6, $w * 0.35); + $this->image($pdf, $this->logoFile, $x + $pad, $y + $pad, $logoW, 6, true); + $titleX = $x + $pad + $logoW + 2; + } + $this->fitText($pdf, $titleX, $y + $pad + 0.5, $x + $w - $pad - $titleX, 5, $this->title, 'B', 8, [70, 70, 70]); + + $qr = $this->settings['code_type'] === 'qr'; + $qrSize = 22.0; + $textW = $w - 2 * $pad - ($qr ? $qrSize + 3 : 0); + $this->fitText($pdf, $x + $pad, $y + 15, $textW, 8, $card['name'], 'B', 14); + if ($this->settings['show_expiry'] && $card['expiry'] !== '') { + $this->fitText($pdf, $x + $pad, $y + 24, $textW, 4, __('Scadenza') . ': ' . $card['expiry'], '', 7, [80, 80, 80]); + } + if ($qr) { + $this->qr($pdf, $x + $w - $pad - $qrSize, $y + 14, $qrSize, $card['code']); + $this->codeLine($pdf, $x + $pad, $y + 30, $textW, $card['code'], 10); + } else { + $this->barcode($pdf, $x + $pad, $y + 30, $w - 2 * $pad, 13, $card['code']); + $this->codeLine($pdf, $x + $pad, $y + 44, $w - 2 * $pad, $card['code'], 9, 'C'); + } + } + + /** + * Vertical card: band on top, photo centred, then name, code and expiry. + * + * @param array{name:string, code:string, expiry:string, photo:?string} $card + */ + private function frontPortrait(TCPDF $pdf, float $x, float $y, float $w, float $h, array $card): void + { + $band = 12.0; + $this->band($pdf, $x, $y, $w, $band); + $pad = 3.0; + $cursor = $y + $band + 3; + if ($this->settings['show_photo']) { + $this->photo($pdf, $x + ($w - 26) / 2, $cursor, 26, 32, $card); + $cursor += 34; + } else { + $cursor += 4; + } + $this->fitText($pdf, $x + $pad, $cursor, $w - 2 * $pad, 6, $card['name'], 'B', 10, [0, 0, 0], 'C'); + $cursor += 7; + if ($this->settings['code_type'] === 'qr') { + $this->qr($pdf, $x + ($w - 17) / 2, $cursor, 17, $card['code']); + $cursor += 18; + } else { + $this->barcode($pdf, $x + $pad + 2, $cursor, $w - 2 * $pad - 4, 10, $card['code']); + $cursor += 11; + } + $this->codeLine($pdf, $x + $pad, $cursor, $w - 2 * $pad, $card['code'], 8, 'C'); + if ($this->settings['show_expiry'] && $card['expiry'] !== '') { + $this->fitText($pdf, $x + $pad, $y + $h - $pad - 4, $w - 2 * $pad, 4, __('Scadenza') . ': ' . $card['expiry'], '', 7, [80, 80, 80], 'C'); + } + } + + private function back(TCPDF $pdf, float $x, float $y, float $w, float $h): void + { + $pad = 4.0; + $this->fitText($pdf, $x + $pad, $y + $pad, $w - 2 * $pad, 5, $this->title, 'B', 8); + $areaY = $y + $pad + 6; + $areaH = $h - 2 * $pad - 6; + $areaW = $w - 2 * $pad; + $text = $this->settings['back_text']; + $size = 7.0; + $pdf->SetFont(self::FONT, '', $size); + while ($size > 4.5 && $pdf->getStringHeight($areaW, $text) > $areaH) { + $size -= 0.25; + $pdf->SetFont(self::FONT, '', $size); + } + $pdf->SetTextColor(40, 40, 40); + $pdf->MultiCell($areaW, $areaH, $text, 0, 'L', false, 1, $x + $pad, $areaY, true, 0, false, true, $areaH, 'T', true); + $pdf->SetTextColor(0, 0, 0); + } + + private function band(TCPDF $pdf, float $x, float $y, float $w, float $hBand): void + { + $pdf->SetFillColor(31, 41, 55); + $pdf->Rect($x, $y, $w, $hBand, 'F'); + $titleX = $x + 3; + if ($this->logoFile !== null) { + $logoW = $this->logoWidth($hBand - 3, $w * 0.35); + $this->image($pdf, $this->logoFile, $x + 3, $y + 1.5, $logoW, $hBand - 3, true); + $titleX = $x + 3 + $logoW + 2; + } + $this->fitText($pdf, $titleX, $y + ($hBand - 5) / 2, $x + $w - 3 - $titleX, 5, $this->title, 'B', 9, [255, 255, 255]); + } + + /** @param array{name:string, code:string, expiry:string, photo:?string} $card */ + private function photo(TCPDF $pdf, float $x, float $y, float $w, float $h, array $card): void + { + $file = $card['photo']; + if ($file !== null && self::isUsableImage($file)) { + // Fit the photo in the box, centred, keeping its proportions. + $this->image($pdf, $file, $x, $y, $w, $h, true); + $pdf->SetDrawColor(200, 200, 200); + $pdf->Rect($x, $y, $w, $h, 'D'); + return; + } + $pdf->SetFillColor(229, 231, 235); + $pdf->Rect($x, $y, $w, $h, 'F'); + $initials = ''; + foreach (preg_split('/\s+/u', trim($card['name'])) ?: [] as $part) { + if ($part !== '' && mb_strlen($initials) < 2) { + $initials .= mb_strtoupper(mb_substr($part, 0, 1)); + } + } + $pdf->SetFont(self::FONT, 'B', 16); + $pdf->SetTextColor(120, 120, 120); + $pdf->MultiCell($w, $h, $initials, 0, 'C', false, 1, $x, $y, true, 0, false, true, $h, 'M'); + $pdf->SetTextColor(0, 0, 0); + } + + private function barcode(TCPDF $pdf, float $x, float $y, float $w, float $h, string $code): void + { + if ($code === '' || preg_match('/^[\x20-\x7E]+$/', $code) !== 1) { + return; // Code128 encodes printable ASCII only; the text line still shows the code. + } + $pdf->write1DBarcode($code, 'C128', $x, $y, $w, $h, 0.4, [ + 'stretch' => true, + 'fitwidth' => false, + 'border' => false, + 'text' => false, + 'fgcolor' => [0, 0, 0], + 'bgcolor' => false, + ], 'N'); + } + + private function qr(TCPDF $pdf, float $x, float $y, float $size, string $code): void + { + if ($code === '') { + return; + } + $pdf->write2DBarcode($code, 'QRCODE,M', $x, $y, $size, $size, [ + 'border' => false, + 'padding' => 0, + 'fgcolor' => [0, 0, 0], + 'bgcolor' => false, + ], 'N'); + } + + /** The card code as text: Helvetica when it is plain ASCII, the Unicode font otherwise. */ + private function codeLine(TCPDF $pdf, float $x, float $y, float $w, string $code, float $size, string $align = 'L'): void + { + $font = preg_match('/^[\x20-\x7E]*$/', $code) === 1 ? self::CODE_FONT : self::FONT; + $pdf->SetFont($font, 'B', $size); + while ($size > 5 && $pdf->GetStringWidth($code) > $w) { + $size -= 0.5; + $pdf->SetFont($font, 'B', $size); + } + $pdf->SetTextColor(0, 0, 0); + $pdf->SetXY($x, $y); + $pdf->Cell($w, 4, $code, 0, 0, $align, false, '', 0, false, 'T', 'T'); + } + + /** @param array{0:int,1:int,2:int} $color */ + private function fitText(TCPDF $pdf, float $x, float $y, float $w, float $h, string $text, string $style, float $size, array $color = [0, 0, 0], string $align = 'L'): void + { + if ($text === '' || $w <= 1) { + return; + } + $pdf->SetFont(self::FONT, $style, $size); + while ($size > 5 && $pdf->GetStringWidth($text) > $w) { + $size -= 0.5; + $pdf->SetFont(self::FONT, $style, $size); + } + $pdf->SetTextColor($color[0], $color[1], $color[2]); + $pdf->SetXY($x, $y); + // Cell with stretch 0 and no fitting beyond the font loop: a name + // longer than the minimum font allows is clipped, never overprinted. + $pdf->Cell($w, $h, $text, 0, 0, $align, false, '', 1, false, 'T', 'T'); + $pdf->SetTextColor(0, 0, 0); + } + + /** Width (mm) the logo takes at height $h, from its pixel proportions, capped at $max. */ + private function logoWidth(float $h, float $max): float + { + $info = $this->logoFile !== null ? @getimagesize($this->logoFile) : false; + if ($info === false || $info[1] <= 0) { + return $h; + } + return min($max, $h * ($info[0] / $info[1])); + } + + private function cutGuide(TCPDF $pdf, float $x, float $y, float $w, float $h): void + { + $pdf->SetLineWidth(0.1); + $pdf->SetDrawColor(190, 190, 190); + $pdf->Rect($x, $y, $w, $h, 'D'); + } + + private function image(TCPDF $pdf, string $file, float $x, float $y, float $w, float $h, bool $fit = false): void + { + try { + $pdf->Image($file, $x, $y, $w, $h, '', '', '', true, 300, '', false, false, 0, $fit ? 'CM' : false); + } catch (\Throwable $e) { + SecureLogger::warning('[PatronCardPdf] image skipped: ' . $e->getMessage()); + } + } + + /** TCPDF stops the request on an unreadable image, so only proven JPEG/PNG files reach it. */ + private static function isUsableImage(string $file): bool + { + if (!is_file($file) || !is_readable($file)) { + return false; + } + $info = @getimagesize($file); + return $info !== false && in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG], true); + } +} diff --git a/app/Support/PatronCardSettings.php b/app/Support/PatronCardSettings.php new file mode 100644 index 000000000..19e7cae55 --- /dev/null +++ b/app/Support/PatronCardSettings.php @@ -0,0 +1,111 @@ + 'classic', + 'code_type' => 'code128', + 'format' => 'single', + 'title' => '', + 'back_text' => '', + 'show_photo' => true, + 'show_logo' => true, + 'show_expiry' => true, + ]; + } + + /** + * @return array{layout:string, code_type:string, format:string, title:string, back_text:string, show_photo:bool, show_logo:bool, show_expiry:bool} + */ + public static function resolve(SettingsRepository $repository): array + { + $d = self::defaults(); + $raw = [ + 'card_layout' => (string) $repository->get(self::CATEGORY, 'layout', $d['layout']), + 'card_code_type' => (string) $repository->get(self::CATEGORY, 'code_type', $d['code_type']), + 'card_format' => (string) $repository->get(self::CATEGORY, 'format', $d['format']), + 'card_title' => (string) $repository->get(self::CATEGORY, 'title', $d['title']), + 'card_back_text' => (string) $repository->get(self::CATEGORY, 'back_text', $d['back_text']), + ]; + foreach (['show_photo', 'show_logo', 'show_expiry'] as $flag) { + if ($repository->get(self::CATEGORY, $flag, $d[$flag] ? '1' : '0') === '1') { + $raw['card_' . $flag] = '1'; + } + } + return self::sanitize($raw); + } + + /** + * Normalise a submitted settings form (or stored values) into the settings + * array. Unknown enum values fall back to the default; text is trimmed, + * stripped of tags and length-capped; an absent checkbox means "off". + * + * @param array $post + * @return array{layout:string, code_type:string, format:string, title:string, back_text:string, show_photo:bool, show_logo:bool, show_expiry:bool} + */ + public static function sanitize(array $post): array + { + $d = self::defaults(); + $pick = static function (mixed $value, array $allowed, string $fallback): string { + $value = is_scalar($value) ? strtolower(trim((string) $value)) : ''; + return in_array($value, $allowed, true) ? $value : $fallback; + }; + $text = static function (mixed $value, int $max, bool $multiline): string { + $value = is_scalar($value) ? (string) $value : ''; + $value = strip_tags(str_replace("\r\n", "\n", $value)); + $value = $multiline + ? (string) preg_replace("/\n{3,}/", "\n\n", $value) + : (string) preg_replace('/\s+/u', ' ', $value); + $value = trim($value); + return mb_substr($value, 0, $max, 'UTF-8'); + }; + return [ + 'layout' => $pick($post['card_layout'] ?? null, self::LAYOUTS, $d['layout']), + 'code_type' => $pick($post['card_code_type'] ?? null, self::CODE_TYPES, $d['code_type']), + 'format' => $pick($post['card_format'] ?? null, self::FORMATS, $d['format']), + 'title' => $text($post['card_title'] ?? '', self::TITLE_MAX, false), + 'back_text' => $text($post['card_back_text'] ?? '', self::BACK_TEXT_MAX, true), + 'show_photo' => !empty($post['card_show_photo']), + 'show_logo' => !empty($post['card_show_logo']), + 'show_expiry' => !empty($post['card_show_expiry']), + ]; + } + + /** + * @param array{layout:string, code_type:string, format:string, title:string, back_text:string, show_photo:bool, show_logo:bool, show_expiry:bool} $settings + */ + public static function save(SettingsRepository $repository, array $settings): void + { + foreach (['layout', 'code_type', 'format', 'title', 'back_text'] as $key) { + $repository->set(self::CATEGORY, $key, $settings[$key]); + } + foreach (['show_photo', 'show_logo', 'show_expiry'] as $flag) { + $repository->set(self::CATEGORY, $flag, $settings[$flag] ? '1' : '0'); + } + } +} diff --git a/app/Support/PatronPhoto.php b/app/Support/PatronPhoto.php new file mode 100644 index 000000000..184115ebc --- /dev/null +++ b/app/Support/PatronPhoto.php @@ -0,0 +1,189 @@ +-.jpg`); a random part in the name means a new + * photo never reuses a cached URL of the old one. + */ +final class PatronPhoto +{ + public const DIR = 'patron_photos'; + public const MAX_BYTES = 5 * 1024 * 1024; + public const MAX_EDGE = 600; + private const NAME_PATTERN = '/^patron_photos\/(\d+)-[a-f0-9]{16}\.jpg$/D'; + + /** Absolute directory holding the photos (created on first use). */ + public static function directory(): string + { + return dirname(__DIR__, 2) . '/storage/' . self::DIR; + } + + /** + * Absolute path of a stored photo, or null when the stored value is not + * one this class wrote (or the file is gone). The pattern check is what + * keeps a tampered column from pointing the reader at another file. + */ + public static function absolutePath(?string $stored): ?string + { + $stored = (string) $stored; + if ($stored === '' || preg_match(self::NAME_PATTERN, $stored) !== 1) { + return null; + } + $path = dirname(__DIR__, 2) . '/storage/' . $stored; + return is_file($path) ? $path : null; + } + + /** Stored value for a user, or null when none. */ + public static function storedFor(mysqli $db, int $userId): ?string + { + $stmt = $db->prepare('SELECT foto_profilo_url FROM utenti WHERE id = ? LIMIT 1'); + if ($stmt === false) { + return null; + } + $stmt->bind_param('i', $userId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + $value = is_array($row) ? ($row['foto_profilo_url'] ?? null) : null; + return is_string($value) && $value !== '' ? $value : null; + } + + /** + * Decode $sourcePath, re-encode it as a bounded JPEG and make it the + * user's photo. Returns null on success or an error code: + * 'too_large', 'invalid_image', 'storage', 'db'. + */ + public static function store(mysqli $db, int $userId, string $sourcePath): ?string + { + if ($userId <= 0 || !is_file($sourcePath)) { + return 'invalid_image'; + } + $size = filesize($sourcePath); + if ($size === false || $size === 0) { + return 'invalid_image'; + } + if ($size > self::MAX_BYTES) { + return 'too_large'; + } + $jpeg = self::normalise($sourcePath); + if ($jpeg === null) { + return 'invalid_image'; + } + + $dir = self::directory(); + if (!is_dir($dir) && !@mkdir($dir, 0750, true) && !is_dir($dir)) { + SecureLogger::error('[PatronPhoto] cannot create the photo directory'); + return 'storage'; + } + $name = self::DIR . '/' . $userId . '-' . bin2hex(random_bytes(8)) . '.jpg'; + $target = dirname(__DIR__, 2) . '/storage/' . $name; + if (@file_put_contents($target, $jpeg, LOCK_EX) === false) { + SecureLogger::error('[PatronPhoto] cannot write the photo file', ['user_id' => $userId]); + return 'storage'; + } + @chmod($target, 0640); + + $previous = self::storedFor($db, $userId); + try { + $stmt = $db->prepare('UPDATE utenti SET foto_profilo_url = ? WHERE id = ?'); + if ($stmt === false) { + throw new \RuntimeException($db->error); + } + $stmt->bind_param('si', $name, $userId); + $stmt->execute(); + $stmt->close(); + } catch (\Throwable $e) { + @unlink($target); + SecureLogger::error('[PatronPhoto] cannot record the photo', ['user_id' => $userId, 'error' => $e->getMessage()]); + return 'db'; + } + if ($previous !== null && $previous !== $name) { + self::deleteFile($previous); + } + return null; + } + + /** Remove the user's photo: the file and the reference. True when none is left. */ + public static function delete(mysqli $db, int $userId): bool + { + $previous = self::storedFor($db, $userId); + try { + $stmt = $db->prepare('UPDATE utenti SET foto_profilo_url = NULL WHERE id = ?'); + if ($stmt === false) { + throw new \RuntimeException($db->error); + } + $stmt->bind_param('i', $userId); + $stmt->execute(); + $stmt->close(); + } catch (\Throwable $e) { + SecureLogger::error('[PatronPhoto] cannot clear the photo', ['user_id' => $userId, 'error' => $e->getMessage()]); + return false; + } + if ($previous !== null) { + self::deleteFile($previous); + } + return true; + } + + /** Delete the file behind a stored value (used when the account itself is deleted). */ + public static function deleteFile(?string $stored): void + { + $path = self::absolutePath($stored); + if ($path !== null) { + @unlink($path); + } + } + + /** Decode any GD-readable JPEG/PNG/WebP and re-encode it as a bounded JPEG. */ + private static function normalise(string $sourcePath): ?string + { + if (!function_exists('imagecreatefromstring')) { + return null; + } + $info = @getimagesize($sourcePath); + if ($info === false || !in_array($info[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) { + return null; + } + [$width, $height] = [(int) $info[0], (int) $info[1]]; + if ($width < 16 || $height < 16 || $width > 8000 || $height > 8000) { + return null; + } + $bytes = @file_get_contents($sourcePath); + if ($bytes === false) { + return null; + } + $src = @imagecreatefromstring($bytes); + if ($src === false) { + return null; + } + $scale = min(1.0, self::MAX_EDGE / max($width, $height)); + $newW = max(1, (int) round($width * $scale)); + $newH = max(1, (int) round($height * $scale)); + $dst = imagecreatetruecolor($newW, $newH); + // A transparent PNG becomes white, not black. + imagefill($dst, 0, 0, (int) imagecolorallocate($dst, 255, 255, 255)); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $width, $height); + ob_start(); + imagejpeg($dst, null, 85); + $out = (string) ob_get_clean(); + return $out !== '' ? $out : null; + } +} diff --git a/app/Support/PatronTypeImport.php b/app/Support/PatronTypeImport.php new file mode 100644 index 000000000..4fd2bf236 --- /dev/null +++ b/app/Support/PatronTypeImport.php @@ -0,0 +1,108 @@ +|null code (lowercase) => id */ + private ?array $codes = null; + private ?bool $available = null; + + public function __construct( + private readonly mysqli $db, + private readonly string $typesTable = 'tipologie_lettore', + private readonly string $usersTable = 'utenti' + ) { + } + + /** True when the patron types schema exists (checked once per instance). */ + public function available(): bool + { + if ($this->available !== null) { + return $this->available; + } + $this->available = $this->columnExists($this->typesTable, 'id') + && $this->columnExists($this->typesTable, 'codice') + && $this->columnExists($this->usersTable, 'tipologia_id'); + return $this->available; + } + + /** + * Assign the patron type with code $code to the user. + * + * @return 'skipped'|'assigned'|'unknown' skipped = nothing to do (no code, + * or no patron types schema); unknown = the code matches no type, + * so the reader keeps the default type + */ + public function assign(int $userId, ?string $code): string + { + $code = trim((string) $code); + if ($code === '' || $userId <= 0 || !$this->available()) { + return 'skipped'; + } + $typeId = $this->idFor($code); + if ($typeId === null) { + return 'unknown'; + } + $stmt = $this->db->prepare("UPDATE `{$this->usersTable}` SET tipologia_id = ? WHERE id = ?"); + if ($stmt === false) { + return 'unknown'; + } + $stmt->bind_param('ii', $typeId, $userId); + $stmt->execute(); + $stmt->close(); + return 'assigned'; + } + + private function idFor(string $code): ?int + { + if ($this->codes === null) { + $this->codes = []; + $result = $this->db->query("SELECT id, codice FROM `{$this->typesTable}`"); + if ($result instanceof \mysqli_result) { + while ($row = $result->fetch_assoc()) { + $this->codes[mb_strtolower((string) $row['codice'], 'UTF-8')] = (int) $row['id']; + } + $result->free(); + } + } + return $this->codes[mb_strtolower($code, 'UTF-8')] ?? null; + } + + private function columnExists(string $table, string $column): bool + { + try { + $stmt = $this->db->prepare( + 'SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1' + ); + if ($stmt === false) { + return false; + } + $stmt->bind_param('ss', $table, $column); + $stmt->execute(); + $exists = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + return $exists; + } catch (\Throwable) { + return false; + } + } +} diff --git a/app/Support/Updater.php b/app/Support/Updater.php index 0ac058a17..fd0ceeb9d 100644 --- a/app/Support/Updater.php +++ b/app/Support/Updater.php @@ -111,6 +111,7 @@ class Updater 'storage/cache', 'storage/logs', 'storage/calendar', + 'storage/patron_photos', 'storage/tmp', 'storage/sessions', 'public/uploads', diff --git a/app/Support/UserImport.php b/app/Support/UserImport.php new file mode 100644 index 000000000..7f35095ef --- /dev/null +++ b/app/Support/UserImport.php @@ -0,0 +1,333 @@ + [max length or 0, required]. */ + private const FIELDS = [ + 'nome' => [100, true], + 'cognome' => [100, false], + 'email' => [255, true], + 'telefono' => [20, false], + 'indirizzo' => [500, false], + 'data_nascita' => [0, false], + 'sesso' => [0, false], + 'cod_fiscale' => [16, false], + 'codice_tessera' => [20, false], + 'data_scadenza_tessera' => [0, false], + 'note_utente' => [2000, false], + 'locale' => [0, false], + 'tipologia' => [40, false], + ]; + + /** Header spellings recognised for each field (normalised with normalizeHeader()). */ + private const ALIASES = [ + 'nome' => ['nome', 'name', 'firstname', 'first', 'givenname', 'vorname', 'prenom', 'fornavn'], + 'cognome' => ['cognome', 'surname', 'lastname', 'last', 'familyname', 'nachname', 'nom', 'nomdefamille', 'efternavn'], + 'email' => ['email', 'mail', 'emailaddress', 'indirizzoemail', 'posta', 'courriel', 'adressemail'], + 'telefono' => ['telefono', 'phone', 'tel', 'telephone', 'telefon', 'cellulare', 'mobile', 'telefonnummer'], + 'indirizzo' => ['indirizzo', 'address', 'adresse', 'indirizzocompleto'], + 'data_nascita' => ['datanascita', 'datadinascita', 'birthdate', 'dateofbirth', 'birthday', 'geburtsdatum', 'datedenaissance', 'fodselsdato'], + 'sesso' => ['sesso', 'gender', 'sex', 'geschlecht', 'sexe', 'kon'], + 'cod_fiscale' => ['codfiscale', 'codicefiscale', 'cf', 'taxcode', 'fiscalcode', 'steuernummer', 'codefiscal'], + 'codice_tessera' => ['codicetessera', 'tessera', 'card', 'cardnumber', 'cardcode', 'librarycard', 'barcode', 'ausweisnummer', 'ausweis', 'numerodecarte', 'kortnummer', 'lanerkort'], + 'data_scadenza_tessera' => ['datascadenzatessera', 'scadenzatessera', 'scadenza', 'expiry', 'expirydate', 'cardexpiry', 'expires', 'ablaufdatum', 'gultigbis', 'datedexpiration', 'udlobsdato'], + 'note_utente' => ['noteutente', 'note', 'notes', 'notizen', 'noter', 'noteinterne'], + 'locale' => ['locale', 'lingua', 'language', 'sprache', 'langue', 'sprog'], + 'tipologia' => ['tipologia', 'tipologialettore', 'patrontype', 'membertype', 'readertype', 'lesertyp', 'typedelecteur', 'lanertype'], + ]; + + /** + * Target fields shown in the mapping step, in order, as key => label. + * Custom registration fields follow as `custom:`; `tipologia` is offered + * only when the patron types exist (PatronTypeImport::available()). + * + * @param list $customDefinitions + * @return array + */ + public static function targetFields(array $customDefinitions, bool $withPatronType): array + { + $fields = [ + 'nome' => __('Nome'), + 'cognome' => __('Cognome'), + 'email' => __('Email'), + 'telefono' => __('Telefono'), + 'indirizzo' => __('Indirizzo'), + 'data_nascita' => __('Data di nascita'), + 'sesso' => __('Sesso'), + 'cod_fiscale' => __('Codice Fiscale'), + 'codice_tessera' => __('Codice tessera'), + 'data_scadenza_tessera' => __('Scadenza tessera'), + 'note_utente' => __('Note interne'), + 'locale' => __('Lingua dell\'interfaccia'), + ]; + if ($withPatronType) { + $fields['tipologia'] = __('Tipologia lettore (codice)'); + } + foreach ($customDefinitions as $def) { + $fields['custom:' . $def['id']] = $def['etichetta']; + } + return $fields; + } + + /** Lowercase, accents removed, only letters and digits. */ + public static function normalizeHeader(string $header): string + { + $header = preg_replace('/^\xEF\xBB\xBF/', '', $header) ?? $header; + $header = mb_strtolower(trim($header), 'UTF-8'); + $header = strtr($header, ['ø' => 'o', 'æ' => 'ae', 'å' => 'a', 'ß' => 'ss']); + $ascii = function_exists('iconv') ? @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $header) : false; + if (is_string($ascii) && $ascii !== '') { + $header = strtolower($ascii); + } + return (string) preg_replace('/[^a-z0-9]/', '', $header); + } + + /** + * Suggested mapping: column index => field key ('' = ignore). Each field is + * proposed for one column at most (the first that matches). + * + * @param list $headers + * @param array $targetFields from targetFields() + * @return array + */ + public static function guessMapping(array $headers, array $targetFields): array + { + $byAlias = []; + foreach (self::ALIASES as $field => $aliases) { + if (!isset($targetFields[$field])) { + continue; + } + foreach ($aliases as $alias) { + $byAlias[$alias] ??= $field; + } + // The label in the current language too (an export from this + // installation carries localized headers). + $byAlias[self::normalizeHeader($targetFields[$field])] ??= $field; + } + foreach ($targetFields as $key => $label) { + if (str_starts_with($key, 'custom:')) { + $byAlias[self::normalizeHeader($label)] ??= $key; + } + } + $mapping = []; + $taken = []; + foreach ($headers as $i => $header) { + $norm = self::normalizeHeader((string) $header); + $field = $byAlias[$norm] ?? ''; + if ($field !== '' && !isset($taken[$field])) { + $mapping[$i] = $field; + $taken[$field] = true; + } else { + $mapping[$i] = ''; + } + } + return $mapping; + } + + /** + * Validate a submitted mapping. Returns the clean mapping, or an error + * message when a field is mapped twice or nome/email are missing. + * + * @param mixed $mapping column index => field key, as sent by the browser + * @param array $targetFields + * @return array{mapping: array, error: ?string} + */ + public static function validateMapping(mixed $mapping, array $targetFields, int $columnCount): array + { + if (!is_array($mapping)) { + return ['mapping' => [], 'error' => __('Mappatura delle colonne non valida.')]; + } + $clean = []; + $used = []; + foreach ($mapping as $index => $field) { + $index = (int) $index; + $field = is_string($field) ? $field : ''; + if ($index < 0 || $index >= $columnCount || $field === '') { + continue; + } + if (!isset($targetFields[$field])) { + return ['mapping' => [], 'error' => __('Mappatura delle colonne non valida.')]; + } + if (isset($used[$field])) { + return ['mapping' => [], 'error' => sprintf(__('Il campo "%s" è associato a più di una colonna.'), $targetFields[$field])]; + } + $used[$field] = true; + $clean[$index] = $field; + } + foreach (['nome', 'email'] as $required) { + if (!isset($used[$required])) { + return ['mapping' => [], 'error' => sprintf(__('Associa una colonna al campo obbligatorio "%s".'), $targetFields[$required])]; + } + } + ksort($clean); + return ['mapping' => $clean, 'error' => null]; + } + + /** + * Turn one CSV row into an account record. + * + * @param list $row raw cells + * @param array $mapping column index => field key + * @param list $customDefinitions + * @param list $locales valid interface locale codes + * @param array{sesso:bool, cod_fiscale:bool} $visible whether the optional profile fields are collected + * @return array{record: array, custom: array, tipologia: ?string, error: ?string} + */ + public static function normalizeRow(array $row, array $mapping, array $customDefinitions, array $locales, array $visible = ['sesso' => true, 'cod_fiscale' => true]): array + { + $fail = static fn (string $message): array => ['record' => [], 'custom' => [], 'tipologia' => null, 'error' => $message]; + $raw = []; + $customRaw = []; + foreach ($mapping as $index => $field) { + $value = self::cell($row[$index] ?? null); + if (str_starts_with($field, 'custom:')) { + $customRaw[(int) substr($field, 7)] = $value; + } else { + $raw[$field] = $value; + } + } + + $record = []; + foreach (self::FIELDS as $field => [$max]) { + $value = $raw[$field] ?? ''; + if ($max > 0 && mb_strlen($value, 'UTF-8') > $max) { + return $fail(sprintf(__('Il valore di "%s" è troppo lungo (massimo %d caratteri).'), $field, $max)); + } + $record[$field] = $value === '' ? null : $value; + } + + if ($record['nome'] === null) { + return $fail(__('Nome mancante.')); + } + if ($record['email'] === null) { + return $fail(__('Email mancante.')); + } + $email = mb_strtolower((string) $record['email'], 'UTF-8'); + if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + return $fail(sprintf(__('Email non valida: %s'), (string) $record['email'])); + } + $record['email'] = $email; + $record['cognome'] ??= ''; + + foreach (['data_nascita', 'data_scadenza_tessera'] as $dateField) { + if ($record[$dateField] !== null) { + $date = self::parseDate($record[$dateField]); + if ($date === null) { + return $fail(sprintf(__('Data non valida: %s'), $record[$dateField])); + } + $record[$dateField] = $date; + } + } + + $record['sesso'] = $visible['sesso'] ? self::normalizeGender($record['sesso']) : null; + $record['cod_fiscale'] = $visible['cod_fiscale'] && $record['cod_fiscale'] !== null ? strtoupper($record['cod_fiscale']) : null; + + if ($record['locale'] !== null) { + $code = str_replace('-', '_', $record['locale']); + $match = null; + foreach ($locales as $locale) { + if (strcasecmp($locale, $code) === 0 || strcasecmp(substr($locale, 0, 2), $code) === 0) { + $match = $locale; + break; + } + } + $record['locale'] = $match; // an unknown language falls back to the installation one + } + + $tipologia = $record['tipologia']; + unset($record['tipologia']); + + $custom = []; + if ($customDefinitions !== [] && $customRaw !== []) { + $mapped = array_values(array_filter($customDefinitions, static fn (array $d): bool => array_key_exists($d['id'], $customRaw))); + $result = RegistrationFields::validate($mapped, ['custom_field' => $customRaw], false); + if ($result['error'] !== null) { + return $fail(sprintf(__('Valore non valido per il campo "%s".'), $result['error'])); + } + $custom = array_filter($result['values'], static fn (string $v): bool => $v !== ''); + } + + return ['record' => $record, 'custom' => $custom, 'tipologia' => $tipologia, 'error' => null]; + } + + /** Y-m-d from the spellings a spreadsheet produces, or null. */ + public static function parseDate(string $value): ?string + { + $value = trim($value); + $formats = ['Y-m-d', 'd/m/Y', 'd.m.Y', 'd-m-Y', 'Y/m/d', 'd/m/y', 'd.m.y']; + foreach ($formats as $format) { + $date = \DateTimeImmutable::createFromFormat('!' . $format, $value); + if ($date !== false && $date->format($format) === $value) { + $year = (int) $date->format('Y'); + if ($year >= 1900 && $year <= 2200) { + return $date->format('Y-m-d'); + } + } + } + return null; + } + + public static function normalizeGender(?string $value): ?string + { + if ($value === null) { + return null; + } + $v = self::normalizeHeader($value); + return match (true) { + in_array($v, ['m', 'maschio', 'male', 'man', 'uomo', 'mannlich', 'homme', 'masculin', 'mand'], true) => 'M', + in_array($v, ['f', 'femmina', 'female', 'woman', 'donna', 'w', 'weiblich', 'femme', 'feminin', 'kvinde', 'k'], true) => 'F', + in_array($v, ['altro', 'other', 'x', 'divers', 'autre', 'andet', 'nonbinary', 'nb'], true) => 'Altro', + default => null, + }; + } + + /** + * Delimiter of a CSV sample: the most frequent of ";", "," and TAB, or null + * when none occurs. + * + * @param list $lines + */ + public static function detectDelimiter(array $lines): ?string + { + $candidates = [';' => 0, ',' => 0, "\t" => 0]; + foreach ($lines as $line) { + if (!is_string($line)) { + continue; + } + foreach (array_keys($candidates) as $delimiter) { + $candidates[$delimiter] += substr_count($line, $delimiter); + } + } + arsort($candidates); + $best = (string) array_key_first($candidates); + return $candidates[$best] > 0 ? $best : null; + } + + /** A CSV cell as trimmed text, with the export's formula-escape apostrophe undone. */ + private static function cell(mixed $value): string + { + $value = trim(is_scalar($value) ? (string) $value : ''); + if (strlen($value) > 1 && $value[0] === "'" && in_array($value[1], ['=', '+', '-', '@'], true)) { + $value = substr($value, 1); + } + return strip_tags($value); + } +} diff --git a/app/Views/admin/imports_history.php b/app/Views/admin/imports_history.php index 38d04a5c5..4570627ce 100644 --- a/app/Views/admin/imports_history.php +++ b/app/Views/admin/imports_history.php @@ -181,6 +181,7 @@ 'csv' => ['label' => 'CSV', 'color' => 'bg-blue-100 text-blue-800'], 'librarything' => ['label' => 'LibraryThing', 'color' => 'bg-purple-100 text-purple-800'], 'marc' => ['label' => 'MARC', 'color' => 'bg-green-100 text-green-800'], + 'users' => ['label' => __('Utenti'), 'color' => 'bg-amber-100 text-amber-800'], ]; $type = $typeLabels[$import['import_type']] ?? ['label' => 'Unknown', 'color' => 'bg-gray-100 text-gray-800']; diff --git a/app/Views/partials/settings-sections.php b/app/Views/partials/settings-sections.php index 6b308a6db..de3f3a7d8 100644 --- a/app/Views/partials/settings-sections.php +++ b/app/Views/partials/settings-sections.php @@ -10,6 +10,7 @@ 'privacy' => ['group' => __('Sito e sistema'), 'label' => __('Privacy'), 'description' => __('Gestisci informativa, cookie e preferenze di consenso.'), 'icon' => 'fa-shield-alt'], 'messages' => ['group' => __('Comunicazione'), 'label' => __('Messaggi'), 'description' => __('Consulta le richieste ricevute dal modulo di contatto.'), 'icon' => 'fa-inbox'], 'labels' => ['group' => __('Biblioteca'), 'label' => __('Etichette'), 'description' => __('Imposta formato e contenuto delle etichette da stampare.'), 'icon' => 'fa-barcode'], + 'cards' => ['group' => __('Biblioteca'), 'label' => __('Tessere'), 'description' => __('Imposta layout, codice e retro delle tessere della biblioteca da stampare.'), 'icon' => 'fa-id-card'], 'sharing' => ['group' => __('Sito e sistema'), 'label' => __('Condivisione'), 'description' => __('Personalizza le anteprime e i contenuti condivisi dal sito.'), 'icon' => 'fa-share-alt'], 'advanced' => ['group' => __('Sito e sistema'), 'label' => __('Avanzate'), 'description' => __('Gestisci integrazioni e configurazioni avanzate del sistema.'), 'icon' => 'fa-cogs'], 'loans' => ['group' => __('Biblioteca'), 'label' => __('Prestiti'), 'description' => __('Configura le regole e le automazioni della circolazione.'), 'icon' => 'fa-book-open'], diff --git a/app/Views/settings/cards-tab.php b/app/Views/settings/cards-tab.php new file mode 100644 index 000000000..7feb8f784 --- /dev/null +++ b/app/Views/settings/cards-tab.php @@ -0,0 +1,146 @@ + 'classic', 'name' => __('Classica'), 'desc' => __('Fascia con logo e nome della biblioteca, foto a sinistra, nome, codice e scadenza a destra.')], + ['key' => 'minimal', 'name' => __('Essenziale'), 'desc' => __('Senza foto: nome grande e codice a barre su tutta la larghezza.')], + ['key' => 'portrait', 'name' => __('Verticale'), 'desc' => __('Tessera in verticale con la foto al centro (9 per foglio A4).')], +]; +$cardCodeTypes = [ + ['key' => 'code128', 'name' => __('Codice a barre (Code128)'), 'desc' => __('Leggibile da ogni lettore di codici a barre del banco prestiti.')], + ['key' => 'qr', 'name' => __('QR code'), 'desc' => __('Leggibile anche con la fotocamera dello smartphone.')], +]; +$cardFormats = [ + ['key' => 'single', 'name' => __('Tessera singola (CR80)'), 'desc' => __('Una tessera per pagina, 85,6×54mm: per stampanti di tessere.')], + ['key' => 'sheet', 'name' => __('Foglio A4 da 10'), 'desc' => __('Dieci tessere per foglio A4 con i bordi di taglio.')], +]; +?> + +
+
+ + +
+
+

+ + +

+

+ + +

+
+ +
+
+

+ +

+
+ + + + +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ __('Foto dell\'utente'), + 'show_logo' => __('Logo della biblioteca'), + 'show_expiry' => __('Data di scadenza'), + ] as $settingKey => $settingLabel): ?> + + +
+ +
+ +
+ +

+ +
+ +
+
+ +
+

+

+
+
+
+
+
+ +
+ +
+
+
diff --git a/app/Views/settings/index.php b/app/Views/settings/index.php index 5ace6cf99..c231e8395 100644 --- a/app/Views/settings/index.php +++ b/app/Views/settings/index.php @@ -892,6 +892,9 @@ class="mt-1 w-4 h-4 aspect-square shrink-0 text-gray-900 focus:ring-gray-500"> + + +
diff --git a/app/Views/utenti/dettagli_utente.php b/app/Views/utenti/dettagli_utente.php index ad7e6d70d..76b5fee2a 100644 --- a/app/Views/utenti/dettagli_utente.php +++ b/app/Views/utenti/dettagli_utente.php @@ -21,6 +21,9 @@ $creatoIl = $utente['created_at'] ?? ''; $aggiornatoIl = $utente['updated_at'] ?? ''; $note = $utente['note_utente'] ?? ''; +$photoRaw = (string) ($utente['foto_profilo_url'] ?? ''); +$photoStored = \App\Support\PatronPhoto::absolutePath($photoRaw) !== null ? $photoRaw : null; +$photoUrl = $photoStored !== null ? url('/admin/users/' . $id . '/photo') . '?v=' . substr(sha1($photoStored), 0, 10) : null; $statusLabels = [ 'attivo' => __('Attivo'), @@ -122,6 +125,10 @@ ?>
+ + + + @@ -149,6 +156,47 @@ unset($workspaceHeader); ?> +
+

+
+
+

diff --git a/app/Views/utenti/import.php b/app/Views/utenti/import.php new file mode 100644 index 000000000..e87d0b9f3 --- /dev/null +++ b/app/Views/utenti/import.php @@ -0,0 +1,353 @@ + __('— Ignora colonna —'), + 'uploading' => __('Caricamento del file...'), + 'rows' => __('%d righe da importare'), + 'processing' => __('Importazione in corso...'), + 'progress' => __('%d di %d righe elaborate'), + 'invalidResponse' => __('Risposta non valida dal server'), + 'networkError' => __('Errore di rete. Riprova.'), + 'line' => __('Riga'), + 'name' => __('Utente'), + 'type' => __('Tipo'), + 'message' => __('Messaggio'), + 'types' => [ + 'duplicate' => __('Duplicato'), + 'validation' => __('Riga non valida'), + 'database' => __('Errore del database'), + 'email' => __('Email'), + 'warning' => __('Avviso'), + ], +]; +?> +
+
+

+ + + +

+ + + + +
+
+ + +
+

+ + +

+
+ + + +
+
+
+ + ;', ',') ?> +
+
+ +
+
+
+ + + + + + + + +
+

+ + +

+

+ + + + +
+
+ + +
+
+

+ + +

+
    +
  • +
  • +
  • +
  • +
  • + +
  • + +
  • +
+

+ + . +

+
+
+
+

+
+ + diff --git a/app/Views/utenti/index.php b/app/Views/utenti/index.php index 62443edff..660cdbbc1 100644 --- a/app/Views/utenti/index.php +++ b/app/Views/utenti/index.php @@ -22,11 +22,15 @@

-
+