Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
705f22a
Heal every public/.htaccess Pinakes ever shipped, not only the 0.7.71…
fabiodalez-dev Oct 20, 2026
e4dc83e
Heal .htaccess files with CRLF line ends, and say so when the heal de…
fabiodalez-dev Oct 20, 2026
a370fe2
Count any spelling of the private uploads rule as safe
fabiodalez-dev Oct 20, 2026
eb7de34
Test the long flag names of the private uploads rule, and a rule with…
fabiodalez-dev Oct 20, 2026
a7fa522
Fix legacy digital attachment URLs and FRBR role lookups
fabiodalez-dev Oct 20, 2026
80164ac
Leave a heal alone when an operator RewriteCond still waits above the…
fabiodalez-dev Oct 20, 2026
e0ef520
Run the patterns above the private rule instead of matching two spell…
fabiodalez-dev Oct 20, 2026
ad47c7d
Report a stopping passthrough unless its pattern provably misses the …
fabiodalez-dev Oct 20, 2026
1bfc4f8
Keep the no-gzip update when the rewrite heal declines
fabiodalez-dev Oct 20, 2026
572ad97
Count a rule chained into the private one as a condition
fabiodalez-dev Oct 20, 2026
f96e474
Test Digital Library files uploaded on an older release across the up…
fabiodalez-dev Oct 20, 2026
62a5144
Cover the one-file eBook and audiobook fields of releases before 0.7.…
fabiodalez-dev Oct 20, 2026
efc14a2
Sync the existing tables of an inactive bundled plugin when its versi…
fabiodalez-dev Oct 20, 2026
af22673
Re-register the Digital Library hooks on old releases that installed …
fabiodalez-dev Oct 20, 2026
a07aba1
Sync an inactive plugin's tables even when its version bump is alread…
fabiodalez-dev Oct 20, 2026
5789146
Give the real cadence of the inactive plugin schema pass
fabiodalez-dev Oct 20, 2026
0cbbed3
Skip the upgrade spec without a phase under a reason the skip audit a…
fabiodalez-dev Oct 20, 2026
eba51aa
Default the return date of a loan request to the configured loan dura…
fabiodalez-dev Oct 20, 2026
68b1af5
Lighten the public pages and clean their sitemap and meta tags
fabiodalez-dev Oct 20, 2026
233565b
Keep the schema-only instance of a switched-off plugin from registeri…
fabiodalez-dev Oct 20, 2026
561b192
Say in the template editor which mail an approved loan actually sends
fabiodalez-dev Oct 20, 2026
8ad6c6b
Give the app the loan duration a loan request adds to its start
fabiodalez-dev Oct 20, 2026
5dbd8fd
Put the whole advanced and email settings back after the role-boundar…
fabiodalez-dev Oct 20, 2026
5743f40
Serve the translation script without a .js extension
fabiodalez-dev Oct 20, 2026
01628c3
Name the directly created loans in the approval mail descriptions, re…
fabiodalez-dev Oct 20, 2026
b5a4a97
Load the admin translation script in the head
fabiodalez-dev Oct 20, 2026
bae4e16
Reach the database and Mailpit the way CI provides them in the mail t…
fabiodalez-dev Oct 20, 2026
47c1fa1
Filter unroutable names before the sitemap LIMIT, version the transla…
fabiodalez-dev Oct 20, 2026
a099335
Keep public/.htaccess when PCRE gives up, accept a private rule endin…
fabiodalez-dev Oct 20, 2026
f94dc0f
Document the helpers of the loan-request default-end spec
fabiodalez-dev Oct 20, 2026
13dbdd4
Report an Apache file PCRE could not read, cover a replace that gives…
fabiodalez-dev Oct 20, 2026
716221c
Keep the checked rewrite heal when only the no-gzip replace gives up
fabiodalez-dev Oct 20, 2026
37f995c
Restore the mail settings and templates in one transaction after the …
fabiodalez-dev Oct 20, 2026
fc28895
test: cover upload rewrite backup and filesystem failures
fabiodalez-dev Oct 20, 2026
ed1cfbb
test: cover translation script safety and canonical CMS URLs
fabiodalez-dev Oct 20, 2026
453e651
Put the guarded settings back in one transaction after the role-bound…
fabiodalez-dev Oct 20, 2026
22d06f7
Do not read a PCRE give-up in the public rule search as "no public rule"
fabiodalez-dev Oct 20, 2026
1ae4aff
Put back a locale file the translation-script probes would overwrite
fabiodalez-dev Oct 20, 2026
185dd4f
Read a lower-case RewriteRule as a rewrite rule in acceptable()
fabiodalez-dev Oct 21, 2026
af4e650
Merge remote-tracking branch 'origin/fix/htaccess-heal-old-formats' i…
fabiodalez-dev Oct 21, 2026
6349738
Merge remote-tracking branch 'origin/fix/loan-request-default-end' in…
fabiodalez-dev Oct 21, 2026
6fdb74b
Merge remote-tracking branch 'origin/fix/seo-public-pages' into relea…
fabiodalez-dev Oct 21, 2026
da88831
Merge remote-tracking branch 'origin/fix/mail-template-descriptions' …
fabiodalez-dev Oct 21, 2026
e7ce6d4
Merge remote-tracking branch 'origin/fix/digital-url-frbr-review' int…
fabiodalez-dev Oct 21, 2026
e4b6a1f
Release 0.8.6: version, changelog and README highlights
fabiodalez-dev Oct 21, 2026
fcbe8aa
Make a percent-encoded URL a scan candidate only with the Digital Lib…
fabiodalez-dev Oct 21, 2026
71945cb
Merge branch 'fix/digital-url-frbr-review' into release/0.8.6
fabiodalez-dev Oct 21, 2026
bafff7f
README: version badge and What's New in v0.8.6 section for the release
fabiodalez-dev Oct 21, 2026
7c00ca8
Describe the whole .htaccess heal at its two call sites, keep the dyn…
fabiodalez-dev Oct 21, 2026
57cd35a
Answer 404 for a locale file that cannot be read, always restore the …
fabiodalez-dev Oct 21, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/<locale>` 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]

Expand Down
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)**.

Expand Down
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
45 changes: 33 additions & 12 deletions app/Support/DigitalAccess.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
Expand All @@ -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. */
Expand Down Expand Up @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions app/Support/Permissions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Loading
Loading