Skip to content
Merged
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here.

## [0.7.94]

### Added
- **An admin page for each article** ([#453](https://github.com/fabiodalez-dev/Pinakes/issues/453), [#454](https://github.com/fabiodalez-dev/Pinakes/issues/454)), laid out like the admin book page. It shows the cover, authors, publication, masthead and issue, genre with its path, keywords and the rest of the record, the PDF and the RIS and MARCXML exports, with Edit, Delete and the public page as buttons. `/admin/periodicals/articles/{id}` is now this page and the form moved to `/admin/periodicals/articles/{id}/edit`. The quick search, the Articles list (which gains View and Edit icons) and the Details button on the author page open it, and saving the form returns to it. Before, every link opened the form, so saving looked as if nothing had happened.
- **`GET /admin/updates/status`** ([#450](https://github.com/fabiodalez-dev/Pinakes/issues/450)): the installed version, whether the update lock is held, the latest update attempt and the outcome of the latest run, and, with `?attempt=`, the update log row and outcome of that one install attempt. Admin only (staff get 403), never cached, reachable during maintenance.

### Fixed
- **An update behind a reverse proxy is no longer reported as failed when it succeeded** ([#450](https://github.com/fabiodalez-dev/Pinakes/issues/450)). The install request runs the backup, the files and the migrations in one go. A proxy in front of the site (Apache `mod_proxy`, a NAS's remote access, Cloudflare) can give up on it after a minute with a 502 or 504 while PHP, which ignores the aborted connection, finishes the update. The page treated that as a failure. On a gateway error or a dropped connection it now polls the status endpoint until the update lock is free, and reports how the run ended: success, or the error it stopped on. The page sends an identifier with its install request, and the run files its update log row and its outcome under it, so the page reads its own result and never another administrator's update. When nothing is filed under its attempt, the page says the outcome is to check and shows the installed version, instead of guessing. A gateway error whose body is empty or cut short is waited out like the others. The run writes its outcome before it releases the lock, so a failure before the install step (space, backup, extraction, package checks) is reported with its own message too, and an attempt the server left half-way is reported as interrupted. The install request releases the PHP session before the update runs, so those polls are not held behind it.
- **Only the most specific admin menu entry is highlighted.** On an article page both Periodicals and Articles lit up, because the address of one starts with the other's.

The Emeroteca plugin goes to 1.12.1. No migration.

## [0.7.93]

### Added
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri

Highlights of the latest release are below. The full version-by-version history (v0.7.59 → v0.6.x) lives in **[CHANGELOG.md](CHANGELOG.md)**.

### v0.7.93 — latest
### v0.7.94 — latest

**An article has its own page in the admin** ([#453](https://github.com/fabiodalez-dev/Pinakes/issues/453), [#454](https://github.com/fabiodalez-dev/Pinakes/issues/454)). Like a book, it opens on a page that shows the record (cover, authors, publication, genre, keywords, PDF and exports) with Edit and Delete as buttons, instead of opening straight into its form. The quick search, the Articles list and the author page lead there, and saving the form brings you back to it.

**An update behind a proxy reports what really happened** ([#450](https://github.com/fabiodalez-dev/Pinakes/issues/450)). A reverse proxy, such as a NAS's remote access, can give up on the install request after a minute while the server carries the update to its end. The update page then waits for the server and shows the real outcome, instead of an error for an update that succeeded. No migration.

### v0.7.93

**Articles are found, listed and edited the way books are** ([#453](https://github.com/fabiodalez-dev/Pinakes/issues/453), [#454](https://github.com/fabiodalez-dev/Pinakes/issues/454), [#455](https://github.com/fabiodalez-dev/Pinakes/issues/455)). The admin menu has an Articles entry, and the quick search at the top of the admin finds articles (opening their form) and periodicals. On an author's page the articles are cards with an image, Details and Edit, like the books. The public article page has an Edit button for staff, shows the genre, and lists the author's other works, books included. In the article form, keywords and genre sit in the advanced description, and the genre puts the article in the catalogue filtered by that genre. The Emeroteca plugin 1.12.0 adds the genre column to articles, applied on its own when the plugin updates.

Expand Down
47 changes: 47 additions & 0 deletions app/Controllers/UpdateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,20 @@ public function installManualUpdate(Request $request, Response $response, mysqli
], 400);
}

// Release the session before the long part. PHP holds the session
// file locked for the whole request, so the page's status polls
// (status(), same session) would wait for the install to end: and
// when a proxy has dropped this request (#450) they are how the page
// learns the outcome. The package keys removed above are written now;
// nothing below writes to the session.
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}

// The page's own name for this install: it finds the outcome by it
// when a proxy drops this request (#450). Validated by the Updater.
$updater->setAttemptId((string) ($data['attempt'] ?? ''));

// Perform the update from uploaded file (use resolved path to prevent TOCTOU)
$this->answerJsonOnFatal();
$result = $updater->performUpdateFromFile($realTempPath);
Expand All @@ -855,6 +869,39 @@ public function installManualUpdate(Request $request, Response $response, mysqli
], 500);
}

/**
* API: where an update stands, for the page whose install request a proxy
* cut short (#450). The update itself carries on in PHP after the proxy
* gives up, so the page asks here until it ends: the installed version,
* whether the update lock is still held, and the latest attempt logged.
* Reachable during maintenance (index.php allows /admin/updates). Admin
* only, like the update it reports on: AdminAuthMiddleware also lets staff
* through, so the role is checked here.
*/
public function status(Request $request, Response $response, mysqli $db): Response
{
if (($_SESSION['user']['tipo_utente'] ?? '') !== 'admin') {
return $this->jsonResponse($response, ['error' => __('Operazione riservata agli amministratori')], 403);
}
try {
$updater = new Updater($db);
} catch (\Throwable $e) {
return $this->jsonResponse($response, [
'success' => false,
'error' => $this->updaterUnavailable($e, 'status'),
], 503);
}
return $this->jsonResponse($response, [
'success' => true,
'version' => $updater->getCurrentVersion(),
'running' => $updater->isUpdateRunning(),
'last' => $updater->lastUpdateAttempt(),
'outcome' => $updater->lastUpdateOutcome(),
// This page's own install, by the identifier it sent with it.
'attempt' => $updater->attemptStatus((string) ($request->getQueryParams()['attempt'] ?? '')),
])->withHeader('Cache-Control', 'no-store');
}

/**
* API: Save GitHub API token
*/
Expand Down
8 changes: 8 additions & 0 deletions app/Routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -3573,6 +3573,14 @@
return $controller->clearMaintenance($request, $response);
})->add(new CsrfMiddleware())->add(new AdminAuthMiddleware());

// Where an update stands: polled by the page when a proxy dropped its
// install request while the server carried on (#450)
$app->get('/admin/updates/status', function ($request, $response) use ($app) {
$db = $app->getContainer()->get('db');
$controller = new \App\Controllers\UpdateController();
return $controller->status($request, $response, $db);
})->add(new AdminAuthMiddleware());

// View updater logs (for debugging)
$app->get('/admin/updates/logs', function ($request, $response) {
$controller = new \App\Controllers\UpdateController();
Expand Down
233 changes: 233 additions & 0 deletions app/Support/Updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -2141,6 +2141,13 @@ public function performUpdateFromFile(string $uploadTempPath): array
} finally {
$this->cleanup();

// The outcome is written while the lock is still held, so a page
// whose request a proxy dropped (#450) reads a final answer as
// soon as it sees the lock free, failures before installUpdate()
// (space, backup, extraction, package checks) included: those
// leave no update_logs row of their own.
$this->recordUpdateOutcome($result ?? ['success' => false, 'error' => null]);

// Normal cleanup is complete while this request still owns the
// lock. Disarm the shutdown fallback before releasing it: once
// another request acquires the lock, this request must never remove
Expand Down Expand Up @@ -4348,6 +4355,12 @@ private function logUpdateStart(string $fromVersion, string $toVersion, ?string
$id = $this->db->insert_id;
$stmt->close();

// The page that started this install finds its log row by its
// attempt identifier (#450). Backups share the table, not the role.
if ($toVersion !== 'backup' && $id > 0) {
$this->rememberAttempt(['log_id' => (int) $id]);
}

return $id;
} catch (\Throwable $e) {
$this->debugLog('WARNING', 'Log update start fallito', ['error' => $e->getMessage()]);
Expand Down Expand Up @@ -4381,6 +4394,226 @@ private function logUpdateComplete(int $logId, bool $success, ?string $error = n
}
}

/**
* Whether an update is running right now: some request holds the update
* lock. The page asks when the proxy in front of the site dropped its
* install request (#450) while PHP, with ignore_user_abort, carried on.
* A shared, non-blocking probe: it never waits and never takes the lock
* from the update; a lock it cannot even open counts as not running.
*/
public function isUpdateRunning(): bool
{
$lockFile = $this->rootPath . '/storage/cache/update.lock';
if (!is_file($lockFile)) {
return false;
}
$handle = @fopen($lockFile, 'r');
if ($handle === false) {
return false;
}
try {
if (flock($handle, LOCK_SH | LOCK_NB)) {
flock($handle, LOCK_UN);
return false;
}
return true;
} finally {
fclose($handle);
}
}

/** The page's identifier for the install it started (see setAttemptId()), or ''. */
private string $attemptId = '';

/**
* Name the install about to run, so its outcome can be told apart from
* another one. The update page makes the identifier before it sends the
* request, and finds its own outcome by it when a proxy dropped the
* request (#450): timestamps cannot tell two administrators' runs apart.
* Anything but 32 hex characters is ignored.
*/
public function setAttemptId(string $attemptId): void
{
$this->attemptId = preg_match('/^[a-f0-9]{32}$/', $attemptId) === 1 ? $attemptId : '';
}

/**
* Write how the update this request ran ended, for the status endpoint.
* Atomic (temp file + rename); a write that fails is logged and skipped:
* the status then falls back to update_logs and the installed version.
*
* @param array<string, mixed> $result
*/
private function recordUpdateOutcome(array $result): void
{
$file = $this->rootPath . '/storage/cache/update-outcome.json';
$payload = json_encode([
'at' => microtime(true),
'attempt' => $this->attemptId,
'success' => !empty($result['success']),
'error' => (string) ($result['error'] ?? ''),
'version' => $this->getCurrentVersion(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$tmp = $file . '.' . bin2hex(random_bytes(4)) . '.tmp';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if ($payload === false || @file_put_contents($tmp, $payload) === false || !@rename($tmp, $file)) {
@unlink($tmp);
$this->debugLog('WARNING', 'Esito aggiornamento non registrato', ['file' => $file]);
}
$this->rememberAttempt(['outcome' => [
'success' => !empty($result['success']),
'error' => (string) ($result['error'] ?? ''),
'version' => $this->getCurrentVersion(),
]]);
}

/** Most recent attempts kept in update-attempts.json; older ones drop off. */
private const ATTEMPTS_KEPT = 10;

/**
* Merge $fields into this run's entry of storage/cache/update-attempts.json,
* the per-attempt record the update page reads (#450): its update_logs row
* and its outcome, under the identifier the page sent. A single "latest"
* record cannot tell two administrators' runs apart. Called while the
* update lock is held, so writers never overlap. Without an identifier
* there is nothing to file it under; a failed write is logged and skipped.
*
* @param array<string, mixed> $fields
*/
private function rememberAttempt(array $fields): void
{
if ($this->attemptId === '') {
return;
}
$file = $this->rootPath . '/storage/cache/update-attempts.json';
$raw = is_file($file) ? @file_get_contents($file) : '';
$attempts = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
$attempts = is_array($attempts) ? $attempts : [];
$entry = is_array($attempts[$this->attemptId] ?? null) ? $attempts[$this->attemptId] : [];
unset($attempts[$this->attemptId]);
$attempts[$this->attemptId] = array_merge($entry, $fields, ['at' => microtime(true)]);
$attempts = array_slice($attempts, -self::ATTEMPTS_KEPT, null, true);
$payload = json_encode($attempts, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$tmp = $file . '.' . bin2hex(random_bytes(4)) . '.tmp';
if ($payload === false || @file_put_contents($tmp, $payload) === false || !@rename($tmp, $file)) {
@unlink($tmp);
$this->debugLog('WARNING', 'Tentativo di aggiornamento non registrato', ['file' => $file]);
}
}

/**
* What is known of one install attempt (see rememberAttempt()): its
* update_logs row and its outcome, each null until written. Null when the
* identifier is malformed or unknown.
*
* @return array{log:array{id:int,to_version:string,status:string,error:string}|null,outcome:array{success:bool,error:string,version:string}|null}|null
*/
public function attemptStatus(string $attemptId): ?array
{
if (preg_match('/^[a-f0-9]{32}$/', $attemptId) !== 1) {
return null;
}
$raw = @file_get_contents($this->rootPath . '/storage/cache/update-attempts.json');
$attempts = is_string($raw) ? json_decode($raw, true) : null;
$entry = is_array($attempts) && is_array($attempts[$attemptId] ?? null) ? $attempts[$attemptId] : null;
if ($entry === null) {
return null;
}
$outcome = is_array($entry['outcome'] ?? null) ? [
'success' => !empty($entry['outcome']['success']),
'error' => (string) ($entry['outcome']['error'] ?? ''),
'version' => (string) ($entry['outcome']['version'] ?? ''),
] : null;
$log = isset($entry['log_id']) ? $this->updateLogRow((int) $entry['log_id']) : null;
return ['log' => $log, 'outcome' => $outcome];
}

/**
* One update_logs row by id, or null when it is missing or unreadable.
*
* @return array{id:int,to_version:string,status:string,error:string}|null
*/
private function updateLogRow(int $id): ?array
{
if ($id <= 0) {
return null;
}
try {
$stmt = $this->db->prepare('SELECT id, to_version, status, error_message FROM update_logs WHERE id = ?');
if ($stmt === false) {
return null;
}
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
$row = $result instanceof \mysqli_result ? $result->fetch_assoc() : null;
$stmt->close();
if (!is_array($row)) {
return null;
}
return [
'id' => (int) $row['id'],
'to_version' => (string) $row['to_version'],
'status' => (string) $row['status'],
'error' => (string) ($row['error_message'] ?? ''),
];
} catch (\Throwable $e) {
$this->debugLog('WARNING', 'Lettura riga aggiornamento fallita', ['error' => $e->getMessage()]);
return null;
}
}

/**
* How the latest update run ended (see recordUpdateOutcome()), or null.
*
* @return array{at:float,attempt:string,success:bool,error:string,version:string}|null
*/
public function lastUpdateOutcome(): ?array
{
$raw = @file_get_contents($this->rootPath . '/storage/cache/update-outcome.json');
$data = is_string($raw) ? json_decode($raw, true) : null;
if (!is_array($data) || !isset($data['at'])) {
return null;
}
return [
'at' => (float) $data['at'],
'attempt' => (string) ($data['attempt'] ?? ''),
'success' => !empty($data['success']),
'error' => (string) ($data['error'] ?? ''),
'version' => (string) ($data['version'] ?? ''),
];
}

/**
* The latest update attempt in update_logs (backups excluded), or null
* when there is none or the table is missing.
*
* @return array{id:int,to_version:string,status:string,error:string}|null
*/
public function lastUpdateAttempt(): ?array
{
try {
$tableCheck = $this->db->query("SHOW TABLES LIKE 'update_logs'");
if ($tableCheck === false || $tableCheck->num_rows === 0) {
return null;
}
$tableCheck->free();
$result = $this->db->query("SELECT id, to_version, status, error_message FROM update_logs WHERE to_version <> 'backup' ORDER BY id DESC LIMIT 1");
$row = $result instanceof \mysqli_result ? $result->fetch_assoc() : null;
if (!is_array($row)) {
return null;
}
return [
'id' => (int) $row['id'],
'to_version' => (string) $row['to_version'],
'status' => (string) $row['status'],
'error' => (string) ($row['error_message'] ?? ''),
];
} catch (\Throwable $e) {
$this->debugLog('WARNING', 'Lettura ultimo aggiornamento fallita', ['error' => $e->getMessage()]);
return null;
}
}

/**
* Get update history
* @return array<array>
Expand Down
Loading
Loading