diff --git a/CHANGELOG.md b/CHANGELOG.md index be87a811c..67b9d9a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,11 +31,18 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **RDA content, media and carrier types in cataloguing.** The book form shows the three RDA types derived from the media type and lets the cataloguer override each one for a single record (`libri.rda_content_type`, `rda_media_type`, `rda_carrier_type`, NULL = derived). The book page shows the effective types. The BIBFRAME output carries the same effective types: books keep txt/n/nc as before; DVD, disc and audiobook records now emit their real RDA types (tdi/v/vd, prm/s/sd, spw/s/sd) instead of a book's; an override wins over the derived value. - **Statistics.** `/admin/statistics` adds loans of the last 12 months by material and the visitors of the last 30 days. The outcomes of the last closed stocktake (expected, found, misplaced, on loan, missing, unknown codes) appear once a session has been closed. Loans by patron type and fines have their place on the page and appear as soon as those features provide data (`App\Support\StatisticsSections`). +### Library cards, patron photo and user import +- **Printable library cards.** Every reader's page has "Stampa tessera": a TCPDF PDF in CR80 size (85.6 × 54 mm, one card per page) or on A4 sheets of business-card stock with cut lines (10 per sheet, 9 for the portrait layout). The front carries the library logo and name, the reader's name, the card code as a Code128 barcode or a QR code, the photo and the expiry date; the back carries a text of your choice (rules, website, opening hours), mirrored on the A4 sheet so a long-edge duplex print puts each back behind its own card. The users list gained a selection column: selected readers print in one PDF, as single cards or A4 sheets. +- **Settings → Tessere** (`/admin/settings/cards`): three layouts (classic with a header band, minimal without photo, portrait), Code128 or QR, default format, header text, which elements to show, back text. Untouched settings give the classic Code128 card; nothing else changes for an installation that never prints a card. +- **Patron photo from the webcam.** The user form has "Scatta foto" (the browser camera, cropped to a portrait frame on a canvas) and "Carica foto"; both go to the same endpoint, which re-encodes the image as a plain JPEG of at most 600 px without EXIF. Photos live in `storage/patron_photos/`, outside the web root, and reach a browser only through `/admin/users/{id}/photo` behind the same permission as the reader record. "Elimina foto" removes file and reference at once (the reader's right to erasure), and deleting an account deletes its photo, also when the account has a loan history and is therefore only suspended. `Permissions-Policy` keeps `camera=(self)` (now built by `ContentSecurityPolicy::permissionsPolicy()`); the CSP needed no change. +- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers (accented letters included, whatever the server's locale) and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. The separator (";", "," or TAB) is the one that splits the first lines into the same number of columns as the header, quoted fields respected, so a ";" file whose addresses hold commas is no longer read as a one-column "," file. A patron-type column is assigned by code once the patron types schema exists; until then it is not offered. + ### Migration - `migrate_0.9.0-rc.1.sql`, section "Inventory stocktake": creates `inventario_sessioni` (with `azione_mancanti`, the close's choice), `inventario_attese` (with `esito_chiusura`, the bucket frozen at close, and `codice_atteso`/`titolo_atteso`, the copy code and title frozen at opening so a copy deleted mid-session stays identifiable in the report; each added with a guard to a table an earlier build created) and `inventario_scansioni` (UNIQUE `sessione_id, copia_id`) and seeds the "Revisore inventario" role. Additive and idempotent; nothing existing changes. - `migrate_0.9.0-rc.1.sql`, section "MARC bulk import / copy cataloguing": `import_logs.import_type` gains the value `marc`. Idempotent; existing rows are untouched. - `migrate_0.9.0-rc.1.sql`, section "minor features": creates `presenze` (visitor log) and `fornitori` (suppliers), adds `libri.fornitore_id` (ON DELETE SET NULL) and the nullable `libri.rda_content_type`, `rda_media_type`, `rda_carrier_type` overrides. Additive and idempotent. +- `migrate_0.9.0-rc.1.sql`, section "patron-cards-import": `import_logs.import_type` gains the value `users`; the new enum is computed from the current one, so `marc` and any value added by another section are kept. ## [0.8.9] diff --git a/app/Controllers/ImportHistoryController.php b/app/Controllers/ImportHistoryController.php index 6348f80e2..71af1f2be 100644 --- a/app/Controllers/ImportHistoryController.php +++ b/app/Controllers/ImportHistoryController.php @@ -85,7 +85,10 @@ public function index(Request $request, Response $response, \mysqli $db): Respon /** * Download error report as CSV */ - public function downloadErrors(Request $request, Response $response, \mysqli $db): Response + /** + * @param string|null $requiredType only serve imports of this import_type (404 otherwise) + */ + public function downloadErrors(Request $request, Response $response, \mysqli $db, ?string $requiredType = null): Response { $importId = $request->getQueryParams()['import_id'] ?? null; @@ -109,7 +112,7 @@ public function downloadErrors(Request $request, Response $response, \mysqli $db $stmt->execute(); $result = $stmt->get_result(); - if (!($row = $result->fetch_assoc())) { + if (!($row = $result->fetch_assoc()) || ($requiredType !== null && ($row['import_type'] ?? '') !== $requiredType)) { $stmt->close(); $response->getBody()->write('Import non trovato'); return $response->withStatus(404); diff --git a/app/Controllers/PatronCardsController.php b/app/Controllers/PatronCardsController.php new file mode 100644 index 000000000..3d2a08431 --- /dev/null +++ b/app/Controllers/PatronCardsController.php @@ -0,0 +1,259 @@ +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) { + // Never a PDF with fewer cards than were selected: the page warns + // before opening it, and a request that gets here anyway is refused. + $response->getBody()->write(sprintf(__('Si possono stampare al massimo %d tessere alla volta: selezionane di meno.'), self::MAX_BULK)); + return $response->withStatus(422)->withHeader('Content-Type', 'text/plain; charset=UTF-8'); + } + $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..fdf5c96be --- /dev/null +++ b/app/Controllers/UserImportController.php @@ -0,0 +1,841 @@ + ';', ',' => ',', 'tab' => "\t"]; + + public function page(Request $request, Response $response, mysqli $db): Response + { + if (!\App\Support\Permissions::can('users.manage')) { + return $response->withStatus(403); + } + // Coming back to the page abandons a preview that was never started + // ("Annulla" reloads it); an import in progress is left alone. + $state = $_SESSION[self::SESSION_KEY] ?? null; + if (is_array($state) && empty($state['started'])) { + $this->discardSessionFile(); + unset($_SESSION[self::SESSION_KEY]); + } + // The session's own interrupted run keeps its staged file while its + // page is visited; a file swept meanwhile (the session outlived it) + // closes the run into the import history instead of offering a + // resume that could only fail. + $this->touchSessionFile(); + UserImport::sweepStaleUploads(self::uploadDir()); + $expiredImport = $this->closeVanishedImport($db); + $patronTypes = (new PatronTypeImport($db))->available(); + $resumeImport = self::resumable($_SESSION[self::SESSION_KEY] ?? null); + 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); + } + // A run still under way (another tab) is never replaced: its next + // block would find the session gone and lose its position. + $current = $_SESSION[self::SESSION_KEY] ?? null; + if (is_array($current) && self::isRunning($current)) { + return $this->json($response, [ + 'success' => false, + 'error' => __('Un import è già in corso: attendi la fine o riprendilo da questa pagina.'), + 'processed' => (int) ($current['next'] ?? 0), + 'total_rows' => (int) ($current['total'] ?? 0), + ], 409); + } + // The staged file of a run already in the session is replaced only + // once the new upload proved valid (below): a refused upload leaves + // the import in progress intact. + UserImport::sweepStaleUploads(self::uploadDir()); + + $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 .tsv, .csv o .txt')], 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); + } + + $bytes = @file_get_contents($tmp); + if ($bytes === false) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile leggere il file caricato')], 400); + } + // The staged copy is always UTF-8 without BOM: a Windows-1252 export + // is converted here, once, so every later step reads clean text. + ['text' => $text, 'converted' => $converted] = UserImport::toUtf8($bytes); + unset($bytes); + // The separator chosen on the form wins; otherwise it is detected on + // the first logical records (a quoted cell may span several lines). + $chosen = (array) ($request->getParsedBody() ?? []); + $delimiter = self::DELIMITERS[is_string($chosen['delimiter'] ?? null) ? $chosen['delimiter'] : ''] ?? UserImport::detectDelimiter(UserImport::sampleRecords($text, 5)); + if ($delimiter === null) { + return $this->json($response, ['success' => false, 'error' => __('File CSV non valido: usa ";", "," o TAB come separatore.')], 400); + } + + $dir = self::uploadDir(); + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + $saved = $dir . '/user_import_' . bin2hex(random_bytes(12)) . '.csv'; + if (@file_put_contents($saved, $text) === false) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile salvare il file CSV caricato')], 500); + } + unset($text); + + 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); + // '' = the run this upload replaces was closed but its history entry + // could not be written: said apart from the conversion notice. + $closedId = $this->closeSessionImport($db); + // Start, every block and "Annulla import" name the preview they come + // from: an upload in another tab replaces this state, and a page + // still showing the earlier preview must not apply its mapping to + // the rows of the new file. + $previewId = bin2hex(random_bytes(12)); + $_SESSION[self::SESSION_KEY] = [ + 'preview_id' => $previewId, + 'file' => $saved, + 'name' => mb_substr($name, 0, 255), + 'delimiter' => $delimiter, + 'total' => $total, + 'columns' => count($headers), + 'started' => false, + ]; + + return $this->json($response, [ + 'success' => true, + 'preview_id' => $previewId, + 'file_name' => $name, + 'total_rows' => $total, + 'headers' => $headers, + 'preview' => $preview, + 'fields' => $fields, + 'mapping' => UserImport::guessMapping($headers, $fields), + 'notice' => $converted ? __('Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell\'anteprima.') : null, + 'history_warning' => $closedId === '' ? __('L\'import precedente è stato chiuso, ma lo storico import non è stato aggiornato: gli utenti già creati restano.') : null, + ]); + } + + 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); + } + if (!self::samePreview($state, $body)) { + return $this->stale($response); + } + if (!empty($state['started'])) { + // A replayed start (double click, a second tab, a lost answer) + // would reset the counters and the position of a run already + // under way: the answer carries that position, so the page + // resumes the run instead. + return $this->json($response, [ + 'success' => false, + 'error' => __('Import già avviato: attendi la fine o ricarica il file.'), + 'processed' => (int) ($state['next'] ?? 0), + 'total_rows' => (int) ($state['total'] ?? 0), + ], 409); + } + $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']; + // A real boolean: the string "false" must not send invitations. + $state['send_invites'] = filter_var($body['send_invites'] ?? false, FILTER_VALIDATE_BOOLEAN); + $state['started'] = true; + $state['created'] = 0; + $state['duplicates'] = 0; + $state['failed'] = 0; + $state['invited'] = 0; + $state['invite_failed'] = 0; + $state['errors'] = []; + $state['next'] = 0; + $state['active_at'] = time(); + $_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); + } + $body = json_decode((string) $request->getBody(), true); + if (is_array($_SESSION[self::SESSION_KEY] ?? null) && is_array($body) && !self::samePreview($_SESSION[self::SESSION_KEY], $body)) { + // A block from a preview another upload has replaced: never + // import the new file under the old mapping. No position in the + // answer, so the page does not "resume" a run that is not its own. + return $this->stale($response); + } + if (is_array($_SESSION[self::SESSION_KEY] ?? null) && !empty($_SESSION[self::SESSION_KEY]['finished']) && is_array($_SESSION[self::SESSION_KEY]['result'] ?? null)) { + // The run is over: this is the retry of a last block whose answer + // was lost (a gateway timeout while the invitations went out). + // Replaying the final answer shows the summary instead of an + // error that would invite uploading the same file again. + return $this->json($response, $_SESSION[self::SESSION_KEY]['result']); + } + if (!is_array($_SESSION[self::SESSION_KEY] ?? null) || empty($_SESSION[self::SESSION_KEY]['started']) || !is_array($body)) { + return $this->json($response, ['success' => false, 'error' => __('Sessione import scaduta o dati non validi')], 400); + } + // Every change below lands in the session at once, row by row: when + // a request dies mid-chunk (timeout, fatal error), PHP still saves + // the session at shutdown, so the position survives and the page + // resumes from it instead of replaying the rows already written. + $state = &$_SESSION[self::SESSION_KEY]; + $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. The + // answer carries the position to resume from. + if ($start !== (int) $state['next']) { + return $this->json($response, ['success' => false, 'error' => __('Blocco di righe non valido.'), 'processed' => (int) $state['next']], 409); + } + $total = (int) $state['total']; + if (!is_file((string) $state['file'])) { + // Swept or deleted meanwhile: touch() would recreate it empty and + // the run would end as if the remaining rows did not exist. The + // rows already done are written to the import history first. + unset($state); + $importId = $this->closeSessionImport($db); + return $this->json($response, [ + 'success' => false, + 'error' => $importId === '' + ? __('Il file dell\'import non è più disponibile e lo storico import non è stato aggiornato: gli utenti già creati restano. Carica di nuovo il file per completarlo.') + : __('Il file dell\'import non è più disponibile: le righe già elaborate sono state registrate nello storico import. Carica di nuovo il file per completarlo.'), + 'import_id' => $importId, + ], 409); + } + @touch((string) $state['file']); // in use: not stale for sweepStaleUploads() + $state['active_at'] = time(); // driven by a page: not abandoned (isRunning()) + + $handle = @fopen((string) $state['file'], 'rb'); + if ($handle === false) { + return $this->json($response, ['success' => false, 'error' => __('Impossibile aprire il file CSV')], 500); + } + // Each block resumes at the byte where the previous one stopped, so + // a large file is read once instead of again from the top for every + // block. Only the first block (or a run started before the offset + // was kept) skips the header and the rows already done by counting. + $delimiter = (string) $state['delimiter']; + if (is_int($state['offset'] ?? null)) { + fseek($handle, $state['offset']); + } else { + for ($skip = 1 + $start; $skip > 0; $skip--) { + if (self::nextRecord($handle, $delimiter) === null) { + break; + } + } + } + + $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 + $processed = 0; + while ($processed < UserImport::CHUNK_SIZE && ($row = self::nextRecord($handle, $delimiter)) !== null) { + $offset = (int) ftell($handle); + $processed++; + $label = ''; + try { + $parsed = UserImport::normalizeRow($row, $state['mapping'], $customDefinitions, $locales, $visible); + $record = $parsed['record']; + $label = $this->text(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'] ?? ''); + // The rows inserted by earlier blocks are in the table already: + // exists() catches a repeat within the file too, without a + // per-row set in the session that grows with every import. + if ($this->exists($db, 'email', $email)) { + $this->duplicate($state, $line, $label, sprintf(__('Email già registrata: %s'), $email)); + continue; + } + if ($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['created']++; + // The account exists from here on: what follows completes it, so + // a failure is a warning on a created row, never a failed row + // (that would count it twice and invite a duplicate re-import). + $this->completeImportedUser($db, $state, $userId, $line, $label, $parsed, $patronTypes, $notifier); + } 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++; + // Position and byte offset move together, row by row. + $state['next'] = $start + $processed; + $state['offset'] = $offset; + $state['active_at'] = time(); + } + } + fclose($handle); + + $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); + if ($payload['import_id'] === '') { + // The accounts are created and counted either way; only the + // history entry (and the downloadable report) is missing. + $payload['log_error'] = __('Import completato, ma lo storico non è stato salvato: il report degli errori non è scaricabile.'); + } + $payload['invite_failed'] = (int) $state['invite_failed']; + $payload['errors'] = array_slice($state['errors'], 0, 50); + $summary = ['created' => $state['created'], 'duplicates' => $state['duplicates'], 'failed' => $state['failed']]; + $this->discardSessionFile(); + unset($state); + // Only the final answer is kept, for a retry of this request; + // the next upload or a visit to the page replaces it. + $_SESSION[self::SESSION_KEY] = ['finished' => true, 'preview_id' => (string) ($_SESSION[self::SESSION_KEY]['preview_id'] ?? ''), 'result' => $payload]; + SecureLogger::info('Users imported from CSV', $summary + ['by' => $_SESSION['user']['id'] ?? null]); + } + return $this->json($response, $payload); + } + + /** + * The next non-blank record of an open CSV, or null at its end. Split + * with the same controls as Csv::readerFromPath() (double-quote + * enclosure, no escape character, blank lines skipped), so the rows match + * the ones the league Reader counted and previewed at upload. + * + * @param resource $handle + * @return list|null + */ + private static function nextRecord($handle, string $delimiter): ?array + { + while (($row = fgetcsv($handle, null, $delimiter, '"', '')) !== false) { + if ($row !== [null]) { + return $row; + } + } + return null; + } + + /** + * "Annulla import" on an interrupted run: the rows already written stay + * (they are real accounts now) and go into the import history as a + * partial run; the staged file and the session state are released. A run + * still active is refused, so a second tab cannot cut it short. + */ + public function cancel(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) && !self::samePreview($state, is_array($body) ? $body : [])) { + // A page left over from an earlier preview: the import now in the + // session belongs to another upload and is left alone. + return $this->json($response, ['success' => true, 'stale' => true, 'import_id' => null]); + } + if (!is_array($state) || empty($state['started']) || !empty($state['finished'])) { + unset($_SESSION[self::SESSION_KEY]); + return $this->json($response, ['success' => true, 'import_id' => null]); + } + if (self::isRunning($state)) { + return $this->json($response, [ + 'success' => false, + 'error' => sprintf(__('L\'import è ancora attivo: riprendilo, oppure riprova tra %d minuti.'), (int) ceil(self::IDLE_SECONDS / 60)), + 'processed' => (int) ($state['next'] ?? 0), + ], 409); + } + $importId = $this->closeSessionImport($db); + // The run is closed either way; when its history entry could not be + // written the page says so instead of moving on as if it were. + return $this->json($response, [ + 'success' => true, + 'import_id' => $importId, + 'history_warning' => $importId === '' ? __('L\'import precedente è stato chiuso, ma lo storico import non è stato aggiornato: gli utenti già creati restano.') : null, + ]); + } + + /** + * Position of an interrupted run the page can resume, or null. + * + * @return array{preview_id:string, name:string, processed:int, total:int}|null + */ + public static function resumable(mixed $state): ?array + { + if (!is_array($state) || empty($state['started']) || !empty($state['finished'])) { + return null; + } + return [ + 'preview_id' => (string) ($state['preview_id'] ?? ''), + 'name' => (string) ($state['name'] ?? ''), + 'processed' => (int) ($state['next'] ?? 0), + 'total' => (int) ($state['total'] ?? 0), + ]; + } + + /** + * Whether a request names the preview held in the session. A missing or + * empty id never matches: every page that can start, drive or cancel an + * import received the id with its preview. + * + * @param array $state + * @param array $body + */ + private static function samePreview(array $state, array $body): bool + { + $current = (string) ($state['preview_id'] ?? ''); + $given = $body['preview_id'] ?? null; + return $current !== '' && is_string($given) && $given !== '' && hash_equals($current, $given); + } + + /** 409 for a request from a preview another upload has replaced. */ + private function stale(Response $response): Response + { + return $this->json($response, [ + 'success' => false, + 'stale' => true, + 'error' => __('Questa anteprima è stata sostituita da un altro file caricato: carica di nuovo il file per importarlo.'), + ], 409); + } + + /** A started run whose last request is recent enough to be still driven by a page. */ + private static function isRunning(array $state): bool + { + return !empty($state['started']) && empty($state['finished']) + && (int) ($state['active_at'] ?? 0) > time() - self::IDLE_SECONDS; + } + + /** + * Release the session's import: a started run is written to the import + * history first (as far as it got), so the accounts it created are not + * left without a trace. Returns that run's import_id, '' when its history + * entry could not be written, or null when no started run was closed. + */ + private function closeSessionImport(mysqli $db): ?string + { + $state = $_SESSION[self::SESSION_KEY] ?? null; + $importId = null; + if (is_array($state) && !empty($state['started']) && empty($state['finished'])) { + $importId = $this->finish($db, $state, (int) ($state['next'] ?? 0)); + SecureLogger::info('User import closed before its end', [ + 'processed' => (int) ($state['next'] ?? 0), + 'total' => (int) ($state['total'] ?? 0), + 'created' => (int) ($state['created'] ?? 0), + 'by' => $_SESSION['user']['id'] ?? null, + ]); + } + $this->discardSessionFile(); + unset($_SESSION[self::SESSION_KEY]); + return $importId; + } + + /** Mark the staged file of the session's interrupted run as in use, so the sweep keeps it. */ + private function touchSessionFile(): void + { + $state = $_SESSION[self::SESSION_KEY] ?? null; + if (is_array($state) && !empty($state['started']) && empty($state['finished']) + && is_string($state['file'] ?? null) && is_file($state['file'])) { + @touch($state['file']); + } + } + + /** + * Close the session's interrupted run when its staged file is gone: it + * can no longer be resumed, so it is written to the import history as far + * as it got. Returns what the page tells the user, or null. + * + * @return array{name:string, processed:int, total:int, import_id:?string}|null + */ + public function closeVanishedImport(mysqli $db): ?array + { + $state = $_SESSION[self::SESSION_KEY] ?? null; + if (!is_array($state) || empty($state['started']) || !empty($state['finished']) + || is_file((string) ($state['file'] ?? ''))) { + return null; + } + $importId = $this->closeSessionImport($db); + return [ + 'name' => (string) ($state['name'] ?? ''), + 'processed' => (int) ($state['next'] ?? 0), + 'total' => (int) ($state['total'] ?? 0), + 'import_id' => $importId, + ]; + } + + /** Write the run into import_logs; returns its import_id ('' when logging failed). */ + private function finish(mysqli $db, array $state, ?int $rows = null): 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']); + } + // complete() reports a failed write by returning false: the id of + // a log that was never completed must not reach the page. + return $logger->complete($rows ?? (int) $state['total']) ? $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']++; + self::report($state, ['line' => $line, 'title' => $label, 'message' => $message, 'type' => $type]); + } + + private function duplicate(array &$state, int $line, string $label, string $message): void + { + $state['duplicates']++; + self::report($state, ['line' => $line, 'title' => $label, 'message' => $message, 'type' => 'duplicate']); + } + + /** + * Add an entry to the run's error report, up to MAX_STORED_ERRORS. The + * first entry past the cap becomes one `truncated` notice, so the report + * says it is incomplete instead of silently disagreeing with the counters. + * + * @param array $state + * @param array{line:int, title:string, message:string, type:string} $entry + */ + public static function report(array &$state, array $entry): void + { + $count = count((array) ($state['errors'] ?? [])); + if ($count < self::MAX_STORED_ERRORS) { + $state['errors'][] = $entry; + } elseif ($count === self::MAX_STORED_ERRORS) { + $state['errors'][] = [ + 'line' => $entry['line'], + 'title' => '', + 'message' => sprintf(__('Report limitato a %d voci: le righe successive non sono elencate, ma sono incluse nei conteggi.'), self::MAX_STORED_ERRORS), + 'type' => 'truncated', + ]; + } + } + + /** + * 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 $this->text(implode(' ', $parts)); + } + + /** A label for the error report: valid UTF-8 (whatever the cells held) and at most 200 characters. */ + private function text(string $value): string + { + return mb_substr(mb_scrub($value, 'UTF-8'), 0, 200); + } + + /** + * Custom fields, patron type and invitation of a user already inserted. + * + * @param array $state + * @param array $parsed + */ + private function completeImportedUser(mysqli $db, array &$state, int $userId, int $line, string $label, array $parsed, PatronTypeImport $patronTypes, ?NotificationService $notifier): void + { + $warn = static function (string $message, \Throwable $e) use (&$state, $line, $label, $userId): void { + SecureLogger::error('[UserImport] post-insert step failed', ['line' => $line, 'user_id' => $userId, 'error' => $e->getMessage()]); + self::report($state, ['line' => $line, 'title' => $label, 'message' => $message, 'type' => 'warning']); + }; + if ($parsed['custom'] !== []) { + try { + RegistrationFields::saveValues($db, $userId, $parsed['custom']); + } catch (\Throwable $e) { + $warn(__('Utente importato, ma i campi personalizzati non sono stati salvati.'), $e); + } + } + try { + if ($patronTypes->assign($userId, $parsed['tipologia']) === 'unknown') { + self::report($state, ['line' => $line, 'title' => $label, 'message' => sprintf(__('Tipologia lettore sconosciuta: %s (assegnata quella predefinita)'), (string) $parsed['tipologia']), 'type' => 'warning']); + } + } catch (\Throwable $e) { + $warn(__('Utente importato, ma la tipologia lettore non è stata assegnata.'), $e); + } + if ($notifier !== null) { + try { + $token = PasswordSetupToken::issue($db, $userId); + $sent = $token !== null && $notifier->sendUserPasswordSetup($userId, $token); + } catch (\Throwable $e) { + SecureLogger::error('[UserImport] invitation failed', ['line' => $line, 'user_id' => $userId, 'error' => $e->getMessage()]); + $sent = false; + } + if ($sent) { + $state['invited']++; + } else { + $state['invite_failed']++; + self::report($state, ['line' => $line, 'title' => $label, 'message' => __('Utente importato, ma l\'invito non è stato inviato.'), 'type' => 'email']); + } + } + } + + /** + * The error report of a user import, for whoever may import users: the + * generic one under /admin/imports belongs to the catalogue permission. + */ + public function downloadErrors(Request $request, Response $response, mysqli $db): Response + { + if (!\App\Support\Permissions::can('users.manage')) { + return $response->withStatus(403); + } + return (new ImportHistoryController())->downloadErrors($request, $response, $db, 'users'); + } + + private static function uploadDir(): string + { + return dirname(__DIR__, 2) . '/writable/uploads'; + } + + /** @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 + { + // A stray invalid byte becomes U+FFFD rather than an empty body the page + // cannot parse. + $response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)); + return $response->withHeader('Content-Type', 'application/json')->withStatus($status); + } +} diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index 01202f669..bfe814622 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -678,6 +678,12 @@ public function delete(Request $request, Response $response, mysqli $db, int $id $updateStmt->execute(); $updateStmt->close(); + // The photo is personal data and goes with the account even when + // the record itself is kept for the loan history. delete() unlinks + // the file and clears the reference; its failures are logged, never + // thrown, so the request completes either way and says so. + $photoRemoved = \App\Support\PatronPhoto::delete($db, $id); + // Audit logging \App\Support\SecureLogger::info('User marked as deleted (has loan history)', [ 'user_id' => $id, @@ -688,24 +694,53 @@ public function delete(Request $request, Response $response, mysqli $db, int $id ]); $_SESSION['success_message'] = 'L\'utente ha uno storico prestiti e non può essere eliminato. È stato contrassegnato come sospeso.'; + if (!$photoRemoved) { + $_SESSION['error_message'] = __('La foto del lettore non è stata rimossa: eliminala dalla scheda del lettore.'); + } return $response->withHeader('Location', url('/admin/users'))->withStatus(302); } - // No loans - safe to delete - $stmt = $db->prepare("DELETE FROM utenti WHERE id = ?"); - $stmt->bind_param("i", $id); - $success = $stmt->execute(); - $error = $stmt->error; - $stmt->close(); + // No loans - safe to delete. The row and its photo (personal data) + // go together: the DELETE runs in a transaction and the file is + // removed before the commit. A DELETE that fails (a reservation still + // pointing to the reader) leaves the photo untouched, and a file that + // cannot be removed rolls the DELETE back instead of being orphaned. + $photoStuck = false; + try { + \App\Support\DbAtomic::run($db, static function () use ($db, $id, &$photoStuck): void { + $stmt = $db->prepare("SELECT foto_profilo_url FROM utenti WHERE id = ? FOR UPDATE"); + $stmt->bind_param("i", $id); + $stmt->execute(); + $photo = $stmt->get_result()->fetch_assoc()['foto_profilo_url'] ?? null; + $stmt->close(); - if (!$success) { + $stmt = $db->prepare("DELETE FROM utenti WHERE id = ?"); + $stmt->bind_param("i", $id); + $success = $stmt->execute(); + $error = $stmt->error; + $stmt->close(); + if (!$success) { + throw new \RuntimeException($error); + } + if (!\App\Support\PatronPhoto::deleteFile(is_string($photo) ? $photo : null)) { + $photoStuck = true; + throw new \RuntimeException('The photo file cannot be removed'); + } + }, 'UsersController::delete'); + } catch (\Throwable $e) { + if ($photoStuck) { + $_SESSION['error_message'] = __('Utente non eliminato: la sua foto non può essere rimossa dal disco. Riprova o controlla i permessi della cartella storage.'); + return $response->withHeader('Location', url('/admin/users'))->withStatus(302); + } \App\Support\SecureLogger::error('User deletion failed', [ 'user_id' => $id, - 'mysqli_error' => $error, + 'mysqli_error' => $e->getMessage(), ]); $_SESSION['error_message'] = __('Errore durante l\'eliminazione'); return $response->withHeader('Location', url('/admin/users?error=db_error'))->withStatus(302); } + // Leftovers of earlier photos whose unlink failed go with the account. + \App\Support\PatronPhoto::sweepOrphans($id, null); // Audit logging for user deletion \App\Support\SecureLogger::info('User deleted', [ 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 7e9eaddef..d40df1a61 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,58 @@ 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->get('/admin/users/import/errors', function ($request, $response) use ($app) { + $controller = new \App\Controllers\UserImportController(); + return $controller->downloadErrors($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()); + $app->post('/admin/users/import/cancel', function ($request, $response) use ($app) { + $controller = new \App\Controllers\UserImportController(); + return $controller->cancel($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..b32dfc069 --- /dev/null +++ b/app/Support/PatronPhoto.php @@ -0,0 +1,400 @@ +-.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; + /** Largest decoded image accepted (16 megapixels, about 64 MB in GD). */ + public const MAX_PIXELS = 16000000; + private const NAME_PATTERN = '/^patron_photos\/(\d+)-[a-f0-9]{16}\.jpg$/D'; + /** + * A file of a reader that no reference points to is swept once it is + * older than this: a younger one may belong to a store() of the same + * reader that has written its file but not yet its reference. + */ + public const ORPHAN_GRACE_SECONDS = 300; + + /** 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'; + } + + // Read the reference to replace before writing anything: a failure + // here must not leave a personal photo on disk that nothing points to. + try { + $previous = self::storedFor($db, $userId); + } catch (\Throwable $e) { + SecureLogger::error('[PatronPhoto] cannot read the current photo', ['user_id' => $userId, 'error' => $e->getMessage()]); + return 'db'; + } + + $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 (!self::writeFile($target, $jpeg)) { + SecureLogger::error('[PatronPhoto] cannot write the photo file', ['user_id' => $userId]); + return 'storage'; + } + @chmod($target, 0640); + + try { + $stmt = $db->prepare('UPDATE utenti SET foto_profilo_url = ? WHERE id = ?'); + if ($stmt === false) { + throw new \RuntimeException($db->error); + } + try { + $stmt->bind_param('si', $name, $userId); + $stmt->execute(); + // The name is new (random part), so a matched row always + // changes: 0 means the account is gone and the file is orphan. + if ($stmt->affected_rows !== 1) { + throw new \RuntimeException('No account to attach the photo to'); + } + } finally { + $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)) { + // The new photo is in place; the old file is now unreferenced. + // It is logged, and the next store() or delete() of this reader + // retries it through sweepOrphans(). + SecureLogger::error('[PatronPhoto] cannot delete the replaced photo file', ['user_id' => $userId, 'file' => $previous]); + } + self::sweepOrphans($userId, $name); + return null; + } + + /** + * Remove the user's photo: the file, then the reference. True when the + * photo is gone (a replacement stored meanwhile by another request is + * kept). When the file cannot be removed the reference stays, so the photo + * is still known (and deletable) instead of lingering on disk unreferenced. + */ + public static function delete(mysqli $db, int $userId): bool + { + try { + $previous = self::storedFor($db, $userId); + } catch (\Throwable $e) { + SecureLogger::error('[PatronPhoto] cannot read the photo to delete', ['user_id' => $userId, 'error' => $e->getMessage()]); + return false; + } + if ($previous !== null && !self::deleteFile($previous)) { + SecureLogger::error('[PatronPhoto] cannot delete the photo file', ['user_id' => $userId]); + return false; + } + try { + // Not cleared: another request stored a new photo meanwhile, and + // that one stays (it is what the sweep below keeps). + $current = self::clearReference($db, $userId, $previous) ? null : self::storedFor($db, $userId); + } catch (\Throwable $e) { + // The file is already gone: a reference to a missing file shows + // no photo (absolutePath() checks the file), and the next delete + // clears it. + SecureLogger::error('[PatronPhoto] cannot clear the photo', ['user_id' => $userId, 'error' => $e->getMessage()]); + return false; + } + self::sweepOrphans($userId, $current); + return true; + } + + /** + * Clear the reference only while it is still $expected, the value whose + * file was just removed: a photo stored meanwhile by another request is + * a valid replacement and keeps its reference. True when the row changed. + */ + public static function clearReference(mysqli $db, int $userId, ?string $expected): bool + { + $stmt = $db->prepare('UPDATE utenti SET foto_profilo_url = NULL WHERE id = ? AND foto_profilo_url <=> ?'); + if ($stmt === false) { + throw new \RuntimeException($db->error); + } + try { + $stmt->bind_param('is', $userId, $expected); + $stmt->execute(); + return $stmt->affected_rows === 1; + } finally { + $stmt->close(); + } + } + + /** + * Remove the files of a reader other than $keep that are older than + * ORPHAN_GRACE_SECONDS: the leftovers of a replaced or deleted photo whose + * unlink failed earlier. Returns how many were removed. + */ + public static function sweepOrphans(int $userId, ?string $keep, ?int $now = null): int + { + if ($userId <= 0) { + return 0; + } + $now ??= time(); + $keepPath = $keep !== null ? self::absolutePath($keep) : null; + $removed = 0; + foreach (glob(self::directory() . '/' . $userId . '-*.jpg') ?: [] as $file) { + if ($file === $keepPath || preg_match(self::NAME_PATTERN, self::DIR . '/' . basename($file)) !== 1) { + continue; + } + $mtime = @filemtime($file); + if ($mtime === false || $mtime > $now - self::ORPHAN_GRACE_SECONDS) { + continue; + } + if (@unlink($file)) { + $removed++; + } else { + SecureLogger::error('[PatronPhoto] cannot delete an orphaned photo file', ['user_id' => $userId, 'file' => basename($file)]); + } + } + return $removed; + } + + /** + * Write $bytes to $path in full. A failed or short write (the disk + * filling up mid-file) removes what reached the disk at once, instead of + * leaving a partial personal photo for sweepOrphans() to find later. No + * lock: the name is new and random, nothing reads it before store() + * records it. + */ + public static function writeFile(string $path, string $bytes): bool + { + if (@file_put_contents($path, $bytes) === strlen($bytes)) { + return true; + } + @unlink($path); + return false; + } + + /** + * Delete the file behind a stored value (also used when the account + * itself is deleted). True when no file is left: removed, or none there. + */ + public static function deleteFile(?string $stored): bool + { + $path = self::absolutePath($stored); + if ($path === null) { + return true; + } + return @unlink($path) || !is_file($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]]; + // The pixel count bounds what GD has to allocate (~4 bytes a pixel): + // a tiny flat PNG can declare 8000 x 8000 and exhaust memory_limit. + if ($width < 16 || $height < 16 || $width > 8000 || $height > 8000 || $width * $height > self::MAX_PIXELS) { + return null; + } + $bytes = @file_get_contents($sourcePath); + if ($bytes === false) { + return null; + } + $src = @imagecreatefromstring($bytes); + if ($src === false) { + return null; + } + // Phone photos carry their rotation in EXIF Orientation, which the + // re-encode drops: turn the pixels so the card shows them upright. + // Read now, applied after the downscale, so GD never holds two + // full-resolution copies at once. + $transform = ['flip' => null, 'angle' => 0]; + if ($info[2] === IMAGETYPE_JPEG) { + // Without the exif extension the tag is read from the APP1 block + // directly, so the rotation never depends on an optional module. + $orientation = null; + if (function_exists('exif_read_data')) { + $exif = @exif_read_data($sourcePath); + $orientation = is_array($exif) && isset($exif['Orientation']) ? (int) $exif['Orientation'] : null; + } + $transform = self::orientationTransform($orientation ?? self::jpegOrientation($bytes)); + } + $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); + unset($src); // free the full-size image before the rotation + if ($transform['flip'] !== null) { + imageflip($dst, $transform['flip']); + } + if ($transform['angle'] !== 0) { + // A quarter turn keeps the long edge within MAX_EDGE. + $rotated = imagerotate($dst, $transform['angle'], 0); + if ($rotated !== false) { + $dst = $rotated; + } + } + ob_start(); + imagejpeg($dst, null, 85); + $out = (string) ob_get_clean(); + return $out !== '' ? $out : null; + } + + /** + * How to turn the pixels of an EXIF Orientation upright: an optional + * mirror (IMG_FLIP_*), applied first, then a GD angle (counter-clockwise). + * 1 or unknown: nothing. 2/4 mirror; 3/6/8 rotate; 5 (transpose) and 7 + * (transverse) mirror horizontally, then rotate. + * + * @return array{flip: ?int, angle: int} + */ + public static function orientationTransform(?int $orientation): array + { + return match ($orientation) { + 2 => ['flip' => IMG_FLIP_HORIZONTAL, 'angle' => 0], + 3 => ['flip' => null, 'angle' => 180], + 4 => ['flip' => IMG_FLIP_VERTICAL, 'angle' => 0], + 5 => ['flip' => IMG_FLIP_HORIZONTAL, 'angle' => 90], + 6 => ['flip' => null, 'angle' => -90], + 7 => ['flip' => IMG_FLIP_HORIZONTAL, 'angle' => -90], + 8 => ['flip' => null, 'angle' => 90], + default => ['flip' => null, 'angle' => 0], + }; + } + + /** + * The EXIF Orientation of a JPEG read from its APP1 segment, without the + * exif extension; null when there is none (or the block is malformed). + */ + public static function jpegOrientation(string $jpeg): ?int + { + $length = strlen($jpeg); + if ($length < 4 || substr($jpeg, 0, 2) !== "\xFF\xD8") { + return null; + } + $pos = 2; + while ($pos + 4 <= $length && $jpeg[$pos] === "\xFF") { + $marker = ord($jpeg[$pos + 1]); + $size = (int) unpack('n', substr($jpeg, $pos + 2, 2))[1]; + if ($marker === 0xDA || $size < 2) { + return null; // image data starts: no APP1 before it + } + if ($marker === 0xE1 && substr($jpeg, $pos + 4, 6) === "Exif\x00\x00") { + return self::tiffOrientation(substr($jpeg, $pos + 10, $size - 8)); + } + $pos += 2 + $size; + } + return null; + } + + /** Orientation tag (0x0112) of IFD0 in a TIFF block. */ + private static function tiffOrientation(string $tiff): ?int + { + $order = substr($tiff, 0, 2); + if ($order !== 'II' && $order !== 'MM' || strlen($tiff) < 8) { + return null; + } + $short = $order === 'II' ? 'v' : 'n'; + $long = $order === 'II' ? 'V' : 'N'; + $ifd = (int) unpack($long, substr($tiff, 4, 4))[1]; + if ($ifd < 8 || $ifd + 2 > strlen($tiff)) { + return null; + } + $entries = (int) unpack($short, substr($tiff, $ifd, 2))[1]; + for ($i = 0; $i < $entries; $i++) { + $entry = $ifd + 2 + $i * 12; + if ($entry + 12 > strlen($tiff)) { + return null; + } + if ((int) unpack($short, substr($tiff, $entry, 2))[1] === 0x0112) { + $value = (int) unpack($short, substr($tiff, $entry + 8, 2))[1]; + return $value >= 1 && $value <= 8 ? $value : null; + } + } + return 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..c0c84dda1 --- /dev/null +++ b/app/Support/UserImport.php @@ -0,0 +1,482 @@ + [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', 'ndecarte', '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; + } + + /** + * Accented letters of the five languages' headers and gender words, to + * their ASCII base. iconv's ASCII//TRANSLIT depends on LC_CTYPE (under + * the "C" locale glibc gives "?", musl gives nothing) and would turn + * "Prénom" into "prnom"; this table is applied first, iconv only mops up. + */ + private const TRANSLIT = [ + 'à' => 'a', 'á' => 'a', 'â' => 'a', 'ä' => 'a', 'ã' => 'a', 'å' => 'a', 'æ' => 'ae', + 'ç' => 'c', + 'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', + 'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', + 'ñ' => 'n', + 'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'ö' => 'o', 'õ' => 'o', 'ø' => 'o', 'œ' => 'oe', + 'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', + 'ý' => 'y', 'ÿ' => 'y', + 'ß' => 'ss', + // Abbreviated card headers ("Nº de carte") must not depend on whether + // iconv transliterates the ordinal indicator as "o" or drops it. + 'º' => '', + 'À' => 'a', 'Á' => 'a', 'Â' => 'a', 'Ä' => 'a', 'Ã' => 'a', 'Å' => 'a', 'Æ' => 'ae', + 'Ç' => 'c', + 'È' => 'e', 'É' => 'e', 'Ê' => 'e', 'Ë' => 'e', + 'Ì' => 'i', 'Í' => 'i', 'Î' => 'i', 'Ï' => 'i', + 'Ñ' => 'n', + 'Ò' => 'o', 'Ó' => 'o', 'Ô' => 'o', 'Ö' => 'o', 'Õ' => 'o', 'Ø' => 'o', 'Œ' => 'oe', + 'Ù' => 'u', 'Ú' => 'u', 'Û' => 'u', 'Ü' => 'u', + 'Ý' => 'y', 'Ÿ' => 'y', + ]; + + /** Lowercase, accents removed, only letters and digits. */ + public static function normalizeHeader(string $header): string + { + $header = preg_replace('/^\xEF\xBB\xBF/', '', $header) ?? $header; + $header = strtr(mb_strtolower(trim($header), 'UTF-8'), self::TRANSLIT); + if (!preg_match('/^[\x00-\x7F]*$/', $header)) { + // Letters outside the table (a Polish ł, a Czech č): iconv's + // locale-dependent best effort, or nothing — never a wrong match. + $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) { + // PHP turns a canonical integer key ("1") into an int already, so + // a string key here is a spelling like "01" or "1x": cast to int + // it would alias another column and drop one of the two fields. + if (!is_int($index)) { + return ['mapping' => [], 'error' => __('Mappatura delle colonne non valida.')]; + } + $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, + }; + } + + /** + * The uploaded bytes as UTF-8. A BOM is dropped. A file that is not valid + * UTF-8 — a spreadsheet saved as "CSV" on Windows writes the system code + * page — is read as Windows-1252, the superset of ISO-8859-1 such exports + * fall under; "Caffè" then keeps its accent instead of breaking the JSON + * preview. Whatever comes back is valid UTF-8. + * + * @return array{text: string, converted: bool} + */ + public static function toUtf8(string $bytes): array + { + if (str_starts_with($bytes, "\xEF\xBB\xBF")) { + $bytes = substr($bytes, 3); + } + if (mb_check_encoding($bytes, 'UTF-8')) { + return ['text' => $bytes, 'converted' => false]; + } + $text = mb_convert_encoding($bytes, 'UTF-8', 'Windows-1252'); + if (!mb_check_encoding($text, 'UTF-8')) { + $text = mb_scrub($bytes, 'UTF-8'); + } + return ['text' => $text, 'converted' => true]; + } + + /** + * The first $max logical CSV records of $text, for detectDelimiter(): a + * quoted cell may hold line breaks ("Via Roma 1\nint. 3"), so physical + * lines are joined until the quotes balance — enclosure '"', a doubled + * '""' inside a cell keeps the count even. At most MAX_SAMPLE_LINES + * physical lines are read, so an unbalanced quote cannot scan the file. + * + * @return list + */ + public static function sampleRecords(string $text, int $max): array + { + $records = []; + $buffer = null; + $offset = 0; + $length = strlen($text); + for ($read = 0; $read < self::MAX_SAMPLE_LINES && $offset < $length && count($records) < $max; $read++) { + $end = strpos($text, "\n", $offset); + $line = $end === false ? substr($text, $offset) : substr($text, $offset, $end - $offset); + $offset = $end === false ? $length : $end + 1; + $buffer = $buffer === null ? $line : $buffer . "\n" . $line; + if (substr_count($buffer, '"') % 2 === 0) { + $records[] = $buffer; + $buffer = null; + } + } + if ($buffer !== null && count($records) < $max) { + $records[] = $buffer; + } + return $records; + } + + /** + * Delimiter of a CSV sample (the header and the first few records): the one + * of ";", "," and TAB that splits the lines into columns most consistently, + * or null when none splits the header at all. + * + * Each candidate is judged by the structure it yields, not by how often it + * occurs: a ";" file whose addresses hold commas ("Via Roma 1, int. 3, + * 00100 Roma") has more commas than semicolons, yet only ";" gives every + * line the header's column count. Quoted fields are respected as the + * reader respects them (enclosure '"', no escape character), so a comma + * inside "…" never counts. The candidate whose column count is shared by + * the largest share of the data lines wins; the wider one on a tie. + * + * @param list $lines + */ + public static function detectDelimiter(array $lines): ?string + { + $sample = []; + foreach ($lines as $line) { + if (is_string($line) && trim($line) !== '') { + $sample[] = rtrim($line, "\r\n"); + } + } + if ($sample === []) { + return null; + } + $header = array_shift($sample); + $best = null; + $bestScore = [0.0, 1]; + foreach ([';', ',', "\t"] as $delimiter) { + $columns = count(str_getcsv($header, $delimiter, '"', '')); + if ($columns < 2) { + continue; + } + $matching = 0; + foreach ($sample as $line) { + if (count(str_getcsv($line, $delimiter, '"', '')) === $columns) { + $matching++; + } + } + $score = [$sample === [] ? 1.0 : $matching / count($sample), $columns]; + if ($score > $bestScore) { + $best = $delimiter; + $bestScore = $score; + } + } + return $best; + } + + /** 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); + } + + /** + * Delete user_import_*.csv files in $dir not modified for $maxAge seconds. + * + * @return int number of files removed + */ + public static function sweepStaleUploads(string $dir, int $maxAge = self::STALE_UPLOAD_SECONDS, ?int $now = null): int + { + $now ??= time(); + $removed = 0; + foreach (glob(rtrim($dir, '/') . '/user_import_*.csv') ?: [] as $file) { + $mtime = @filemtime($file); + if ($mtime !== false && $now - $mtime > $maxAge && @unlink($file)) { + $removed++; + } + } + return $removed; + } +} 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..f26764daf --- /dev/null +++ b/app/Views/settings/cards-tab.php @@ -0,0 +1,145 @@ + '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 o QR) 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 (nove con il layout Verticale), 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..9a6276ce4 --- /dev/null +++ b/app/Views/utenti/import.php @@ -0,0 +1,517 @@ + __('— 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.'), + 'stale' => __('Questa anteprima è stata sostituita da un altro file caricato: carica di nuovo il file per importarlo.'), + 'line' => __('Riga'), + 'name' => __('Utente'), + 'type' => __('Tipo'), + 'message' => __('Messaggio'), + 'types' => [ + 'duplicate' => __('Duplicato'), + 'validation' => __('Riga non valida'), + 'database' => __('Errore del database'), + 'email' => __('Email'), + 'warning' => __('Avviso'), + 'truncated' => __('Report troncato'), + ], +]; +?> +
+
+

+ + + +

+ + + + +
+
+ + + +
+ + + + + + + + +
+ + + + +
+

+ + +

+

+
+ + +
+
+ + + +
+

+ + +

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

+ + +

+

+ + + + +
+
+ + +
+
+

+ + +

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

+ + . +

+ +
+
+
+

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

-
+