Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/Controllers/SeoController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions app/Middleware/PrivateModeMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions app/Routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions app/Support/SessionPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 50 additions & 4 deletions app/Support/SitemapGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,string>
*/
private const LEGACY_CMS_SLUGS = [
'about-us' => 'about',
'privacy-policy' => 'privacy',
'cookie-policy' => 'cookies',
];

public function __construct(mysqli $db, string $baseUrl)
{
$this->db = $db;
Expand Down Expand Up @@ -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,
Expand All @@ -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<int,array<string,mixed>>
*/
Expand Down Expand Up @@ -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}
Expand All @@ -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)) {
Comment thread
fabiodalez-dev marked this conversation as resolved.
continue;
}

Expand Down Expand Up @@ -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
Expand All @@ -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;
}

Expand Down Expand Up @@ -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
Expand All @@ -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;
}

Expand Down
70 changes: 70 additions & 0 deletions app/Support/TranslationScript.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);

namespace App\Support;

/**
* The JavaScript translation table, served as one cacheable script per locale.
*
* The layouts used to inline the whole locale file in every page: about 600 KB of
* HTML on each request (8,000+ strings), and on an Italian install every entry
* mapped a string to itself. As a script versioned by the file's content, the
* browser downloads it once per release and reuses it on every page.
*/
final class TranslationScript
{
/** Route path, without the locale. */
public const ROUTE_PREFIX = '/i18n/';

/**
* URL of the script for a locale, versioned so a new translation file is
* fetched at once while an unchanged one stays cached for a year.
*
* The version hashes the file's content, not its mtime and size: a deploy that
* keeps timestamps can change a translation without changing the size, and the
* immutable URL would then serve the old table for a year. xxh3 reads the
* ~650 KB file in about 0.1 ms.
*/
public static function url(string $locale): string
{
$file = self::file($locale);
$hash = $file !== null ? hash_file('xxh3', $file) : false;
$version = $hash !== false ? substr($hash, 0, 12) : '0';
// No ".js" extension: see the route in app/Routes/web.php.
return url(self::ROUTE_PREFIX . rawurlencode($locale)) . '?v=' . $version;
}

/**
* Body of the script, or null for a locale with no translation file.
*/
public static function body(string $locale): ?string
{
$file = self::file($locale);
if ($file === null) {
return null;
}
$decoded = json_decode((string) file_get_contents($file), true);
// The keys are the Italian source strings and __() falls back to the key,
// so an entry that maps a string to itself only adds weight.
$translations = array_filter(
is_array($decoded) ? $decoded : [],
static fn ($value, $key): bool => $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
);
return 'window.i18nTranslations = ' . ($json !== false ? $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;
}
}
9 changes: 5 additions & 4 deletions app/Views/frontend/book-detail.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -289,7 +290,7 @@
$bookSchema["description"] = strip_tags($bookDescription);
}

if ($bookCover) {
if ($ogImage !== null) {
$bookSchema["image"] = $ogImage;
}

Expand Down
37 changes: 22 additions & 15 deletions app/Views/frontend/layout.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,16 +205,27 @@
$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;
}
?>
<title><?= HtmlHelper::e($documentTitle) ?></title>

<!-- SEO Meta Tags -->
<?php
// The site description is a better fallback than a generic sentence (which
// also calls a physical library "digital"). Line breaks typed in the settings
// textarea do not belong in a meta tag.
// A CMS page with no SEO description passes an empty string, not null.
$metaDescription = isset($seoDescription) && trim((string) $seoDescription) !== ''
? $seoDescription
: ($footerDescription ?: __('Biblioteca digitale con catalogo completo di libri disponibili per il prestito'));
$metaDescription = trim((string) preg_replace('/\s+/u', ' ', (string) $metaDescription));
?>
<meta name="description"
content="<?= htmlspecialchars($seoDescription ?? __('Biblioteca digitale con catalogo completo di libri disponibili per il prestito'), ENT_QUOTES, 'UTF-8') ?>">
content="<?= htmlspecialchars($metaDescription, ENT_QUOTES, 'UTF-8') ?>">
<?php if (isset($seoKeywords) && !empty($seoKeywords)): ?>
<meta name="keywords" content="<?= htmlspecialchars($seoKeywords) ?>">
<?php endif; ?>
Expand All @@ -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;
Expand All @@ -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) : '';
?>
Expand Down Expand Up @@ -1545,19 +1558,13 @@

