diff --git a/.gitignore b/.gitignore index fb9b6c12d..13ce1ae29 100644 --- a/.gitignore +++ b/.gitignore @@ -472,6 +472,7 @@ tests/fixtures/* !tests/fixtures/*.csv !tests/fixtures/*.tsv !tests/fixtures/sru/ +!tests/fixtures/htaccess-history/ !tests/helpers/ tests/helpers/* !tests/helpers/*.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ad777ed..facf34834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,7 @@ Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here. -## [0.8.6-rc.1] - -Release candidate: published as a GitHub prerelease, offered only to installations that opt in with `UPDATER_CHANNEL=rc` or `UPDATER_ALLOW_PRERELEASE=1`. +## [0.8.6] ### Added - **Who may open each Digital Library file** ([#480](https://github.com/fabiodalez-dev/Pinakes/issues/480)). Every attachment can be open to everyone, to signed-in readers, to authorised operators and readers, or only to the reader who has the book on loan right now; "Default" follows a library-wide setting in Administration → Plugins → Digital Library. A locked file keeps its card on the book page with the reason (sign in, borrow the book), or can be hidden; staff-only files are never listed to anyone else. The book page, the file route, the public API, OAI-PMH and the Mobile API all ask the same core decision, also with the plugin switched off. @@ -18,8 +16,15 @@ Release candidate: published as a GitHub prerelease, offered only to installatio ### Fixed - Seeking forward in an audiobook (an open-ended `Range` request) sent the first bytes of the file under the requested range. - -Plugin versions: Digital Library 1.5.0, Mobile API 1.8.0, OAI-PMH Server 1.1.2 (each now requires 0.8.6-rc.1). Migration `migrate_0.8.6-rc.1.sql`: additive; creates `ruoli`, adds `utenti.ruolo_id` and `utenti.accesso_digitale`, seeds the presets, and leaves every existing account exactly as it was. +- **Every older `public/.htaccess` is healed, not only the 0.7.71 one** ([#483](https://github.com/fabiodalez-dev/Pinakes/pull/483)). The file is kept across upgrades, so installations first set up before 0.7.71 still served private uploads straight from disk (before 0.7.18 always, up to 0.7.70 under a subfolder). On the first request after the upgrade each shipped shape becomes the 0.8.6 pair: the private rule with `[NC]` first, then the public passthrough. Lines an operator edited, and blocks under a pending `RewriteCond` or chain, are left alone; a backup is kept next to the file. +- Digital Library files uploaded on an older release keep working after the upgrade, both from the attachments list (0.7.89+) and from the single eBook and audiobook fields of older releases. +- A bundled plugin that is installed but switched off now gets its existing tables brought to its version on upgrade, without being switched on: an install upgraded from 0.7.x kept NCIP's `ncip_transactions` without `prenotazione_id` until NCIP was activated. +- **Downloads of older Digital Library links** ([#484](https://github.com/fabiodalez-dev/Pinakes/pull/484)). Attachments saved with a relative path, percent-encoded or JSON-escaped address showed on the book page and in the API but answered 403 when opened. Catalogatore and Archivista accounts can use the FRBR work and expression lookups of the book editor. +- **The return date of a loan request defaults to the configured loan duration** ([#485](https://github.com/fabiodalez-dev/Pinakes/pull/485)). A reader who sent the request without touching the calendars asked for a one-day loan. The Mobile API availability calendar now reports `loan_duration_days`, so the app shows the return date before the request is sent. +- **Lighter public pages** ([#486](https://github.com/fabiodalez-dev/Pinakes/pull/486)). The JavaScript translation table is no longer inlined in every page: it is a cached `/i18n/` script that carries only real translations (an Italian home page went from 828 KB to 196 KB). The sitemap lists CMS pages at their route instead of a redirect and leaves out names with a slash; the home title no longer repeats the site name; descriptions have no raw line breaks; a book without a cover uses the site's social image; `llms.txt` names the site language only. +- **The mail template editor says which mail an approved loan sends** ([#487](https://github.com/fabiodalez-dev/Pinakes/pull/487)). Approving a loan that starts today sends «Pronto per il ritiro», not «Prestito approvato»; both descriptions now say when each one goes out, and the expiry reminder names the advance set in the advanced settings instead of "three days". + +Plugin versions: Digital Library 1.5.0, Mobile API 1.8.1, OAI-PMH Server 1.1.2 (each requires 0.8.6-rc.1 or later). Migration `migrate_0.8.6-rc.1.sql`: additive; creates `ruoli`, adds `utenti.ruolo_id` and `utenti.accesso_digitale`, seeds the presets, and leaves every existing account exactly as it was. It runs when upgrading from 0.8.5 or earlier; an installation already on 0.8.6-rc.1 has it and gets no migration. ## [0.8.5] diff --git a/README.md b/README.md index ce6876c3e..fecf27900 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,23 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri [![Documentation](https://img.shields.io/badge/Documentazione-Docsify-4285f4?style=for-the-badge&logo=readthedocs&logoColor=white)](https://fabiodalez-dev.github.io/Pinakes/) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/fabiodalez) +## What's New in v0.8.6 + +**Who may open each digital file, files out of the web root, and custom roles.** + +- **Visibility per Digital Library file** ([#480](https://github.com/fabiodalez-dev/Pinakes/issues/480)): public, signed-in readers, authorised staff, or only the reader who has the book on loan. Every download passes through Pinakes, also from the Android app with signed links (Mobile API 1.8.x). +- **Files out of the web root**: new uploads live in `storage/digital/`; files of older versions move there on their own, at the same addresses. +- **Custom roles and a permission matrix** (Administration → Roles and permissions), with three editable presets: Catalogatore, Addetto prestiti, Archivista. +- **Upgrade heals every older `public/.htaccess`** ([#483](https://github.com/fabiodalez-dev/Pinakes/pull/483)): private uploads are checked on installations first set up before 0.7.71 too; edited rules are left alone and a backup is kept. +- **Older Digital Library links download again** ([#484](https://github.com/fabiodalez-dev/Pinakes/pull/484)), and cataloguing roles can use the FRBR lookups of the book editor. +- **A loan request defaults to the configured loan duration** ([#485](https://github.com/fabiodalez-dev/Pinakes/pull/485)) instead of one day; the Mobile API calendar reports `loan_duration_days`. +- **Lighter public pages** ([#486](https://github.com/fabiodalez-dev/Pinakes/pull/486)): the translation table is a cached script instead of being inlined in every page; sitemap and meta tags cleaned up. +- **The mail template editor says which mail an approved loan sends** ([#487](https://github.com/fabiodalez-dev/Pinakes/pull/487)). + +**Migration:** `migrate_0.8.6-rc.1.sql`, additive: creates `ruoli`, adds `utenti.ruolo_id` and `utenti.accesso_digitale`, seeds the three presets and leaves every existing account as it was. It runs when upgrading from 0.8.5 or earlier; an installation on 0.8.6-rc.1 already has it. + +**Plugins:** no new bundled plugin; Digital Library 1.5.0, Mobile API 1.8.1, OAI-PMH Server 1.1.2. **No manual steps:** the `.htaccess` heal and the move of digital files run on their own after the upgrade. Full details in **[CHANGELOG.md](CHANGELOG.md)**. + --- ## Live demo @@ -49,11 +66,7 @@ The showcase contains 50 catalogue books, 8 desiderata, 3 periodicals with 10 is Highlights of the 0.8 releases are below. The full version-by-version history lives in **[CHANGELOG.md](CHANGELOG.md)**. -### v0.8.6-rc.1 — release candidate - -**Who may open each digital file, files out of the web root, and custom roles.** Every Digital Library attachment can be public, for signed-in readers, for authorised staff, or for the current borrower only, and every download now passes through Pinakes, also from the app with signed links; administrators can create roles such as Archivist or Conductor with exactly the permissions they need ([#480](https://github.com/fabiodalez-dev/Pinakes/issues/480)). - -### v0.8.5 — latest +### v0.8.5 **An admin area in the Android app, an offline catalogue and a private library calendar.** Admin and staff accounts can catalogue, lend, return and manage plugin content from the app through the same pipeline as the web forms (Mobile API 1.7.0); the app keeps an offline copy of the catalogue in sync and readers can subscribe to a calendar of their own loans. The anonymous library calendar no longer lists borrowers' names and emails, renaming a series by changing only its case no longer deletes it, and an installable update reads in green again on the updates page. No core migration; see **[CHANGELOG.md](CHANGELOG.md)**. diff --git a/app/Controllers/SeoController.php b/app/Controllers/SeoController.php index 74b8d1db1..215a38b8c 100644 --- a/app/Controllers/SeoController.php +++ b/app/Controllers/SeoController.php @@ -73,7 +73,9 @@ public function llmsTxt(Request $request, Response $response, mysqli $db): Respo $baseUrl = self::resolveBaseUrl($request); $appName = (string) ConfigStore::get('app.name', 'Pinakes'); $appDesc = (string) ConfigStore::get('app.footer_description', ''); - $locales = I18n::getAvailableLocales(); + // The catalogue's content is written in the site language; the other + // interface translations do not make it available in those languages. + $locales = I18n::getContentLocales(); // Gather stats $stats = $db->query( diff --git a/app/Middleware/PrivateModeMiddleware.php b/app/Middleware/PrivateModeMiddleware.php index 1b39712e7..dfa19c6c2 100644 --- a/app/Middleware/PrivateModeMiddleware.php +++ b/app/Middleware/PrivateModeMiddleware.php @@ -48,6 +48,8 @@ class PrivateModeMiddleware implements MiddlewareInterface '/assets/', '/css/', '/js/', '/img/', '/images/', '/fonts/', '/uploads/settings/', '/installer', '/language/', '/health', '/favicon', '/robots.txt', + // Interface strings only, the same for every visitor: the login page needs them. + '/i18n/', '/.well-known/', // Test-only cache-flush endpoint (web.php): the route 404s unless the // server env sets PINAKES_E2E_CACHE_FLUSH=1, so allowing the prefix diff --git a/app/Routes/web.php b/app/Routes/web.php index 5d13f3acd..5b75cdbab 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -247,6 +247,25 @@ return $controller->llmsTxt($request, $response, $db); }); + // JavaScript translation table, one cacheable script per locale (layouts load + // it instead of inlining the whole table in every page). No ".js" in the path: + // nginx configs send every *.js to a static-file location that answers 404 + // without reaching PHP (.nginx.conf.example), and those configs are not + // updated with the code. + $app->get('/i18n/{locale:[a-z]{2}_[A-Z]{2}}', function ($request, $response, $args) { + $body = \App\Support\TranslationScript::body((string) $args['locale']); + if ($body === null) { + throw new \Slim\Exception\HttpNotFoundException($request); + } + $response->getBody()->write($body); + // The URL carries the file's version (?v=), so the content behind it never + // changes and the browser can keep it for a year. + return $response + ->withHeader('Content-Type', 'application/javascript; charset=UTF-8') + ->withHeader('Cache-Control', 'public, max-age=31536000, immutable') + ->withHeader('X-Content-Type-Options', 'nosniff'); + }); + // Public language switch endpoint $app->get('/language/{locale}', function ($request, $response, $args) use ($app) { $controller = new LanguageController(); diff --git a/app/Support/DigitalAccess.php b/app/Support/DigitalAccess.php index 1e529ed3f..11020efe0 100644 --- a/app/Support/DigitalAccess.php +++ b/app/Support/DigitalAccess.php @@ -39,6 +39,22 @@ final class DigitalAccess /** URL path prefix of the files this class protects. */ public const URL_PREFIX = '/uploads/digital/'; + /** + * A conservative SQL prefilter only: lists() checks decoded, local URLs exactly. + * Percent-encoded bytes and JSON Unicode escapes may hide every literal character + * of a name, so those rows must reach the parser even when LIKE cannot find it. + * fileNameOf() needs the folder prefix unencoded, so a percent-encoded row is a + * candidate only when it carries that prefix (in JSON its slashes may be `\/`); + * only a Unicode escape can hide the prefix itself. + */ + private const FILE_CANDIDATE_FILTER = <<<'SQL' + (file_url LIKE ? OR audio_url LIKE ? OR digital_attachments LIKE ? + OR (LOCATE('%', file_url) > 0 AND file_url LIKE '%uploads/digital/%') + OR (LOCATE('%', audio_url) > 0 AND audio_url LIKE '%uploads/digital/%') + OR (LOCATE('%', digital_attachments) > 0 AND digital_attachments LIKE '%uploads%digital%') + OR LOCATE(CONCAT(CHAR(92), 'u'), digital_attachments) > 0) + SQL; + /** Signed links for the app last a day: long enough for an audiobook, short enough to expire. */ public const SIGNED_TTL = 86400; @@ -315,12 +331,11 @@ private static function recordsListing(string $fileName, \mysqli $db): array self::forgetIndex($fileName, $db); } - // Stored URLs spell a name in more than one way (percent-encoded spaces, raw or - // encoded accented letters): every character outside the plain ASCII set becomes a - // wildcard, and lists() below keeps only the records that really name this file. + // The prefilter includes legacy relative URLs and encoded JSON/URL values; + // lists() below keeps only records naming this exact local file. $like = self::likePattern($fileName); $stmt = $db->prepare('SELECT id, file_url, audio_url, digital_attachments FROM libri - WHERE deleted_at IS NULL AND (file_url LIKE ? OR audio_url LIKE ? OR digital_attachments LIKE ?)'); + WHERE deleted_at IS NULL AND ' . self::FILE_CANDIDATE_FILTER); if ($stmt === false) { throw new \RuntimeException('records scan prepare failed: ' . $db->error); } @@ -341,14 +356,14 @@ private static function recordsListing(string $fileName, \mysqli $db): array } /** - * Stored URLs spell a name in more than one way (percent-encoded spaces, raw or encoded - * accented letters): every character outside the plain ASCII set becomes a wildcard. + * Common spellings: no required leading slash, and separators/non-ASCII characters + * become wildcards so both raw paths and JSON-escaped slashes are candidates. + * Encoded bytes that LIKE cannot recognise are included by FILE_CANDIDATE_FILTER. */ private static function likePattern(string $fileName): string { - $pieces = preg_split('#[^A-Za-z0-9._/-]+#', $fileName) ?: [$fileName]; - return '%' . addcslashes(self::URL_PREFIX, '%_\\') - . implode('%', array_map(static fn (string $p): string => addcslashes($p, '%_\\'), $pieces)) . '%'; + $pieces = preg_split('#[^A-Za-z0-9._-]+#', ltrim(self::URL_PREFIX, '/') . $fileName) ?: [$fileName]; + return '%' . implode('%', array_map(static fn (string $p): string => addcslashes($p, '%_\\'), $pieces)) . '%'; } /** Whether record $bookId lists file $name open to anyone. A failure is "not known": false. */ @@ -442,15 +457,21 @@ private static function indexOutdated(string $fileName, \mysqli $db): bool // CI-SOFT-DELETE-EXEMPT: a soft-deleted record changed since must outdate the index too. // verified_at is a Unix time; FROM_UNIXTIME() and the TIMESTAMP column are compared in // the same session time zone, which RememberMeService may switch to UTC mid-request. - $stmt = $db->prepare('SELECT 1 FROM libri WHERE updated_at >= FROM_UNIXTIME(?) - AND (file_url LIKE ? OR audio_url LIKE ? OR digital_attachments LIKE ?) LIMIT 1'); + $stmt = $db->prepare('SELECT id, file_url, audio_url, digital_attachments FROM libri WHERE updated_at >= FROM_UNIXTIME(?) + AND ' . self::FILE_CANDIDATE_FILTER); if ($stmt === false) { return true; } $stmt->bind_param('isss', $since, $like, $like, $like); $stmt->execute(); $result = $stmt->get_result(); - $outdated = $result !== false && $result->num_rows > 0; + $outdated = false; + while ($result !== false && ($book = $result->fetch_assoc())) { + if (self::lists($book, $fileName)) { + $outdated = true; + break; + } + } $stmt->close(); return $outdated; } diff --git a/app/Support/Permissions.php b/app/Support/Permissions.php index d4c19bfc6..41f073016 100644 --- a/app/Support/Permissions.php +++ b/app/Support/Permissions.php @@ -112,6 +112,12 @@ public static function permissionForPath(string $path): ?string return 'loans.manage'; } if (preg_match('#^/api/v1/staff/books/\d+/digital$#', $path) === 1) { return 'digital.manage'; } + // Lookups used by the FRBR work picker in the book editor. Keep these exact: + // cataloguing does not grant every route under a plugin or API prefix. + if ($path === '/api/opere/search' + || preg_match('#^/admin/frbr-lrm/opere/\d+/espressioni$#', $path) === 1) { + return 'catalog.manage'; + } $groups = [ 'catalog.manage' => ['/admin/books', '/admin/authors', '/admin/publishers', '/admin/genres', '/admin/series', '/admin/placement', '/admin/copies', '/admin/import', '/admin/imports', '/admin/imports-history', '/api/books', '/api/authors', '/api/publishers', '/api/genres', '/api/series', '/api/copies', '/api/dewey', '/api/scrape', '/api/libri', '/api/autori', '/api/editori', '/api/collane', '/api/collocazione', '/api/cover', '/admin/periodicals', '/api/v1/staff/lookups', '/api/v1/staff/isbn', '/api/v1/staff/copies', '/api/v1/staff/books', '/api/v1/staff/authors', '/api/v1/staff/publishers', '/api/v1/staff/imports', '/api/v1/staff/periodicals', '/api/v1/staff/series', '/api/v1/staff/genres', '/api/v1/staff/works', '/api/v1/staff/covers', '/api/v1/staff/enrich', '/api/v1/staff/shelves', '/api/v1/staff/archives', '/api/search/editori', '/api/search/generi', '/api/search/preview', '/api/generi', '/admin/archives', '/admin/opere', '/admin/espressioni'], 'loans.manage' => ['/admin/loans', '/admin/reservations', '/api/loans', '/api/reservations', '/api/prestiti', '/prestiti', '/api/v1/staff/loans', '/api/v1/staff/reservations'], diff --git a/app/Support/PluginManager.php b/app/Support/PluginManager.php index bc57314fd..402d974ed 100644 --- a/app/Support/PluginManager.php +++ b/app/Support/PluginManager.php @@ -483,9 +483,21 @@ public function autoRegisterBundledPlugins(): int // that is behind. Each plugin now syncs independently. SecureLogger::error("[PluginManager] onActivate failed during upgrade for $pluginName; version rolled back to $dbVersion, other plugins keep syncing, retry next boot. Error: " . $e->getMessage()); } + } else { + $this->syncInactivePluginSchema((int) $row['id'], $pluginName, $pluginMeta); } - } elseif ($diskVersion === $dbVersion && (int) ($row['is_active'] ?? 0) === 1) { - // Same disk/DB version, active plugin. Re-run onActivate when + } elseif ((int) ($row['is_active'] ?? 0) !== 1) { + // Same version, but the bump may already have been taken by the + // previous release's Updater, which runs this sync with its own + // PluginManager still in memory: an install upgraded from 0.7.16 + // arrives here with NCIP at 1.0.5 and prenotazione_id missing. + // This pass runs on every maintenance window (900 s per version + // stamp) and on every getAllPlugins() call (each load of the admin + // plugins page), and the check is read-only until something is + // actually missing. + $this->syncInactivePluginSchema((int) $row['id'], $pluginName, $pluginMeta); + } elseif ($diskVersion === $dbVersion) { + // Same disk/DB version, active plugin (the inactive ones took the branch above). Re-run onActivate when // EITHER: // (a) hooks are missing — code added hooks not yet in DB // (e.g. merging branches that extend the same version), or @@ -2314,10 +2326,13 @@ public function loadActivePlugins(): void // Same window, for public/.htaccess (#480): the in-app upgrade runs the OLD // Updater already in memory, which knows nothing of this repair, and the // manual upgrade preserves the file. The first request under the new code - // adds [NC] to the private-uploads rewrite. Idempotent; a file the operator - // edited by hand is left alone. + // brings whichever shape an older version shipped (no private rule, the + // anchored REQUEST_URI rules, or the rule without [NC]) to the 0.8.6 private + // rule with [NC] before the public one. Idempotent; lines the operator edited + // are left alone, and false means the file still serves private uploads from + // disk: the rewrite failed, or the heal declined a rule the operator edited. if (!PrivateUploadsRewriteHeal::heal()) { - SecureLogger::error('[PluginManager] public/.htaccess not updated for #480: add [NC] to the RewriteRule ^uploads/(digital|archives/documents|storage)/ line by hand'); + SecureLogger::error('[PluginManager] public/.htaccess still lets private uploads be served from disk (#480): put "RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [NC,QSA,L]" before the public /uploads/ rule, or restore the shipped file'); } QueryCache::set($maintenanceKey, 1, 900); @@ -2748,6 +2763,45 @@ public static function clearPluginCache(): void SitemapCache::invalidate('plugin lifecycle change'); } + /** + * An inactive plugin whose tables already exist only gets them synced by + * onActivate: a column or foreign key a later version adds stays missing until + * someone switches the plugin on (ncip_transactions.prenotazione_id on an + * install upgraded from 0.7.x). Bring those tables to this version without + * registering any hook. A plugin whose tables are not there (never installed, + * like an untouched Archives) is left alone: activating it creates them. + * + * @param array $pluginMeta + */ + private function syncInactivePluginSchema(int $pluginId, string $pluginName, array $pluginMeta): void + { + try { + // Schema only: no setPluginId(), which would register the hooks of a + // plugin that is switched off (or, for digital-library, move its files). + $schemaInstance = $this->instantiatePlugin([ + 'id' => $pluginId, + 'name' => $pluginName, + 'path' => $pluginName, + 'main_file' => $pluginMeta['main_file'] ?? 'wrapper.php', + ], false); + // is_callable, not method_exists: most wrappers forward to the + // plugin class through __call. + $declared = is_callable([$schemaInstance, 'expectedTables']) ? $schemaInstance->expectedTables() : []; + $tablesInstalled = is_array($declared) && $declared !== [] + && !$this->expectedTablesMissing($schemaInstance); + if ($tablesInstalled && is_callable([$schemaInstance, 'ensureSchema']) + && ($this->expectedColumnsMissing($schemaInstance) || $this->expectedForeignKeysMissing($schemaInstance))) { + $schemaResult = $schemaInstance->ensureSchema(); + if (is_array($schemaResult) && !empty($schemaResult['failed'])) { + SecureLogger::warning("[PluginManager] Schema sync of inactive $pluginName left: " . implode(', ', (array) $schemaResult['failed'])); + } + } + } catch (\Throwable $e) { + // Not fatal: activating the plugin runs ensureSchema again. + SecureLogger::warning("[PluginManager] Schema sync of inactive $pluginName failed: " . $e->getMessage()); + } + } + /** * Version stamp used to scope the maintenance marker: a new release (new * version.json) invalidates it so bundled plugins re-sync right away. @@ -2871,7 +2925,13 @@ private function disableOrphanHook(int $pluginId, string $hookName, string $call return $affected > 0; } - private function instantiatePlugin(array $plugin): object + /** + * Build a plugin instance. $setPluginId false gives a schema-only instance: some + * plugins (digital-library, open-library, goodlib, scraping-pro) register their + * hooks in setPluginId(), and digital-library also moves its files, which a + * plugin the admin switched off must not do. No ensureSchema() reads the id. + */ + private function instantiatePlugin(array $plugin, bool $setPluginId = true): object { // Save plugin data to prefixed variables before require_once // This prevents plugin files from overwriting $plugin variable (which some do) @@ -2893,7 +2953,7 @@ private function instantiatePlugin(array $plugin): object $instance = new $className($this->db, $this->hookManager); - if (is_callable([$instance, 'setPluginId'])) { + if ($setPluginId && is_callable([$instance, 'setPluginId'])) { try { $instance->setPluginId($_pluginId); } catch (\Throwable $e) { diff --git a/app/Support/PrivateUploadsRewriteHeal.php b/app/Support/PrivateUploadsRewriteHeal.php index d9580895a..3218032da 100644 --- a/app/Support/PrivateUploadsRewriteHeal.php +++ b/app/Support/PrivateUploadsRewriteHeal.php @@ -4,39 +4,103 @@ namespace App\Support; /** - * Brings the two 0.8.6 lines of public/.htaccess to installs that predate them (#480). + * Brings public/.htaccess of an upgraded installation to the 0.8.6 private-uploads rules (#480). * - * public/.htaccess is on the updater's preserved list, because it holds the operator's - * own rules, so a change to the shipped file never reaches an upgraded install: + * public/.htaccess is on the updater's preserved list, because it holds the operator's own + * rules, so a change to the shipped file never reaches an upgraded install. Four shapes of + * the /uploads/ rules were shipped over the years, and an install keeps whichever it was + * installed with: * - * - the private-uploads rewrite gained [NC]. On a case-insensitive disk (macOS, some - * shared hosts) /uploads/Digital/x is the same file as /uploads/digital/x; without the - * flag that spelling skipped the rewrite to Pinakes and the public rule below served - * a Digital Library file still waiting in the old folder without any check; - * - PDF and WAV joined the no-gzip list, so a Range request gets the bytes on disk. + * A. (≤ 0.7.17) no private rule at all: every existing file under /uploads/ is served as + * it is, Digital Library files and archive documents included; + * B. (0.7.18 – 0.7.70) private rules on an anchored %{REQUEST_URI}: correct at the domain + * root, but under a subfolder the anchor never matches and existing private files are + * served as they are; + * C. (0.7.71 – 0.8.5) the per-directory private rule without [NC]: on a case-insensitive + * disk /uploads/Digital/x skips it and reaches the public rule; + * D. (0.8.6) the private rule with [NC], before the public one. * - * Each line is replaced only when it is still exactly the line an older version - * shipped: a line the operator edited is theirs, and is left alone. + * Each shape is replaced only where its lines are still exactly what was shipped: a rule + * the operator wrote is theirs, and is left alone. The result must have the private rule + * with [NC] before any public /uploads/ rule; otherwise the rewrite rules are not touched + * at all. PDF and WAV join the no-gzip list either way, so a Range request gets the bytes + * on disk. */ final class PrivateUploadsRewriteHeal { - private const REWRITE_OLD = '/^([ \t]*)RewriteRule \^uploads\/\(digital\|archives\/documents\|storage\)\/ index\.php \[QSA,L\][ \t]*$/m'; - private const REWRITE_NEW = '$1RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [NC,QSA,L]'; + private const PRIVATE_RULE = 'RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [NC,QSA,L]'; + private const PUBLIC_RULE = 'RewriteRule ^uploads/ - [L]'; - private const NOGZIP_OLD = '/^([ \t]*)SetEnvIfNoCase Request_URI \\\\\.\(\?:gif\|jpe\?g\|png\|webp\|ico\|woff2\?\|gz\|zip\|epub\|mp4\|webm\|mp3\|m4a\|ogg\)\$ no-gzip dont-vary[ \t]*$/m'; + /** C: the 0.7.71 – 0.8.5 private rule, without [NC]. */ + private const REWRITE_C = '/^([ \t]*)RewriteRule \^uploads\/\(digital\|archives\/documents\|storage\)\/ index\.php \[QSA,L\][ \t]*(?=\r?$)/m'; + + /** A: "RewriteCond ^/uploads/ + RewriteRule ^ - [L]" with nothing in between. */ + private const PASSTHROUGH_A = '/^([ \t]*)RewriteCond %\{REQUEST_URI\} \^\/uploads\/ \[NC\][ \t]*\r?\n[ \t]*RewriteRule \^ - \[L\][ \t]*(?=\r?\n|$)/m'; + + /** B, first half: the public passthrough excluding the private folders. */ + private const PASSTHROUGH_B = '/^([ \t]*)RewriteCond %\{REQUEST_URI\} \^\/uploads\/ \[NC\][ \t]*\r?\n[ \t]*RewriteCond %\{REQUEST_URI\} !\^\/uploads\/\(digital\|archives\/documents\|storage\)\/ \[NC\][ \t]*\r?\n[ \t]*RewriteRule \^ - \[L\][ \t]*(?=\r?\n|$)/m'; + + /** B, second half: the private rule on REQUEST_URI, with the comment that introduced it. */ + private const PRIVATE_B = '/^(?:[ \t]*# PRIVATE uploaded content[^\r\n]*\r?\n(?:[ \t]*#[^\r\n]*\r?\n)*)?[ \t]*RewriteCond %\{REQUEST_URI\} \^\/uploads\/\(digital\|archives\/documents\|storage\)\/ \[NC\][ \t]*\r?\n[ \t]*RewriteRule \^ index\.php \[QSA,L\][ \t]*\r?\n?/m'; + + private const NOGZIP_OLD = '/^([ \t]*)SetEnvIfNoCase Request_URI \\\\\.\(\?:gif\|jpe\?g\|png\|webp\|ico\|woff2\?\|gz\|zip\|epub\|mp4\|webm\|mp3\|m4a\|ogg\)\$ no-gzip dont-vary[ \t]*(?=\r?$)/m'; private const NOGZIP_NEW = '$1SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|webp|ico|woff2?|gz|zip|epub|pdf|mp4|webm|mp3|m4a|ogg|wav)$ no-gzip dont-vary'; - /** The content with the old shipped lines replaced; unchanged when there is nothing to do. */ + /** The content with the shipped rules brought to 0.8.6; the rewrite rules stay as they are when nothing applies or the result would not be safe. */ public static function apply(string $content): string { - $out = preg_replace(self::REWRITE_OLD, self::REWRITE_NEW, $content); - $out = is_string($out) ? preg_replace(self::NOGZIP_OLD, self::NOGZIP_NEW, $out) : null; - return is_string($out) ? $out : $content; + $out = $content; + // A file saved on Windows keeps its CRLF line ends, also in the lines added here. + $eol = str_contains($content, "\r\n") ? "\r\n" : "\n"; + // A RewriteCond the operator put above a matched block binds to the first RewriteRule + // below it, whatever else lies in between: rewriting or splicing the block would move + // that condition onto the inserted private rule (or, for B's removal, onto the next + // rule), so such a block is left as it is and safe() reports the file. + if (preg_match(self::REWRITE_C, $out) === 1) { + $out = preg_replace_callback( + self::REWRITE_C, + static fn (array $m): string => self::conditionPending($out, $m[0][1]) ? $m[0][0] : $m[1][0] . self::PRIVATE_RULE, + $out, + -1, + $count, + PREG_OFFSET_CAPTURE + ); + } elseif (preg_match(self::PASSTHROUGH_B, $out, $passthrough, PREG_OFFSET_CAPTURE) === 1 + && preg_match(self::PRIVATE_B, $out, $private, PREG_OFFSET_CAPTURE) === 1) { + // Both halves of B, exactly as shipped: drop the REQUEST_URI private rule and put + // the per-directory pair where the public passthrough was, private rule first. + // The private half's offset is above its optional comment block, which the + // backward scan skips anyway. + if (!self::conditionPending($out, $passthrough[0][1]) && !self::conditionPending($out, $private[0][1])) { + $out = preg_replace(self::PRIVATE_B, '', $out, 1); + $out = $out === null ? null : preg_replace_callback(self::PASSTHROUGH_B, static fn (array $m): string => self::block($m[1], $eol), $out, 1); + } + } elseif (preg_match(self::PASSTHROUGH_A, $out, $passthrough, PREG_OFFSET_CAPTURE) === 1 && !self::hasPrivateRule($out) + && !self::conditionPending($out, $passthrough[0][1])) { + $out = preg_replace_callback(self::PASSTHROUGH_A, static fn (array $m): string => self::block($m[1], $eol), $out, 1); + } + // preg_* return null when PCRE gives up (backtrack or JIT limits): cast to a string, + // that null became an empty file, which heal() wrote over public/.htaccess and then + // called acceptable because it has no RewriteRule. A failure keeps the file instead. + if ($out === null) { + return $content; + } + // Whatever changed in the rewrite rules must leave the private rule, with [NC], ahead + // of every public /uploads/ rule; anything else is a file we do not understand, and + // its rewrite rules stay as they are. + if (self::rulesChanged($content, $out) && !self::safe($out)) { + $out = $content; + } + // The no-gzip list does not depend on the rewrite rules, so a declined heal still + // gets PDF and WAV in it. + return preg_replace(self::NOGZIP_OLD, self::NOGZIP_NEW, $out) ?? $out; } /** - * Heal the file in place, keeping a backup (in storage/backups) and the file's mode. True - * when nothing needed doing or the rewrite succeeded; false when it was needed and failed. + * Heal the file in place, keeping a backup (in storage/backups) and the file's mode. + * True when the file ends up safe (or has no rewrite rules at all); false when it still + * lets private uploads be served from disk: the rewrite failed, or the heal declined a + * rule the operator edited. Callers log false, so a declined heal is never silent. */ public static function heal(?string $path = null, ?string $backupDir = null): bool { @@ -50,7 +114,7 @@ public static function heal(?string $path = null, ?string $backupDir = null): bo } $healed = self::apply($content); if ($healed === $content) { - return true; + return self::acceptable($content); } $mode = @fileperms($path); if (!is_int($mode) || (!is_dir($backupDir) && !@mkdir($backupDir, 0775, true)) @@ -64,11 +128,157 @@ public static function heal(?string $path = null, ?string $backupDir = null): bo try { return @file_put_contents($temporary, $healed, LOCK_EX) !== false && @chmod($temporary, $mode & 07777) - && @rename($temporary, $path); + && @rename($temporary, $path) + && self::acceptable($healed); } finally { if (file_exists($temporary)) { @unlink($temporary); } } } + + /** Whether $content is in the safe 0.8.6 shape: the [NC] private rule before every public /uploads/ rule. */ + public static function safe(string $content): bool + { + // Any spelling of the private rule counts (flags in another order, extra flags, more + // spaces) as long as it ignores case and stops there: a custom but safe file must + // not be reported as leaking on every maintenance window. + $private = null; + if (preg_match_all('/^[ \t]*RewriteRule[ \t]+\^uploads\/\(digital\|archives\/documents\|storage\)\/[ \t]+index\.php[ \t]+\[([^\]\r\n]*)\]/mi', $content, $all, PREG_SET_ORDER | PREG_OFFSET_CAPTURE) > 0) { + foreach ($all as $match) { + $flags = array_map(static fn (string $f): string => strtoupper(trim($f)), explode(',', $match[1][0])); + // NC / nocase and L / last are the same flags under their long names; END + // stops rewriting too, so a private rule ending in END protects as well. + // A RewriteCond above it, or a rule chained into it with C, makes it conditional: + // whatever the condition rejects goes on to the public rule, so such a rule + // protects nothing on its own. + if (array_intersect(['NC', 'NOCASE'], $flags) !== [] && array_intersect(['L', 'LAST', 'END'], $flags) !== [] + && !self::conditionPending($content, $match[0][1])) { + $private = $match[0][1]; + break; + } + } + } + return $private !== null && !self::publicRuleBefore($content, $private); + } + + /** + * Whether a rule before $offset may hand a private file to Apache as it is on disk: a + * passthrough ("-") that stops rewriting (L, last, END) or skips rules (S, skip), unless + * its pattern provably never matches under a private folder. Trying sample paths proves + * nothing ("\.epub$ - [L]" misses a .pdf sample and serves every private EPUB), so the + * only proof taken is a pattern anchored with ^ whose literal start leads away from every + * private folder, compared without case (a disk that ignores case serves + * /uploads/Digital/ too): "^uploads/thumbs/" or "^assets/" pass, while "^uploads/", + * "^/?uploads", "\.epub$", "^ - [L]" behind any RewriteCond, a lookahead and a negated + * pattern are reported. A passthrough without L goes on to the private rule below it, + * so it leaks nothing. + */ + private static function publicRuleBefore(string $content, int $offset): bool + { + $rule = '/^[ \t]*RewriteRule[ \t]+("[^"\r\n]*"|[^ \t\r\n]+)[ \t]+-(?:[ \t]+\[([^\]\r\n]*)\])?[ \t]*(?=\r?$)/mi'; + $found = preg_match_all($rule, substr($content, 0, $offset), $rules, PREG_SET_ORDER); + // false is PCRE giving up, not "no rule": a file it could not read is not safe. + if ($found === false) { + return true; + } + if ($found === 0) { + return false; + } + foreach ($rules as $match) { + $flags = array_map(static fn (string $f): string => strtoupper(trim($f)), explode(',', $match[2] ?? '')); + $stops = array_intersect(['L', 'LAST', 'END'], $flags) !== [] + || preg_grep('/^(?:S|SKIP)=[1-9]/', $flags) !== []; + if ($stops && !self::missesPrivateFolders(trim($match[1], '"'))) { + return true; + } + } + return false; + } + + /** Whether $pattern can only match paths that start away from every private folder. */ + private static function missesPrivateFolders(string $pattern): bool + { + // "^" and then plain characters, up to the first one with a regex meaning; a + // character followed by a quantifier may be absent, so it is not part of the start. + // Any "|" may open an unanchored branch ("^assets/|uploads/"), so it is no proof. + if (str_contains($pattern, '|') || preg_match('/^\^([A-Za-z0-9_\/-]*)(.?)/', $pattern, $m) !== 1) { + return false; + } + // Belt and braces: whatever the start says, no private path may match. + foreach (['uploads/digital/a.pdf', 'uploads/Digital/a.pdf', 'uploads/digital/a.epub', 'uploads/digital/a.mp3', 'uploads/digital/a.m4a', + 'uploads/archives/documents/a.pdf', 'uploads/archives/documents/a.jpg', 'uploads/storage/a', 'uploads/storage/a.png'] as $path) { + if (@preg_match('#' . str_replace('#', '\#', $pattern) . '#i', $path) !== 0) { + return false; + } + } + $start = strtolower(in_array($m[2], ['?', '*', '{'], true) ? substr($m[1], 0, -1) : $m[1]); + if ($start === '') { + return false; + } + foreach (['uploads/digital/', 'uploads/archives/documents/', 'uploads/storage/'] as $folder) { + // Some path starts with both exactly when one start is a prefix of the other. + if (str_starts_with($folder, $start) || str_starts_with($start, $folder)) { + return false; + } + } + return true; + } + + /** Safe, or not an Apache rewrite file at all (nothing this heal could act on). */ + private static function acceptable(string $content): bool + { + return self::safe($content) || preg_match('/^\s*RewriteRule\b/mi', $content) === 0; + } + + private static function hasPrivateRule(string $content): bool + { + return preg_match('/^[ \t]*Rewrite(?:Rule|Cond)[^\r\n]*uploads\/?\(digital\|archives\/documents\|storage\)/m', $content) === 1; + } + + /** + * Whether a RewriteCond is waiting for the rule that starts at $offset (a line start). + * mod_rewrite queues every RewriteCond it reads and hands the queue to the next + * RewriteRule, which is the only directive that takes it: comments, blank lines and any + * other directive in between (SetEnvIf, Header, Options, RewriteBase, RewriteEngine…) + * leave it pending. So the scan walks back past everything that is not a RewriteRule or + * a RewriteCond: the first RewriteCond means a condition is pending, the first + * RewriteRule (such as the shipped E=HTTP_AUTHORIZATION one) means the queue was taken. + * That RewriteRule still makes the next one conditional when it carries C (chain): a + * chained rule runs only when the one before it matched, so "^uploads/thumbs/ - [C]" + * above the private rule skips it for every private path, whatever the substitution. + * Lines are read in file order, without following / sections. + */ + private static function conditionPending(string $content, int $offset): bool + { + $lines = preg_split('/\r?\n/', substr($content, 0, $offset)) ?: []; + for ($i = count($lines) - 1; $i >= 0; $i--) { + // Directive names are case-insensitive in Apache. + if (preg_match('/^[ \t]*Rewrite(Rule|Cond)\b/i', $lines[$i], $m) === 1) { + if (strtolower($m[1]) === 'cond') { + return true; + } + $flags = preg_match('/\[([^\]\r\n]*)\][ \t]*$/', $lines[$i], $f) === 1 ? explode(',', $f[1]) : []; + return array_intersect(['C', 'CHAIN'], array_map(static fn (string $x): string => strtoupper(trim($x)), $flags)) !== []; + } + } + return false; + } + + private static function rulesChanged(string $before, string $after): bool + { + $rules = static fn (string $s): string => implode("\n", preg_grep('/^\s*Rewrite/', preg_split('/\r?\n/', $s) ?: []) ?: []); + return $rules($before) !== $rules($after); + } + + /** The 0.8.6 pair, at the indentation of the lines it replaces. */ + private static function block(string $indent, string $eol = "\n"): string + { + return $indent . '# Private uploads (Digital Library files, archive documents, generic storage) go' . $eol + . $indent . '# through index.php, which checks who may open them; [NC] keeps /uploads/Digital/' . $eol + . $indent . '# from slipping past on a case-insensitive disk. Added by the 0.8.6 upgrade (#480).' . $eol + . $indent . self::PRIVATE_RULE . $eol . $eol + . $indent . '# Allow direct access to the remaining PUBLIC static files in /uploads/.' . $eol + . $indent . self::PUBLIC_RULE; + } } diff --git a/app/Support/SessionPolicy.php b/app/Support/SessionPolicy.php index f5eb302e0..fb3b153cd 100644 --- a/app/Support/SessionPolicy.php +++ b/app/Support/SessionPolicy.php @@ -48,6 +48,9 @@ final class SessionPolicy '/feed.xml', '/llms.txt', '/language/', + // The JavaScript translation table: interface strings, the same for every + // visitor, cached by the browser for a year. + '/i18n/', // Only the genuinely public /uploads subtrees are sessionless. The // private ones — /uploads/digital/, /uploads/archives/documents/, // /uploads/storage/ — are deliberately NOT listed: PrivateModeMiddleware diff --git a/app/Support/SettingsMailTemplates.php b/app/Support/SettingsMailTemplates.php index 71f0ec692..6d335a44c 100644 --- a/app/Support/SettingsMailTemplates.php +++ b/app/Support/SettingsMailTemplates.php @@ -130,7 +130,7 @@ public static function all(?string $locale = null): array ], 'loan_expiring_warning' => [ 'label' => __('Promemoria scadenza'), - 'description' => __("Promemoria agli utenti tre giorni prima della scadenza del prestito."), + 'description' => __("Promemoria agli utenti prima della scadenza del prestito, con i giorni di preavviso impostati nelle impostazioni avanzate."), 'subject' => '⚠️ Il tuo prestito sta per scadere', 'placeholders' => ['utente_nome', 'libro_titolo', 'data_scadenza', 'giorni_rimasti'], 'body' => <<<'HTML' @@ -234,7 +234,7 @@ public static function all(?string $locale = null): array ], 'loan_approved' => [ 'label' => __('Prestito approvato'), - 'description' => __("Inviata all'utente quando un amministratore approva una richiesta di prestito."), + 'description' => __("Inviata quando un amministratore approva o crea un prestito che inizia in una data futura. Per un prestito che inizia subito parte invece «Pronto per il ritiro»."), 'subject' => '✅ La tua richiesta di prestito è stata approvata!', 'placeholders' => ['utente_nome', 'libro_titolo', 'data_inizio', 'data_fine', 'giorni_prestito', 'pickup_instructions', 'sezione_calendario'], 'body' => <<<'HTML' @@ -364,7 +364,7 @@ public static function all(?string $locale = null): array ], 'loan_pickup_ready' => [ 'label' => __('Pronto per il ritiro'), - 'description' => __("Inviata quando un prestito è stato approvato e il libro è pronto per il ritiro."), + 'description' => __("Inviata quando il libro è pronto per il ritiro: quando un amministratore approva un prestito che inizia subito o ne crea uno da ritirare, e il giorno d'inizio di un prestito programmato."), 'subject' => '📦 Libro pronto per il ritiro!', 'placeholders' => ['utente_nome', 'libro_titolo', 'data_inizio', 'data_fine', 'giorni_prestito', 'scadenza_ritiro', 'pickup_instructions', 'sezione_calendario'], 'body' => <<<'HTML' diff --git a/app/Support/SitemapGenerator.php b/app/Support/SitemapGenerator.php index 3f330fdd6..83dfdd413 100644 --- a/app/Support/SitemapGenerator.php +++ b/app/Support/SitemapGenerator.php @@ -67,6 +67,18 @@ class SitemapGenerator 'plugins' => 0, ]; + /** + * CMS slugs whose path is a legacy alias redirected (301) to a route, see the + * legacy redirects in app/Routes/web.php. + * + * @var array + */ + private const LEGACY_CMS_SLUGS = [ + 'about-us' => 'about', + 'privacy-policy' => 'privacy', + 'cookie-policy' => 'cookies', + ]; + public function __construct(mysqli $db, string $baseUrl) { $this->db = $db; @@ -421,7 +433,7 @@ private function getCmsEntries(): array } $entries[] = [ - 'loc' => $this->baseUrl . '/' . rawurlencode($slug), + 'loc' => $this->baseUrl . $this->cmsPagePath($slug), 'changefreq' => 'monthly', 'priority' => '0.6', 'lastmod' => $row['updated_at'] ?? $row['created_at'] ?? null, @@ -435,6 +447,37 @@ private function getCmsEntries(): array return $entries; } + /** + * An encoded slash (%2F) in a path is refused by Apache before PHP sees it + * (AllowEncodedSlashes Off), so a name such as "Seminar (2005 Ljubljana/…)" + * has no page that answers: listing it only reports a 404. + */ + private static function isRoutableName(string $name): bool + { + return !str_contains($name, '/') && !str_contains($name, '\\'); + } + + /** + * The same test in SQL, applied before LIMIT: filtering only the fetched rows + * would let names that have no page take places under the cap from valid ones. + * CHAR(92) is the backslash, which LIKE would read as its escape character. + */ + private static function routableNameSql(string $column): string + { + return "INSTR({$column}, '/') = 0 AND INSTR({$column}, CHAR(92)) = 0"; + } + + /** + * Path a CMS page is actually served at. A page seeded with another locale's + * slug (about-us and privacy-policy on an Italian install) only answers with a + * 301 to its route, so the sitemap lists the route instead of the redirect. + */ + private function cmsPagePath(string $slug): string + { + $pageId = CmsHelper::getPageIdFromSlug($slug) ?? (self::LEGACY_CMS_SLUGS[$slug] ?? null); + return $pageId !== null ? $this->routePath($pageId) : '/' . rawurlencode($slug); + } + /** * @return array> */ @@ -532,6 +575,7 @@ private function getAuthorEntries(int $cap): array FROM autori a JOIN libri_autori la ON la.autore_id = a.id JOIN libri l ON l.id = la.libro_id AND l.deleted_at IS NULL AND " . BookVisibility::catalogue($this->db, 'l') . " + WHERE " . self::routableNameSql('a.nome') . " GROUP BY a.id, a.nome, a.created_at ORDER BY a.created_at DESC LIMIT {$limit} @@ -541,7 +585,7 @@ private function getAuthorEntries(int $cap): array $authorPath = $this->routePath('author'); while ($row = $result->fetch_assoc()) { $name = trim((string)($row['nome'] ?? '')); - if ($name === '') { + if ($name === '' || !self::isRoutableName($name)) { continue; } @@ -585,6 +629,7 @@ private function getPublisherEntries(int $limit): array SELECT e.nome FROM editori e JOIN libri l ON ({$publisherMatch}) AND l.deleted_at IS NULL AND " . BookVisibility::catalogue($this->db, 'l') . " + WHERE " . self::routableNameSql('e.nome') . " GROUP BY e.id, e.nome HAVING COUNT(l.id) > 0 ORDER BY e.nome ASC @@ -595,7 +640,7 @@ private function getPublisherEntries(int $limit): array $publisherPath = $this->routePath('publisher'); while ($row = $result->fetch_assoc()) { $name = trim((string)($row['nome'] ?? '')); - if ($name === '') { + if ($name === '' || !self::isRoutableName($name)) { continue; } @@ -624,6 +669,7 @@ private function getGenreEntries(int $limit): array SELECT g.nome FROM generi g JOIN libri l ON l.genere_id = g.id AND l.deleted_at IS NULL AND " . BookVisibility::catalogue($this->db, 'l') . " + WHERE " . self::routableNameSql('g.nome') . " GROUP BY g.id, g.nome HAVING COUNT(l.id) > 0 ORDER BY g.nome ASC @@ -634,7 +680,7 @@ private function getGenreEntries(int $limit): array $genrePath = $this->routePath('genre'); while ($row = $result->fetch_assoc()) { $name = trim((string)($row['nome'] ?? '')); - if ($name === '') { + if ($name === '' || !self::isRoutableName($name)) { continue; } diff --git a/app/Support/TranslationScript.php b/app/Support/TranslationScript.php new file mode 100644 index 000000000..12d6a5228 --- /dev/null +++ b/app/Support/TranslationScript.php @@ -0,0 +1,82 @@ + $value !== $key, + ARRAY_FILTER_USE_BOTH + ); + $json = json_encode( + (object) $translations, + JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_HEX_APOS + ); + if ($json === false) { + return null; + } + return 'window.i18nTranslations = ' . $json . ";\n"; + } + + private static function file(string $locale): ?string + { + // Only a locale code reaches the filesystem: no path from the URL. + if (preg_match('/^[a-z]{2}_[A-Z]{2}$/D', $locale) !== 1) { + return null; + } + $file = dirname(__DIR__, 2) . '/locale/' . $locale . '.json'; + return is_file($file) ? $file : null; + } +} diff --git a/app/Support/Updater.php b/app/Support/Updater.php index 49bd23bf8..b0370011e 100644 --- a/app/Support/Updater.php +++ b/app/Support/Updater.php @@ -4043,13 +4043,16 @@ public function runMigrations(string $fromVersion, string $toVersion): array $this->debugLog('WARNING', 'Stale CSP header left in public/.htaccess; remove the Header set Content-Security-Policy line by hand'); } - // 0.8.6 (#480): the private-uploads rewrite needs [NC], or on a - // case-insensitive disk /uploads/Digital/… slips past the checked route - // to the public rule. public/.htaccess is preserved on upgrade, so the - // shipped line never arrives: replace it in place while it is still the - // exact line an older version shipped. Idempotent. + // 0.8.6 (#480): public/.htaccess is preserved on upgrade, so the shipped + // private-uploads rules never arrive. Every shape an older version shipped + // (no private rule, the anchored REQUEST_URI rules that miss a subfolder, + // the rule without [NC] that /uploads/Digital/… slips past) is brought to + // the private rule with [NC] before the public one, wherever its lines are + // still exactly what was shipped. Idempotent; false means the file still + // serves private uploads from disk: the rewrite failed, or the heal + // declined a rule the operator edited. if (!PrivateUploadsRewriteHeal::heal()) { - $this->debugLog('WARNING', 'public/.htaccess not updated for #480: add [NC] to the RewriteRule ^uploads/(digital|archives/documents|storage)/ line by hand'); + $this->debugLog('WARNING', 'public/.htaccess still lets private uploads be served from disk (#480): put "RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [NC,QSA,L]" before the public /uploads/ rule, or restore the shipped file'); } return [ diff --git a/app/Views/frontend/book-detail.php b/app/Views/frontend/book-detail.php index c24134334..b70108f4e 100644 --- a/app/Views/frontend/book-detail.php +++ b/app/Views/frontend/book-detail.php @@ -149,12 +149,13 @@ // Open Graph Image - Ensure absolute URLs $baseUrl = HtmlHelper::getBaseUrl(); -if ($bookCover) { +// A book without a cover leaves $ogImage null, so the layout shares the site's +// social image instead of the grey placeholder. +$ogImage = null; +if ($bookCover && !str_ends_with((string) parse_url($bookCover, PHP_URL_PATH), '/uploads/copertine/placeholder.jpg')) { // $bookCover already includes base path via url(), make it absolute $isAbsolute = preg_match('#^(https?:)?//#', $bookCover); $ogImage = $isAbsolute ? $bookCover : absoluteUrl($bookCover); -} else { - $ogImage = absoluteUrl('/uploads/copertine/placeholder.jpg'); } // Breadcrumb Schema @@ -289,7 +290,7 @@ $bookSchema["description"] = strip_tags($bookDescription); } -if ($bookCover) { +if ($ogImage !== null) { $bookSchema["image"] = $ogImage; } @@ -1798,6 +1799,11 @@ function addDaysToIso(isoDate, days) { endPicker = window.flatpickr(endEl, { ...baseOpts, minDate: suggestedDate, + // baseOpts.defaultDate is the start date: inherited here, it made a + // request sent without touching the calendars a one-day loan. The + // start picker's onChange only fires on a click, so the default + // return date has to be set here too. + defaultDate: addDaysToIso(suggestedDate, defaultRequestLoanDays), maxDate: undefined // End date can extend beyond availability range }); diff --git a/app/Views/frontend/layout.php b/app/Views/frontend/layout.php index 70de483be..b3409930f 100644 --- a/app/Views/frontend/layout.php +++ b/app/Views/frontend/layout.php @@ -205,7 +205,8 @@ $documentSite = (string) $appName; if ($documentSite !== '' && $documentTitle !== '' && $documentTitle !== $documentSite) { $documentTitle = (string) preg_replace('/\s*[-–—|]\s*' . preg_quote($documentSite, '/') . '$/u', '', $documentTitle); - $documentTitle .= ' – ' . $documentSite; + // A home whose hero title is the site name arrives as "Site - Site". + $documentTitle = $documentTitle === $documentSite ? $documentSite : $documentTitle . ' – ' . $documentSite; } elseif ($documentTitle === '') { $documentTitle = $documentSite; } @@ -213,8 +214,18 @@ <?= HtmlHelper::e($documentTitle) ?> + + content=""> @@ -236,7 +247,8 @@ $resolvedDefaultOgImage = $defaultOgImagePath !== '' ? absoluteUrl($defaultOgImagePath) : ''; $ogTitle = $ogTitle ?? ($seoTitle ?? $title ?? $appName); - $ogDescription = $ogDescription ?? ($seoDescription ?? ($footerDescription ?: __('Esplora il nostro catalogo digitale'))); + $ogDescription = isset($ogDescription) && trim((string) $ogDescription) !== '' ? $ogDescription : $metaDescription; + $ogDescription = trim((string) preg_replace('/\s+/u', ' ', (string) $ogDescription)); $ogType = $ogType ?? 'website'; $ogUrl = $ogUrl ?? ($seoCanonical ?? HtmlHelper::getCurrentUrlWithoutQuery()); $ogImage = $ogImage ?? $resolvedDefaultOgImage; @@ -245,6 +257,7 @@ $twitterCard = $twitterCard ?? 'summary_large_image'; $twitterTitle = $twitterTitle ?? $ogTitle; $twitterDescription = $twitterDescription ?? $ogDescription; + $twitterDescription = trim((string) preg_replace('/\s+/u', ' ', (string) $twitterDescription)); $twitterImage = $twitterImage ?? $ogImage; $twitterImage = $twitterImage !== '' ? absoluteUrl($twitterImage) : ''; ?> @@ -1545,19 +1558,13 @@ + + &'; + file_put_contents($probeFile, json_encode([$dangerous => $dangerous . ' traduit'])); + $script = TranslationScript::body('zz_ZZ'); + check_seo(!str_contains((string) $script, '') && !str_contains((string) $script, '