diff --git a/CHANGELOG.md b/CHANGELOG.md index 38cf307cc..51a257ec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,59 @@ Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here. +## [0.7.66] - 2026-08-24 + +A full audit of all 20 bundled plugins — security, correctness and schema fixes — plus four previously-orphaned plugin features wired into the UI. + +### Security + +- **Centralised SSRF guard for caller-configurable endpoints**: `HttpClient` gains + an `ssrf_guard` option that resolves the target host, rejects hosts resolving to + private/reserved address space, pins the connection to the vetted public IP, and + rejects cross-host/port/scheme redirects. Enabled for the book-club AI endpoint + and the z39 SRU client, both of which also moved off raw `curl` onto the guarded + client. +- **book-club AI settings are admin-only**: the global API key and outbound + endpoint were reachable by `staff` (AdminAuthMiddleware admits both roles); an + inline admin re-check now gates both read and save. +- **resource-sync** Basic Auth is rate-limited; **dewey-editor** destructive + endpoints require admin inline; **digital-library** file-preview links get + `rel="noopener"`. + +### Features + +- **api-book-scraper settings page** is wired (`hasSettingsPage`/ + `getSettingsViewPath`) — previously a 404. +- **z39-server UNIMARC import** completes the advertised round-trip: a pasted or + uploaded UNIMARC record pre-fills the book form (like the SBN import), never a + direct insert, so validation and soft-delete rules are preserved. +- **viaf-authority** surfaces a VIAF/ISNI panel on the author edit page via the + existing `author.form.fields` hook. +- **frbr-lrm** wires Book↔Work linking and full Expression CRUD into the admin UI + via `book.form.fields`; the manifest drops the unbuilt dedup/relator claims. + +### Fixed + +- **api-book-scraper no longer wipes its stored API key** on a settings save that + leaves the key field blank (blank means "keep the existing key"). +- **archives soft-delete** frees the UNIQUE `reference_code`/`ark_identifier` for + reuse by appending a per-id token (truncated to fit `VARCHAR(64)`), keeping the + original code as a readable prefix. +- **dewey-editor** keys its data file off the full locale (`en_US` ≠ `en_GB`) with + a legacy fallback; readers and writers share one resolver. +- Plugin hook registration (api-book-scraper) is now transactional and rethrows on + failure; removed dead `activate()` code in discogs/open-library; aligned + plugin.json versions and dropped stale `max_app_version` fields. + +### Upgrade + +- The five plugins with schema or hook changes (ncip-server, digital-library, + frbr-lrm, viaf-authority, z39-server) get a plugin.json version bump so the + Updater re-runs their `ensureSchema()`/hook registration on existing installs — + the ncip `ncip_transactions` FOREIGN KEYs, digital-library's `file_url`/ + `audio_url` columns and the new frbr/viaf hooks are applied on upgrade, not only + on fresh install. No core SQL migration. + ## [0.7.65] - 2026-08-22 Add-to-calendar links in loan emails, the #366 legacy ready-pickup backfill, and a public-facing SEO pass. diff --git a/app/Controllers/DeweyApiController.php b/app/Controllers/DeweyApiController.php index b1a8a7ead..a2e9f3457 100644 --- a/app/Controllers/DeweyApiController.php +++ b/app/Controllers/DeweyApiController.php @@ -5,6 +5,7 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; +use App\Support\DeweyDataFiles; use App\Support\I18n; class DeweyApiController @@ -17,20 +18,10 @@ private function loadDeweyData(): array $locale = $this->getActiveLocale(); if (!isset(self::$deweyDataCache[$locale])) { - // Prima prova a caricare il nuovo formato completo (con decimali) - $jsonFile = ($locale === 'en_US') ? 'dewey_completo_en.json' : 'dewey_completo_it.json'; - $jsonPath = __DIR__ . '/../../data/dewey/' . $jsonFile; - - // Fallback al vecchio formato se il completo non esiste - if (!file_exists($jsonPath)) { - $jsonFile = ($locale === 'en_US') ? 'dewey_en.json' : 'dewey.json'; - $jsonPath = __DIR__ . '/../../data/dewey/' . $jsonFile; - } - - // Fallback finale al file italiano - if (!file_exists($jsonPath)) { - $jsonPath = __DIR__ . '/../../data/dewey/dewey_completo_it.json'; - } + // Prefer the full-locale file written by Dewey Editor, retain the + // language-prefix file as an upgrade fallback, then fall back to + // Italian when the selected locale has no Dewey dataset. + $jsonPath = DeweyDataFiles::resolveReadPath($locale, 'it_IT'); if (!file_exists($jsonPath)) { throw new \Exception('Dewey JSON file not found'); diff --git a/app/Controllers/PluginController.php b/app/Controllers/PluginController.php index d2f6d8aa9..3886ca351 100644 --- a/app/Controllers/PluginController.php +++ b/app/Controllers/PluginController.php @@ -380,7 +380,14 @@ public function updateSettings(Request $request, Response $response, array $args // Save all settings $this->pluginManager->setSetting($pluginId, 'api_endpoint', $apiEndpoint, true); - $this->pluginManager->setSetting($pluginId, 'api_key', $apiKey, true); + // Preserve the stored API key when the field is left blank: the admin + // modal always clears the key input on open and tells the admin that + // blank means "keep the existing key". Only overwrite when a new value + // is actually entered, otherwise a save that just toggles enabled or + // changes the timeout would silently wipe the key and disable scraping. + if ($apiKey !== '') { + $this->pluginManager->setSetting($pluginId, 'api_key', $apiKey, true); + } $this->pluginManager->setSetting($pluginId, 'timeout', (string) $timeout, true); $this->pluginManager->setSetting($pluginId, 'enabled', $enabled ? '1' : '0', true); @@ -392,7 +399,7 @@ public function updateSettings(Request $request, Response $response, array $args 'message' => __('Impostazioni API Book Scraper salvate correttamente.'), 'data' => [ 'api_endpoint' => $apiEndpoint !== '' ? 'saved' : 'empty', - 'api_key' => $apiKey !== '' ? 'saved' : 'empty', + 'api_key' => $apiKey !== '' ? 'saved' : 'preserved', 'timeout' => $timeout, 'enabled' => $enabled ] diff --git a/app/Support/DeweyAutoPopulator.php b/app/Support/DeweyAutoPopulator.php index 14589ab7b..98817fe01 100644 --- a/app/Support/DeweyAutoPopulator.php +++ b/app/Support/DeweyAutoPopulator.php @@ -22,16 +22,6 @@ class DeweyAutoPopulator 'k10plus' => 'de', // K10plus is German ]; - /** - * Map of locale codes to JSON file names - */ - private const LOCALE_FILES = [ - 'it' => 'dewey_completo_it.json', - 'en' => 'dewey_completo_en.json', - ]; - - private static string $dataDir = ''; - /** * Add or update a Dewey entry if missing * @@ -55,23 +45,24 @@ public static function addIfMissing(string $code, string $name, string $source = } // Get current app locale - $appLocale = self::getAppLocaleShort(); + $appLocale = self::getAppLocale(); + $appLanguage = substr($appLocale, 0, 2); // Only update if source language matches app locale - if ($sourceLanguage !== $appLocale) { + if ($sourceLanguage !== $appLanguage) { // Don't update - language mismatch return false; } // Get JSON file for this locale - $jsonFile = self::getJsonFilePath($appLocale); - if (!$jsonFile || !file_exists($jsonFile)) { + $readPath = DeweyDataFiles::resolveReadPath($appLocale); + if (!file_exists($readPath)) { error_log("[DeweyAutoPopulator] JSON file not found for locale: $appLocale"); return false; } // Load existing data - $data = self::loadJson($jsonFile); + $data = self::loadJson($readPath); if ($data === null) { return false; } @@ -90,7 +81,7 @@ public static function addIfMissing(string $code, string $name, string $source = } // Save back to file - return self::saveJson($jsonFile, $data); + return self::saveJson(DeweyDataFiles::canonicalPath($appLocale), $data); } /** @@ -113,37 +104,16 @@ public static function processBookData(array $bookData): bool } /** - * Get short locale code (it, en, etc.) + * Get normalized full locale code (it_IT, en_US, etc.). */ - private static function getAppLocaleShort(): string + private static function getAppLocale(): string { if (class_exists('\App\Support\I18n')) { - $locale = I18n::getLocale(); - return substr($locale, 0, 2); + return I18n::normalizeLocaleCode(I18n::getLocale()); } // Fallback to ENV - $locale = $_ENV['APP_LOCALE'] ?? 'it_IT'; - return substr($locale, 0, 2); - } - - /** - * Get JSON file path for a locale - */ - private static function getJsonFilePath(string $locale): ?string - { - $file = self::LOCALE_FILES[$locale] ?? null; - if (!$file) { - return null; - } - - if (empty(self::$dataDir)) { - // Determine base path - $basePath = defined('BASE_PATH') ? BASE_PATH : dirname(__DIR__, 2); - self::$dataDir = $basePath . '/data/dewey'; - } - - return self::$dataDir . '/' . $file; + return I18n::normalizeLocaleCode((string) ($_ENV['APP_LOCALE'] ?? 'it_IT')); } /** diff --git a/app/Support/DeweyDataFiles.php b/app/Support/DeweyDataFiles.php new file mode 100644 index 000000000..9afd835d9 --- /dev/null +++ b/app/Support/DeweyDataFiles.php @@ -0,0 +1,72 @@ + 'dewey.json', + 'en' => 'dewey_en.json', + default => null, + }; + if ($historicName !== null) { + $historic = self::dataDir() . '/' . $historicName; + if (is_file($historic)) { + return $historic; + } + } + + if ($fallbackLocale !== null) { + return self::resolveReadPath($fallbackLocale); + } + + return $canonical; + } + + private static function dataDir(): string + { + $basePath = defined('BASE_PATH') ? BASE_PATH : dirname(__DIR__, 2); + return $basePath . '/data/dewey'; + } + + private static function normalizeLocale(string $locale): string + { + $locale = str_replace('-', '_', trim($locale)); + if (preg_match('/^([A-Za-z]{2,3})(?:_([A-Za-z]{2}))?$/', $locale, $matches) !== 1) { + return 'it_IT'; + } + + $language = strtolower($matches[1]); + return isset($matches[2]) ? $language . '_' . strtoupper($matches[2]) : $language; + } +} diff --git a/app/Support/HttpClient.php b/app/Support/HttpClient.php index bbcaf0bcd..ab53eec03 100644 --- a/app/Support/HttpClient.php +++ b/app/Support/HttpClient.php @@ -46,7 +46,8 @@ final class HttpClient * @param array $headers Extra request headers * @param array $options Overrides: timeout, connect_timeout, * user_agent, max_redirects, verify, - * query (array), https_only (bool) + * query (array), https_only (bool), + * ssrf_guard (bool) * @return array{ok:bool,status:int,body:string} */ public static function get(string $url, array $headers = [], array $options = []): array @@ -110,6 +111,7 @@ private static function request(string $method, string $url, array $headers, arr // Authorization token (e.g. the Discogs API), which must never travel // over cleartext after a redirect. $httpsOnly = (bool) ($options['https_only'] ?? false); + $ssrfGuard = (bool) ($options['ssrf_guard'] ?? false); $allowedSchemes = $httpsOnly ? ['https'] : ['http', 'https']; $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); @@ -117,18 +119,65 @@ private static function request(string $method, string $url, array $headers, arr return ['ok' => false, 'status' => 0, 'body' => '']; } + // For caller-configurable endpoints, fail closed unless the host + // resolves exclusively to public addresses and cURL is available for + // connection pinning. Scheme validation alone is not an SSRF boundary: + // http://127.0.0.1 and DNS names resolving to RFC1918 space are still + // syntactically valid HTTP(S) URLs. + $pinnedHost = strtolower(rtrim((string) parse_url($url, PHP_URL_HOST), '.')); + $pinnedPorts = []; + if ($ssrfGuard) { + if ($pinnedHost === '' || !\extension_loaded('curl')) { + return ['ok' => false, 'status' => 0, 'body' => '']; + } + $pinnedIp = SsrfGuard::resolvePinnedIp($pinnedHost); + if ($pinnedIp === null) { + SecureLogger::warning('HttpClient SSRF guard rejected endpoint host', ['host' => $pinnedHost]); + return ['ok' => false, 'status' => 0, 'body' => '']; + } + $options['pin_ip'] = $pinnedIp; + $initialPort = (int) (parse_url($url, PHP_URL_PORT) ?? ($scheme === 'https' ? 443 : 80)); + $pinnedPorts = array_values(array_unique([$initialPort, 80, 443])); + } + $userAgent = (string) ($options['user_agent'] ?? self::DEFAULT_USER_AGENT); $maxRedirects = (int) ($options['max_redirects'] ?? self::DEFAULT_MAX_REDIRECTS); + $redirectOptions = $maxRedirects > 0 + ? ['max' => $maxRedirects, 'protocols' => $allowedSchemes, 'strict' => false] + : false; + if ($ssrfGuard && is_array($redirectOptions)) { + // CURLOPT_RESOLVE keeps every same-authority hop on the vetted IP. + // Reject cross-host and unpinned-port redirects: accepting them + // would re-enter DNS without validating/pinning the new target. + $redirectOptions['on_redirect'] = static function ( + \Psr\Http\Message\RequestInterface $request, + \Psr\Http\Message\ResponseInterface $response, + \Psr\Http\Message\UriInterface $uri + ) use ( + $pinnedHost, + $pinnedPorts, + $allowedSchemes + ): void { + unset($request, $response); + $redirectScheme = strtolower($uri->getScheme()); + $redirectHost = strtolower(rtrim($uri->getHost(), '.')); + $redirectPort = $uri->getPort() ?? ($redirectScheme === 'https' ? 443 : 80); + if ($redirectHost !== $pinnedHost + || !in_array($redirectScheme, $allowedSchemes, true) + || !in_array($redirectPort, $pinnedPorts, true)) { + throw new \RuntimeException('SSRF guard rejected redirect target'); + } + }; + } + $guzzleOptions = [ RequestOptions::TIMEOUT => (float) ($options['timeout'] ?? self::DEFAULT_TIMEOUT), RequestOptions::CONNECT_TIMEOUT => (float) ($options['connect_timeout'] ?? self::DEFAULT_CONNECT_TIMEOUT), RequestOptions::VERIFY => $options['verify'] ?? true, RequestOptions::HTTP_ERRORS => false, RequestOptions::HEADERS => ['User-Agent' => $userAgent] + $headers, - RequestOptions::ALLOW_REDIRECTS => $maxRedirects > 0 - ? ['max' => $maxRedirects, 'protocols' => $allowedSchemes, 'strict' => false] - : false, + RequestOptions::ALLOW_REDIRECTS => $redirectOptions, ]; // Optional IP pinning (SSRF / DNS-rebind defense). When the caller has @@ -140,8 +189,15 @@ private static function request(string $method, string $url, array $headers, arr $pinHost = strtolower((string) parse_url($url, PHP_URL_HOST)); $pinPort = (int) (parse_url($url, PHP_URL_PORT) ?? ($scheme === 'https' ? 443 : 80)); if ($pinHost !== '') { + $ports = $pinnedPorts !== [] ? $pinnedPorts : [$pinPort]; + $curlPin = str_contains($options['pin_ip'], ':') + ? '[' . $options['pin_ip'] . ']' + : $options['pin_ip']; $guzzleOptions['curl'] = [ - CURLOPT_RESOLVE => ["$pinHost:$pinPort:" . $options['pin_ip']], + CURLOPT_RESOLVE => array_map( + static fn(int $port): string => "$pinHost:$port:$curlPin", + $ports + ), ]; } } @@ -164,7 +220,7 @@ private static function request(string $method, string $url, array $headers, arr 'status' => $response->getStatusCode(), 'body' => (string) $response->getBody(), ]; - } catch (GuzzleException $e) { + } catch (GuzzleException|\RuntimeException $e) { // Transport-level failure (DNS / connection / TLS). Mirror the old // `curl_exec() === false` path: surface an empty, non-ok result. SecureLogger::warning('HttpClient request failed', [ diff --git a/app/Support/RateLimiter.php b/app/Support/RateLimiter.php index e8c929c88..3f9b93043 100644 --- a/app/Support/RateLimiter.php +++ b/app/Support/RateLimiter.php @@ -14,7 +14,11 @@ class RateLimiter */ public static function isLimited(string $identifier, int $maxAttempts = self::MAX_ATTEMPTS, int $window = self::WINDOW): bool { - $currentTime = time(); + // Elapsed-based window measurement: microtime(true) keeps the window a + // true float duration instead of quantizing the first-attempt marker to + // whole seconds. Same limits and same file-backed shared state — only + // the window boundary is finer-grained. + $currentTime = microtime(true); $file = self::getStateFile($identifier); // Open (or create) state file with exclusive lock for atomic read-modify-write @@ -34,20 +38,20 @@ public static function isLimited(string $identifier, int $maxAttempts = self::MA // Read current state under lock $raw = stream_get_contents($handle); - $state = ['attempts' => 0, 'first_attempt' => 0]; + $state = ['attempts' => 0, 'first_attempt' => 0.0]; if ($raw !== false && $raw !== '') { $data = json_decode($raw, true); if (is_array($data)) { $state = [ 'attempts' => (int) ($data['attempts'] ?? 0), - 'first_attempt' => (int) ($data['first_attempt'] ?? 0), + 'first_attempt' => (float) ($data['first_attempt'] ?? 0), ]; } } // Reset if window expired if ($state['first_attempt'] > 0 && ($currentTime - $state['first_attempt']) > $window) { - $state = ['attempts' => 0, 'first_attempt' => 0]; + $state = ['attempts' => 0, 'first_attempt' => 0.0]; } if ($state['attempts'] === 0) { diff --git a/locale/da_DK.json b/locale/da_DK.json index b7ac89fef..5bdfadd5a 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -1,4 +1,10 @@ { + "Importa UNIMARC (MARCXchange)": "Importér UNIMARC (MARCXchange)", + "Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Indsæt en UNIMARC/MARCXchange-post, eller upload en .xml-fil, for at udfylde formularen ud fra den eksporterede post.", + "Incolla o carica un record UNIMARC.": "Indsæt eller upload en UNIMARC-post.", + "Dati importati da UNIMARC.": "Data importeret fra UNIMARC.", + "Record UNIMARC non valido o non riconosciuto.": "Ugyldig eller ukendt UNIMARC-post.", + "Nessun dato UNIMARC valido da importare.": "Ingen gyldige UNIMARC-data at importere.", "Esiste già un utente con l'email %s. Se stai reinstallando su un database esistente, accedi con le credenziali esistenti oppure usa un'altra email.": "En bruger med e-mailadressen %s findes allerede. Hvis du geninstallerer på en eksisterende database, skal du logge ind med de eksisterende legitimationsoplysninger eller bruge en anden e-mailadresse.", " libri": " bøger", "\"%s\" prestato a %s è in ritardo di %d giorni": "\"%s\" udlånt til %s er %d dage forsinket", @@ -6901,5 +6907,69 @@ "Tutte le opere di %s presenti in biblioteca: sfoglia i titoli disponibili e richiedili in prestito.": "Alle værker af %s i biblioteket: gennemse de tilgængelige titler og bestil dem til udlån.", "Tutti i libri pubblicati da %s presenti nella nostra biblioteca, disponibili per il prestito.": "Alle bøger udgivet af %s i vores bibliotek, tilgængelige til udlån.", "Esplora i libri del genere %s disponibili nella nostra biblioteca per il prestito.": "Udforsk bøgerne i genren %s, som kan lånes på vores bibliotek.", - "Pagina %d": "Side %d" + "Pagina %d": "Side %d", + "API key non valida": "Ugyldig API-nøgle", + "Chiave configurata": "Nøgle konfigureret", + "Chiave configurata — lascia vuoto per mantenere": "Nøgle konfigureret — lad feltet stå tomt for at beholde den", + "Codici Risposta HTTP": "HTTP-svarkoder", + "Configura il client per il servizio web di scraping dati libri tramite API personalizzata.": "Konfigurer klienten til webtjenesten til indsamling af bogdata via et brugerdefineret API.", + "Configurazione API": "API-konfiguration", + "Documentazione Completa:": "Fuld dokumentation:", + "Errore server": "Serverfejl", + "Formato Richiesta": "Anmodningsformat", + "Formato Risposta JSON (Campi Supportati)": "JSON-svarformat (understøttede felter)", + "Guida Rapida": "Hurtig guide", + "Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.": "Det har prioritet 3 (højere end Open Library, som har prioritet 5), så det forespørges først.", + "ISBN non trovato nel database": "ISBN blev ikke fundet i databasen", + "Impostazioni salvate correttamente!": "Indstillinger gemt!", + "L'API key viene criptata con AES-256-GCM prima di essere salvata nel database.": "API-nøglen krypteres med AES-256-GCM, før den gemmes i databasen.", + "L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.": "API-nøglen krypteres i databasen og sendes via en sikker HTTP-header.", + "Libro trovato, dati restituiti": "Bog fundet, data returneret", + "Mostra/nascondi chiave": "Vis/skjul nøgle", + "Per la guida completa su come implementare il server API, consulta il file": "Den komplette vejledning i, hvordan du implementerer API-serveren, finder du i filen", + "Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.": "Dette plugin opretter forbindelse til en ekstern webtjeneste for automatisk at hente bogdata under oprettelse eller redigering.", + "Sicurezza:": "Sikkerhed:", + "Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi.": "Maksimal ventetid på API-svaret. Anbefalet: 10 sekunder.", + "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Testen er endnu ikke implementeret. Gem indstillingerne, og prøv at importere en bog.", + "Timeout Richiesta (secondi)": "Timeout for anmodning (sekunder)", + "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "Alle felter er valgfrie. Pluginnet bruger kun de felter, der findes i svaret.", + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Brug pladsholderen {isbn} i URL'en; ellers tilføjes ISBN som parameteren ?isbn=...", + "Authority control VIAF / ISNI": "VIAF-/ISNI-autoritetskontrol", + "Collega l'autore al Virtual International Authority File (VIAF) e a ISNI. Cerca per nome per trovare un identificatore, oppure inseriscilo manualmente, e salva.": "Knyt forfatteren til Virtual International Authority File (VIAF) og til ISNI. Søg efter navn for at finde en identifikator, eller indtast den manuelt, og gem.", + "VIAF ID": "VIAF-id", + "es. 102333412": "f.eks. 102333412", + "Apri in VIAF": "Åbn i VIAF", + "ISNI": "ISNI", + "es. 0000 0001 2143 6543": "f.eks. 0000 0001 2143 6543", + "16 cifre (l'ultima può essere una X).": "16 cifre (det sidste kan være et X).", + "Cerca su VIAF": "Søg i VIAF", + "Salva VIAF / ISNI": "Gem VIAF / ISNI", + "Ricerca su VIAF…": "Søger i VIAF…", + "Nessun risultato trovato su VIAF.": "Ingen resultater fundet i VIAF.", + "Inserisci prima il nome dell'autore.": "Indtast forfatterens navn først.", + "Opera FRBR/LRM (Work)": "FRBR/LRM-værk (Work)", + "Collega questo libro a un'Opera per raggruppare tutte le sue edizioni sotto un unico record.": "Knyt denne bog til et værk for at samle alle dens udgaver under én post.", + "Salva prima il libro: potrai collegarlo a un'Opera dalla pagina di modifica.": "Gem bogen først: du kan knytte den til et værk fra redigeringssiden.", + "Collegato a": "Knyttet til", + "Vedi Opera": "Vis værk", + "Scollega": "Fjern tilknytning", + "Cerca un'Opera": "Søg efter et værk", + "Digita un titolo uniforme…": "Skriv en ensartet titel…", + "Crea una nuova Opera": "Opret et nyt værk", + "Ricerca in corso…": "Søger…", + "Nessuna Opera trovata.": "Intet værk fundet.", + "Collegamento aggiornato.": "Tilknytning opdateret.", + "Libro scollegato dall'Opera.": "Bogens tilknytning til værket er fjernet.", + "Testo (originale)": "Tekst (original)", + "Traduzione": "Oversættelse", + "Revisione": "Revision", + "Adattamento": "Bearbejdning", + "Edizione critica": "Kritisk udgave", + "Modifica espressione": "Rediger udtryk", + "Tipo di espressione": "Udtrykstype", + "Titolo dell'espressione": "Udtrykkets titel", + "Revisore": "Revisor", + "Salva espressione": "Gem udtryk", + "Eliminare questa espressione?": "Slet dette udtryk?", + "Aggiungi espressione": "Tilføj udtryk" } diff --git a/locale/de_DE.json b/locale/de_DE.json index 790157fdf..0385811fa 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -1,4 +1,10 @@ { + "Importa UNIMARC (MARCXchange)": "UNIMARC importieren (MARCXchange)", + "Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Fügen Sie einen UNIMARC/MARCXchange-Datensatz ein oder laden Sie eine .xml-Datei hoch, um das Formular aus dem exportierten Datensatz vorauszufüllen.", + "Incolla o carica un record UNIMARC.": "Fügen Sie einen UNIMARC-Datensatz ein oder laden Sie ihn hoch.", + "Dati importati da UNIMARC.": "Daten aus UNIMARC importiert.", + "Record UNIMARC non valido o non riconosciuto.": "Ungültiger oder nicht erkannter UNIMARC-Datensatz.", + "Nessun dato UNIMARC valido da importare.": "Keine gültigen UNIMARC-Daten zum Importieren.", "Esiste già un utente con l'email %s. Se stai reinstallando su un database esistente, accedi con le credenziali esistenti oppure usa un'altra email.": "Ein Benutzer mit der E-Mail-Adresse %s existiert bereits. Wenn Sie auf einer bestehenden Datenbank neu installieren, melden Sie sich mit den vorhandenen Zugangsdaten an oder verwenden Sie eine andere E-Mail-Adresse.", " libri": " Bücher", "\"%s\" prestato a %s è in ritardo di %d giorni": "\"%s\" ausgeliehen an %s ist %d Tage überfällig", @@ -6901,5 +6907,69 @@ "Tutte le opere di %s presenti in biblioteca: sfoglia i titoli disponibili e richiedili in prestito.": "Alle Werke von %s in der Bibliothek: stöbern Sie in den verfügbaren Titeln und leihen Sie sie aus.", "Tutti i libri pubblicati da %s presenti nella nostra biblioteca, disponibili per il prestito.": "Alle von %s veröffentlichten Bücher in unserer Bibliothek, zum Ausleihen verfügbar.", "Esplora i libri del genere %s disponibili nella nostra biblioteca per il prestito.": "Entdecken Sie die Bücher des Genres %s, die in unserer Bibliothek zum Ausleihen verfügbar sind.", - "Pagina %d": "Seite %d" + "Pagina %d": "Seite %d", + "API key non valida": "Ungültiger API-Schlüssel", + "Chiave configurata": "Schlüssel konfiguriert", + "Chiave configurata — lascia vuoto per mantenere": "Schlüssel konfiguriert — leer lassen, um ihn beizubehalten", + "Codici Risposta HTTP": "HTTP-Antwortcodes", + "Configura il client per il servizio web di scraping dati libri tramite API personalizzata.": "Konfigurieren Sie den Client für den Webdienst zum Auslesen von Buchdaten über eine benutzerdefinierte API.", + "Configurazione API": "API-Konfiguration", + "Documentazione Completa:": "Vollständige Dokumentation:", + "Errore server": "Serverfehler", + "Formato Richiesta": "Anfrageformat", + "Formato Risposta JSON (Campi Supportati)": "JSON-Antwortformat (unterstützte Felder)", + "Guida Rapida": "Kurzanleitung", + "Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.": "Es hat Priorität 3 (höher als Open Library mit Priorität 5) und wird daher zuerst abgefragt.", + "ISBN non trovato nel database": "ISBN nicht in der Datenbank gefunden", + "Impostazioni salvate correttamente!": "Einstellungen erfolgreich gespeichert!", + "L'API key viene criptata con AES-256-GCM prima di essere salvata nel database.": "Der API-Schlüssel wird mit AES-256-GCM verschlüsselt, bevor er in der Datenbank gespeichert wird.", + "L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.": "Der API-Schlüssel wird in der Datenbank verschlüsselt und über einen sicheren HTTP-Header übertragen.", + "Libro trovato, dati restituiti": "Buch gefunden, Daten zurückgegeben", + "Mostra/nascondi chiave": "Schlüssel anzeigen/ausblenden", + "Per la guida completa su come implementare il server API, consulta il file": "Die vollständige Anleitung zur Implementierung des API-Servers finden Sie in der Datei", + "Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.": "Dieses Plugin verbindet sich mit einem externen Webdienst, um Buchdaten beim Erstellen oder Bearbeiten automatisch abzurufen.", + "Sicurezza:": "Sicherheit:", + "Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi.": "Maximale Wartezeit auf die API-Antwort. Empfohlen: 10 Sekunden.", + "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Test noch nicht implementiert. Speichern Sie die Einstellungen und versuchen Sie, ein Buch zu importieren.", + "Timeout Richiesta (secondi)": "Anfrage-Timeout (Sekunden)", + "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "Alle Felder sind optional. Das Plugin verwendet nur die in der Antwort vorhandenen Felder.", + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Verwenden Sie den Platzhalter {isbn} in der URL; andernfalls wird die ISBN als Parameter ?isbn=... angehängt.", + "Authority control VIAF / ISNI": "VIAF-/ISNI-Normdaten", + "Collega l'autore al Virtual International Authority File (VIAF) e a ISNI. Cerca per nome per trovare un identificatore, oppure inseriscilo manualmente, e salva.": "Verknüpfe den Autor mit dem Virtual International Authority File (VIAF) und mit ISNI. Suche nach Namen, um einen Bezeichner zu finden, oder gib ihn manuell ein und speichere.", + "VIAF ID": "VIAF-ID", + "es. 102333412": "z. B. 102333412", + "Apri in VIAF": "In VIAF öffnen", + "ISNI": "ISNI", + "es. 0000 0001 2143 6543": "z. B. 0000 0001 2143 6543", + "16 cifre (l'ultima può essere una X).": "16 Ziffern (die letzte kann ein X sein).", + "Cerca su VIAF": "In VIAF suchen", + "Salva VIAF / ISNI": "VIAF / ISNI speichern", + "Ricerca su VIAF…": "Suche in VIAF…", + "Nessun risultato trovato su VIAF.": "Keine Ergebnisse in VIAF gefunden.", + "Inserisci prima il nome dell'autore.": "Gib zuerst den Namen des Autors ein.", + "Opera FRBR/LRM (Work)": "FRBR/LRM-Werk (Work)", + "Collega questo libro a un'Opera per raggruppare tutte le sue edizioni sotto un unico record.": "Verknüpfen Sie dieses Buch mit einem Werk, um alle seine Ausgaben unter einem Datensatz zu gruppieren.", + "Salva prima il libro: potrai collegarlo a un'Opera dalla pagina di modifica.": "Speichern Sie zuerst das Buch: Sie können es dann auf der Bearbeitungsseite mit einem Werk verknüpfen.", + "Collegato a": "Verknüpft mit", + "Vedi Opera": "Werk anzeigen", + "Scollega": "Trennen", + "Cerca un'Opera": "Werk suchen", + "Digita un titolo uniforme…": "Einheitstitel eingeben…", + "Crea una nuova Opera": "Neues Werk erstellen", + "Ricerca in corso…": "Suche läuft…", + "Nessuna Opera trovata.": "Kein Werk gefunden.", + "Collegamento aggiornato.": "Verknüpfung aktualisiert.", + "Libro scollegato dall'Opera.": "Buch vom Werk getrennt.", + "Testo (originale)": "Text (Original)", + "Traduzione": "Übersetzung", + "Revisione": "Überarbeitung", + "Adattamento": "Bearbeitung", + "Edizione critica": "Kritische Ausgabe", + "Modifica espressione": "Expression bearbeiten", + "Tipo di espressione": "Expressionstyp", + "Titolo dell'espressione": "Titel der Expression", + "Revisore": "Bearbeiter", + "Salva espressione": "Expression speichern", + "Eliminare questa espressione?": "Diese Expression löschen?", + "Aggiungi espressione": "Expression hinzufügen" } diff --git a/locale/en_US.json b/locale/en_US.json index 78c9392c1..bf6927684 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -1,4 +1,10 @@ { + "Importa UNIMARC (MARCXchange)": "Import UNIMARC (MARCXchange)", + "Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Paste a UNIMARC/MARCXchange record, or upload an .xml file, to pre-fill the form from the exported record.", + "Incolla o carica un record UNIMARC.": "Paste or upload a UNIMARC record.", + "Dati importati da UNIMARC.": "Data imported from UNIMARC.", + "Record UNIMARC non valido o non riconosciuto.": "Invalid or unrecognised UNIMARC record.", + "Nessun dato UNIMARC valido da importare.": "No valid UNIMARC data to import.", "Esiste già un utente con l'email %s. Se stai reinstallando su un database esistente, accedi con le credenziali esistenti oppure usa un'altra email.": "A user with the email %s already exists. If you are reinstalling over an existing database, log in with the existing credentials or use a different email.", " libri": " books", "\"%s\" prestato a %s è in ritardo di %d giorni": "\"%s\" loaned to %s is %d days overdue", @@ -6901,5 +6907,69 @@ "Tutte le opere di %s presenti in biblioteca: sfoglia i titoli disponibili e richiedili in prestito.": "All works by %s in the library: browse the available titles and request them on loan.", "Tutti i libri pubblicati da %s presenti nella nostra biblioteca, disponibili per il prestito.": "All books published by %s in our library, available for loan.", "Esplora i libri del genere %s disponibili nella nostra biblioteca per il prestito.": "Explore the books in the %s genre available for loan in our library.", - "Pagina %d": "Page %d" + "Pagina %d": "Page %d", + "API key non valida": "Invalid API key", + "Chiave configurata": "Key configured", + "Chiave configurata — lascia vuoto per mantenere": "Key configured — leave empty to keep", + "Codici Risposta HTTP": "HTTP Response Codes", + "Configura il client per il servizio web di scraping dati libri tramite API personalizzata.": "Configure the client for the book data scraping web service via a custom API.", + "Configurazione API": "API Configuration", + "Documentazione Completa:": "Full Documentation:", + "Errore server": "Server error", + "Formato Richiesta": "Request Format", + "Formato Risposta JSON (Campi Supportati)": "JSON Response Format (Supported Fields)", + "Guida Rapida": "Quick Guide", + "Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.": "It has priority 3 (higher than Open Library, which has priority 5), so it is queried first.", + "ISBN non trovato nel database": "ISBN not found in the database", + "Impostazioni salvate correttamente!": "Settings saved successfully!", + "L'API key viene criptata con AES-256-GCM prima di essere salvata nel database.": "The API key is encrypted with AES-256-GCM before being saved to the database.", + "L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.": "The API key is encrypted in the database and transmitted via a secure HTTP header.", + "Libro trovato, dati restituiti": "Book found, data returned", + "Mostra/nascondi chiave": "Show/hide key", + "Per la guida completa su come implementare il server API, consulta il file": "For the complete guide on how to implement the API server, see the file", + "Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.": "This plugin connects to an external web service to automatically retrieve book data during creation or editing.", + "Sicurezza:": "Security:", + "Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi.": "Maximum wait time for the API response. Recommended: 10 seconds.", + "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Test not yet implemented. Save the settings and try importing a book.", + "Timeout Richiesta (secondi)": "Request Timeout (seconds)", + "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "All fields are optional. The plugin will only use the fields present in the response.", + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Use the {isbn} placeholder in the URL; otherwise the ISBN is appended as an ?isbn=... parameter.", + "Authority control VIAF / ISNI": "VIAF / ISNI authority control", + "Collega l'autore al Virtual International Authority File (VIAF) e a ISNI. Cerca per nome per trovare un identificatore, oppure inseriscilo manualmente, e salva.": "Link the author to the Virtual International Authority File (VIAF) and to ISNI. Search by name to find an identifier, or enter it manually, then save.", + "VIAF ID": "VIAF ID", + "es. 102333412": "e.g. 102333412", + "Apri in VIAF": "Open in VIAF", + "ISNI": "ISNI", + "es. 0000 0001 2143 6543": "e.g. 0000 0001 2143 6543", + "16 cifre (l'ultima può essere una X).": "16 digits (the last one may be an X).", + "Cerca su VIAF": "Search VIAF", + "Salva VIAF / ISNI": "Save VIAF / ISNI", + "Ricerca su VIAF…": "Searching VIAF…", + "Nessun risultato trovato su VIAF.": "No results found on VIAF.", + "Inserisci prima il nome dell'autore.": "Enter the author's name first.", + "Opera FRBR/LRM (Work)": "FRBR/LRM Work", + "Collega questo libro a un'Opera per raggruppare tutte le sue edizioni sotto un unico record.": "Link this book to a Work to group all its editions under a single record.", + "Salva prima il libro: potrai collegarlo a un'Opera dalla pagina di modifica.": "Save the book first: you can link it to a Work from the edit page.", + "Collegato a": "Linked to", + "Vedi Opera": "View Work", + "Scollega": "Unlink", + "Cerca un'Opera": "Search for a Work", + "Digita un titolo uniforme…": "Type a uniform title…", + "Crea una nuova Opera": "Create a new Work", + "Ricerca in corso…": "Searching…", + "Nessuna Opera trovata.": "No Work found.", + "Collegamento aggiornato.": "Link updated.", + "Libro scollegato dall'Opera.": "Book unlinked from the Work.", + "Testo (originale)": "Text (original)", + "Traduzione": "Translation", + "Revisione": "Revision", + "Adattamento": "Adaptation", + "Edizione critica": "Critical edition", + "Modifica espressione": "Edit expression", + "Tipo di espressione": "Expression type", + "Titolo dell'espressione": "Expression title", + "Revisore": "Reviser", + "Salva espressione": "Save expression", + "Eliminare questa espressione?": "Delete this expression?", + "Aggiungi espressione": "Add expression" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 21c83078e..86c9eaff2 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -1,4 +1,10 @@ { + "Importa UNIMARC (MARCXchange)": "Importer UNIMARC (MARCXchange)", + "Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Collez une notice UNIMARC/MARCXchange, ou téléversez un fichier .xml, pour préremplir le formulaire à partir de la notice exportée.", + "Incolla o carica un record UNIMARC.": "Collez ou téléversez une notice UNIMARC.", + "Dati importati da UNIMARC.": "Données importées depuis UNIMARC.", + "Record UNIMARC non valido o non riconosciuto.": "Notice UNIMARC invalide ou non reconnue.", + "Nessun dato UNIMARC valido da importare.": "Aucune donnée UNIMARC valide à importer.", "Esiste già un utente con l'email %s. Se stai reinstallando su un database esistente, accedi con le credenziali esistenti oppure usa un'altra email.": "Un utilisateur avec l'e-mail %s existe déjà. Si vous réinstallez sur une base de données existante, connectez-vous avec les identifiants existants ou utilisez une autre adresse e-mail.", " libri": " livres", "\"%s\" prestato a %s è in ritardo di %d giorni": "\"%s\" emprunté par %s est en retard de %d jours", @@ -6901,5 +6907,69 @@ "Tutte le opere di %s presenti in biblioteca: sfoglia i titoli disponibili e richiedili in prestito.": "Toutes les œuvres de %s présentes dans la bibliothèque : parcourez les titres disponibles et demandez-les en prêt.", "Tutti i libri pubblicati da %s presenti nella nostra biblioteca, disponibili per il prestito.": "Tous les livres publiés par %s présents dans notre bibliothèque, disponibles au prêt.", "Esplora i libri del genere %s disponibili nella nostra biblioteca per il prestito.": "Explorez les livres du genre %s disponibles au prêt dans notre bibliothèque.", - "Pagina %d": "Page %d" + "Pagina %d": "Page %d", + "API key non valida": "Clé API non valide", + "Chiave configurata": "Clé configurée", + "Chiave configurata — lascia vuoto per mantenere": "Clé configurée — laisser vide pour la conserver", + "Codici Risposta HTTP": "Codes de réponse HTTP", + "Configura il client per il servizio web di scraping dati libri tramite API personalizzata.": "Configurez le client pour le service web de récupération de données de livres via une API personnalisée.", + "Configurazione API": "Configuration de l'API", + "Documentazione Completa:": "Documentation complète :", + "Errore server": "Erreur du serveur", + "Formato Richiesta": "Format de la requête", + "Formato Risposta JSON (Campi Supportati)": "Format de réponse JSON (champs pris en charge)", + "Guida Rapida": "Guide rapide", + "Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.": "Il a la priorité 3 (supérieure à Open Library qui a la priorité 5), il est donc interrogé en premier.", + "ISBN non trovato nel database": "ISBN introuvable dans la base de données", + "Impostazioni salvate correttamente!": "Paramètres enregistrés avec succès !", + "L'API key viene criptata con AES-256-GCM prima di essere salvata nel database.": "La clé API est chiffrée avec AES-256-GCM avant d'être enregistrée dans la base de données.", + "L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.": "La clé API est chiffrée dans la base de données et transmise via un en-tête HTTP sécurisé.", + "Libro trovato, dati restituiti": "Livre trouvé, données renvoyées", + "Mostra/nascondi chiave": "Afficher/masquer la clé", + "Per la guida completa su come implementare il server API, consulta il file": "Pour le guide complet sur la mise en œuvre du serveur API, consultez le fichier", + "Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.": "Ce plugin se connecte à un service web externe pour récupérer automatiquement les données des livres lors de la création ou de la modification.", + "Sicurezza:": "Sécurité :", + "Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi.": "Temps d'attente maximal pour la réponse de l'API. Recommandé : 10 secondes.", + "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Test pas encore implémenté. Enregistrez les paramètres et essayez d'importer un livre.", + "Timeout Richiesta (secondi)": "Délai d'expiration de la requête (secondes)", + "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "Tous les champs sont facultatifs. Le plugin n'utilisera que les champs présents dans la réponse.", + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Utilisez le paramètre fictif {isbn} dans l'URL ; sinon l'ISBN est ajouté comme paramètre ?isbn=...", + "Authority control VIAF / ISNI": "Contrôle d'autorité VIAF / ISNI", + "Collega l'autore al Virtual International Authority File (VIAF) e a ISNI. Cerca per nome per trovare un identificatore, oppure inseriscilo manualmente, e salva.": "Reliez l'auteur au Virtual International Authority File (VIAF) et à l'ISNI. Recherchez par nom pour trouver un identifiant, ou saisissez-le manuellement, puis enregistrez.", + "VIAF ID": "Identifiant VIAF", + "es. 102333412": "ex. 102333412", + "Apri in VIAF": "Ouvrir dans VIAF", + "ISNI": "ISNI", + "es. 0000 0001 2143 6543": "ex. 0000 0001 2143 6543", + "16 cifre (l'ultima può essere una X).": "16 chiffres (le dernier peut être un X).", + "Cerca su VIAF": "Rechercher dans VIAF", + "Salva VIAF / ISNI": "Enregistrer VIAF / ISNI", + "Ricerca su VIAF…": "Recherche dans VIAF…", + "Nessun risultato trovato su VIAF.": "Aucun résultat trouvé sur VIAF.", + "Inserisci prima il nome dell'autore.": "Saisissez d'abord le nom de l'auteur.", + "Opera FRBR/LRM (Work)": "Œuvre FRBR/LRM (Work)", + "Collega questo libro a un'Opera per raggruppare tutte le sue edizioni sotto un unico record.": "Reliez ce livre à une Œuvre pour regrouper toutes ses éditions sous une seule notice.", + "Salva prima il libro: potrai collegarlo a un'Opera dalla pagina di modifica.": "Enregistrez d'abord le livre : vous pourrez le relier à une Œuvre depuis la page de modification.", + "Collegato a": "Relié à", + "Vedi Opera": "Voir l'Œuvre", + "Scollega": "Dissocier", + "Cerca un'Opera": "Rechercher une Œuvre", + "Digita un titolo uniforme…": "Saisissez un titre uniforme…", + "Crea una nuova Opera": "Créer une nouvelle Œuvre", + "Ricerca in corso…": "Recherche en cours…", + "Nessuna Opera trovata.": "Aucune Œuvre trouvée.", + "Collegamento aggiornato.": "Lien mis à jour.", + "Libro scollegato dall'Opera.": "Livre dissocié de l'Œuvre.", + "Testo (originale)": "Texte (original)", + "Traduzione": "Traduction", + "Revisione": "Révision", + "Adattamento": "Adaptation", + "Edizione critica": "Édition critique", + "Modifica espressione": "Modifier l'expression", + "Tipo di espressione": "Type d'expression", + "Titolo dell'espressione": "Titre de l'expression", + "Revisore": "Réviseur", + "Salva espressione": "Enregistrer l'expression", + "Eliminare questa espressione?": "Supprimer cette expression ?", + "Aggiungi espressione": "Ajouter une expression" } diff --git a/locale/it_IT.json b/locale/it_IT.json index 9c39c85fe..d61048fa7 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -1,4 +1,10 @@ { + "Importa UNIMARC (MARCXchange)": "Importa UNIMARC (MARCXchange)", + "Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.": "Incolla un record UNIMARC/MARCXchange, oppure carica un file .xml, per precompilare il form dal record esportato.", + "Incolla o carica un record UNIMARC.": "Incolla o carica un record UNIMARC.", + "Dati importati da UNIMARC.": "Dati importati da UNIMARC.", + "Record UNIMARC non valido o non riconosciuto.": "Record UNIMARC non valido o non riconosciuto.", + "Nessun dato UNIMARC valido da importare.": "Nessun dato UNIMARC valido da importare.", "Esiste già un utente con l'email %s. Se stai reinstallando su un database esistente, accedi con le credenziali esistenti oppure usa un'altra email.": "Esiste già un utente con l'email %s. Se stai reinstallando su un database esistente, accedi con le credenziali esistenti oppure usa un'altra email.", " libri": " libri", "\"%s\" prestato a %s è in ritardo di %d giorni": "\"%s\" prestato a %s è in ritardo di %d giorni", @@ -6901,5 +6907,69 @@ "Tutte le opere di %s presenti in biblioteca: sfoglia i titoli disponibili e richiedili in prestito.": "Tutte le opere di %s presenti in biblioteca: sfoglia i titoli disponibili e richiedili in prestito.", "Tutti i libri pubblicati da %s presenti nella nostra biblioteca, disponibili per il prestito.": "Tutti i libri pubblicati da %s presenti nella nostra biblioteca, disponibili per il prestito.", "Esplora i libri del genere %s disponibili nella nostra biblioteca per il prestito.": "Esplora i libri del genere %s disponibili nella nostra biblioteca per il prestito.", - "Pagina %d": "Pagina %d" + "Pagina %d": "Pagina %d", + "API key non valida": "API key non valida", + "Chiave configurata": "Chiave configurata", + "Chiave configurata — lascia vuoto per mantenere": "Chiave configurata — lascia vuoto per mantenere", + "Codici Risposta HTTP": "Codici Risposta HTTP", + "Configura il client per il servizio web di scraping dati libri tramite API personalizzata.": "Configura il client per il servizio web di scraping dati libri tramite API personalizzata.", + "Configurazione API": "Configurazione API", + "Documentazione Completa:": "Documentazione Completa:", + "Errore server": "Errore server", + "Formato Richiesta": "Formato Richiesta", + "Formato Risposta JSON (Campi Supportati)": "Formato Risposta JSON (Campi Supportati)", + "Guida Rapida": "Guida Rapida", + "Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.": "Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.", + "ISBN non trovato nel database": "ISBN non trovato nel database", + "Impostazioni salvate correttamente!": "Impostazioni salvate correttamente!", + "L'API key viene criptata con AES-256-GCM prima di essere salvata nel database.": "L'API key viene criptata con AES-256-GCM prima di essere salvata nel database.", + "L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.": "L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.", + "Libro trovato, dati restituiti": "Libro trovato, dati restituiti", + "Mostra/nascondi chiave": "Mostra/nascondi chiave", + "Per la guida completa su come implementare il server API, consulta il file": "Per la guida completa su come implementare il server API, consulta il file", + "Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.": "Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.", + "Sicurezza:": "Sicurezza:", + "Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi.": "Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi.", + "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.": "Test non ancora implementato. Salva le impostazioni e prova a importare un libro.", + "Timeout Richiesta (secondi)": "Timeout Richiesta (secondi)", + "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.": "Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta.", + "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...": "Usa il segnaposto {isbn} nell'URL; in alternativa l'ISBN sarà aggiunto come parametro ?isbn=...", + "Authority control VIAF / ISNI": "Authority control VIAF / ISNI", + "Collega l'autore al Virtual International Authority File (VIAF) e a ISNI. Cerca per nome per trovare un identificatore, oppure inseriscilo manualmente, e salva.": "Collega l'autore al Virtual International Authority File (VIAF) e a ISNI. Cerca per nome per trovare un identificatore, oppure inseriscilo manualmente, e salva.", + "VIAF ID": "VIAF ID", + "es. 102333412": "es. 102333412", + "Apri in VIAF": "Apri in VIAF", + "ISNI": "ISNI", + "es. 0000 0001 2143 6543": "es. 0000 0001 2143 6543", + "16 cifre (l'ultima può essere una X).": "16 cifre (l'ultima può essere una X).", + "Cerca su VIAF": "Cerca su VIAF", + "Salva VIAF / ISNI": "Salva VIAF / ISNI", + "Ricerca su VIAF…": "Ricerca su VIAF…", + "Nessun risultato trovato su VIAF.": "Nessun risultato trovato su VIAF.", + "Inserisci prima il nome dell'autore.": "Inserisci prima il nome dell'autore.", + "Opera FRBR/LRM (Work)": "Opera FRBR/LRM (Work)", + "Collega questo libro a un'Opera per raggruppare tutte le sue edizioni sotto un unico record.": "Collega questo libro a un'Opera per raggruppare tutte le sue edizioni sotto un unico record.", + "Salva prima il libro: potrai collegarlo a un'Opera dalla pagina di modifica.": "Salva prima il libro: potrai collegarlo a un'Opera dalla pagina di modifica.", + "Collegato a": "Collegato a", + "Vedi Opera": "Vedi Opera", + "Scollega": "Scollega", + "Cerca un'Opera": "Cerca un'Opera", + "Digita un titolo uniforme…": "Digita un titolo uniforme…", + "Crea una nuova Opera": "Crea una nuova Opera", + "Ricerca in corso…": "Ricerca in corso…", + "Nessuna Opera trovata.": "Nessuna Opera trovata.", + "Collegamento aggiornato.": "Collegamento aggiornato.", + "Libro scollegato dall'Opera.": "Libro scollegato dall'Opera.", + "Testo (originale)": "Testo (originale)", + "Traduzione": "Traduzione", + "Revisione": "Revisione", + "Adattamento": "Adattamento", + "Edizione critica": "Edizione critica", + "Modifica espressione": "Modifica espressione", + "Tipo di espressione": "Tipo di espressione", + "Titolo dell'espressione": "Titolo dell'espressione", + "Revisore": "Revisore", + "Salva espressione": "Salva espressione", + "Eliminare questa espressione?": "Eliminare questa espressione?", + "Aggiungi espressione": "Aggiungi espressione" } diff --git a/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php b/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php index 85ab878c1..1745c299f 100644 --- a/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php +++ b/storage/plugins/api-book-scraper/ApiBookScraperPlugin.php @@ -153,33 +153,91 @@ private function registerHooks(): void ['scrape.isbn.validate', 'validateIsbn', 2], ]; - // Delete existing hooks for this plugin - $this->deleteHooks(); + // The DELETE + N INSERTs must be atomic: a mid-loop failure must never + // leave plugin_hooks with a partial subset of the hooks. saveSettings() + // may already own an outer transaction, so detect it with a savepoint + // probe and scope this work to a savepoint there; otherwise own a fresh + // transaction. Nesting begin_transaction() would implicitly commit the + // caller's transaction, so it must never be nested. + $inTransaction = $this->hasActiveTransaction(); + $savepoint = 'pinakes_sp_' . bin2hex(random_bytes(6)); + if ($inTransaction) { + $this->db->query('SAVEPOINT ' . $savepoint); + } else { + $this->db->begin_transaction(); + } + + try { + // Delete existing hooks for this plugin + $this->deleteHooks(); - foreach ($hooks as [$hookName, $method, $priority]) { - $stmt = $this->db->prepare( - "INSERT INTO plugin_hooks (plugin_id, hook_name, callback_class, callback_method, priority, is_active, created_at) - VALUES (?, ?, ?, ?, ?, 1, NOW())" - ); + foreach ($hooks as [$hookName, $method, $priority]) { + $stmt = $this->db->prepare( + "INSERT INTO plugin_hooks (plugin_id, hook_name, callback_class, callback_method, priority, is_active, created_at) + VALUES (?, ?, ?, ?, ?, 1, NOW())" + ); - if (!$stmt) { - \App\Support\SecureLogger::error('[ApiBookScraper] Failed to prepare statement: ' . $this->db->error); - continue; - } + if (!$stmt) { + throw new \RuntimeException('[ApiBookScraper] prepare() failed for hook ' . $hookName . ': ' . $this->db->error); + } - $className = __CLASS__; - $stmt->bind_param('isssi', $this->pluginId, $hookName, $className, $method, $priority); + $className = __CLASS__; + $stmt->bind_param('isssi', $this->pluginId, $hookName, $className, $method, $priority); - if (!$stmt->execute()) { - \App\Support\SecureLogger::error('[ApiBookScraper] Failed to register hook ' . $hookName . ': ' . $stmt->error); + if (!$stmt->execute()) { + $err = $stmt->error; + $stmt->close(); + throw new \RuntimeException('[ApiBookScraper] hook insert failed for ' . $hookName . ': ' . $err); + } + + $stmt->close(); } - $stmt->close(); + if ($inTransaction) { + $this->db->query('RELEASE SAVEPOINT ' . $savepoint); + } else { + $this->db->commit(); + } + } catch (\Throwable $e) { + if ($inTransaction) { + $this->db->query('ROLLBACK TO SAVEPOINT ' . $savepoint); + } else { + $this->db->rollback(); + } + throw $e; } \App\Support\SecureLogger::debug('[ApiBookScraper] Hooks registered'); } + /** + * Whether a transaction is already open on the connection. @@autocommit is + * unreliable here — it stays 1 after begin_transaction() — so probe with a + * savepoint instead: outside a transaction the SAVEPOINT is a no-op that + * autocommit discards, so the following RELEASE cannot find it. Works under + * both mysqli exception and silent error modes. Unique probe name per call + * so it can never clobber a caller's own savepoint boundary. + */ + private function hasActiveTransaction(): bool + { + if ($this->db === null) { + return false; + } + $probe = 'pinakes_sp_' . bin2hex(random_bytes(6)); + try { + if ($this->db->query('SAVEPOINT ' . $probe) === false) { + return false; + } + } catch (\mysqli_sql_exception $e) { + return false; + } + try { + return $this->db->query('RELEASE SAVEPOINT ' . $probe) !== false; + } catch (\mysqli_sql_exception $e) { + return false; + } + } + /** * Delete all hooks for this plugin */ @@ -190,10 +248,15 @@ private function deleteHooks(): void } $stmt = $this->db->prepare("DELETE FROM plugin_hooks WHERE plugin_id = ?"); - if ($stmt) { - $stmt->bind_param('i', $this->pluginId); - $stmt->execute(); - $stmt->close(); + if ($stmt === false) { + throw new \RuntimeException('[ApiBookScraper] prepare() failed for hook delete: ' . $this->db->error); + } + $stmt->bind_param('i', $this->pluginId); + $ok = $stmt->execute(); + $err = $stmt->error; + $stmt->close(); + if (!$ok) { + throw new \RuntimeException('[ApiBookScraper] hook delete failed: ' . $err); } } @@ -525,44 +588,81 @@ public function saveSettings(array $settings): bool return false; } - $success = true; - - foreach ($settings as $key => $value) { - $shouldEncrypt = ($key === 'api_key'); - $finalValue = $shouldEncrypt ? $this->encryptValue($value) : $value; - - // Delete existing - $deleteStmt = $this->db->prepare( - "DELETE FROM plugin_settings WHERE plugin_id = ? AND setting_key = ?" - ); - if ($deleteStmt) { + // Reject enabling the plugin with no effective API key. The view's HTML + // `required` attribute does not protect a hand-built POST: without a key + // saveSettings would "succeed" while registerHooks() silently registers + // nothing (its own guard requires a key). An empty submitted key keeps + // the stored one, so only reject when neither exists. + $enabledRequested = array_key_exists('enabled', $settings) + ? (bool) $settings['enabled'] + : $this->enabled; + $submittedKey = array_key_exists('api_key', $settings) + ? trim((string) $settings['api_key']) + : ''; + $effectiveKey = $submittedKey !== '' ? $submittedKey : $this->apiKey; + if ($enabledRequested && $effectiveKey === '') { + throw new \RuntimeException('[ApiBookScraper] cannot enable plugin without an API key'); + } + + // Wrap the settings replacement AND the hook re-registration in one + // transaction: if registerHooks() fails after deleteHooks() has run, + // the whole change must roll back so we never leave the scrape.* hooks + // missing while reporting success (which would silently stop scraping). + $this->db->begin_transaction(); + try { + foreach ($settings as $key => $value) { + $shouldEncrypt = ($key === 'api_key'); + $finalValue = $shouldEncrypt ? $this->encryptValue($value) : $value; + + // Delete existing + $deleteStmt = $this->db->prepare( + "DELETE FROM plugin_settings WHERE plugin_id = ? AND setting_key = ?" + ); + if ($deleteStmt === false) { + throw new \RuntimeException('[ApiBookScraper] prepare() failed for setting delete ' . $key . ': ' . $this->db->error); + } $deleteStmt->bind_param('is', $this->pluginId, $key); - $deleteStmt->execute(); + $ok = $deleteStmt->execute(); + $err = $deleteStmt->error; $deleteStmt->close(); - } - - // Insert new - $insertStmt = $this->db->prepare( - "INSERT INTO plugin_settings (plugin_id, setting_key, setting_value, autoload) - VALUES (?, ?, ?, 1)" - ); + if (!$ok) { + throw new \RuntimeException('[ApiBookScraper] setting delete failed for ' . $key . ': ' . $err); + } - if ($insertStmt) { + // Insert new + $insertStmt = $this->db->prepare( + "INSERT INTO plugin_settings (plugin_id, setting_key, setting_value, autoload) + VALUES (?, ?, ?, 1)" + ); + if ($insertStmt === false) { + throw new \RuntimeException('[ApiBookScraper] prepare() failed for setting insert ' . $key . ': ' . $this->db->error); + } $insertStmt->bind_param('iss', $this->pluginId, $key, $finalValue); - $success = $success && $insertStmt->execute(); + $ok = $insertStmt->execute(); + $err = $insertStmt->error; $insertStmt->close(); - } else { - $success = false; + if (!$ok) { + throw new \RuntimeException('[ApiBookScraper] setting insert failed for ' . $key . ': ' . $err); + } } - } - if ($success) { $this->loadSettings(); - // Re-register hooks con nuove impostazioni + // Re-register hooks con nuove impostazioni (throws on any failure) $this->registerHooks(); + + $this->db->commit(); + } catch (\Throwable $e) { + $this->db->rollback(); + // The rollback reverts the DB, but the in-memory properties were + // already repopulated by loadSettings() from the (uncommitted) + // transaction. Reload from the now-restored persisted state so the + // instance/UI never reflect unpersisted values after the failure. + $this->loadSettings(); + \App\Support\SecureLogger::error('[ApiBookScraper] saveSettings failed, rolled back: ' . $e->getMessage()); + throw $e; } - return $success; + return true; } /** @@ -608,4 +708,20 @@ public function getSettings(): array 'enabled' => $this->enabled ]; } + + /** + * Whether this plugin has a dedicated settings page + */ + public function hasSettingsPage(): bool + { + return true; + } + + /** + * Get the path to the settings view file + */ + public function getSettingsViewPath(): string + { + return __DIR__ . '/views/settings.php'; + } } diff --git a/storage/plugins/api-book-scraper/views/settings.php b/storage/plugins/api-book-scraper/views/settings.php index 8402e0bb4..689739c34 100644 --- a/storage/plugins/api-book-scraper/views/settings.php +++ b/storage/plugins/api-book-scraper/views/settings.php @@ -4,7 +4,7 @@ */ // Recupera il plugin -$plugin = $GLOBALS['plugins']['api-book-scraper'] ?? null; +$plugin = $pluginInstance ?? ($GLOBALS['plugins']['api-book-scraper'] ?? null); if (!$plugin) { echo '
Errore: Plugin non caricato correttamente.
'; return; @@ -14,35 +14,48 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_api_scraper_settings'])) { if (\App\Support\Csrf::validate($_POST['csrf_token'] ?? null)) { $settings = [ - 'api_endpoint' => $_POST['api_endpoint'] ?? '', - 'api_key' => $_POST['api_key'] ?? '', - 'timeout' => $_POST['timeout'] ?? 10, - 'enabled' => isset($_POST['enabled']) ? true : false + 'api_endpoint' => trim((string) ($_POST['api_endpoint'] ?? '')), + 'timeout' => max(5, min(60, (int) ($_POST['timeout'] ?? 10))), + 'enabled' => isset($_POST['enabled']), ]; - if ($plugin->saveSettings($settings)) { - $successMessage = 'Impostazioni salvate correttamente!'; - } else { - $errorMessage = 'Errore nel salvataggio delle impostazioni.'; + // Only overwrite the stored API key when a new value is submitted; + // an empty field means "keep the existing key" (the input renders empty + // so the encrypted key is never round-tripped through the form). + $submittedKey = trim((string) ($_POST['api_key'] ?? '')); + if ($submittedKey !== '') { + $settings['api_key'] = $submittedKey; + } + + try { + if ($plugin->saveSettings($settings)) { + $successMessage = __('Impostazioni salvate correttamente!'); + } else { + $errorMessage = __('Errore nel salvataggio delle impostazioni.'); + } + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('[ApiBookScraper] settings save failed: ' . $e->getMessage()); + $errorMessage = __('Errore nel salvataggio delle impostazioni.'); } } else { - $errorMessage = 'Token CSRF non valido.'; + $errorMessage = __('Token CSRF non valido.'); } } // Test connessione $testResult = null; if (isset($_POST['test_connection']) && \App\Support\Csrf::validate($_POST['csrf_token'] ?? null)) { - $testIsbn = '9788804668619'; // ISBN di test // In produzione, qui chiameremmo l'API per testare la connessione $testResult = [ 'success' => false, - 'message' => 'Funzione di test non ancora implementata. Salva le impostazioni e prova a importare un libro.' + 'message' => __('Test non ancora implementato. Salva le impostazioni e prova a importare un libro.'), ]; } $currentSettings = $plugin->getSettings(); +$hasApiKey = !empty($currentSettings['api_key']); $csrfToken = \App\Support\Csrf::ensureToken(); +$pluginsRoute = htmlspecialchars(url('/admin/plugins'), ENT_QUOTES, 'UTF-8'); ?>
@@ -50,10 +63,10 @@

- API Book Scraper - Configurazione + API Book Scraper -

- Configura il client per il servizio web di scraping dati libri tramite API personalizzata. +

@@ -61,14 +74,14 @@
- +
- +
@@ -80,7 +93,7 @@ : 'bg-yellow-50 border border-yellow-200 text-yellow-800'; ?>
- Test Connessione: + :
@@ -89,11 +102,11 @@
-

Informazioni Plugin

+

-

Funzionamento: Questo plugin si collega a un servizio web esterno per recuperare automaticamente i dati dei libri durante la creazione o modifica.

-

Priorità: Ha priorità 3 (più alta di Open Library che ha priorità 5), quindi verrà interrogato per primo.

-

Sicurezza: L'API key viene criptata nel database e trasmessa tramite header HTTP sicuro.

+

+

+

@@ -101,14 +114,14 @@
- +

- Configurazione API +

@@ -116,20 +129,22 @@

- Suggerimento: Usa {isbn} come placeholder per l'ISBN. - Esempio: https://api.example.com/books/{isbn} - oppure https://api.example.com/books/search (ISBN verrà aggiunto come parametro ?isbn=...) + + +

+

+ https://api.example.com/books/{isbn}

@@ -137,25 +152,29 @@ class="block w-full rounded-xl border-gray-300 focus:border-blue-500 focus:ring-
+ placeholder="" + >
+ + +

- L'API key viene criptata con AES-256-GCM prima di essere salvata nel database. +

@@ -163,7 +182,7 @@ class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-7
- secondi (min: 5, max: 60) +

- Tempo massimo di attesa per la risposta dell'API. Consigliato: 10 secondi. +

@@ -192,10 +211,10 @@ class="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
- Abilita Plugin +

- Quando abilitato, il plugin interrogherà l'API durante l'importazione dati libri. +

@@ -210,7 +229,7 @@ class="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"> value="1" class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-blue-600 text-white text-sm font-semibold hover:bg-blue-700 transition-colors"> - Salva Impostazioni + - - Torna ai Plugin +
@@ -235,14 +254,14 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gray-100 text-gray

- Guida Rapida +

-

1. Formato Richiesta

+

1.

GET {api_endpoint}?isbn={ISBN}
Header: X-API-Key: {api_key} @@ -250,7 +269,7 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gray-100 text-gray
-

2. Formato Risposta JSON (Campi Supportati)

+

2.

{
  "success": true,
@@ -273,17 +292,17 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gray-100 text-gray }

- Nota: Tutti i campi sono opzionali. Il plugin userà solo i campi presenti nella risposta. +

-

3. Codici Risposta HTTP

+

3.

    -
  • 200 OK - Libro trovato, dati restituiti
  • -
  • 404 Not Found - ISBN non trovato nel database
  • -
  • 401 Unauthorized - API key non valida
  • -
  • 500 Server Error - Errore server
  • +
  • 200 OK -
  • +
  • 404 Not Found -
  • +
  • 401 Unauthorized -
  • +
  • 500 Server Error -
@@ -291,8 +310,8 @@ class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gray-100 text-gray
- Documentazione Completa: - Per la guida completa su come implementare il server API, consulta il file + + storage/plugins/api-book-scraper/SERVER_IMPLEMENTATION_GUIDE.md
diff --git a/storage/plugins/archives/ArchivesPlugin.php b/storage/plugins/archives/ArchivesPlugin.php index e878cea35..80a1d1d9f 100644 --- a/storage/plugins/archives/ArchivesPlugin.php +++ b/storage/plugins/archives/ArchivesPlugin.php @@ -1822,8 +1822,27 @@ public function destroyAction( } if (!$wasInTransaction) { $db->begin_transaction(); } try { + // Free the UNIQUE-indexed identifiers on soft-delete so the codes + // can be reissued to a new/corrected record. Neither uq_reference + // (institution_code, reference_code) nor uq_ark_identifier is scoped + // by deleted_at, so leaving the values in place would permanently + // block reuse — the same reason core libri nullifies isbn10/13/ean. + // reference_code is NOT NULL and only VARCHAR(64), so rather than + // overwriting it (which would lose the original code needed for audit + // and reference lookups) I APPEND a per-id token, truncating the + // original prefix just enough to keep the result within 64 chars with + // the token intact. This frees the combined key while preserving the + // original code as a readable prefix; ark_identifier is nullable and + // set to NULL (multiple NULLs are allowed in a UNIQUE index). $stmt = $this->db->prepare( - 'UPDATE archival_units SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL' + "UPDATE archival_units + SET deleted_at = NOW(), + reference_code = CONCAT( + LEFT(reference_code, GREATEST(0, 64 - CHAR_LENGTH(CONCAT('__deleted_', id)))), + '__deleted_', id + ), + ark_identifier = NULL + WHERE id = ? AND deleted_at IS NULL" ); if ($stmt === false) { SecureLogger::error('[Archives] delete prepare failed: ' . $this->db->error, ['id' => $id]); diff --git a/storage/plugins/book-club/src/AiController.php b/storage/plugins/book-club/src/AiController.php index 9348d5868..cff7741a2 100644 --- a/storage/plugins/book-club/src/AiController.php +++ b/storage/plugins/book-club/src/AiController.php @@ -224,6 +224,13 @@ public function generateMinutes(ServerRequestInterface $request, ResponseInterfa public function adminSettings(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { + // AdminAuthMiddleware admits both 'admin' and 'staff'; the AI settings + // expose the global API key and outbound endpoint, so require a real + // admin inline (a staff account must not read the masked key or repoint + // the endpoint that AiService::generate() sends prompt content to). + if (($_SESSION['user']['tipo_utente'] ?? null) !== 'admin') { + return $this->notFound($response); + } return $this->renderAdmin($response, 'admin/ai_settings', [ 'pluginFound' => $this->ai->pluginId() !== null, 'enabled' => $this->ai->isEnabled(), @@ -242,6 +249,12 @@ public function adminSettings(ServerRequestInterface $request, ResponseInterface public function adminSettingsSave(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { + // Admin-only: rotating/clearing the global API key and repointing the + // outbound endpoint must not be reachable by a 'staff' account that + // AdminAuthMiddleware would otherwise let through (see adminSettings()). + if (($_SESSION['user']['tipo_utente'] ?? null) !== 'admin') { + return $this->notFound($response); + } if ($this->ai->pluginId() === null) { $this->flash('error', __('Plugin Book Club non trovato nel registro dei plugin.')); return $this->redirect($response, '/admin/book-club/ai'); diff --git a/storage/plugins/book-club/src/AiService.php b/storage/plugins/book-club/src/AiService.php index 454ed4bc9..b805f9348 100644 --- a/storage/plugins/book-club/src/AiService.php +++ b/storage/plugins/book-club/src/AiService.php @@ -156,8 +156,8 @@ public function maskedKey(): string public function generate(string $system, string $user): ?string { $apiKey = $this->apiKey(); - if ($apiKey === '' || !function_exists('curl_init')) { - SecureLogger::warning('[BookClub:ai] generate() called without API key or cURL extension'); + if ($apiKey === '') { + SecureLogger::warning('[BookClub:ai] generate() called without API key'); return null; } @@ -175,46 +175,33 @@ public function generate(string $system, string $user): ?string } try { - $ch = curl_init($this->endpoint()); - if ($ch === false) { - SecureLogger::error('[BookClub:ai] curl_init failed'); - return null; - } - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $payload, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CONNECTTIMEOUT => 10, - CURLOPT_TIMEOUT => 20, - // SSRF/redirect/TLS hardening: HTTPS-only, no redirects, - // certificate verification always on. - CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, - CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, - CURLOPT_FOLLOWLOCATION => false, - CURLOPT_MAXREDIRS => 0, - CURLOPT_SSL_VERIFYPEER => true, - CURLOPT_SSL_VERIFYHOST => 2, - CURLOPT_HTTPHEADER => [ - 'Content-Type: application/json', - 'anthropic-version: 2023-06-01', - 'x-api-key: ' . $apiKey, - ], + // Route through the project HttpClient (centralised SSRF / + // DNS-rebinding / redirect hardening). https_only pins the scheme so + // the x-api-key header can never follow a 30x onto cleartext, and + // max_redirects=0 forbids redirects outright — the endpoint is + // admin-configurable, so it must go through the guarded client. + $res = \App\Support\HttpClient::post($this->endpoint(), $payload, [ + 'Content-Type' => 'application/json', + 'anthropic-version' => '2023-06-01', + 'x-api-key' => $apiKey, + ], [ + 'timeout' => 20, + 'connect_timeout' => 10, + 'max_redirects' => 0, + 'https_only' => true, + 'ssrf_guard' => true, ]); - $raw = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - $curlError = curl_error($ch); - /* curl_close(): no-op since PHP 8.0, deprecated 8.5 */ - if ($raw === false || $raw === '') { - SecureLogger::error('[BookClub:ai] HTTP request failed: ' . ($curlError !== '' ? $curlError : 'empty response')); + if (!$res['ok'] || $res['body'] === '') { + SecureLogger::error('[BookClub:ai] HTTP request failed (transport or blocked target)'); return null; } - if ($httpCode < 200 || $httpCode >= 300) { + if ($res['status'] < 200 || $res['status'] >= 300) { // Body may contain provider error details but never the key. - SecureLogger::error('[BookClub:ai] endpoint returned HTTP ' . $httpCode . ': ' . mb_substr((string) $raw, 0, 500)); + SecureLogger::error('[BookClub:ai] endpoint returned HTTP ' . $res['status'] . ': ' . mb_substr($res['body'], 0, 500)); return null; } - $data = json_decode((string) $raw, true); + $data = json_decode($res['body'], true); $text = is_array($data) ? ($data['content'][0]['text'] ?? null) : null; if (!is_string($text) || trim($text) === '') { SecureLogger::error('[BookClub:ai] unexpected response shape (no content[0].text)'); diff --git a/storage/plugins/dewey-editor/DeweyEditorPlugin.php b/storage/plugins/dewey-editor/DeweyEditorPlugin.php index 8e0f195df..0997862e9 100644 --- a/storage/plugins/dewey-editor/DeweyEditorPlugin.php +++ b/storage/plugins/dewey-editor/DeweyEditorPlugin.php @@ -6,7 +6,7 @@ * Allows adding, modifying, and deleting decimal codes with validation. * * @package DeweyEditorPlugin - * @version 1.0.0 + * @version 1.0.1 */ declare(strict_types=1); @@ -25,7 +25,6 @@ class DeweyEditorPlugin /** @phpstan-ignore property.onlyWritten */ private HookManager $hookManager; private ?int $pluginId = null; - private string $dataDir; private string $backupDir; private const MAX_BACKUPS = 5; @@ -53,7 +52,6 @@ public function __construct(mysqli $db, HookManager $hookManager) { $this->db = $db; $this->hookManager = $hookManager; - $this->dataDir = dirname(__DIR__, 3) . '/data/dewey'; $this->backupDir = dirname(__DIR__, 3) . '/storage/backups/dewey'; $result = $db->query("SELECT id FROM plugins WHERE name = 'dewey-editor' LIMIT 1"); @@ -206,7 +204,7 @@ public function getData(Request $_request, Response $response, array $args): Res return $this->jsonError($response, __('Locale non supportato.'), 400); } - $filePath = $this->getJsonPath($locale); + $filePath = $this->resolveDataPath($locale); if (!file_exists($filePath)) { return $this->jsonError($response, __('File Dewey non trovato.'), 404); } @@ -222,6 +220,7 @@ public function getData(Request $_request, Response $response, array $args): Res public function saveData(Request $request, Response $response, array $args): Response { + if (($denied = $this->requireAdmin($response)) !== null) { return $denied; } $locale = $args['locale'] ?? 'it_IT'; if (!$this->isLocaleSupported($locale)) { return $this->jsonError($response, __('Locale non supportato.'), 400); @@ -305,7 +304,7 @@ public function exportData(Request $_request, Response $response, array $args): return $this->jsonError($response, __('Locale non supportato.'), 400); } - $filePath = $this->getJsonPath($locale); + $filePath = $this->resolveDataPath($locale); if (!file_exists($filePath)) { return $this->jsonError($response, __('File non trovato.'), 404); } @@ -325,6 +324,7 @@ public function exportData(Request $_request, Response $response, array $args): public function importData(Request $request, Response $response, array $args): Response { + if (($denied = $this->requireAdmin($response)) !== null) { return $denied; } $locale = $args['locale'] ?? 'it_IT'; if (!$this->isLocaleSupported($locale)) { return $this->jsonError($response, __('Locale non supportato.'), 400); @@ -380,6 +380,7 @@ public function importData(Request $request, Response $response, array $args): R public function mergeImportData(Request $request, Response $response, array $args): Response { + if (($denied = $this->requireAdmin($response)) !== null) { return $denied; } $locale = $args['locale'] ?? 'it_IT'; if (!$this->isLocaleSupported($locale)) { return $this->jsonError($response, __('Locale non supportato.'), 400); @@ -405,11 +406,13 @@ public function mergeImportData(Request $request, Response $response, array $arg return $this->jsonError($response, __('Formato dati non valido.'), 400); } - // Load existing data + // Load existing data (read may fall back to the legacy file) but always + // write the merged result to the canonical full-locale path. + $readPath = $this->resolveDataPath($locale); $filePath = $this->getJsonPath($locale); $existingData = []; - if (file_exists($filePath)) { - $existingContent = file_get_contents($filePath); + if (file_exists($readPath)) { + $existingContent = file_get_contents($readPath); if ($existingContent === false) { return $this->jsonError($response, __('Errore nella lettura del file Dewey esistente.'), 500); } @@ -553,6 +556,7 @@ public function listBackups(Request $_request, Response $response, array $args): public function restoreBackup(Request $request, Response $response, array $args): Response { + if (($denied = $this->requireAdmin($response)) !== null) { return $denied; } $locale = $args['locale'] ?? 'it_IT'; if (!$this->isLocaleSupported($locale)) { return $this->jsonError($response, __('Locale non supportato.'), 400); @@ -588,12 +592,32 @@ public function restoreBackup(Request $request, Response $response, array $args) return $this->jsonSuccess($response, ['message' => __('Backup ripristinato con successo.')]); } + /** + * Canonical (write) path for a locale's Dewey data file. + * + * Uses the FULL locale code so distinct locales sharing a language prefix + * (e.g. en_US vs en_GB, pt_PT vs pt_BR) map to separate files and never + * clobber each other. Previously the locale was collapsed to its first two + * characters, silently aliasing such pairs onto one shared file. + */ private function getJsonPath(string $locale): string { - // Extract language code from locale (e.g., 'it_IT' -> 'it', 'en_US' -> 'en') - $langCode = strtolower(substr($locale, 0, 2)); - $filename = "dewey_completo_{$langCode}.json"; - return $this->dataDir . '/' . $filename; + return \App\Support\DeweyDataFiles::canonicalPath($locale); + } + + /** + * Resolve the path to READ a locale's Dewey data. + * + * Prefers the canonical full-locale file; falls back to the legacy + * language-prefix file (e.g. dewey_completo_en.json) shipped/created before + * the full-locale scheme, so existing installs keep reading their data. + * Writes always target getJsonPath() (full locale), so the first save of a + * locale migrates it to the canonical file and separates prefix-sharing + * locales going forward. + */ + private function resolveDataPath(string $locale): string + { + return \App\Support\DeweyDataFiles::resolveReadPath($locale); } private function createBackup(string $locale): void @@ -605,7 +629,7 @@ private function createBackup(string $locale): void } } - $sourcePath = $this->getJsonPath($locale); + $sourcePath = $this->resolveDataPath($locale); if (!file_exists($sourcePath)) { return; } @@ -691,4 +715,21 @@ private function jsonError(Response $response, string $message, int $status, arr $response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus($status); } + + /** + * Inline admin-only re-check for destructive endpoints. + * + * AdminAuthMiddleware admits both 'admin' and 'staff', but the save/import/ + * merge/restore endpoints overwrite the sitewide Dewey classification, so + * they must be limited to full administrators — mirroring the project-wide + * pattern for destructive admin actions. Returns a 403 response when the + * current session is not an admin, or null when access is allowed. + */ + private function requireAdmin(Response $response): ?Response + { + if (($_SESSION['user']['tipo_utente'] ?? null) !== 'admin') { + return $this->jsonError($response, __('Non autorizzato.'), 403); + } + return null; + } } diff --git a/storage/plugins/dewey-editor/plugin.json b/storage/plugins/dewey-editor/plugin.json index afdd2d189..492d96341 100644 --- a/storage/plugins/dewey-editor/plugin.json +++ b/storage/plugins/dewey-editor/plugin.json @@ -9,7 +9,6 @@ "main_file": "DeweyEditorPlugin.php", "requires_php": "7.4", "requires_app": "0.4.0", - "max_app_version": "0.5.9.6", "settings": false, "metadata": { "category": "admin", diff --git a/storage/plugins/digital-library/DigitalLibraryPlugin.php b/storage/plugins/digital-library/DigitalLibraryPlugin.php index d3a7957a0..128ef065e 100644 --- a/storage/plugins/digital-library/DigitalLibraryPlugin.php +++ b/storage/plugins/digital-library/DigitalLibraryPlugin.php @@ -15,7 +15,7 @@ * - Optional and fully disableable * * @package Pinakes\Plugins\DigitalLibrary - * @version 1.1.0 + * @version 1.3.2 */ class DigitalLibraryPlugin { @@ -129,10 +129,66 @@ public function onActivate(): void ])); } + // Ensure the schema columns exist. Upgrades re-call onActivate() (not + // onInstall()) for an already-active plugin, so the schema logic must + // run here too — per the project's Plugin Schema Rule. + $this->ensureSchema(); + // Register hooks in database $this->registerHooks(); } + /** + * Ensure the digital-content columns exist on `libri`. + * + * Idempotent (SHOW COLUMNS guards make it safe to call repeatedly) and + * called from BOTH onActivate() and onInstall() so the schema is never + * skipped on an upgrade of an already-active plugin. Widths match the + * canonical installer/database/schema.sql definition (VARCHAR(255)). + */ + private function ensureSchema(): void + { + if (!$this->db) { + return; + } + + $this->ensureColumn( + 'file_url', + "ALTER TABLE libri ADD COLUMN file_url VARCHAR(255) DEFAULT NULL COMMENT 'eBook file URL' AFTER note_varie" + ); + $this->ensureColumn( + 'audio_url', + "ALTER TABLE libri ADD COLUMN audio_url VARCHAR(255) DEFAULT NULL COMMENT 'Audiobook file URL' AFTER file_url" + ); + } + + /** + * Add a column to `libri` if it is missing, failing loudly on any DDL + * error. Returning silently would let onActivate() complete with a partial + * schema (missing file_url/audio_url), so a later book save then fails. + */ + private function ensureColumn(string $column, string $alterSql): void + { + if (!$this->db) { + return; + } + + $result = $this->db->query("SHOW COLUMNS FROM libri LIKE '" . $this->db->real_escape_string($column) . "'"); + if ($result === false) { + $err = "[Digital Library] SHOW COLUMNS for {$column} failed: " . $this->db->error; + \App\Support\SecureLogger::error($err); + throw new \RuntimeException($err); + } + + if ($result instanceof \mysqli_result && $result->num_rows === 0) { + if ($this->db->query($alterSql) === false) { + $err = "[Digital Library] ALTER TABLE adding {$column} failed: " . $this->db->error; + \App\Support\SecureLogger::error($err); + throw new \RuntimeException($err); + } + } + } + /** * Plugin deactivation hook */ @@ -157,22 +213,8 @@ public function onDeactivate(): void */ public function onInstall(): void { - // Verify database columns exist - if (!$this->db) { - return; - } - - $result = $this->db->query("SHOW COLUMNS FROM libri LIKE 'file_url'"); - if ($result instanceof \mysqli_result && $result->num_rows === 0) { - // Add file_url column if missing - $this->db->query("ALTER TABLE libri ADD COLUMN file_url VARCHAR(500) DEFAULT NULL COMMENT 'eBook file URL' AFTER note_varie"); - } - - $result = $this->db->query("SHOW COLUMNS FROM libri LIKE 'audio_url'"); - if ($result instanceof \mysqli_result && $result->num_rows === 0) { - // Add audio_url column if missing - $this->db->query("ALTER TABLE libri ADD COLUMN audio_url VARCHAR(500) DEFAULT NULL COMMENT 'Audiobook file URL' AFTER file_url"); - } + // Verify database columns exist (shared idempotent schema logic). + $this->ensureSchema(); } /** diff --git a/storage/plugins/digital-library/plugin.json b/storage/plugins/digital-library/plugin.json index 44d8d9d07..0d18eff42 100644 --- a/storage/plugins/digital-library/plugin.json +++ b/storage/plugins/digital-library/plugin.json @@ -1,7 +1,7 @@ { "name": "digital-library", "display_name": "Digital Library (eBooks/Audiobooks)", - "version": "1.3.1", + "version": "1.3.2", "author": "Fabiodalez", "author_url": "", "plugin_url": "", diff --git a/storage/plugins/digital-library/views/admin-form-fields.php b/storage/plugins/digital-library/views/admin-form-fields.php index b0b5d5ecc..a04789700 100644 --- a/storage/plugins/digital-library/views/admin-form-fields.php +++ b/storage/plugins/digital-library/views/admin-form-fields.php @@ -34,7 +34,7 @@
: - +
@@ -84,7 +84,7 @@ class="ui-button btn-primary flex items-center justify-center gap-2 w-full md:w-
: - +
diff --git a/storage/plugins/discogs/DiscogsPlugin.php b/storage/plugins/discogs/DiscogsPlugin.php index eafe16ffa..3f425e798 100644 --- a/storage/plugins/discogs/DiscogsPlugin.php +++ b/storage/plugins/discogs/DiscogsPlugin.php @@ -4,8 +4,6 @@ namespace App\Plugins\Discogs; -use App\Support\Hooks; - /** * Multi-source Music Scraper Plugin (Discogs, MusicBrainz, Deezer) * @@ -37,17 +35,6 @@ public function __construct(?\mysqli $db = null, ?object $hookManager = null) $this->hookManager = $hookManager; } - /** - * Activate the plugin and register all hooks - */ - public function activate(): void - { - Hooks::add('scrape.sources', [$this, 'addDiscogsSource'], 8); - Hooks::add('scrape.fetch.custom', [$this, 'fetchFromDiscogs'], 8); - Hooks::add('scrape.data.modify', [$this, 'enrichWithDiscogsData'], 15); - Hooks::add('scrape.isbn.validate', [$this, 'validateBarcode'], 8); - } - /** * Validate barcode: accept ISBN, EAN-13, UPC-A, and Discogs Catalog Numbers. * diff --git a/storage/plugins/discogs/wrapper.php b/storage/plugins/discogs/wrapper.php index a9ca868b9..a977a1a7f 100644 --- a/storage/plugins/discogs/wrapper.php +++ b/storage/plugins/discogs/wrapper.php @@ -2,9 +2,9 @@ /** * Discogs Plugin Wrapper * - * This wrapper allows the plugin to work with both: - * 1. Direct loading via activate.php (with namespace) - * 2. PluginManager installation (without namespace) + * Exposes a global (non-namespaced) DiscogsPlugin class that proxies to the + * namespaced implementation, as required by PluginManager. Hooks are persisted + * to the DB via the namespaced onActivate()/registerHooks() path. */ // Load the main plugin file @@ -26,16 +26,6 @@ public function __construct($db = null, $hookManager = null) $this->instance = new \App\Plugins\Discogs\DiscogsPlugin($db, $hookManager); } - /** - * Activate the plugin - */ - public function activate(): void - { - if (is_callable([$this->instance, 'activate'])) { - $this->instance->activate(); - } - } - /** * Deactivate the plugin (called by PluginManager) */ @@ -65,8 +55,6 @@ public function onActivate(): void { if (is_callable([$this->instance, 'onActivate'])) { $this->instance->onActivate(); - } elseif (is_callable([$this->instance, 'activate'])) { - $this->instance->activate(); } \App\Support\SecureLogger::debug('[Discogs] Plugin activated'); } diff --git a/storage/plugins/frbr-lrm/EspressioniRepository.php b/storage/plugins/frbr-lrm/EspressioniRepository.php index a96671213..92406908b 100644 --- a/storage/plugins/frbr-lrm/EspressioniRepository.php +++ b/storage/plugins/frbr-lrm/EspressioniRepository.php @@ -60,14 +60,14 @@ public function create(array $data): int VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; $stmt = $this->db->prepare($sql); $operaId = (int) ($data['opera_id'] ?? 0); - $lingua = $data['lingua'] !== '' ? ($data['lingua'] ?? null) : null; + $lingua = ($data['lingua'] ?? '') !== '' ? (string) $data['lingua'] : null; $tipo = (string) ($data['tipo_espressione'] ?? 'testo'); $trad = !empty($data['traduttore_autore_id']) ? (int) $data['traduttore_autore_id'] : null; $cur = !empty($data['curatore_autore_id']) ? (int) $data['curatore_autore_id'] : null; $rev = !empty($data['revisore_autore_id']) ? (int) $data['revisore_autore_id'] : null; - $titolo = $data['titolo_espressione'] !== '' ? ($data['titolo_espressione'] ?? null) : null; - $anno = $data['anno_espressione'] !== '' ? (int) ($data['anno_espressione'] ?? 0) : null; - $note = $data['note'] !== '' ? ($data['note'] ?? null) : null; + $titolo = ($data['titolo_espressione'] ?? '') !== '' ? (string) $data['titolo_espressione'] : null; + $anno = ($data['anno_espressione'] ?? '') !== '' ? (int) $data['anno_espressione'] : null; + $note = ($data['note'] ?? '') !== '' ? (string) $data['note'] : null; // 9 params: opera_id(i) lingua(s) tipo(s) trad(i) cur(i) rev(i) titolo(s) anno(i) note(s) $stmt->bind_param('issiiisis', $operaId, $lingua, $tipo, $trad, $cur, $rev, $titolo, $anno, $note); $stmt->execute(); @@ -76,6 +76,30 @@ public function create(array $data): int return $id; } + /** @param array $data */ + public function update(int $id, array $data): bool + { + $sql = "UPDATE espressioni SET + lingua = ?, tipo_espressione = ?, traduttore_autore_id = ?, + curatore_autore_id = ?, revisore_autore_id = ?, titolo_espressione = ?, + anno_espressione = ?, note = ?, updated_at = NOW() + WHERE id = ? AND deleted_at IS NULL"; + $stmt = $this->db->prepare($sql); + $lingua = ($data['lingua'] ?? '') !== '' ? (string) $data['lingua'] : null; + $tipo = (string) ($data['tipo_espressione'] ?? 'testo'); + $trad = !empty($data['traduttore_autore_id']) ? (int) $data['traduttore_autore_id'] : null; + $cur = !empty($data['curatore_autore_id']) ? (int) $data['curatore_autore_id'] : null; + $rev = !empty($data['revisore_autore_id']) ? (int) $data['revisore_autore_id'] : null; + $titolo = ($data['titolo_espressione'] ?? '') !== '' ? (string) $data['titolo_espressione'] : null; + $anno = ($data['anno_espressione'] ?? '') !== '' ? (int) $data['anno_espressione'] : null; + $note = ($data['note'] ?? '') !== '' ? (string) $data['note'] : null; + // 9 params: lingua(s) tipo(s) trad(i) cur(i) rev(i) titolo(s) anno(i) note(s) id(i) + $stmt->bind_param('ssiiisisi', $lingua, $tipo, $trad, $cur, $rev, $titolo, $anno, $note, $id); + $ok = $stmt->execute(); + $stmt->close(); + return $ok; + } + public function softDelete(int $id): bool { $this->db->query("UPDATE libri SET espressione_id = NULL WHERE espressione_id = " . (int) $id); diff --git a/storage/plugins/frbr-lrm/FrbrLrmPlugin.php b/storage/plugins/frbr-lrm/FrbrLrmPlugin.php index 071efd1f4..a395471a7 100644 --- a/storage/plugins/frbr-lrm/FrbrLrmPlugin.php +++ b/storage/plugins/frbr-lrm/FrbrLrmPlugin.php @@ -72,6 +72,9 @@ public function onActivate(): void try { $this->registerHookInDb('app.routes.register', 'registerRoutes', 20); $this->registerHookInDb('admin.menu.render', 'renderAdminMenuEntry', 20); + // Flagship integration (#6): inject the "link this book to a Work" + // panel into the book edit form via the shared book-form hook. + $this->registerHookInDb('book.form.fields', 'renderBookOperaPanel', 20); $this->db->commit(); } catch (\Throwable $e) { $this->db->rollback(); @@ -126,6 +129,26 @@ public function registerRoutes($app): void return $plugin->adminDeleteAction($req, $res, (int) $args['id']); })->add($csrfMiddleware)->add($adminMiddleware); + // ── Admin: create an espressione under an opera ── + $app->post('/admin/opere/{id:[0-9]+}/espressioni', function (ServerRequestInterface $req, ResponseInterface $res, array $args) use ($plugin): ResponseInterface { + return $plugin->adminEspressioneStoreAction($req, $res, (int) $args['id']); + })->add($csrfMiddleware)->add($adminMiddleware); + + // ── Admin: edit espressione form ── + $app->get('/admin/opere/espressioni/{eid:[0-9]+}/edit', function (ServerRequestInterface $req, ResponseInterface $res, array $args) use ($plugin): ResponseInterface { + return $plugin->adminEspressioneEditAction($req, $res, (int) $args['eid']); + })->add($adminMiddleware); + + // ── Admin: update espressione ── + $app->post('/admin/opere/espressioni/{eid:[0-9]+}/edit', function (ServerRequestInterface $req, ResponseInterface $res, array $args) use ($plugin): ResponseInterface { + return $plugin->adminEspressioneUpdateAction($req, $res, (int) $args['eid']); + })->add($csrfMiddleware)->add($adminMiddleware); + + // ── Admin: delete espressione (soft-delete) ── + $app->post('/admin/opere/espressioni/{eid:[0-9]+}/delete', function (ServerRequestInterface $req, ResponseInterface $res, array $args) use ($plugin): ResponseInterface { + return $plugin->adminEspressioneDeleteAction($req, $res, (int) $args['eid']); + })->add($csrfMiddleware)->add($adminMiddleware); + // ── Admin: attach a book to an opera (from the book edit form) ── $app->post('/admin/books/{id:[0-9]+}/attach-opera', function (ServerRequestInterface $req, ResponseInterface $res, array $args) use ($plugin): ResponseInterface { return $plugin->attachBookToOperaAction($req, $res, (int) $args['id']); @@ -183,9 +206,8 @@ public function expectedTables(): array private static function schemaSteps(): array { return [ - 'opere' => self::ddlOpere(), - 'espressioni' => self::ddlEspressioni(), - 'libri_autori_ruoli' => self::ddlLibriAutoriRuoli(), + 'opere' => self::ddlOpere(), + 'espressioni' => self::ddlEspressioni(), ]; } @@ -320,28 +342,13 @@ private static function ddlEspressioni(): string SQL; } - private static function ddlLibriAutoriRuoli(): string - { - return << $opera, 'edizioni' => $repo->editionsForOpera($id), 'espressioni' => (new EspressioniRepository($this->db))->listForOpera($id), + 'autori' => $this->autoriForSelect(), 'pageTitle' => $opera['titolo_uniforme'], ]); } @@ -445,18 +453,139 @@ public function adminDeleteAction(ServerRequestInterface $request, ResponseInter return $this->redirect($response, '/admin/opere'); } - /** Attach a book to an opera (POST from the book edit form). */ + /** + * Attach (or, with an empty opera_id, detach) a book to/from an opera. + * POSTed from the book edit form's Opera panel. Answers JSON to an AJAX + * caller and 302-redirects a plain form submit, so the endpoint is usable + * both ways. + */ public function attachBookToOperaAction(ServerRequestInterface $request, ResponseInterface $response, int $libroId): ResponseInterface { $data = (array) $request->getParsedBody(); $operaId = !empty($data['opera_id']) ? (int) $data['opera_id'] : null; + $wantsJson = $this->wantsJson($request); + + // Reject a non-existent / soft-deleted opera so we never store a dangling FK. + if ($operaId !== null && !(new OpereRepository($this->db))->exists($operaId)) { + if ($wantsJson) { + return $this->json($response, ['success' => false, 'error' => __('Opera non trovata')], 404); + } + return $this->redirect($response, '/admin/books/' . $libroId); + } + $stmt = $this->db->prepare('UPDATE libri SET opera_id = ? WHERE id = ? AND deleted_at IS NULL'); $stmt->bind_param('ii', $operaId, $libroId); $stmt->execute(); $stmt->close(); + + if ($wantsJson) { + $opera = $operaId !== null ? (new OpereRepository($this->db))->getForBook($libroId) : null; + return $this->json($response, ['success' => true, 'opera' => $opera]); + } return $this->redirect($response, '/admin/books/' . $libroId); } + // ───────────────────────────────────────────────────────────────────── + // Book-form hook (book.form.fields) — link a manifestation to a Work + // ───────────────────────────────────────────────────────────────────── + + /** + * Render the "link this book to an Opera" panel inside the book edit form. + * + * @param array|null $book + */ + public function renderBookOperaPanel(?array $book, ?int $bookId): void + { + $id = $bookId ?? (is_array($book) ? (int) ($book['id'] ?? 0) : 0); + $currentOpera = $id > 0 ? (new OpereRepository($this->db))->getForBook($id) : null; + $csrf = \App\Support\Csrf::ensureToken(); + include __DIR__ . '/views/book/opera-panel.php'; + } + + // ───────────────────────────────────────────────────────────────────── + // Admin actions — espressioni CRUD (tied to a Work) + // ───────────────────────────────────────────────────────────────────── + + /** Allowed Expression types — must match the `espressioni.tipo_espressione` ENUM. */ + private const TIPI_ESPRESSIONE = [ + 'testo', 'traduzione', 'revisione', 'adattamento', 'edizione_critica', 'audio', 'altro', + ]; + + public function adminEspressioneStoreAction(ServerRequestInterface $request, ResponseInterface $response, int $operaId): ResponseInterface + { + if (!(new OpereRepository($this->db))->exists($operaId)) { + return $this->redirect($response, '/admin/opere'); + } + $data = $this->sanitizeEspressioneInput((array) $request->getParsedBody()); + $data['opera_id'] = $operaId; + (new EspressioniRepository($this->db))->create($data); + return $this->redirect($response, '/admin/opere/' . $operaId); + } + + public function adminEspressioneEditAction(ServerRequestInterface $request, ResponseInterface $response, int $espressioneId): ResponseInterface + { + $repo = new EspressioniRepository($this->db); + $espressione = $repo->getById($espressioneId); + if ($espressione === null) { + return $this->redirect($response, '/admin/opere'); + } + $currentAuthorIds = array_values(array_filter([ + (int) ($espressione['traduttore_autore_id'] ?? 0), + (int) ($espressione['curatore_autore_id'] ?? 0), + (int) ($espressione['revisore_autore_id'] ?? 0), + ], static fn(int $id): bool => $id > 0)); + return $this->renderView($response, 'admin/espressioni/form', [ + 'espressione' => $espressione, + // Always include the currently linked people even when they fall + // outside the first 1,000 alphabetical rows. Otherwise the select + // has no matching option and an unrelated edit silently NULLs the + // translator/curator/reviser relationship on submit. + 'autori' => $this->autoriForSelect($currentAuthorIds), + 'pageTitle' => __('Modifica espressione'), + ]); + } + + public function adminEspressioneUpdateAction(ServerRequestInterface $request, ResponseInterface $response, int $espressioneId): ResponseInterface + { + $repo = new EspressioniRepository($this->db); + $espressione = $repo->getById($espressioneId); + if ($espressione === null) { + return $this->redirect($response, '/admin/opere'); + } + $data = $this->sanitizeEspressioneInput((array) $request->getParsedBody()); + $repo->update($espressioneId, $data); + return $this->redirect($response, '/admin/opere/' . (int) $espressione['opera_id']); + } + + public function adminEspressioneDeleteAction(ServerRequestInterface $request, ResponseInterface $response, int $espressioneId): ResponseInterface + { + $repo = new EspressioniRepository($this->db); + $espressione = $repo->getById($espressioneId); + if ($espressione === null) { + return $this->redirect($response, '/admin/opere'); + } + $operaId = (int) $espressione['opera_id']; + $repo->softDelete($espressioneId); + return $this->redirect($response, '/admin/opere/' . $operaId); + } + + /** + * Normalise the espressione form payload: clamp the type to the ENUM and + * pass the remaining fields through as-is for the repository to bind. + * + * @param array $data + * @return array + */ + private function sanitizeEspressioneInput(array $data): array + { + $tipo = (string) ($data['tipo_espressione'] ?? 'testo'); + if (!in_array($tipo, self::TIPI_ESPRESSIONE, true)) { + $tipo = 'testo'; + } + $data['tipo_espressione'] = $tipo; + return $data; + } + /** Opera autocomplete JSON for the attach UI. */ public function apiSearchOpereAction(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { @@ -544,18 +673,65 @@ private function redirect(ResponseInterface $response, string $path): ResponseIn return $response->withHeader('Location', url($path))->withStatus(302); } - /** @return array */ - private function autoriForSelect(): array + /** Does the caller want a JSON response (fetch/XHR) rather than a redirect? */ + private function wantsJson(ServerRequestInterface $request): bool { - $out = []; + if (strtolower($request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest') { + return true; + } + return str_contains($request->getHeaderLine('Accept'), 'application/json'); + } + + /** + * @param array $payload + */ + private function json(ResponseInterface $response, array $payload, int $status = 200): ResponseInterface + { + $response->getBody()->write((string) json_encode($payload, JSON_UNESCAPED_UNICODE)); + return $response->withHeader('Content-Type', 'application/json')->withStatus($status); + } + + /** + * @param list $includeIds IDs that must be present beyond the display cap + * @return array + */ + private function autoriForSelect(array $includeIds = []): array + { + /** @var array $byId */ + $byId = []; $display = \App\Support\AuthorName::displaySql('a'); $res = $this->db->query("SELECT a.id, {$display} AS nome FROM autori a ORDER BY nome ASC LIMIT 1000"); if ($res instanceof \mysqli_result) { while ($row = $res->fetch_assoc()) { - $out[] = ['id' => (int) $row['id'], 'nome' => (string) $row['nome']]; + $id = (int) $row['id']; + $byId[$id] = ['id' => $id, 'nome' => (string) $row['nome']]; } $res->free(); } + + $includeIds = array_values(array_unique(array_filter( + array_map('intval', $includeIds), + static fn(int $id): bool => $id > 0 && !isset($byId[$id]) + ))); + if ($includeIds !== []) { + $placeholders = implode(',', array_fill(0, count($includeIds), '?')); + $extraStmt = $this->db->prepare( + "SELECT a.id, {$display} AS nome FROM autori a WHERE a.id IN ({$placeholders})" + ); + if ($extraStmt !== false) { + $extraStmt->bind_param(str_repeat('i', count($includeIds)), ...$includeIds); + $extraStmt->execute(); + $extra = $extraStmt->get_result(); + while ($extra instanceof \mysqli_result && ($row = $extra->fetch_assoc())) { + $id = (int) $row['id']; + $byId[$id] = ['id' => $id, 'nome' => (string) $row['nome']]; + } + $extraStmt->close(); + } + } + + $out = array_values($byId); + usort($out, static fn(array $a, array $b): int => strnatcasecmp($a['nome'], $b['nome'])); return $out; } diff --git a/storage/plugins/frbr-lrm/OpereRepository.php b/storage/plugins/frbr-lrm/OpereRepository.php index fab87ee9c..0e691430d 100644 --- a/storage/plugins/frbr-lrm/OpereRepository.php +++ b/storage/plugins/frbr-lrm/OpereRepository.php @@ -63,6 +63,39 @@ public function getById(int $id): ?array return $row ?: null; } + /** + * The opera (Work) currently attached to a given book, or null. + * + * @return array|null + */ + public function getForBook(int $bookId): ?array + { + $authorDisplay = \App\Support\AuthorName::displaySql('a'); + $sql = "SELECT o.id, o.titolo_uniforme, o.slug, {$authorDisplay} AS autore_nome + FROM libri l + INNER JOIN opere o ON l.opera_id = o.id AND o.deleted_at IS NULL + LEFT JOIN autori a ON o.autore_principale_id = a.id + WHERE l.id = ? AND l.deleted_at IS NULL + LIMIT 1"; + $stmt = $this->db->prepare($sql); + $stmt->bind_param('i', $bookId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + return $row ?: null; + } + + /** True if a non-deleted opera with this id exists. */ + public function exists(int $id): bool + { + $stmt = $this->db->prepare("SELECT 1 FROM opere WHERE id = ? AND deleted_at IS NULL LIMIT 1"); + $stmt->bind_param('i', $id); + $stmt->execute(); + $found = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + return $found; + } + /** @return array|null */ public function getBySlug(string $slug): ?array { diff --git a/storage/plugins/frbr-lrm/plugin.json b/storage/plugins/frbr-lrm/plugin.json index f07505009..9aa1d1b24 100644 --- a/storage/plugins/frbr-lrm/plugin.json +++ b/storage/plugins/frbr-lrm/plugin.json @@ -1,9 +1,9 @@ { "name": "frbr-lrm", "display_name": "FRBR / IFLA LRM (Opere & Espressioni)", - "description": "Introduce il modello concettuale FRBR / IFLA LRM (2017) nel catalogo: tabelle opzionali `opere` (Work) ed `espressioni` (Expression) che permettono di raggruppare più edizioni della stessa opera sotto un unico record, distinguere traduzioni/revisioni/adattamenti dalle manifestazioni fisiche e applicare correttamente i codici di relazione MARC21 (autore / traduttore / curatore / illustratore). Aggiunge la pagina pubblica /opera/{slug} che elenca tutte le edizioni disponibili di un'opera e un assistente di deduplicazione che propone accorpamenti per autore + titolo normalizzato. Le tabelle sono opzionali: con il plugin disattivato il catalogo `libri` continua a funzionare invariato (le FK opera_id / espressione_id restano NULL).", - "version": "1.0.0", - "changelog": "v1.0.0 (Pinakes 0.7.14 — issue #134): modello Work/Expression FRBR-LRM. Tabelle opere, espressioni, libri_autori_ruoli (relator codes MARC21). ALTER libri ADD opera_id, espressione_id (FK nullable, idempotente via INFORMATION_SCHEMA guard). CRUD admin /admin/opere, attach-to-opera sul form libro con autocomplete, pagina autore raggruppata per opera, pagina pubblica /opera/{slug}, assistente di deduplicazione. Predisposto per il cross-link con reicat-sbn (opere.sbn_work_bid) e rda-registry (output Work/Expression JSON-LD).", + "description": "Introduce il modello concettuale FRBR / IFLA LRM (2017) nel catalogo: tabelle opzionali `opere` (Work) ed `espressioni` (Expression) che permettono di raggruppare più edizioni della stessa opera sotto un unico record e distinguere traduzioni/revisioni/adattamenti dalle manifestazioni fisiche (con i ruoli di traduttore / curatore / revisore sulle Espressioni). Aggiunge il pannello «Collega a Opera» sul form libro con autocomplete e la pagina pubblica /opera/{slug} che elenca tutte le edizioni disponibili di un'opera. Le tabelle sono opzionali: con il plugin disattivato il catalogo `libri` continua a funzionare invariato (le FK opera_id / espressione_id restano NULL).", + "version": "1.0.1", + "changelog": "v1.0.0 (Pinakes 0.7.14 — issue #134): modello Work/Expression FRBR-LRM. Tabelle opere, espressioni. ALTER libri ADD opera_id, espressione_id (FK nullable, idempotente via INFORMATION_SCHEMA guard). CRUD admin /admin/opere, CRUD espressioni legate all'opera, attach-to-opera sul form libro con autocomplete, pagina pubblica /opera/{slug}. Predisposto per il cross-link con reicat-sbn (opere.sbn_work_bid) e rda-registry (output Work/Expression JSON-LD).", "author": "Fabiodalez", "author_url": "", "plugin_url": "https://github.com/fabiodalez-dev/Pinakes/issues/134", @@ -19,8 +19,6 @@ "work", "expression", "manifestation", - "relator-codes", - "marc21", "cataloging" ], "priority": 20, @@ -28,11 +26,10 @@ "Tabelle Work (opere) ed Expression (espressioni) FRBR / IFLA LRM 2017", "Raggruppamento di più edizioni della stessa opera sotto un unico record", "Distinzione traduzioni / revisioni / adattamenti (Expression) dalle edizioni fisiche (Manifestation)", - "Codici di relazione MARC21 (aut / edt / trl / ill / ctb) su libri_autori_ruoli", + "Ruoli traduttore / curatore / revisore sulle Espressioni", "Attach-to-Opera sul form libro con autocomplete", - "Pagina autore: opere raggruppate per Work", - "Pagina pubblica /opera/{slug} con tutte le edizioni disponibili", - "Assistente di deduplicazione (clustering per autore + titolo normalizzato)" + "CRUD Espressioni legate all'Opera", + "Pagina pubblica /opera/{slug} con tutte le edizioni disponibili" ], "optional": true, "status": "stable", diff --git a/storage/plugins/frbr-lrm/views/admin/espressioni/form.php b/storage/plugins/frbr-lrm/views/admin/espressioni/form.php new file mode 100644 index 000000000..39aaa36cb --- /dev/null +++ b/storage/plugins/frbr-lrm/views/admin/espressioni/form.php @@ -0,0 +1,115 @@ + $espressione + * @var array $autori + * @var string $pageTitle + */ +$operaId = (int) ($espressione['opera_id'] ?? 0); +$action = url('/admin/opere/espressioni/' . (int) $espressione['id'] . '/edit'); +$val = static function (string $key) use ($espressione): string { + return htmlspecialchars((string) ($espressione[$key] ?? ''), ENT_QUOTES, 'UTF-8'); +}; +/** @var array $tipiEspressione tipo => label */ +$tipiEspressione = [ + 'testo' => __("Testo (originale)"), + 'traduzione' => __("Traduzione"), + 'revisione' => __("Revisione"), + 'adattamento' => __("Adattamento"), + 'edizione_critica' => __("Edizione critica"), + 'audio' => __("Audio"), + 'altro' => __("Altro"), +]; +$currentTipo = (string) ($espressione['tipo_espressione'] ?? 'testo'); +$autoreSelect = static function (string $name, int $current) use ($autori): string { + $html = ''; +}; +?> +
+
+ + +
+

+ + +

+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+
+
diff --git a/storage/plugins/frbr-lrm/views/admin/opere/show.php b/storage/plugins/frbr-lrm/views/admin/opere/show.php index 316e57aff..ffc0408ac 100644 --- a/storage/plugins/frbr-lrm/views/admin/opere/show.php +++ b/storage/plugins/frbr-lrm/views/admin/opere/show.php @@ -3,8 +3,19 @@ * @var array $opera * @var array> $edizioni * @var array> $espressioni + * @var array $autori * @var string $pageTitle */ +/** @var array $tipiEspressione tipo => label */ +$tipiEspressione = [ + 'testo' => __("Testo (originale)"), + 'traduzione' => __("Traduzione"), + 'revisione' => __("Revisione"), + 'adattamento' => __("Adattamento"), + 'edizione_critica' => __("Edizione critica"), + 'audio' => __("Audio"), + 'altro' => __("Altro"), +]; ?>
@@ -69,18 +80,77 @@ -

+

-
    +
      -
    • - - - · + +
    • + + + + · + + + +
      "> + + +
      +
    + + +
    + + + +
    + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    +
diff --git a/storage/plugins/frbr-lrm/views/book/opera-panel.php b/storage/plugins/frbr-lrm/views/book/opera-panel.php new file mode 100644 index 000000000..ab31300da --- /dev/null +++ b/storage/plugins/frbr-lrm/views/book/opera-panel.php @@ -0,0 +1,281 @@ +|null $currentOpera the Work this book is linked to + * @var string $csrf + * + * Chrome copied from the sibling z39-server REICAT/SBN book-form panel so the + * two panels read as one system. All writes go through the existing endpoints + * (POST /admin/books/{id}/attach-opera, GET /api/opere/search) via fetch — + * no nested
(the panel lives inside the book form). + */ + +$hasOpera = $currentOpera !== null; +$operaLabel = $hasOpera ? (string) ($currentOpera['titolo_uniforme'] ?? '') : ''; +$operaAuthor = $hasOpera ? (string) ($currentOpera['autore_nome'] ?? '') : ''; +$operaId = $hasOpera ? (int) ($currentOpera['id'] ?? 0) : 0; +?> + +
+ + + +
+

+ + +

+ + +

+ + +

+ + +
+

+
+ + + + + · + + + + + + + + + + +
+
+ + +
+ +
+ + +
+

+ + +

+
+ + +
+
+ + 0): ?> + + diff --git a/storage/plugins/goodlib/GoodLibPlugin.php b/storage/plugins/goodlib/GoodLibPlugin.php index 111593845..849fd3eb2 100644 --- a/storage/plugins/goodlib/GoodLibPlugin.php +++ b/storage/plugins/goodlib/GoodLibPlugin.php @@ -10,7 +10,7 @@ * Inspired by the GoodLib browser extension. * * @package Pinakes\Plugins\GoodLib - * @version 1.0.0 + * @version 1.0.1 */ class GoodLibPlugin { diff --git a/storage/plugins/goodlib/plugin.json b/storage/plugins/goodlib/plugin.json index 94ebe3135..08621ff7b 100644 --- a/storage/plugins/goodlib/plugin.json +++ b/storage/plugins/goodlib/plugin.json @@ -9,7 +9,6 @@ "main_file": "wrapper.php", "requires_php": "8.0", "requires_app": "0.4.0", - "max_app_version": "0.5.9.6", "metadata": { "category": "discovery", "priority": 10, diff --git a/storage/plugins/ncip-server/NcipServerPlugin.php b/storage/plugins/ncip-server/NcipServerPlugin.php index 889d3e3c1..efc26130a 100644 --- a/storage/plugins/ncip-server/NcipServerPlugin.php +++ b/storage/plugins/ncip-server/NcipServerPlugin.php @@ -149,7 +149,9 @@ private static function schemaSteps(): array created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_partner (partner_id), KEY idx_status (status), - KEY idx_prestito (prestito_id) + KEY idx_prestito (prestito_id), + CONSTRAINT ncip_transactions_ibfk_1 FOREIGN KEY (partner_id) REFERENCES ncip_partners (id) ON DELETE SET NULL, + CONSTRAINT ncip_transactions_ibfk_2 FOREIGN KEY (prestito_id) REFERENCES prestiti (id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ]; } @@ -169,11 +171,91 @@ public function ensureSchema(): array } } + // CREATE TABLE IF NOT EXISTS is a no-op for a pre-existing + // ncip_transactions table, so the two FK constraints are never applied + // on an upgrade from a build that shipped the table without them. + // Add them idempotently now (safe to re-run). If the FK migration + // fails, mark ncip_transactions as failed so onActivate()/onInstall() + // do NOT register hooks against a half-applied schema. + if (!in_array('ncip_transactions', $failed, true) && !$this->ensureForeignKeys()) { + $failed[] = 'ncip_transactions'; + } + // Core schema changes for prestiti.ncip_request_id and origine ENUM are in migrate_0.7.4.sql return ['created' => $created, 'failed' => $failed]; } + /** + * Add the two ncip_transactions foreign keys when missing. Detects each FK + * by column + referenced table (name-agnostic) via KEY_COLUMN_USAGE, nulls + * out orphan rows that would violate the constraint, then ALTERs it in with + * ON DELETE SET NULL. Idempotent and safe to re-run from onActivate/onInstall. + * All table/column names below are static literals — no user input. + * + * @return bool True when every FK is present (or was added); false when any + * probe/cleanup/ALTER failed so the caller can report the schema + * as half-applied instead of registering hooks over it. + */ + private function ensureForeignKeys(): bool + { + $fks = [ + ['column' => 'partner_id', 'ref_table' => 'ncip_partners', 'ref_col' => 'id', 'name' => 'ncip_transactions_ibfk_1'], + ['column' => 'prestito_id', 'ref_table' => 'prestiti', 'ref_col' => 'id', 'name' => 'ncip_transactions_ibfk_2'], + ]; + + $ok = true; + foreach ($fks as $fk) { + $stmt = $this->db->prepare( + "SELECT COUNT(*) AS c FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'ncip_transactions' + AND COLUMN_NAME = ? + AND REFERENCED_TABLE_NAME = ?" + ); + if ($stmt === false) { + SecureLogger::error('[NcipServer] FK probe prepare failed: ' . $this->db->error); + $ok = false; + continue; + } + $stmt->bind_param('ss', $fk['column'], $fk['ref_table']); + if (!$stmt->execute()) { + SecureLogger::error('[NcipServer] FK probe failed for ' . $fk['column'] . ': ' . $stmt->error); + $stmt->close(); + $ok = false; + continue; + } + $res = $stmt->get_result(); + $exists = $res instanceof \mysqli_result && ((int) ($res->fetch_assoc()['c'] ?? 0)) > 0; + $stmt->close(); + if ($exists) { + continue; + } + + // Null out orphan references that would fail the FK on ADD. + if ($this->db->query( + "UPDATE ncip_transactions t + LEFT JOIN {$fk['ref_table']} r ON t.{$fk['column']} = r.{$fk['ref_col']} + SET t.{$fk['column']} = NULL + WHERE t.{$fk['column']} IS NOT NULL AND r.{$fk['ref_col']} IS NULL" + ) === false) { + SecureLogger::error('[NcipServer] Orphan cleanup for ' . $fk['column'] . ' failed: ' . $this->db->error); + $ok = false; + continue; + } + + $alter = "ALTER TABLE ncip_transactions + ADD CONSTRAINT {$fk['name']} + FOREIGN KEY ({$fk['column']}) REFERENCES {$fk['ref_table']} ({$fk['ref_col']}) ON DELETE SET NULL"; + if ($this->db->query($alter) === false) { + SecureLogger::error('[NcipServer] Adding FK ' . $fk['name'] . ' failed: ' . $this->db->error); + $ok = false; + } + } + + return $ok; + } + // ─── Hook registration ──────────────────────────────────────────────────── private function registerHookInDb(string $hookName, string $method, int $priority): void diff --git a/storage/plugins/ncip-server/plugin.json b/storage/plugins/ncip-server/plugin.json index 59456475b..60e5e9064 100644 --- a/storage/plugins/ncip-server/plugin.json +++ b/storage/plugins/ncip-server/plugin.json @@ -2,7 +2,7 @@ "name": "ncip-server", "display_name": "NCIP 2.0 Server", "description": "Protocollo NISO Circulation Interchange Protocol (NCIP) 2.0 per lo scambio di informazioni sui prestiti con ILS, self-service kiosk e reti bibliotecarie. Endpoint /ncip con LookupItem, LookupUser, CheckOutItem, CheckInItem, RenewItem, RequestItem e CancelRequestItem.", - "version": "1.0.2", + "version": "1.0.3", "author": "Fabiodalez", "author_url": "", "plugin_url": "https://www.niso.org/standards-committees/ncip", diff --git a/storage/plugins/ncip-server/views/partners.php b/storage/plugins/ncip-server/views/partners.php index 74ad97ae7..22c4f5808 100644 --- a/storage/plugins/ncip-server/views/partners.php +++ b/storage/plugins/ncip-server/views/partners.php @@ -5,7 +5,6 @@ * @var string $error * @var string $success */ -use App\Support\HtmlHelper; ?>
diff --git a/storage/plugins/ncip-server/views/transactions.php b/storage/plugins/ncip-server/views/transactions.php index b7dd9dd03..f6dd40b9f 100644 --- a/storage/plugins/ncip-server/views/transactions.php +++ b/storage/plugins/ncip-server/views/transactions.php @@ -6,7 +6,6 @@ * @var int $page * @var int $perPage */ -use App\Support\HtmlHelper; $totalPages = (int) ceil($total / max(1, $perPage)); $statusColors = [ diff --git a/storage/plugins/open-library/OpenLibraryPlugin.php b/storage/plugins/open-library/OpenLibraryPlugin.php index 47a7cca81..9d362d1bd 100644 --- a/storage/plugins/open-library/OpenLibraryPlugin.php +++ b/storage/plugins/open-library/OpenLibraryPlugin.php @@ -2,8 +2,6 @@ namespace App\Plugins\OpenLibrary; -use App\Support\Hooks; - /** * Open Library API Plugin * @@ -31,21 +29,6 @@ public function __construct(?\mysqli $db = null, ?object $hookManager = null) $this->hookManager = $hookManager; } - /** - * Activate the plugin and register all hooks - */ - public function activate(): void - { - // Add Open Library as a scraping source with high priority - Hooks::add('scrape.sources', [$this, 'addOpenLibrarySource'], 5); - - // Use custom scraping logic for Open Library API - Hooks::add('scrape.fetch.custom', [$this, 'fetchFromOpenLibrary'], 5); - - // Enrich data with additional information - Hooks::add('scrape.data.modify', [$this, 'enrichWithOpenLibraryData'], 10); - } - /** * Called when plugin is installed via PluginManager */ diff --git a/storage/plugins/open-library/wrapper.php b/storage/plugins/open-library/wrapper.php index 37bfd1d56..6d434cd59 100644 --- a/storage/plugins/open-library/wrapper.php +++ b/storage/plugins/open-library/wrapper.php @@ -2,9 +2,9 @@ /** * OpenLibrary Plugin Wrapper * - * This wrapper allows the plugin to work with both: - * 1. Direct loading via activate.php (with namespace) - * 2. PluginManager installation (without namespace) + * Exposes a global (non-namespaced) OpenLibraryPlugin class that proxies to the + * namespaced implementation, as required by PluginManager. Hooks are persisted + * to the DB via the namespaced onActivate()/registerHooks() path. */ // Load the main plugin file @@ -26,19 +26,6 @@ public function __construct($db = null, $hookManager = null) $this->instance = new \App\Plugins\OpenLibrary\OpenLibraryPlugin($db, $hookManager); } - /** - * Activate the plugin - */ - public function activate(): void - { - if (method_exists($this->instance, 'activate')) { - $this->instance->activate(); - } - } - - /** - * Activate the plugin - */ /** * Deactivate the plugin (called by PluginManager) */ @@ -71,8 +58,6 @@ public function onActivate(): void { if (method_exists($this->instance, 'onActivate')) { $this->instance->onActivate(); - } elseif (method_exists($this->instance, 'activate')) { - $this->instance->activate(); } \App\Support\SecureLogger::debug('[OpenLibrary] Plugin activated'); } diff --git a/storage/plugins/openurl-resolver/plugin.json b/storage/plugins/openurl-resolver/plugin.json index b9181c16a..6928efd48 100644 --- a/storage/plugins/openurl-resolver/plugin.json +++ b/storage/plugins/openurl-resolver/plugin.json @@ -8,7 +8,6 @@ "plugin_url": "https://www.niso.org/standards-committees/openurl", "requires_php": "8.1", "requires_app": "0.7.2", - "max_app_version": "0.7.9", "path": "openurl-resolver", "main_file": "wrapper.php", "metadata": { diff --git a/storage/plugins/resource-sync/ResourceSyncPlugin.php b/storage/plugins/resource-sync/ResourceSyncPlugin.php index 30b403913..5d3004108 100644 --- a/storage/plugins/resource-sync/ResourceSyncPlugin.php +++ b/storage/plugins/resource-sync/ResourceSyncPlugin.php @@ -5,6 +5,7 @@ namespace App\Plugins\ResourceSync; use App\Support\HookManager; +use App\Support\RateLimiter; use App\Support\SecureLogger; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -30,6 +31,8 @@ class ResourceSyncPlugin private HookManager $hookManager; private \mysqli $db; private ?int $pluginId = null; + /** Per-request cache for the bibframe-linked-data active check. */ + private ?bool $bibframeActiveCache = null; public function __construct(\mysqli $db, HookManager $hookManager) { @@ -209,10 +212,25 @@ public function gateRequest( $auth = $request->getHeaderLine('Authorization'); if ($auth !== '' && str_starts_with($auth, 'Basic ')) { + // Throttle credential guessing per client IP. This is the only + // brute-force control on the Basic Auth gate, so every attempt must + // count toward the limit (mirrors the core login throttle: + // RateLimitMiddleware(15, 300, 'login') and mobile_login 10/300). + $ip = (string) ($request->getServerParams()['REMOTE_ADDR'] ?? 'unknown'); + $rlId = 'resourcesync_basic:' . $ip; + if (RateLimiter::isLimited($rlId, 10, 300)) { + $out = $response + ->withStatus(429) + ->withHeader('Retry-After', '300'); + return false; + } + $decoded = base64_decode(substr($auth, 6), true); if ($decoded !== false) { $parts = explode(':', $decoded, 2); if (count($parts) === 2 && $this->authenticateBasic($parts[0], $parts[1])) { + // Successful auth clears the throttle for this IP. + RateLimiter::reset($rlId); return true; } } @@ -245,7 +263,14 @@ private function authenticateBasic(string $email, string $pass): bool $res = $stmt->get_result(); $row = ($res instanceof \mysqli_result) ? $res->fetch_assoc() : null; $stmt->close(); - return $row !== null && password_verify($pass, (string) $row['password']); + if ($row === null) { + // Constant-time dummy verify: avoids an account-enumeration timing + // side channel between "no such admin/staff email" and "wrong + // password" by always spending one bcrypt verification. + password_verify($pass, '$2y$12$FYWkjQ0krgMEuFnovQ3C6.vL6MZP/pdGGrLm.Q1PBhX29YNNu.Bfe'); + return false; + } + return password_verify($pass, (string) $row['password']); } /** @@ -473,15 +498,14 @@ private function buildResourceList(string $base, array $books, int $page = 0): s } foreach ($books as $book) { - $id = (int) $book['id']; $modified = $this->w3cDate((string) ($book['updated_at'] ?? $book['created_at'] ?? '')); - $loc = $base . '/api/bibframe/book/' . $id; + $loc = $this->resourceLoc($base, $book); $xw->startElement('url'); $xw->writeElement('loc', $loc); $xw->writeElement('lastmod', $modified); $xw->startElementNs('rs', 'md', null); - $xw->writeAttribute('type', 'application/ld+json'); + $xw->writeAttribute('type', $this->resourceType()); $xw->endElement(); $xw->endElement(); // url } @@ -531,7 +555,7 @@ private function buildChangeList(string $base, array $books, ?string $since, int // Pagination links: next/prev for harvesters $sinceParam = $since !== null ? '&from=' . urlencode($since) : ''; - if (count($books) === 500) { + if (count($books) === self::PAGE_SIZE) { $xw->startElementNs('rs', 'ln', null); $xw->writeAttribute('rel', 'next'); $xw->writeAttribute('href', $base . '/resync/changelist.xml?page=' . ($page + 1) . $sinceParam); @@ -545,7 +569,6 @@ private function buildChangeList(string $base, array $books, ?string $since, int } foreach ($books as $book) { - $id = (int) $book['id']; if ($book['deleted_at'] !== null) { $modified = $this->w3cDate((string) $book['deleted_at']); $change = 'deleted'; @@ -555,11 +578,11 @@ private function buildChangeList(string $base, array $books, ?string $since, int } $xw->startElement('url'); - $xw->writeElement('loc', $base . '/api/bibframe/book/' . $id); + $xw->writeElement('loc', $this->resourceLoc($base, $book)); $xw->writeElement('lastmod', $modified); $xw->startElementNs('rs', 'md', null); $xw->writeAttribute('change', $change); - $xw->writeAttribute('type', 'application/ld+json'); + $xw->writeAttribute('type', $this->resourceType()); $xw->endElement(); $xw->endElement(); // url } @@ -579,7 +602,7 @@ private function fetchBooks(int $page = 0): array { $offset = $page * self::PAGE_SIZE; $stmt = $this->db->prepare( - 'SELECT id, updated_at, created_at + 'SELECT id, titolo, updated_at, created_at FROM libri WHERE deleted_at IS NULL ORDER BY id ASC @@ -615,7 +638,7 @@ private function fetchChangedBooks(?string $since, int $page = 0): array // the harvester asks (?from=1970-01-01 no longer leaks every soft-deleted // id/timestamp ever recorded). $stmt = $this->db->prepare( - 'SELECT id, updated_at, created_at, deleted_at, + 'SELECT id, titolo, updated_at, created_at, deleted_at, (created_at >= ?) AS is_new_entry FROM libri WHERE (deleted_at IS NULL AND updated_at >= ?) @@ -627,13 +650,13 @@ private function fetchChangedBooks(?string $since, int $page = 0): array if ($stmt === false) { return []; } - $limit = 500; - $offset = max(0, $page) * 500; + $limit = self::PAGE_SIZE; + $offset = max(0, $page) * self::PAGE_SIZE; $stmt->bind_param('sssii', $since, $since, $since, $limit, $offset); } else { // Include recent tombstones (≤30 days) for ResourceSync — intentional exception to strict deleted_at IS NULL rule $stmt = $this->db->prepare( - 'SELECT id, updated_at, created_at, deleted_at, 0 AS is_new_entry + 'SELECT id, titolo, updated_at, created_at, deleted_at, 0 AS is_new_entry FROM libri WHERE (deleted_at IS NULL OR deleted_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)) ORDER BY COALESCE(deleted_at, updated_at, created_at) DESC @@ -642,8 +665,8 @@ private function fetchChangedBooks(?string $since, int $page = 0): array if ($stmt === false) { return []; } - $limit = 500; - $offset = max(0, $page) * 500; + $limit = self::PAGE_SIZE; + $offset = max(0, $page) * self::PAGE_SIZE; $stmt->bind_param('ii', $limit, $offset); } @@ -661,6 +684,57 @@ private function baseUrl(): string return \App\Support\HtmlHelper::getBaseUrl(); } + /** + * Resolve the canonical URL for a book. + * + * Prefers the BIBFRAME JSON-LD endpoint (richer linked-data view) only when + * the bibframe-linked-data plugin is active, since that route is registered + * exclusively by that plugin. Otherwise falls back to the always-available + * core book detail URL, so ResourceSync documents never emit URLs that + * 404 for harvesters when the optional plugin is not installed/active. + * + * @param array $book + */ + private function resourceLoc(string $base, array $book): string + { + if ($this->bibframeActive()) { + return $base . '/api/bibframe/book/' . (int) $book['id']; + } + // book_path() is host-relative and WITHOUT the base path; $base already + // carries scheme+host+base path, so the concatenation is absolute. + return $base . \book_path($book); + } + + /** MIME type served by the URL returned from resourceLoc(). */ + private function resourceType(): string + { + return $this->bibframeActive() ? 'application/ld+json' : 'text/html'; + } + + /** + * Whether the bibframe-linked-data plugin is installed and active. + * Cached per request. + */ + private function bibframeActive(): bool + { + if ($this->bibframeActiveCache !== null) { + return $this->bibframeActiveCache; + } + + $active = false; + $stmt = $this->db->prepare( + "SELECT id FROM plugins WHERE name = 'bibframe-linked-data' AND is_active = 1 LIMIT 1" + ); + if ($stmt !== false) { + $stmt->execute(); + $res = $stmt->get_result(); + $active = ($res instanceof \mysqli_result) && $res->num_rows > 0; + $stmt->close(); + } + + return $this->bibframeActiveCache = $active; + } + private function w3cDate(string $mysqlDatetime): string { if ($mysqlDatetime === '' || $mysqlDatetime === '0000-00-00 00:00:00') { diff --git a/storage/plugins/resource-sync/plugin.json b/storage/plugins/resource-sync/plugin.json index 3bfa908f4..b81881bd0 100644 --- a/storage/plugins/resource-sync/plugin.json +++ b/storage/plugins/resource-sync/plugin.json @@ -8,7 +8,6 @@ "plugin_url": "https://www.openarchives.org/rs/toc/", "requires_php": "8.1", "requires_app": "0.7.1", - "max_app_version": "0.7.9", "path": "resource-sync", "main_file": "wrapper.php", "metadata": { diff --git a/storage/plugins/viaf-authority/ViafAuthorityPlugin.php b/storage/plugins/viaf-authority/ViafAuthorityPlugin.php index e5767ce72..3cf28575b 100644 --- a/storage/plugins/viaf-authority/ViafAuthorityPlugin.php +++ b/storage/plugins/viaf-authority/ViafAuthorityPlugin.php @@ -107,6 +107,10 @@ public function onActivate(): void $this->db->begin_transaction(); try { $this->registerHookInDb('app.routes.register', 'registerRoutes', 20); + // Finding #32: surface the VIAF/ISNI endpoints in the author edit UI. + // Renders the authority widget into the core author form's + // `author.form.fields` action hook (same hook z39-server uses). + $this->registerHookInDb('author.form.fields', 'renderAuthorFields', 20); $this->db->commit(); } catch (\Throwable $e) { $this->db->rollback(); @@ -305,6 +309,27 @@ private function deleteHooksFromDb(): void $stmt->close(); } + // ── View hooks ──────────────────────────────────────────────────────────── + + /** + * Hook: author.form.fields — render the VIAF/ISNI authority widget in the + * author edit form (finding #32). Reuses the existing plugin endpoints + * (`/api/viaf/suggest` and `/api/viaf/author/{id}/set`); no new endpoints. + */ + public function renderAuthorFields(?array $autore): void + { + $authorId = is_array($autore) ? (int) ($autore['id'] ?? 0) : 0; + $authorName = is_array($autore) ? (string) ($autore['nome'] ?? '') : ''; + $viafId = is_array($autore) ? (string) ($autore['viaf_id'] ?? '') : ''; + $viafUri = is_array($autore) ? (string) ($autore['viaf_uri'] ?? '') : ''; + $isniId = is_array($autore) ? (string) ($autore['isni_id'] ?? '') : ''; + if ($viafUri === '' && $viafId !== '') { + $viafUri = 'https://viaf.org/viaf/' . $viafId; + } + $csrf = \App\Support\Csrf::ensureToken(); + include __DIR__ . '/views/author-fields.php'; + } + // ── Route registration ──────────────────────────────────────────────────── public function registerRoutes($app): void diff --git a/storage/plugins/viaf-authority/plugin.json b/storage/plugins/viaf-authority/plugin.json index a83dd96ec..105ec9cf8 100644 --- a/storage/plugins/viaf-authority/plugin.json +++ b/storage/plugins/viaf-authority/plugin.json @@ -2,7 +2,7 @@ "name": "viaf-authority", "display_name": "VIAF Authority Control", "description": "Collegamento degli autori al Virtual International Authority File (VIAF/OCLC) e ISNI (ISO 27729). Arricchisce la tabella autori con viaf_id, viaf_uri, isni_id, isni_uri, authority_source e authority_confidence. Espone endpoint per AutoSuggest VIAF, set/get dati di autorità e validazione check digit ISNI (MOD 11-2).", - "version": "1.1.0", + "version": "1.1.1", "author": "Fabiodalez", "author_url": "", "plugin_url": "https://viaf.org/", diff --git a/storage/plugins/viaf-authority/views/author-fields.php b/storage/plugins/viaf-authority/views/author-fields.php new file mode 100644 index 000000000..53d3b4033 --- /dev/null +++ b/storage/plugins/viaf-authority/views/author-fields.php @@ -0,0 +1,219 @@ + + +
+
+

+ + +

+
+
+

+ + +

+ +
+
+ + +

+ + + + + + + +

+
+
+ + +

+
+
+ +
+ + + +
+ + +
+
+ + diff --git a/storage/plugins/z39-server/Z39ServerPlugin.php b/storage/plugins/z39-server/Z39ServerPlugin.php index c3a121708..c851461a4 100644 --- a/storage/plugins/z39-server/Z39ServerPlugin.php +++ b/storage/plugins/z39-server/Z39ServerPlugin.php @@ -15,7 +15,7 @@ * - Comprehensive logging * * @package Z39ServerPlugin - * @version 1.0.0 + * @version 1.3.1 * @see https://www.loc.gov/standards/sru/ */ @@ -702,6 +702,16 @@ public function registerRoutes($app): void return $plugin->bulkImportSbnAction($request, $response); })->add($csrfMiddleware)->add($adminMiddleware); + // POST /admin/books/import-unimarc — parse a UNIMARC/MARCXchange record + // (paste or .xml upload) into libri fields for the form to pre-fill. + // Closes the round-trip advertised alongside export.unimarc.xml. + $app->post('/admin/books/import-unimarc', function ( + ServerRequestInterface $request, + ResponseInterface $response + ) use ($plugin): ResponseInterface { + return $plugin->importUnimarcAction($request, $response); + })->add($csrfMiddleware)->add($adminMiddleware); + // GET /admin/books/{id}/export.unimarc.xml — single-book UNIMARC export. $app->get('/admin/books/{id:[0-9]+}/export.unimarc.xml', function ( ServerRequestInterface $request, @@ -741,6 +751,7 @@ public function registerRoutes($app): void 'routes' => [ '/api/sru', '/api/sbn/search', '/admin/books/import-sbn', '/admin/books/bulk-import-sbn', + '/admin/books/import-unimarc', '/admin/books/{id}/export.unimarc.xml', '/admin/books/soggettario-search', '/admin/authors/{id}/lookup-ccn', '/admin/authors/{id}/apply-authority', ] @@ -1298,6 +1309,54 @@ public function bulkImportSbnAction(ServerRequestInterface $request, ResponseInt ]); } + /** + * POST /admin/books/import-unimarc — parse a UNIMARC/MARCXchange record. + * + * The inverse of {@see exportUnimarcAction()}: accepts a pasted record or an + * uploaded `.xml` file, runs it through {@see UnimarcLibriParser::fromUnimarcXml()} + * and returns the parsed libri fields for the book form to pre-fill (copy + * cataloguing). It never writes to `libri`; book creation stays on the + * standard catalogue validation flow, exactly like the SBN import. + */ + public function importUnimarcAction(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $this->loadReicatClasses(); + $body = (array) $request->getParsedBody(); + $xml = trim((string) ($body['xml'] ?? '')); + + // Accept an uploaded .xml file as an alternative to pasting. + $files = $request->getUploadedFiles(); + if ($xml === '' && isset($files['xml_file']) + && $files['xml_file'] instanceof \Psr\Http\Message\UploadedFileInterface + && $files['xml_file']->getError() === UPLOAD_ERR_OK) { + $xml = trim((string) $files['xml_file']->getStream()); + } + + if ($xml === '') { + return $this->jsonResponse($response, ['success' => false, 'error' => __('Incolla o carica un record UNIMARC.')], 400); + } + + try { + $parser = new \Z39Server\UnimarcLibriParser(); + $book = $parser->fromUnimarcXml($xml); + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('[Z39 import-unimarc] error', ['error' => $e->getMessage()]); + return $this->jsonResponse($response, ['success' => false, 'error' => __('Record UNIMARC non valido o non riconosciuto.')], 422); + } + + // fromUnimarcXml() returns [] on unparseable XML and may return a record + // with no bibliographic core if the tags are unrecognised. Guard on the + // VALUES, not just the keys: a present-but-empty title/ISBN is still no + // usable bibliographic data. + if (trim((string) ($book['titolo'] ?? '')) === '' + && trim((string) ($book['isbn13'] ?? '')) === '' + && trim((string) ($book['isbn10'] ?? '')) === '') { + return $this->jsonResponse($response, ['success' => false, 'error' => __('Nessun dato UNIMARC valido da importare.')], 422); + } + + return $this->jsonResponse($response, ['success' => true, 'book' => $book]); + } + /** * GET /admin/books/{id}/export.unimarc.xml — UNIMARC (MARCXchange) export. */ diff --git a/storage/plugins/z39-server/classes/SruClient.php b/storage/plugins/z39-server/classes/SruClient.php index b0cf84175..88ee018c9 100644 --- a/storage/plugins/z39-server/classes/SruClient.php +++ b/storage/plugins/z39-server/classes/SruClient.php @@ -283,77 +283,34 @@ private function queryServer(array $server, string $index, string $term): ?array } /** - * Fetch URL with cURL for better error handling and TLS validation + * Fetch a URL through the project HttpClient, which centralises the + * SSRF / DNS-rebinding / redirect-scheme hardening and TLS handling. + * Replaces the previous raw curl + file_get_contents fallback (the latter + * also relied on $http_response_header, deprecated in PHP 8.5). The SRU + * endpoint URLs are admin-configured, so they must go through the guarded + * client rather than hitting arbitrary hosts directly. */ private function fetchUrl(string $url): ?string { - // Try cURL first (better SSL/TLS handling) - if (function_exists('curl_init')) { - $ch = curl_init(); - curl_setopt_array($ch, [ - CURLOPT_URL => $url, - CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, - CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => $this->timeout, - CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_MAXREDIRS => 3, - CURLOPT_USERAGENT => 'Pinakes/1.0 (Z39.50/SRU Client)', - CURLOPT_SSL_VERIFYPEER => $this->verifySsl, - CURLOPT_SSL_VERIFYHOST => $this->verifySsl ? 2 : 0, - ]); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $error = curl_error($ch); - /* curl_close(): no-op since PHP 8.0, deprecated 8.5 */ - - if ($response === false || !empty($error)) { - \App\Support\SecureLogger::error('[SruClient] cURL error', [ - 'url' => $url, - 'error' => $error, - ]); - throw new \Exception("Connection failed: $error"); - } - - if ($httpCode >= 400) { - throw new \Exception("HTTP $httpCode error from server"); - } - - return $response ?: null; - } - - // Fallback to file_get_contents - $context = stream_context_create([ - 'http' => [ - 'timeout' => $this->timeout, - 'user_agent' => 'Pinakes/1.0 (Z39.50/SRU Client)', - 'ignore_errors' => true, - ], - 'ssl' => [ - 'verify_peer' => $this->verifySsl, - 'verify_peer_name' => $this->verifySsl, - ], + $res = \App\Support\HttpClient::get($url, [], [ + 'timeout' => $this->timeout, + 'connect_timeout' => 5, + 'max_redirects' => 3, + 'user_agent' => 'Pinakes/1.0 (Z39.50/SRU Client)', + 'verify' => $this->verifySsl, + 'ssrf_guard' => true, ]); - $response = @file_get_contents($url, false, $context); - - if ($response === false) { - throw new \Exception("Failed to connect to server"); + if (!$res['ok']) { + \App\Support\SecureLogger::error('[SruClient] HTTP request failed', ['url' => $url]); + throw new \Exception('Connection failed'); } - // Check HTTP status from headers - if (isset($http_response_header[0])) { - if (preg_match('/HTTP\/\d+\.\d+\s+(\d+)/', $http_response_header[0], $matches)) { - $statusCode = (int)$matches[1]; - if ($statusCode >= 400) { - throw new \Exception("HTTP $statusCode error from server"); - } - } + if ($res['status'] >= 400) { + throw new \Exception("HTTP {$res['status']} error from server"); } - return $response; + return $res['body'] !== '' ? $res['body'] : null; } /** diff --git a/storage/plugins/z39-server/plugin.json b/storage/plugins/z39-server/plugin.json index e39cb8772..c1bdb689d 100644 --- a/storage/plugins/z39-server/plugin.json +++ b/storage/plugins/z39-server/plugin.json @@ -2,7 +2,7 @@ "name": "z39-server", "display_name": "Z39.50/SRU + REICAT/SBN Integration", "description": "Soluzione completa per l'interoperabilità bibliotecaria. Server SRU per esporre il catalogo e Client Z39.50/SRU per importazione (Copy Cataloging) e ricerca federata. Dalla v1.3.0 (issue #133) aggiunge il livello catalografico italiano REICAT/SBN: import UNIMARC da opac.sbn.it, authority control CCN con forme autorizzate REICAT 18.0 e qualificatori per omonimi, picker del Nuovo Soggettario BNCF, ed export/round-trip UNIMARC (MARCXchange ISO 25577). Supporta MARC21, MARCXML, UNIMARC, Dublin Core e MODS.", - "version": "1.3.0", + "version": "1.3.1", "author": "Fabiodalez", "author_url": "", "plugin_url": "https://www.loc.gov/standards/sru/", @@ -53,6 +53,7 @@ "/api/sbn/search - Ricerca SBN (OPAC Nazionale)", "/admin/books/import-sbn - Import UNIMARC da SBN per ISBN/BID", "/admin/books/bulk-import-sbn - Import batch da CSV di ISBN", + "/admin/books/import-unimarc - Import UNIMARC/MARCXchange per precompilare il form (round-trip)", "/admin/books/{id}/export.unimarc.xml - Export UNIMARC del singolo libro", "/admin/authors/{id}/lookup-ccn - Lookup authority CCN dell'autore" ], diff --git a/storage/plugins/z39-server/views/reicat/book-fields.php b/storage/plugins/z39-server/views/reicat/book-fields.php index 7acbfcf2f..feddf3ea2 100644 --- a/storage/plugins/z39-server/views/reicat/book-fields.php +++ b/storage/plugins/z39-server/views/reicat/book-fields.php @@ -61,6 +61,29 @@ class="btn btn-primary flex items-center justify-center gap-2">
+ +
+ +

+ +

+ +
+ + +
+
+ +
@@ -170,6 +193,8 @@ function openReicat() { imported: , error: , removeSubject: , + noUnimarc: , + unimarcImported: , }; function clearEl(el) { while (el.firstChild) { el.removeChild(el.firstChild); } } @@ -329,5 +354,61 @@ function status(msg, kind) { .catch(function () { status(T.error, 'err'); }) .finally(function () { btn.disabled = false; }); }); + + // ── Import from UNIMARC/MARCXchange (round-trip with the export) ────────── + function unimarcStatus(msg, kind) { + const box = document.getElementById('reicat-unimarc-status'); + box.textContent = msg; + box.className = 'text-sm mb-4 ' + (kind === 'err' ? 'text-red-600' : (kind === 'ok' ? 'text-emerald-700' : 'text-gray-600')); + box.classList.remove('hidden'); + } + + document.getElementById('reicat-import-unimarc-btn').addEventListener('click', function () { + const btn = this; + const ta = document.getElementById('reicat_import_unimarc'); + const fileInput = document.getElementById('reicat_import_unimarc_file'); + const xml = (ta && ta.value ? ta.value : '').trim(); + const file = (fileInput && fileInput.files && fileInput.files.length) ? fileInput.files[0] : null; + if (!xml && !file) { unimarcStatus(T.noUnimarc, 'err'); return; } + + btn.disabled = true; + unimarcStatus(T.importing, 'info'); + + const fd = new FormData(); + fd.set('csrf_token', csrf); + if (xml) { fd.set('xml', xml); } + if (file) { fd.set('xml_file', file); } + + fetch(base + '/admin/books/import-unimarc', { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-CSRF-Token': csrf }, + body: fd + }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (!d || !d.success || !d.book) { unimarcStatus((d && d.error) ? d.error : T.error, 'err'); return; } + const b = d.book; + setField('titolo', b.titolo); + setField('sottotitolo', b.sottotitolo); + setField('anno_pubblicazione', b.anno_pubblicazione); + setField('numero_pagine', b.numero_pagine); + setField('isbn13', b.isbn13); + setField('isbn10', b.isbn10); + setField('collana', b.collana); + setField('numero_serie', b.numero_serie); + setField('edizione', b.edizione); + setField('lingua', b.lingua); + if (Array.isArray(b.soggetti)) { + b.soggetti.forEach(function (s) { + if (s && s.termine) { addSubject({ termine: s.termine, bncf_id: s.bncf_id || null, uri: s.uri || null }); } + }); + } + openReicat(); + unimarcStatus(T.unimarcImported, 'ok'); + }) + .catch(function () { unimarcStatus(T.error, 'err'); }) + .finally(function () { btn.disabled = false; }); + }); })(); diff --git a/tests/goodlib-custom-domains.spec.js b/tests/goodlib-custom-domains.spec.js index 334b1e2fe..a45fb7ad9 100644 --- a/tests/goodlib-custom-domains.spec.js +++ b/tests/goodlib-custom-domains.spec.js @@ -54,6 +54,16 @@ test.describe.serial('GoodLib custom domains', () => { await page.goto(`${BASE_URL}/admin/plugins`); await page.getByRole('button', { name: 'Configura Fonti' }).click(); + // Make the test self-contained: the sources + visibility checkboxes reflect + // the plugin's stored (encrypted) settings, which are shared state a prior + // run may have left disabled. Enable them explicitly so the badges render — + // this is also the real operator flow (tick the sources, then set domains). + await page.locator('#goodlib_anna').check(); + await page.locator('#goodlib_zlib').check(); + await page.locator('#goodlib_gutenberg').check(); + await page.locator('#goodlib_frontend').check(); + await page.locator('#goodlib_admin').check(); + await expect(page.locator('#goodlib_anna_domain_select option')).toHaveCount(4); await expect(page.locator('#goodlib_zlib_domain_select option')).toHaveCount(7); diff --git a/tests/plugin-audit-hardening-regressions.unit.php b/tests/plugin-audit-hardening-regressions.unit.php new file mode 100644 index 000000000..75b24b19e --- /dev/null +++ b/tests/plugin-audit-hardening-regressions.unit.php @@ -0,0 +1,70 @@ + true]); +auditOk($loopback === ['ok' => false, 'status' => 0, 'body' => ''], 'HttpClient blocks IPv4 loopback'); +$private = \App\Support\HttpClient::get('https://10.0.0.1/test', [], ['ssrf_guard' => true]); +auditOk($private === ['ok' => false, 'status' => 0, 'body' => ''], 'HttpClient blocks RFC1918 targets'); + +$ai = auditRead($root . '/storage/plugins/book-club/src/AiService.php'); +$sru = auditRead($root . '/storage/plugins/z39-server/classes/SruClient.php'); +auditOk(substr_count($ai, "'ssrf_guard' => true") === 1, 'Book Club enables the SSRF guard'); +auditOk(substr_count($sru, "'ssrf_guard' => true") === 1, 'SRU enables the SSRF guard'); + +// Dewey readers and writers must agree on the full-locale canonical path. +auditOk( + basename(\App\Support\DeweyDataFiles::canonicalPath('it_IT')) === 'dewey_completo_it_IT.json', + 'Dewey canonical path retains the full locale' +); +auditOk( + basename(\App\Support\DeweyDataFiles::resolveReadPath('it_IT')) === 'dewey_completo_it.json', + 'Dewey reads fall back to the legacy file before migration' +); +$deweyApi = auditRead($root . '/app/Controllers/DeweyApiController.php'); +$deweyAuto = auditRead($root . '/app/Support/DeweyAutoPopulator.php'); +$deweyPlugin = auditRead($root . '/storage/plugins/dewey-editor/DeweyEditorPlugin.php'); +auditOk(str_contains($deweyApi, 'DeweyDataFiles::resolveReadPath'), 'Dewey API uses the shared resolver'); +auditOk(str_contains($deweyAuto, 'DeweyDataFiles::canonicalPath'), 'Dewey auto-populator writes the canonical file'); +auditOk(str_contains($deweyPlugin, 'DeweyDataFiles::canonicalPath'), 'Dewey Editor writes the canonical file'); + +// ResourceSync must advertise the representation actually returned by . +$resourceSync = auditRead($root . '/storage/plugins/resource-sync/ResourceSyncPlugin.php'); +auditOk( + substr_count($resourceSync, "writeAttribute('type', \$this->resourceType())") === 2, + 'ResourceSync derives MIME type in both resource and change lists' +); +auditOk( + str_contains($resourceSync, "return \$this->bibframeActive() ? 'application/ld+json' : 'text/html';"), + 'ResourceSync HTML fallback declares text/html' +); + +// Editing an Expression must retain role authors beyond the 1,000-row picker cap. +$frbr = auditRead($root . '/storage/plugins/frbr-lrm/FrbrLrmPlugin.php'); +auditOk(str_contains($frbr, "'autori' => \$this->autoriForSelect(\$currentAuthorIds)"), 'Expression edit includes current role authors'); +auditOk(str_contains($frbr, 'WHERE a.id IN ({$placeholders})'), 'FRBR fetches required authors beyond the cap'); + +fwrite(STDOUT, "\nAll {$passed} assertions passed.\n"); diff --git a/tests/scrape-hooks-z39.unit.php b/tests/scrape-hooks-z39.unit.php index 9e4cd0885..e023ad2c0 100644 --- a/tests/scrape-hooks-z39.unit.php +++ b/tests/scrape-hooks-z39.unit.php @@ -117,10 +117,10 @@ function phpFilesUnder(array $dirs): array { 'T5 core still emits scrape.isbn.validate (discogs validateBarcode is a LIVE hook, issue #101)'); // ─── Group B — plugin registrations against those hooks are all live ──────── -aContains("Hooks::add('scrape.data.modify', [\$this, 'enrichWithDiscogsData']", $discogsSrc, +aContains("['scrape.data.modify', 'enrichWithDiscogsData'", $discogsSrc, 'T6 discogs registers enrichWithDiscogsData on scrape.data.modify'); -aContains("Hooks::add('scrape.isbn.validate', [\$this, 'validateBarcode']", $discogsSrc, +aContains("['scrape.isbn.validate', 'validateBarcode'", $discogsSrc, 'T7 discogs registers validateBarcode on scrape.isbn.validate'); if ($scrapingPro === null) { diff --git a/version.json b/version.json index 7fc5f663b..1803de82d 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { "name": "Pinakes", - "version": "0.7.65", + "version": "0.7.66", "description": "Library Management System - Sistema di Gestione Bibliotecaria" }