<!-- Silktide Consent Manager CSS -->
<link rel="stylesheet" href="<?= htmlspecialchars(assetUrl('/css/silktide-consent-manager.css'), ENT_QUOTES, 'UTF-8') ?>">
<?php // Translation table: one cacheable script per locale, see TranslationScript. ?>
<script src="<?= htmlspecialchars(\App\Support\TranslationScript::url(\App\Support\I18n::getLocale()), ENT_QUOTES, 'UTF-8') ?>"></script>
<script>
// Real translation table + helper for the public frontend (was an
// identity-function no-op before, leaving JS strings in Italian for
// non-IT visitors). Mirrors the admin and user-area layouts.
<?php
$frontendLocale = \App\Support\I18n::getLocale();
$frontendTranslationFile = __DIR__ . '/../../../locale/' . $frontendLocale . '.json';
$frontendTranslations = [];
if (file_exists($frontendTranslationFile)) {
$frontendTranslations = json_decode((string) file_get_contents($frontendTranslationFile), true) ?? [];
}
?>
window.i18nTranslations = <?= json_encode($frontendTranslations, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_HEX_APOS) ?>;
// Translation helper for the public frontend (was an identity-function
// no-op before, leaving JS strings in Italian for non-IT visitors).
// Mirrors the admin and user-area layouts.
window.i18nTranslations = window.i18nTranslations || {};
window.__ = function (key, ...args) {
let translated = window.i18nTranslations[key] || key;
if (args.length > 0) {
Expand Down
19 changes: 6 additions & 13 deletions app/Views/layout.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
<?php $workspaceUiVersion = (string) (@filemtime($assetRoot . '/workspace-ui.css') ?: $appVersion); ?>
<link rel="stylesheet" href="<?= htmlspecialchars(assetUrl('workspace-ui.css'), ENT_QUOTES, 'UTF-8') ?>?v=<?= htmlspecialchars($workspaceUiVersion, ENT_QUOTES, 'UTF-8') ?>">
<link rel="stylesheet" href="<?= htmlspecialchars(assetUrl('css/swal-theme.css'), ENT_QUOTES, 'UTF-8') ?>?v=<?= htmlspecialchars($appVersion, ENT_QUOTES, 'UTF-8') ?>" />
<?php // Translation table: one cacheable script per locale, see TranslationScript. In the
// head, like the frontend layout: at the end of the body this blocking request held back
// the deferred bundles (SweetAlert among them) while the page above it was already
// clickable, so an early click on a confirm button fell back to window.confirm(). ?>
<script src="<?= htmlspecialchars(\App\Support\TranslationScript::url(I18n::getLocale()), ENT_QUOTES, 'UTF-8') ?>"></script>
<script>
(function () {
if (typeof window.__ !== 'function') {
Expand Down Expand Up @@ -859,19 +864,7 @@ class="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-2xl sha

<!-- Scripts -->
<script>
// Load complete translation file FIRST before any script uses it
<?php
// Use I18n to get active session locale (not app default locale)
$currentLocale = I18n::getLocale();
$translationFile = __DIR__ . '/../../locale/' . $currentLocale . '.json';
$translations = [];

if (file_exists($translationFile)) {
$translationsContent = file_get_contents($translationFile);
$translations = json_decode($translationsContent, true) ?? [];
}
?>
window.i18nTranslations = <?= json_encode($translations, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_HEX_APOS) ?>;
window.i18nTranslations = window.i18nTranslations || {};
window.userIsAdminOrStaff = <?= json_encode($isBuiltInOperator, JSON_HEX_TAG) ?>;

// Override translation helper function to use i18nTranslations (overrides head fallback)
Expand Down
Loading
Loading