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/app/Support/PluginManager.php b/app/Support/PluginManager.php index bc57314fd..49ba08342 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 @@ -2317,7 +2329,7 @@ public function loadActivePlugins(): void // adds [NC] to the private-uploads rewrite. Idempotent; a file the operator // edited by hand is left alone. 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 +2760,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 +2922,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 +2950,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/Updater.php b/app/Support/Updater.php index 49bd23bf8..32a5d2908 100644 --- a/app/Support/Updater.php +++ b/app/Support/Updater.php @@ -4049,7 +4049,7 @@ public function runMigrations(string $fromVersion, string $toVersion): array // shipped line never arrives: replace it in place while it is still the // exact line an older version shipped. Idempotent. 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/tests/digital-access-480.spec.js b/tests/digital-access-480.spec.js index fcb6566d4..b89502e57 100644 --- a/tests/digital-access-480.spec.js +++ b/tests/digital-access-480.spec.js @@ -10,7 +10,8 @@ const fs = require('fs'); const path = require('path'); const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; -const ROOT = path.join(__dirname, '..'); +// The install under test: this checkout by default, the upgraded sandbox under scripts/reinstall-test.sh. +const ROOT = process.env.E2E_INSTALL_ROOT || path.join(__dirname, '..'); const STORAGE = path.join(ROOT, 'storage/digital'); const LEGACY = path.join(ROOT, 'public/uploads/digital'); test.skip(!process.env.E2E_ADMIN_EMAIL || !process.env.E2E_DB_NAME, 'E2E credentials required'); @@ -293,7 +294,10 @@ test.describe.serial('Digital Library access control (#480)', () => { expect(mode(path.join(STORAGE, legacyName))).toBe('644'); // No deny rule in the old folder: Apache would apply it before the rewrite to Pinakes. const legacyRule = path.join(LEGACY, '.htaccess'); - expect(fs.existsSync(legacyRule) ? fs.readFileSync(legacyRule, 'utf8') : '').not.toContain('Require all denied'); + // The pre-0.8.6 plugin leaves one there that denies only scripts, inside : + // that one stays, a deny outside it would refuse the PDFs too. + const legacyText = fs.existsSync(legacyRule) ? fs.readFileSync(legacyRule, 'utf8') : ''; + expect(legacyText.replace(//gi, '')).not.toContain('Require all denied'); // The settings page counts nothing left to move. const pluginId = db("SELECT id FROM plugins WHERE name='digital-library'"); await page.goto(`${BASE}/admin/plugins/${pluginId}/settings`); diff --git a/tests/digital-access-matrix-480.unit.php b/tests/digital-access-matrix-480.unit.php index 81d55361d..16b57a184 100644 --- a/tests/digital-access-matrix-480.unit.php +++ b/tests/digital-access-matrix-480.unit.php @@ -628,7 +628,8 @@ function url(string $path = '/'): string $check(\App\Support\PrivateUploadsRewriteHeal::heal($ht, $tmp . '/backups') && count(glob($tmp . '/backups/htaccess.bak-480-*') ?: []) === 1, 'a second upgrade changes nothing'); $own = str_replace('index.php [QSA,L]', 'index.php [QSA,L,E=MINE:1]', $old); file_put_contents($ht, $own); -$check(\App\Support\PrivateUploadsRewriteHeal::heal($ht, $tmp . '/backups') && str_contains((string) file_get_contents($ht), '[QSA,L,E=MINE:1]'), 'a rule the administrator edited is left alone'); +$check(!\App\Support\PrivateUploadsRewriteHeal::heal($ht, $tmp . '/backups') && str_contains((string) file_get_contents($ht), '[QSA,L,E=MINE:1]'), + 'a rule the administrator edited is left alone, and the heal reports it (false: the caller logs it)'); unset($_SESSION); echo "\n" . ($fail === 0 ? "ALL {$pass} PASS" : "{$fail} FAILED, {$pass} passed") . "\n"; diff --git a/tests/digital-upgrade-480.spec.js b/tests/digital-upgrade-480.spec.js new file mode 100644 index 000000000..235426d07 --- /dev/null +++ b/tests/digital-upgrade-480.spec.js @@ -0,0 +1,307 @@ +// @ts-check +// Issue #480 — Digital Library files uploaded on an older release, then the upgrade. +// +// Run twice by scripts/reinstall-test.sh (Test B), against the same sandbox install: +// E2E_UPGRADE_PHASE=before on the old release (files go to public/uploads/digital and +// anyone can download them), the admin uploads through the +// book form like a person would; +// E2E_UPGRADE_PHASE=after on the upgraded install: the same files, byte for byte, now +// in storage/digital, still served at the same addresses, and +// the per-file visibility of 0.8.6 enforced on them. +// The old book form comes in two shapes: since 0.7.89 a list of attachments (two PDFs and +// an audiobook here), before it one eBook and one audiobook field. Each file has a role: +// "open" stays public, "readers" is limited to signed-in readers, "staff" to staff. +// The state between the two runs (book id, addresses, checksums) is a JSON file. +// The database is only used to prepare the book and a reader, never to upload or check. +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ROOT = process.env.E2E_INSTALL_ROOT || path.join(__dirname, '..'); +const PHASE = process.env.E2E_UPGRADE_PHASE || ''; +const STATE = process.env.E2E_UPGRADE_STATE || path.join(ROOT, 'storage/tmp/digital-upgrade-480.json'); +const STORAGE = path.join(ROOT, 'storage/digital'); +const LEGACY = path.join(ROOT, 'public/uploads/digital'); +// Without a phase this is the regular browser run, where the spec has nothing to do; a +// reason with "required" there would read as broken infrastructure to the skip audit. +test.skip(!PHASE, 'Runs only around a real upgrade (phase given by E2E_UPGRADE_PHASE)'); +test.skip(!process.env.E2E_ADMIN_EMAIL || !process.env.E2E_DB_NAME, 'E2E credentials required'); + +// GET exactly the path given: Playwright's request.get parses the URL (WHATWG), which +// drops a "/./" segment before sending, so that variant would repeat the plain request. +function getPathAsIs(url) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'digital-upgrade-480-')); + const bodyPath = path.join(dir, 'response'); + try { + const status = execFileSync('curl', ['--silent', '--show-error', '--path-as-is', '--max-redirs', '0', '--max-time', '15', + '--output', bodyPath, '--write-out', '%{http_code}', url], { encoding: 'utf8', timeout: 20000 }).trim(); + return { status: Number(status), body: fs.existsSync(bodyPath) ? fs.readFileSync(bodyPath) : Buffer.alloc(0) }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function db(sql) { + const args = ['-u', process.env.E2E_DB_USER, process.env.E2E_DB_NAME, '-N', '-B', '-e', sql]; + if (process.env.E2E_DB_SOCKET) args.unshift('-S', process.env.E2E_DB_SOCKET); + return execFileSync('mysql', args, { encoding: 'utf8', env: { ...process.env, MYSQL_PWD: process.env.E2E_DB_PASS } }).trim(); +} +const sha = buffer => crypto.createHash('sha256').update(buffer).digest('hex'); + +async function loginWith(page, email, password) { + await page.locator('input[name=email]').fill(email); + await page.locator('input[name=password]').fill(password); + await page.locator('button[type=submit]').click(); + await page.waitForURL(u => !/accedi|login/.test(u.pathname)); +} +async function loginAdmin(page) { + await page.goto(`${BASE}/admin/dashboard`); + if (await page.locator('input[name=email]').isVisible()) { + await loginWith(page, process.env.E2E_ADMIN_EMAIL, process.env.E2E_ADMIN_PASS); + } +} +/** The book edit page: /admin/books/edit/N since 0.7.18-ish, /admin/libri/modifica/N before. */ +async function openEditForm(page, bookId) { + for (const route of [`/admin/books/edit/${bookId}`, `/admin/libri/modifica/${bookId}`]) { + const response = await page.goto(`${BASE}${route}`); + if (response && response.status() === 200 && await page.locator('#bookForm').count()) return; + } + throw new Error(`no edit form for book ${bookId}`); +} +async function saveBook(page) { + await page.locator('#bookForm button[type=submit]').click(); + const confirmation = page.locator('.swal2-confirm'); + if (await confirmation.isVisible({ timeout: 3000 }).catch(() => false)) await confirmation.click(); + await page.waitForURL(u => !/\/admin\/(books\/(edit|create)|libri\/(modifica|crea))/.test(u.pathname)); +} +/** The Uppy result alert after each upload: dismissed like a person would. */ +async function dismissAlert(page) { + const ok = page.locator('.swal2-confirm'); + if (await ok.isVisible({ timeout: 3000 }).catch(() => false)) await ok.click(); +} +/** Upload through one of the plugin's uploaders (ebook or audio), as the admin would. */ +async function upload(page, kind, files) { + await page.locator(`#upload-${kind}-btn`).click(); + await page.locator(`#${kind}-uploader input[type=file]`).setInputFiles(files); +} + +const pdf = fs.readFileSync(path.join(__dirname, 'fixtures/archive-test.pdf')); +// The audio uploader of the old releases takes MP3, M4A or OGG: an ID3 header and eight +// silent MPEG-1 Layer III frames, which the server's content check reads as audio/mpeg. +const mp3 = Buffer.concat([Buffer.from('ID3\x03\x00\x00\x00\x00\x00\x00', 'latin1'), + ...Array.from({ length: 8 }, () => Buffer.concat([Buffer.from([0xff, 0xfb, 0x90, 0x64]), Buffer.alloc(413)]))]); +const entry = (url, buffer) => ({ url, sha: sha(buffer), size: buffer.length }); + +/** The file at url answers 200 with these bytes, and a Range request 206 with its start. */ +async function expectServed(request, file) { + const response = await request.get(`${BASE}${file.url}`, { maxRedirects: 0 }); + expect(response.status(), file.url).toBe(200); + expect(sha(await response.body()), `${file.url} intact`).toBe(file.sha); + const range = await request.get(`${BASE}${file.url}`, { maxRedirects: 0, headers: { Range: 'bytes=0-99' } }); + expect(range.status(), `${file.url} with Range`).toBe(206); + expect(range.headers()['content-encoding'] || '', `${file.url} not compressed`).not.toContain('gzip'); + expect((await range.body()).length).toBe(Math.min(100, file.size)); +} + +test.describe.serial(`Digital Library across the upgrade (#480) — ${PHASE}`, () => { + test.skip(PHASE !== 'before', 'before-upgrade phase only'); + + /** @type {{bookId:number, mode:string, files:Record}} */ + const state = { bookId: 0, mode: '', files: {} }; + + test('the digital-library plugin is active on the old release, with its hooks', async ({ page }) => { + await loginAdmin(page); + const id = db("SELECT id FROM plugins WHERE name='digital-library'"); + expect(id, 'digital-library registered').not.toBe(''); + /** Click the plugin's own button, confirm, and check the JSON answer of the action. */ + const toggle = async action => { + await page.goto(`${BASE}/admin/plugins`); + const answered = page.waitForResponse(r => r.url().includes(`/admin/plugins/${id}/${action}`)); + await page.locator(`button[onclick="${action}Plugin(${id})"]`).click(); + await page.locator('.swal2-confirm:visible').click(); + const response = await answered; + const body = await response.json().catch(() => ({})); + expect(response.status(), JSON.stringify(body)).toBe(200); + expect(body.success, JSON.stringify(body)).toBe(true); + await page.locator('.swal2-confirm:visible').click(); + }; + const hooks = () => Number(db(`SELECT COUNT(*) FROM plugin_hooks WHERE plugin_id=${id} AND hook_name='book.form.digital_fields'`)); + if (db(`SELECT is_active FROM plugins WHERE id=${id}`) !== '1') await toggle('activate'); + // Some 0.7.x installers left the plugin active with only its routes hook: the book form + // then has no uploader. Switching it off and on again, as an admin would, registers them. + if (hooks() === 0) { + await toggle('deactivate'); + await toggle('activate'); + } + expect(db(`SELECT is_active FROM plugins WHERE id=${id}`)).toBe('1'); + expect(hooks(), 'book.form.digital_fields registered').toBeGreaterThan(0); + }); + + test('the admin uploads the digital files through the old book form', async ({ page }) => { + const title = `Upgrade480-${Date.now()}`; + db(`INSERT INTO libri (titolo, created_at, updated_at) VALUES ('${title}', NOW(), NOW())`); + state.bookId = Number(db(`SELECT id FROM libri WHERE titolo='${title}' AND deleted_at IS NULL`)); + await loginAdmin(page); + await openEditForm(page, state.bookId); + // The plugin's uploader is drawn by its book.form.digital_fields hook: if it is missing, + // say what the database holds for the plugin instead of waiting on a button. + await expect(page.locator('#upload-ebook-btn'), db( + "SELECT CONCAT_WS(' ', p.id, p.is_active, p.version, p.path, ph.hook_name, ph.callback_method, ph.is_active) FROM plugins p LEFT JOIN plugin_hooks ph ON ph.plugin_id = p.id WHERE p.name = 'digital-library'" + )).toBeVisible({ timeout: 10000 }); + state.mode = await page.locator('#add-digital-attachment').count() ? 'attachments' : 'fields'; + + if (state.mode === 'attachments') { + await upload(page, 'ebook', [ + { name: 'upgrade-480-open.pdf', mimeType: 'application/pdf', buffer: pdf }, + { name: 'upgrade-480-readers.pdf', mimeType: 'application/pdf', buffer: pdf }, + ]); + const rows = page.locator('[data-attachment-row]'); + await expect(rows).toHaveCount(2, { timeout: 20000 }); + await dismissAlert(page); + await upload(page, 'audio', { name: 'upgrade-480-staff.mp3', mimeType: 'audio/mpeg', buffer: mp3 }); + await expect(rows).toHaveCount(3, { timeout: 20000 }); + await dismissAlert(page); + for (const [i, [role, label, buffer]] of [['open', 'Public edition', pdf], ['readers', 'Readers edition', pdf], ['staff', 'Audiobook', mp3]].entries()) { + await rows.nth(i).locator('input[name$="[label]"]').fill(label); + state.files[role] = entry(await rows.nth(i).locator('input[name$="[url]"]').inputValue(), buffer); + } + } else { + // One eBook and one audiobook field: the eBook goes to readers, the audiobook to staff. + await upload(page, 'ebook', { name: 'upgrade-480-readers.pdf', mimeType: 'application/pdf', buffer: pdf }); + await expect(page.locator('#file_url').first()).toHaveValue(/\/uploads\/digital\/.+\.pdf$/, { timeout: 20000 }); + await dismissAlert(page); + await upload(page, 'audio', { name: 'upgrade-480-staff.mp3', mimeType: 'audio/mpeg', buffer: mp3 }); + await expect(page.locator('#audio_url').first()).toHaveValue(/\/uploads\/digital\/.+\.mp3$/, { timeout: 20000 }); + await dismissAlert(page); + state.files.readers = entry(await page.locator('#file_url').first().inputValue(), pdf); + state.files.staff = entry(await page.locator('#audio_url').first().inputValue(), mp3); + } + await saveBook(page); + + // The old release keeps them in the public uploads folder, as uploaded, and lists them on the book. + for (const { url, sha: digest } of Object.values(state.files)) { + expect(url).toMatch(/^\/uploads\/digital\/[A-Za-z0-9._-]+\.(pdf|mp3)$/); + const file = path.join(LEGACY, path.basename(url)); + expect(fs.existsSync(file), `${url} in public/uploads/digital`).toBe(true); + expect(sha(fs.readFileSync(file))).toBe(digest); + } + const listed = db(`SELECT CONCAT_WS('|', file_url, audio_url) FROM libri WHERE id=${state.bookId}`) + + (state.mode === 'attachments' ? db(`SELECT digital_attachments FROM libri WHERE id=${state.bookId}`) : ''); + for (const { url } of Object.values(state.files)) expect(listed, `${url} saved on the book`).toContain(url); + fs.mkdirSync(path.dirname(STATE), { recursive: true }); + fs.writeFileSync(STATE, JSON.stringify(state, null, 2)); + }); + + test('on the old release an anonymous visitor downloads every file, Range requests included', async ({ request }) => { + for (const file of Object.values(state.files)) await expectServed(request, file); + }); +}); + +test.describe.serial('Digital Library across the upgrade (#480) — after', () => { + test.skip(PHASE !== 'after', 'after-upgrade phase only'); + + const RUN = Date.now(); + const password = `Pk480u-${RUN}-x`; + const reader = { email: `upgrade-reader-${RUN}@example.test`, id: 0 }; + /** @type {{bookId:number, mode:string, files:Record}} */ + let state; + const name = role => path.basename(state.files[role].url); + + test.beforeAll(() => { + state = JSON.parse(fs.readFileSync(STATE, 'utf8')); + const hash = execFileSync('php', ['-r', 'echo password_hash($argv[1], PASSWORD_DEFAULT);', password], { encoding: 'utf8' }); + db(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata, privacy_accettata) VALUES ('U480${RUN % 1000000}', 'Upgrade', 'Reader', '${reader.email}', '${hash}', 'attivo', 'standard', 1, 1)`); + reader.id = Number(db(`SELECT id FROM utenti WHERE email='${reader.email}'`)); + }); + + test('the uploaded files moved to storage/digital, byte for byte, at the same addresses', async ({ page }) => { + // The first page under the new code finishes whatever the upgrade left to move. + await page.goto(`${BASE}/libro/${state.bookId}`); + for (const [role, { url, sha: digest }] of Object.entries(state.files)) { + expect(fs.existsSync(path.join(STORAGE, name(role))), `${role} file in storage/digital`).toBe(true); + expect(sha(fs.readFileSync(path.join(STORAGE, name(role))))).toBe(digest); + expect(fs.existsSync(path.join(LEGACY, name(role))), `${role} file no longer in public/uploads/digital`).toBe(false); + const listed = db(`SELECT CONCAT_WS('|', file_url, audio_url, digital_attachments) FROM libri WHERE id=${state.bookId}`); + expect(listed, `${url} still the address saved on the book`).toContain(url); + } + }); + + test('with the default left as it was, anonymous visitors still open every file', async ({ page, request }) => { + await page.goto(`${BASE}/libro/${state.bookId}`); + const list = page.locator('.digital-attachments-list'); + await expect(list.locator('.digital-attachment--locked')).toHaveCount(0); + for (const role of Object.keys(state.files)) { + await expect(page.locator(`.digital-attachments-list [href*="${name(role)}"], .digital-attachments-list [src*="${name(role)}"]`).first()).toBeAttached(); + } + for (const file of Object.values(state.files)) await expectServed(request, file); + }); + + test('the admin limits one file to signed-in readers and one to staff on the upgraded form', async ({ page }) => { + await loginAdmin(page); + await openEditForm(page, state.bookId); + const rows = page.locator('[data-attachment-row]'); + await expect(rows).toHaveCount(Object.keys(state.files).length); + for (const [role, visibility] of [['readers', 'users'], ['staff', 'staff']]) { + const row = rows.filter({ has: page.locator(`input[name$="[url]"][value$="${name(role)}"]`) }); + await expect(row).toHaveCount(1); + await row.locator('select[name$="[visibility]"]').selectOption(visibility); + } + await saveBook(page); + const saved = JSON.parse(db(`SELECT digital_attachments FROM libri WHERE id=${state.bookId}`)); + const visibilityOf = role => saved.find(a => a.url === state.files[role].url)?.visibility; + expect(visibilityOf('readers')).toBe('users'); + expect(visibilityOf('staff')).toBe('staff'); + if (state.files.open) expect(visibilityOf('open')).toBe('inherit'); + }); + + test('an anonymous visitor is sent to sign in for the readers file and refused the staff one', async ({ page, request }) => { + const get = (url, headers = {}) => request.get(`${BASE}${url}`, { maxRedirects: 0, headers }); + if (state.files.open) await expectServed(request, state.files.open); + const toLogin = await get(state.files.readers.url); + expect(toLogin.status()).toBe(302); + expect(toLogin.headers()['location']).toContain('return_url='); + expect((await get(state.files.staff.url)).status()).toBe(403); + // Another case of the folder name is no way around the check, nor is a dot segment. + for (const role of ['readers', 'staff']) { + const { url, sha: digest } = state.files[role]; + for (const variant of [url.replace('/digital/', '/Digital/'), url.replace('/uploads/', '/uploads/./'), url.replace('/uploads/', '/UPLOADS/')]) { + const response = getPathAsIs(`${BASE}${variant}`); + const served = response.status === 200 && sha(response.body) === digest; + expect(served, `${variant} must not serve the file`).toBe(false); + } + } + await page.goto(`${BASE}/libro/${state.bookId}`); + await expect(page.locator('.digital-attachments-list .digital-attachment--locked')).toHaveCount(1); + for (const role of ['readers', 'staff']) { + await expect(page.locator(`[href*="${name(role)}"], [src*="${name(role)}"]`)).toHaveCount(0); + } + }); + + test('a signed-in reader opens the readers file but not the staff one; the admin opens both', async ({ page, browser }) => { + const { readers, staff } = state.files; + await page.goto(`${BASE}/libro/${state.bookId}`); + const bookPath = new URL(page.url()).pathname; + await page.locator('.digital-attachment--locked .digital-attachment__login').first().click(); + await loginWith(page, reader.email, password); + await page.waitForURL(u => u.pathname === bookPath); + const asReader = await page.request.get(`${BASE}${readers.url}`, { maxRedirects: 0 }); + expect(asReader.status()).toBe(200); + expect(sha(await asReader.body())).toBe(readers.sha); + expect((await page.request.get(`${BASE}${staff.url}`, { maxRedirects: 0 })).status()).toBe(403); + + const admin = await browser.newPage(); + await loginAdmin(admin); + const asStaff = await admin.request.get(`${BASE}${staff.url}`, { maxRedirects: 0 }); + expect(asStaff.status()).toBe(200); + expect(sha(await asStaff.body())).toBe(staff.sha); + await admin.close(); + }); + + test.afterAll(() => { + if (reader.id) db(`DELETE FROM utenti WHERE id=${reader.id}`); + }); +}); diff --git a/tests/fixtures/htaccess-history/0.1.1.htaccess b/tests/fixtures/htaccess-history/0.1.1.htaccess new file mode 100644 index 000000000..8156b551d --- /dev/null +++ b/tests/fixtures/htaccess-history/0.1.1.htaccess @@ -0,0 +1,35 @@ + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Allow direct access to static files in /uploads/ directory + RewriteCond %{REQUEST_URI} ^/uploads/ [NC] + RewriteRule ^ - [L] + + # Redirect all requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# Security Headers + + # Prevent MIME-type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Enable XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Prevent clickjacking + Header always set X-Frame-Options "SAMEORIGIN" + + # Content Security Policy - restrictive but allows inline styles/scripts for compatibility + # All assets are self-hosted (fonts, styles, scripts) for privacy and performance + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: https: http: blob:; connect-src 'self'; frame-ancestors 'self';" + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + diff --git a/tests/fixtures/htaccess-history/0.1.7.htaccess b/tests/fixtures/htaccess-history/0.1.7.htaccess new file mode 100644 index 000000000..3309653c3 --- /dev/null +++ b/tests/fixtures/htaccess-history/0.1.7.htaccess @@ -0,0 +1,81 @@ + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Allow direct access to static files in /uploads/ directory + RewriteCond %{REQUEST_URI} ^/uploads/ [NC] + RewriteRule ^ - [L] + + # Redirect all requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# Security Headers + + # Prevent MIME-type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Enable XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Prevent clickjacking + Header always set X-Frame-Options "SAMEORIGIN" + + # Content Security Policy - restrictive but allows inline styles/scripts for compatibility + # Allows Google Fonts for Inter font family + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https: http: blob:; connect-src 'self'; frame-src 'self' https://www.openstreetmap.org; frame-ancestors 'self';" + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Block access to sensitive files (defense in depth) +# Even though these files are outside webroot, this prevents misconfiguration attacks + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Audio/Video Streaming Support (for Digital Library plugin) + + # Enable Range Requests for audio/video streaming + # Required for seek functionality in audio players + + Header set Accept-Ranges bytes + # Allow CORS for audio/video files (required for cross-origin playback) + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + Header set Access-Control-Allow-Headers "Range, Content-Type" + Header set Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" + + + # CORS for digital content in uploads/digital/ directory + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + + + +# Correct MIME types for audio files + + AddType audio/mpeg .mp3 + AddType audio/mp4 .m4a + AddType audio/ogg .ogg + AddType audio/wav .wav + AddType audio/webm .weba + AddType video/mp4 .mp4 + AddType video/webm .webm + AddType video/ogg .ogv + AddType application/pdf .pdf + AddType application/epub+zip .epub + diff --git a/tests/fixtures/htaccess-history/0.4.5.htaccess b/tests/fixtures/htaccess-history/0.4.5.htaccess new file mode 100644 index 000000000..387b612d0 --- /dev/null +++ b/tests/fixtures/htaccess-history/0.4.5.htaccess @@ -0,0 +1,82 @@ + + Options +FollowSymLinks + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Allow direct access to static files in /uploads/ directory + RewriteCond %{REQUEST_URI} ^/uploads/ [NC] + RewriteRule ^ - [L] + + # Redirect all requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# Security Headers + + # Prevent MIME-type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Enable XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Prevent clickjacking + Header always set X-Frame-Options "SAMEORIGIN" + + # Content Security Policy - restrictive but allows inline styles/scripts for compatibility + # Allows Google Fonts for Inter font family + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https: http: blob:; connect-src 'self'; frame-src 'self' https://www.openstreetmap.org; frame-ancestors 'self';" + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Block access to sensitive files (defense in depth) +# Even though these files are outside webroot, this prevents misconfiguration attacks + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Audio/Video Streaming Support (for Digital Library plugin) + + # Enable Range Requests for audio/video streaming + # Required for seek functionality in audio players + + Header set Accept-Ranges bytes + # Allow CORS for audio/video files (required for cross-origin playback) + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + Header set Access-Control-Allow-Headers "Range, Content-Type" + Header set Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" + + + # CORS for digital content in uploads/digital/ directory + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + + + +# Correct MIME types for audio files + + AddType audio/mpeg .mp3 + AddType audio/mp4 .m4a + AddType audio/ogg .ogg + AddType audio/wav .wav + AddType audio/webm .weba + AddType video/mp4 .mp4 + AddType video/webm .webm + AddType video/ogg .ogv + AddType application/pdf .pdf + AddType application/epub+zip .epub + diff --git a/tests/fixtures/htaccess-history/0.4.8.htaccess b/tests/fixtures/htaccess-history/0.4.8.htaccess new file mode 100644 index 000000000..c87c8b79f --- /dev/null +++ b/tests/fixtures/htaccess-history/0.4.8.htaccess @@ -0,0 +1,82 @@ + + Options +FollowSymLinks + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Allow direct access to static files in /uploads/ directory + RewriteCond %{REQUEST_URI} ^/uploads/ [NC] + RewriteRule ^ - [L] + + # Redirect all requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# Security Headers + + # Prevent MIME-type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Enable XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Prevent clickjacking + Header always set X-Frame-Options "SAMEORIGIN" + + # Content Security Policy - restrictive but allows inline styles/scripts for compatibility + # Allows Google Fonts for Inter font family + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https: http: blob:; connect-src 'self'; frame-src 'self' data: blob: about: https://www.openstreetmap.org; child-src 'self' data: blob: about:; frame-ancestors 'self';" + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Block access to sensitive files (defense in depth) +# Even though these files are outside webroot, this prevents misconfiguration attacks + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Audio/Video Streaming Support (for Digital Library plugin) + + # Enable Range Requests for audio/video streaming + # Required for seek functionality in audio players + + Header set Accept-Ranges bytes + # Allow CORS for audio/video files (required for cross-origin playback) + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + Header set Access-Control-Allow-Headers "Range, Content-Type" + Header set Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" + + + # CORS for digital content in uploads/digital/ directory + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + + + +# Correct MIME types for audio files + + AddType audio/mpeg .mp3 + AddType audio/mp4 .m4a + AddType audio/ogg .ogg + AddType audio/wav .wav + AddType audio/webm .weba + AddType video/mp4 .mp4 + AddType video/webm .webm + AddType video/ogg .ogv + AddType application/pdf .pdf + AddType application/epub+zip .epub + diff --git a/tests/fixtures/htaccess-history/0.4.9.1.htaccess b/tests/fixtures/htaccess-history/0.4.9.1.htaccess new file mode 100644 index 000000000..89c1ec5ec --- /dev/null +++ b/tests/fixtures/htaccess-history/0.4.9.1.htaccess @@ -0,0 +1,95 @@ +# PHP upload limits (required for Digital Library plugin: eBook up to 100MB, audiobook up to 500MB) + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + Options -Indexes +FollowSymLinks + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Allow direct access to static files in /uploads/ directory + RewriteCond %{REQUEST_URI} ^/uploads/ [NC] + RewriteRule ^ - [L] + + # Redirect all requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# Security Headers + + # Prevent MIME-type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Enable XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Prevent clickjacking + Header always set X-Frame-Options "SAMEORIGIN" + + # Content Security Policy - restrictive but allows inline styles/scripts for compatibility + # Allows Google Fonts for Inter font family + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https: http: blob:; connect-src 'self'; frame-src 'self' data: blob: about: https://www.openstreetmap.org; child-src 'self' data: blob: about:; frame-ancestors 'self';" + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Block access to sensitive files (defense in depth) +# Even though these files are outside webroot, this prevents misconfiguration attacks + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Audio/Video Streaming Support (for Digital Library plugin) + + # Enable Range Requests for audio/video streaming + # Required for seek functionality in audio players + + Header set Accept-Ranges bytes + # Allow CORS for audio/video files (required for cross-origin playback) + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + Header set Access-Control-Allow-Headers "Range, Content-Type" + Header set Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" + + + # CORS for digital content in uploads/digital/ directory + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + + + +# Correct MIME types for audio files + + AddType audio/mpeg .mp3 + AddType audio/mp4 .m4a + AddType audio/ogg .ogg + AddType audio/wav .wav + AddType audio/webm .weba + AddType video/mp4 .mp4 + AddType video/webm .webm + AddType video/ogg .ogv + AddType application/pdf .pdf + AddType application/epub+zip .epub + diff --git a/tests/fixtures/htaccess-history/0.7.13.htaccess b/tests/fixtures/htaccess-history/0.7.13.htaccess new file mode 100644 index 000000000..92fae583c --- /dev/null +++ b/tests/fixtures/htaccess-history/0.7.13.htaccess @@ -0,0 +1,149 @@ +# PHP upload limits (required for Digital Library plugin: eBook up to 100MB, audiobook up to 500MB) + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + Options -Indexes +FollowSymLinks + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Allow direct access to static files in /uploads/ directory + RewriteCond %{REQUEST_URI} ^/uploads/ [NC] + RewriteRule ^ - [L] + + # Redirect all requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# === Pinakes performance block (auto-managed by Pinakes 0.7.13+) === +# HTTP compression + long-term caching for static assets. +# Asset URLs are version-busted via ?v=X.Y.Z (see HtmlHelper::asset), so +# cache invalidation happens automatically at every release — immutable+1y +# is safe for fingerprinted assets. +# Every directive is gated by so the file stays valid on +# hosts that don't ship mod_deflate / mod_expires / mod_headers / mod_brotli. + + + AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml \ + text/javascript application/javascript application/x-javascript \ + application/json application/ld+json application/xml application/rss+xml \ + application/atom+xml application/xhtml+xml \ + image/svg+xml font/ttf font/otf font/woff + # Don't re-compress already-compressed payloads + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|webp|ico|woff2?|gz|zip|epub|mp4|webm|mp3|m4a|ogg)$ no-gzip dont-vary + + + + AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css \ + text/javascript application/javascript application/json \ + application/ld+json application/xml image/svg+xml font/ttf font/otf + + + + ExpiresActive On + # 1 year for versioned static assets + ExpiresByType text/css "access plus 1 year" + ExpiresByType application/javascript "access plus 1 year" + ExpiresByType text/javascript "access plus 1 year" + ExpiresByType font/woff2 "access plus 1 year" + ExpiresByType font/woff "access plus 1 year" + ExpiresByType font/ttf "access plus 1 year" + ExpiresByType image/x-icon "access plus 1 year" + # 1 month for content images (book covers, uploaded media) + ExpiresByType image/jpeg "access plus 1 month" + ExpiresByType image/png "access plus 1 month" + ExpiresByType image/webp "access plus 1 month" + ExpiresByType image/gif "access plus 1 month" + ExpiresByType image/svg+xml "access plus 1 month" + + + + # Mark versioned assets as immutable — browsers skip revalidation until TTL. + # ?v= querystring busts the cache on every release, so this is safe. + + Header set Cache-Control "public, max-age=31536000, immutable" + + + Header set Cache-Control "public, max-age=2592000" + + +# === end Pinakes performance block === + +# Security Headers + + # Prevent MIME-type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Enable XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Prevent clickjacking + Header always set X-Frame-Options "SAMEORIGIN" + + # Content Security Policy - restrictive but allows inline styles/scripts for compatibility + # Allows Google Fonts for Inter font family + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https: http: blob:; connect-src 'self'; frame-src 'self' data: blob: about: https://www.openstreetmap.org; child-src 'self' data: blob: about:; frame-ancestors 'self';" + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Block access to sensitive files (defense in depth) +# Even though these files are outside webroot, this prevents misconfiguration attacks + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Audio/Video Streaming Support (for Digital Library plugin) + + # Enable Range Requests for audio/video streaming + # Required for seek functionality in audio players + + Header set Accept-Ranges bytes + # Allow CORS for audio/video files (required for cross-origin playback) + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + Header set Access-Control-Allow-Headers "Range, Content-Type" + Header set Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" + + + # CORS for digital content in uploads/digital/ directory + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + + + +# Correct MIME types for audio files + + AddType audio/mpeg .mp3 + AddType audio/mp4 .m4a + AddType audio/ogg .ogg + AddType audio/wav .wav + AddType audio/webm .weba + AddType video/mp4 .mp4 + AddType video/webm .webm + AddType video/ogg .ogv + AddType application/pdf .pdf + AddType application/epub+zip .epub + diff --git a/tests/fixtures/htaccess-history/0.7.18.htaccess b/tests/fixtures/htaccess-history/0.7.18.htaccess new file mode 100644 index 000000000..c917169a5 --- /dev/null +++ b/tests/fixtures/htaccess-history/0.7.18.htaccess @@ -0,0 +1,159 @@ +# PHP upload limits (required for Digital Library plugin: eBook up to 100MB, audiobook up to 500MB) + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + Options -Indexes +FollowSymLinks + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Allow direct access to PUBLIC static files in /uploads/ (covers, events, + # CMS images, branding). + RewriteCond %{REQUEST_URI} ^/uploads/ [NC] + RewriteCond %{REQUEST_URI} !^/uploads/(digital|archives/documents|storage)/ [NC] + RewriteRule ^ - [L] + + # PRIVATE uploaded content — digital-library files, archive documents and + # generic storage — must NOT be served directly. Force them through + # index.php (even when the file exists on disk, so the !-f rule below would + # otherwise skip them) so PrivateModeMiddleware (and any future per-file + # auth) governs access (issue #160). See ProtectedUploadController. + RewriteCond %{REQUEST_URI} ^/uploads/(digital|archives/documents|storage)/ [NC] + RewriteRule ^ index.php [QSA,L] + + # Redirect all requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# === Pinakes performance block (auto-managed by Pinakes 0.7.13+) === +# HTTP compression + long-term caching for static assets. +# Asset URLs are version-busted via ?v=X.Y.Z (see HtmlHelper::asset), so +# cache invalidation happens automatically at every release — immutable+1y +# is safe for fingerprinted assets. +# Every directive is gated by so the file stays valid on +# hosts that don't ship mod_deflate / mod_expires / mod_headers / mod_brotli. + + + AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml \ + text/javascript application/javascript application/x-javascript \ + application/json application/ld+json application/xml application/rss+xml \ + application/atom+xml application/xhtml+xml \ + image/svg+xml font/ttf font/otf font/woff + # Don't re-compress already-compressed payloads + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|webp|ico|woff2?|gz|zip|epub|mp4|webm|mp3|m4a|ogg)$ no-gzip dont-vary + + + + AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css \ + text/javascript application/javascript application/json \ + application/ld+json application/xml image/svg+xml font/ttf font/otf + + + + ExpiresActive On + # 1 year for versioned static assets + ExpiresByType text/css "access plus 1 year" + ExpiresByType application/javascript "access plus 1 year" + ExpiresByType text/javascript "access plus 1 year" + ExpiresByType font/woff2 "access plus 1 year" + ExpiresByType font/woff "access plus 1 year" + ExpiresByType font/ttf "access plus 1 year" + ExpiresByType image/x-icon "access plus 1 year" + # 1 month for content images (book covers, uploaded media) + ExpiresByType image/jpeg "access plus 1 month" + ExpiresByType image/png "access plus 1 month" + ExpiresByType image/webp "access plus 1 month" + ExpiresByType image/gif "access plus 1 month" + ExpiresByType image/svg+xml "access plus 1 month" + + + + # Mark versioned assets as immutable — browsers skip revalidation until TTL. + # ?v= querystring busts the cache on every release, so this is safe. + + Header set Cache-Control "public, max-age=31536000, immutable" + + + Header set Cache-Control "public, max-age=2592000" + + +# === end Pinakes performance block === + +# Security Headers + + # Prevent MIME-type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Enable XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Prevent clickjacking + Header always set X-Frame-Options "SAMEORIGIN" + + # Content Security Policy - restrictive but allows inline styles/scripts for compatibility + # Allows Google Fonts for Inter font family + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https: http: blob:; connect-src 'self'; frame-src 'self' data: blob: about: https://www.openstreetmap.org; child-src 'self' data: blob: about:; frame-ancestors 'self';" + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Block access to sensitive files (defense in depth) +# Even though these files are outside webroot, this prevents misconfiguration attacks + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Audio/Video Streaming Support (for Digital Library plugin) + + # Enable Range Requests for audio/video streaming + # Required for seek functionality in audio players + + Header set Accept-Ranges bytes + # Allow CORS for audio/video files (required for cross-origin playback) + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + Header set Access-Control-Allow-Headers "Range, Content-Type" + Header set Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" + + + # CORS for digital content in uploads/digital/ directory + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + + + +# Correct MIME types for audio files + + AddType audio/mpeg .mp3 + AddType audio/mp4 .m4a + AddType audio/ogg .ogg + AddType audio/wav .wav + AddType audio/webm .weba + AddType video/mp4 .mp4 + AddType video/webm .webm + AddType video/ogg .ogv + AddType application/pdf .pdf + AddType application/epub+zip .epub + diff --git a/tests/fixtures/htaccess-history/0.7.21.htaccess b/tests/fixtures/htaccess-history/0.7.21.htaccess new file mode 100644 index 000000000..7d5c4b422 --- /dev/null +++ b/tests/fixtures/htaccess-history/0.7.21.htaccess @@ -0,0 +1,168 @@ +# PHP upload limits (required for Digital Library plugin: eBook up to 100MB, audiobook up to 500MB) +# +# NOTE: these php_value directives ONLY take effect when PHP runs as an Apache +# module (mod_php). On php-fpm / FastCGI / CGI / suExec hosting — the common +# case on cPanel and most shared hosts — the blocks are +# skipped and these limits are IGNORED. There the server's own PHP config wins, +# so raise post_max_size and upload_max_filesize via php.ini / MultiPHP INI +# Editor / the php-fpm pool (php_admin_value[...]). The ~30MB admin-UI upgrade +# ZIP needs post_max_size >= 32M; if it's lower the upload is rejected and the +# app surfaces a clear 413 "file too large" message (see CsrfMiddleware). + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + Options -Indexes +FollowSymLinks + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Allow direct access to PUBLIC static files in /uploads/ (covers, events, + # CMS images, branding). + RewriteCond %{REQUEST_URI} ^/uploads/ [NC] + RewriteCond %{REQUEST_URI} !^/uploads/(digital|archives/documents|storage)/ [NC] + RewriteRule ^ - [L] + + # PRIVATE uploaded content — digital-library files, archive documents and + # generic storage — must NOT be served directly. Force them through + # index.php (even when the file exists on disk, so the !-f rule below would + # otherwise skip them) so PrivateModeMiddleware (and any future per-file + # auth) governs access (issue #160). See ProtectedUploadController. + RewriteCond %{REQUEST_URI} ^/uploads/(digital|archives/documents|storage)/ [NC] + RewriteRule ^ index.php [QSA,L] + + # Redirect all requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# === Pinakes performance block (auto-managed by Pinakes 0.7.13+) === +# HTTP compression + long-term caching for static assets. +# Asset URLs are version-busted via ?v=X.Y.Z (see HtmlHelper::asset), so +# cache invalidation happens automatically at every release — immutable+1y +# is safe for fingerprinted assets. +# Every directive is gated by so the file stays valid on +# hosts that don't ship mod_deflate / mod_expires / mod_headers / mod_brotli. + + + AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml \ + text/javascript application/javascript application/x-javascript \ + application/json application/ld+json application/xml application/rss+xml \ + application/atom+xml application/xhtml+xml \ + image/svg+xml font/ttf font/otf font/woff + # Don't re-compress already-compressed payloads + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|webp|ico|woff2?|gz|zip|epub|mp4|webm|mp3|m4a|ogg)$ no-gzip dont-vary + + + + AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css \ + text/javascript application/javascript application/json \ + application/ld+json application/xml image/svg+xml font/ttf font/otf + + + + ExpiresActive On + # 1 year for versioned static assets + ExpiresByType text/css "access plus 1 year" + ExpiresByType application/javascript "access plus 1 year" + ExpiresByType text/javascript "access plus 1 year" + ExpiresByType font/woff2 "access plus 1 year" + ExpiresByType font/woff "access plus 1 year" + ExpiresByType font/ttf "access plus 1 year" + ExpiresByType image/x-icon "access plus 1 year" + # 1 month for content images (book covers, uploaded media) + ExpiresByType image/jpeg "access plus 1 month" + ExpiresByType image/png "access plus 1 month" + ExpiresByType image/webp "access plus 1 month" + ExpiresByType image/gif "access plus 1 month" + ExpiresByType image/svg+xml "access plus 1 month" + + + + # Mark versioned assets as immutable — browsers skip revalidation until TTL. + # ?v= querystring busts the cache on every release, so this is safe. + + Header set Cache-Control "public, max-age=31536000, immutable" + + + Header set Cache-Control "public, max-age=2592000" + + +# === end Pinakes performance block === + +# Security Headers + + # Prevent MIME-type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Enable XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Prevent clickjacking + Header always set X-Frame-Options "SAMEORIGIN" + + # Content Security Policy - restrictive but allows inline styles/scripts for compatibility + # Allows Google Fonts for Inter font family + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https: http: blob:; connect-src 'self'; frame-src 'self' data: blob: about: https://www.openstreetmap.org; child-src 'self' data: blob: about:; frame-ancestors 'self';" + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Block access to sensitive files (defense in depth) +# Even though these files are outside webroot, this prevents misconfiguration attacks + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Audio/Video Streaming Support (for Digital Library plugin) + + # Enable Range Requests for audio/video streaming + # Required for seek functionality in audio players + + Header set Accept-Ranges bytes + # Allow CORS for audio/video files (required for cross-origin playback) + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + Header set Access-Control-Allow-Headers "Range, Content-Type" + Header set Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" + + + # CORS for digital content in uploads/digital/ directory + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + + + +# Correct MIME types for audio files + + AddType audio/mpeg .mp3 + AddType audio/mp4 .m4a + AddType audio/ogg .ogg + AddType audio/wav .wav + AddType audio/webm .weba + AddType video/mp4 .mp4 + AddType video/webm .webm + AddType video/ogg .ogv + AddType application/pdf .pdf + AddType application/epub+zip .epub + diff --git a/tests/fixtures/htaccess-history/0.7.31.htaccess b/tests/fixtures/htaccess-history/0.7.31.htaccess new file mode 100644 index 000000000..28851c243 --- /dev/null +++ b/tests/fixtures/htaccess-history/0.7.31.htaccess @@ -0,0 +1,168 @@ +# PHP upload limits (required for Digital Library plugin: eBook up to 100MB, audiobook up to 500MB) +# +# NOTE: these php_value directives ONLY take effect when PHP runs as an Apache +# module (mod_php). On php-fpm / FastCGI / CGI / suExec hosting — the common +# case on cPanel and most shared hosts — the blocks are +# skipped and these limits are IGNORED. There the server's own PHP config wins, +# so raise post_max_size and upload_max_filesize via php.ini / MultiPHP INI +# Editor / the php-fpm pool (php_admin_value[...]). The ~30MB admin-UI upgrade +# ZIP needs post_max_size >= 32M; if it's lower the upload is rejected and the +# app surfaces a clear 413 "file too large" message (see CsrfMiddleware). + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + Options -Indexes +FollowSymLinks + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Allow direct access to PUBLIC static files in /uploads/ (covers, events, + # CMS images, branding). + RewriteCond %{REQUEST_URI} ^/uploads/ [NC] + RewriteCond %{REQUEST_URI} !^/uploads/(digital|archives/documents|storage)/ [NC] + RewriteRule ^ - [L] + + # PRIVATE uploaded content — digital-library files, archive documents and + # generic storage — must NOT be served directly. Force them through + # index.php (even when the file exists on disk, so the !-f rule below would + # otherwise skip them) so PrivateModeMiddleware (and any future per-file + # auth) governs access (issue #160). See ProtectedUploadController. + RewriteCond %{REQUEST_URI} ^/uploads/(digital|archives/documents|storage)/ [NC] + RewriteRule ^ index.php [QSA,L] + + # Redirect all requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# === Pinakes performance block (auto-managed by Pinakes 0.7.13+) === +# HTTP compression + long-term caching for static assets. +# Asset URLs are version-busted via ?v=X.Y.Z (see HtmlHelper::asset), so +# cache invalidation happens automatically at every release — immutable+1y +# is safe for fingerprinted assets. +# Every directive is gated by so the file stays valid on +# hosts that don't ship mod_deflate / mod_expires / mod_headers / mod_brotli. + + + AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml \ + text/javascript application/javascript application/x-javascript \ + application/json application/ld+json application/xml application/rss+xml \ + application/atom+xml application/xhtml+xml \ + image/svg+xml font/ttf font/otf font/woff + # Don't re-compress already-compressed payloads + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|webp|ico|woff2?|gz|zip|epub|mp4|webm|mp3|m4a|ogg)$ no-gzip dont-vary + + + + AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css \ + text/javascript application/javascript application/json \ + application/ld+json application/xml image/svg+xml font/ttf font/otf + + + + ExpiresActive On + # 1 year for versioned static assets + ExpiresByType text/css "access plus 1 year" + ExpiresByType application/javascript "access plus 1 year" + ExpiresByType text/javascript "access plus 1 year" + ExpiresByType font/woff2 "access plus 1 year" + ExpiresByType font/woff "access plus 1 year" + ExpiresByType font/ttf "access plus 1 year" + ExpiresByType image/x-icon "access plus 1 year" + # 1 month for content images (book covers, uploaded media) + ExpiresByType image/jpeg "access plus 1 month" + ExpiresByType image/png "access plus 1 month" + ExpiresByType image/webp "access plus 1 month" + ExpiresByType image/gif "access plus 1 month" + ExpiresByType image/svg+xml "access plus 1 month" + + + + # Mark versioned assets as immutable — browsers skip revalidation until TTL. + # ?v= querystring busts the cache on every release, so this is safe. + + Header set Cache-Control "public, max-age=31536000, immutable" + + + Header set Cache-Control "public, max-age=2592000" + + +# === end Pinakes performance block === + +# Security Headers + + # Prevent MIME-type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Enable XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Prevent clickjacking + Header always set X-Frame-Options "SAMEORIGIN" + + # Content Security Policy - restrictive but allows inline styles/scripts for compatibility + # Allows Google Fonts for Inter font family + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; img-src 'self' data: https: http: blob:; connect-src 'self'; frame-src 'self' data: blob: about: https://www.openstreetmap.org; child-src 'self' data: blob: about:; frame-ancestors 'self';" + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Block access to sensitive files (defense in depth) +# Even though these files are outside webroot, this prevents misconfiguration attacks + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Audio/Video Streaming Support (for Digital Library plugin) + + # Enable Range Requests for audio/video streaming + # Required for seek functionality in audio players + + Header set Accept-Ranges bytes + # Allow CORS for audio/video files (required for cross-origin playback) + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + Header set Access-Control-Allow-Headers "Range, Content-Type" + Header set Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" + + + # CORS for digital content in uploads/digital/ directory + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + + + +# Correct MIME types for audio files + + AddType audio/mpeg .mp3 + AddType audio/mp4 .m4a + AddType audio/ogg .ogg + AddType audio/wav .wav + AddType audio/webm .weba + AddType video/mp4 .mp4 + AddType video/webm .webm + AddType video/ogg .ogv + AddType application/pdf .pdf + AddType application/epub+zip .epub + diff --git a/tests/fixtures/htaccess-history/0.7.59.htaccess b/tests/fixtures/htaccess-history/0.7.59.htaccess new file mode 100644 index 000000000..7f0089580 --- /dev/null +++ b/tests/fixtures/htaccess-history/0.7.59.htaccess @@ -0,0 +1,168 @@ +# PHP upload limits (required for Digital Library plugin: eBook up to 100MB, audiobook up to 500MB) +# +# NOTE: these php_value directives ONLY take effect when PHP runs as an Apache +# module (mod_php). On php-fpm / FastCGI / CGI / suExec hosting — the common +# case on cPanel and most shared hosts — the blocks are +# skipped and these limits are IGNORED. There the server's own PHP config wins, +# so raise post_max_size and upload_max_filesize via php.ini / MultiPHP INI +# Editor / the php-fpm pool (php_admin_value[...]). The ~30MB admin-UI upgrade +# ZIP needs post_max_size >= 32M; if it's lower the upload is rejected and the +# app surfaces a clear 413 "file too large" message (see CsrfMiddleware). + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + Options -Indexes +FollowSymLinks + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Allow direct access to PUBLIC static files in /uploads/ (covers, events, + # CMS images, branding). + RewriteCond %{REQUEST_URI} ^/uploads/ [NC] + RewriteCond %{REQUEST_URI} !^/uploads/(digital|archives/documents|storage)/ [NC] + RewriteRule ^ - [L] + + # PRIVATE uploaded content — digital-library files, archive documents and + # generic storage — must NOT be served directly. Force them through + # index.php (even when the file exists on disk, so the !-f rule below would + # otherwise skip them) so PrivateModeMiddleware (and any future per-file + # auth) governs access (issue #160). See ProtectedUploadController. + RewriteCond %{REQUEST_URI} ^/uploads/(digital|archives/documents|storage)/ [NC] + RewriteRule ^ index.php [QSA,L] + + # Redirect all requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# === Pinakes performance block (auto-managed by Pinakes 0.7.13+) === +# HTTP compression + long-term caching for static assets. +# Asset URLs are version-busted via ?v=X.Y.Z (see HtmlHelper::asset), so +# cache invalidation happens automatically at every release — immutable+1y +# is safe for fingerprinted assets. +# Every directive is gated by so the file stays valid on +# hosts that don't ship mod_deflate / mod_expires / mod_headers / mod_brotli. + + + AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml \ + text/javascript application/javascript application/x-javascript \ + application/json application/ld+json application/xml application/rss+xml \ + application/atom+xml application/xhtml+xml \ + image/svg+xml font/ttf font/otf font/woff + # Don't re-compress already-compressed payloads + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|webp|ico|woff2?|gz|zip|epub|mp4|webm|mp3|m4a|ogg)$ no-gzip dont-vary + + + + AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css \ + text/javascript application/javascript application/json \ + application/ld+json application/xml image/svg+xml font/ttf font/otf + + + + ExpiresActive On + # 1 year for versioned static assets + ExpiresByType text/css "access plus 1 year" + ExpiresByType application/javascript "access plus 1 year" + ExpiresByType text/javascript "access plus 1 year" + ExpiresByType font/woff2 "access plus 1 year" + ExpiresByType font/woff "access plus 1 year" + ExpiresByType font/ttf "access plus 1 year" + ExpiresByType image/x-icon "access plus 1 year" + # 1 month for content images (book covers, uploaded media) + ExpiresByType image/jpeg "access plus 1 month" + ExpiresByType image/png "access plus 1 month" + ExpiresByType image/webp "access plus 1 month" + ExpiresByType image/gif "access plus 1 month" + ExpiresByType image/svg+xml "access plus 1 month" + + + + # Mark versioned assets as immutable — browsers skip revalidation until TTL. + # ?v= querystring busts the cache on every release, so this is safe. + + Header set Cache-Control "public, max-age=31536000, immutable" + + + Header set Cache-Control "public, max-age=2592000" + + +# === end Pinakes performance block === + +# Security Headers + + # Prevent MIME-type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Enable XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Prevent clickjacking + Header always set X-Frame-Options "SAMEORIGIN" + + # The application emits a per-response nonce-based Content-Security-Policy. + # Do not override it here: a static header would either break inline elements + # or require unsafe-inline for every response. + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Block access to sensitive files (defense in depth) +# Even though these files are outside webroot, this prevents misconfiguration attacks + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Audio/Video Streaming Support (for Digital Library plugin) + + # Enable Range Requests for audio/video streaming + # Required for seek functionality in audio players + + Header set Accept-Ranges bytes + # Allow CORS for audio/video files (required for cross-origin playback) + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + Header set Access-Control-Allow-Headers "Range, Content-Type" + Header set Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" + + + # CORS for digital content in uploads/digital/ directory + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + + + +# Correct MIME types for audio files + + AddType audio/mpeg .mp3 + AddType audio/mp4 .m4a + AddType audio/ogg .ogg + AddType audio/wav .wav + AddType audio/webm .weba + AddType video/mp4 .mp4 + AddType video/webm .webm + AddType video/ogg .ogv + AddType application/pdf .pdf + AddType application/epub+zip .epub + diff --git a/tests/fixtures/htaccess-history/0.7.71-rc.1.htaccess b/tests/fixtures/htaccess-history/0.7.71-rc.1.htaccess new file mode 100644 index 000000000..11a5e9cf7 --- /dev/null +++ b/tests/fixtures/htaccess-history/0.7.71-rc.1.htaccess @@ -0,0 +1,191 @@ +# PHP upload limits (required for Digital Library plugin: eBook up to 100MB, audiobook up to 500MB) +# +# NOTE: these php_value directives ONLY take effect when PHP runs as an Apache +# module (mod_php). On php-fpm / FastCGI / CGI / suExec hosting — the common +# case on cPanel and most shared hosts — the blocks are +# skipped and these limits are IGNORED. There the server's own PHP config wins, +# so raise post_max_size and upload_max_filesize via php.ini / MultiPHP INI +# Editor / the php-fpm pool (php_admin_value[...]). The ~30MB admin-UI upgrade +# ZIP needs post_max_size >= 32M; if it's lower the upload is rejected and the +# app surfaces a clear 413 "file too large" message (see CsrfMiddleware). + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + Options -Indexes +FollowSymLinks + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # === Pinakes LiteSpeed privacy bypass === + # Application response headers are too late + # when LSCache can answer before PHP, so any mutation, Authorization header + # or visitor-specific cookie prevents a cache hit at the web-server layer. + # A sole validated locale cookie is safe and is varied independently at + # lookup time and by the application's X-LiteSpeed-Vary response header. + + RewriteRule .* - [E=Cache-Vary:pinakes_locale] + + RewriteCond %{REQUEST_METHOD} !^(GET|HEAD)$ [NC] + RewriteRule .* - [E=Cache-Control:no-cache] + + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=Cache-Control:no-cache] + + RewriteCond %{HTTP:Cookie} !^$ + RewriteCond %{HTTP:Cookie} !^\s*pinakes_locale=[A-Za-z]{2}_[A-Za-z]{2}\s*;?\s*$ [NC] + RewriteRule .* - [E=Cache-Control:no-cache] + + # === end Pinakes LiteSpeed privacy bypass === + + # PRIVATE uploaded content — digital-library files, archive documents and + # generic storage — must NOT be served directly. Force them through + # index.php (even when the file exists on disk, so the !-f rule below would + # otherwise skip them) so PrivateModeMiddleware (and any future per-file + # auth) governs access (issue #160). See ProtectedUploadController. + # A relative RewriteRule pattern is matched against the per-directory + # stripped path, so it stays correct under a subdirectory install where + # %{REQUEST_URI} carries the extra prefix (an anchored ^/uploads/ would not + # match there and would leak the private files). Private rule first so its + # [L] wins before the public catch-all below. + RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [QSA,L] + + # Allow direct access to the remaining PUBLIC static files in /uploads/ + # (covers, events, CMS images, branding). + RewriteRule ^uploads/ - [L] + + # Redirect all requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# === Pinakes performance block (auto-managed by Pinakes 0.7.13+) === +# HTTP compression + long-term caching for static assets. +# Asset URLs are version-busted via ?v=X.Y.Z (see HtmlHelper::asset), so +# cache invalidation happens automatically at every release — immutable+1y +# is safe for fingerprinted assets. +# Every directive is gated by so the file stays valid on +# hosts that don't ship mod_deflate / mod_expires / mod_headers / mod_brotli. + + + AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml \ + text/javascript application/javascript application/x-javascript \ + application/json application/ld+json application/xml application/rss+xml \ + application/atom+xml application/xhtml+xml \ + image/svg+xml font/ttf font/otf font/woff + # Don't re-compress already-compressed payloads + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|webp|ico|woff2?|gz|zip|epub|mp4|webm|mp3|m4a|ogg)$ no-gzip dont-vary + + + + AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css \ + text/javascript application/javascript application/json \ + application/ld+json application/xml image/svg+xml font/ttf font/otf + + + + ExpiresActive On + # 1 year for versioned static assets + ExpiresByType text/css "access plus 1 year" + ExpiresByType application/javascript "access plus 1 year" + ExpiresByType text/javascript "access plus 1 year" + ExpiresByType font/woff2 "access plus 1 year" + ExpiresByType font/woff "access plus 1 year" + ExpiresByType font/ttf "access plus 1 year" + ExpiresByType image/x-icon "access plus 1 year" + # 1 month for content images (book covers, uploaded media) + ExpiresByType image/jpeg "access plus 1 month" + ExpiresByType image/png "access plus 1 month" + ExpiresByType image/webp "access plus 1 month" + ExpiresByType image/gif "access plus 1 month" + ExpiresByType image/svg+xml "access plus 1 month" + + + + # Mark versioned assets as immutable — browsers skip revalidation until TTL. + # ?v= querystring busts the cache on every release, so this is safe. + + Header set Cache-Control "public, max-age=31536000, immutable" + + + Header set Cache-Control "public, max-age=2592000" + + +# === end Pinakes performance block === + +# Security Headers + + # Prevent MIME-type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Enable XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Prevent clickjacking + Header always set X-Frame-Options "SAMEORIGIN" + + # The application emits a per-response nonce-based Content-Security-Policy. + # Do not override it here: a static header would either break inline elements + # or require unsafe-inline for every response. + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Block access to sensitive files (defense in depth) +# Even though these files are outside webroot, this prevents misconfiguration attacks + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Audio/Video Streaming Support (for Digital Library plugin) + + # Enable Range Requests for audio/video streaming + # Required for seek functionality in audio players + + Header set Accept-Ranges bytes + # Allow CORS for audio/video files (required for cross-origin playback) + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + Header set Access-Control-Allow-Headers "Range, Content-Type" + Header set Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" + + + # CORS for digital content in uploads/digital/ directory + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + + + +# Correct MIME types for audio files + + AddType audio/mpeg .mp3 + AddType audio/mp4 .m4a + AddType audio/ogg .ogg + AddType audio/wav .wav + AddType audio/webm .weba + AddType video/mp4 .mp4 + AddType video/webm .webm + AddType video/ogg .ogv + AddType application/pdf .pdf + AddType application/epub+zip .epub + diff --git a/tests/fixtures/htaccess-history/0.7.71.htaccess b/tests/fixtures/htaccess-history/0.7.71.htaccess new file mode 100644 index 000000000..39fde00bf --- /dev/null +++ b/tests/fixtures/htaccess-history/0.7.71.htaccess @@ -0,0 +1,196 @@ +# PHP upload limits (required for Digital Library plugin: eBook up to 100MB, audiobook up to 500MB) +# +# NOTE: these php_value directives ONLY take effect when PHP runs as an Apache +# module (mod_php). On php-fpm / FastCGI / CGI / suExec hosting — the common +# case on cPanel and most shared hosts — the blocks are +# skipped and these limits are IGNORED. There the server's own PHP config wins, +# so raise post_max_size and upload_max_filesize via php.ini / MultiPHP INI +# Editor / the php-fpm pool (php_admin_value[...]). The ~30MB admin-UI upgrade +# ZIP needs post_max_size >= 32M; if it's lower the upload is rejected and the +# app surfaces a clear 413 "file too large" message (see CsrfMiddleware). + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + Options -Indexes +FollowSymLinks + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # === Pinakes LiteSpeed privacy bypass === + # Application response headers are too late + # when LSCache can answer before PHP, so any mutation, Authorization header + # or visitor-specific cookie prevents a cache hit at the web-server layer. + # A sole validated locale cookie is safe and is varied independently at + # lookup time and by the application's X-LiteSpeed-Vary response header. + + # Enable LSCache request lookup/store for this vhost. Without it LSWS + # ignores the X-LiteSpeed-Cache-Control response headers and no page is + # ever served from edge cache. + CacheLookup on + + RewriteRule .* - [E=Cache-Vary:pinakes_locale] + + RewriteCond %{REQUEST_METHOD} !^(GET|HEAD)$ [NC] + RewriteRule .* - [E=Cache-Control:no-cache] + + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=Cache-Control:no-cache] + + RewriteCond %{HTTP:Cookie} !^$ + RewriteCond %{HTTP:Cookie} !^\s*pinakes_locale=[A-Za-z]{2}_[A-Za-z]{2}\s*;?\s*$ [NC] + RewriteRule .* - [E=Cache-Control:no-cache] + + # === end Pinakes LiteSpeed privacy bypass === + + # PRIVATE uploaded content — digital-library files, archive documents and + # generic storage — must NOT be served directly. Force them through + # index.php (even when the file exists on disk, so the !-f rule below would + # otherwise skip them) so PrivateModeMiddleware (and any future per-file + # auth) governs access (issue #160). See ProtectedUploadController. + # A relative RewriteRule pattern is matched against the per-directory + # stripped path, so it stays correct under a subdirectory install where + # %{REQUEST_URI} carries the extra prefix (an anchored ^/uploads/ would not + # match there and would leak the private files). Private rule first so its + # [L] wins before the public catch-all below. + RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [QSA,L] + + # Allow direct access to the remaining PUBLIC static files in /uploads/ + # (covers, events, CMS images, branding). + RewriteRule ^uploads/ - [L] + + # Redirect all requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# === Pinakes performance block (auto-managed by Pinakes 0.7.13+) === +# HTTP compression + long-term caching for static assets. +# Asset URLs are version-busted via ?v=X.Y.Z (see HtmlHelper::asset), so +# cache invalidation happens automatically at every release — immutable+1y +# is safe for fingerprinted assets. +# Every directive is gated by so the file stays valid on +# hosts that don't ship mod_deflate / mod_expires / mod_headers / mod_brotli. + + + AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml \ + text/javascript application/javascript application/x-javascript \ + application/json application/ld+json application/xml application/rss+xml \ + application/atom+xml application/xhtml+xml \ + image/svg+xml font/ttf font/otf font/woff + # Don't re-compress already-compressed payloads + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|webp|ico|woff2?|gz|zip|epub|mp4|webm|mp3|m4a|ogg)$ no-gzip dont-vary + + + + AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css \ + text/javascript application/javascript application/json \ + application/ld+json application/xml image/svg+xml font/ttf font/otf + + + + ExpiresActive On + # 1 year for versioned static assets + ExpiresByType text/css "access plus 1 year" + ExpiresByType application/javascript "access plus 1 year" + ExpiresByType text/javascript "access plus 1 year" + ExpiresByType font/woff2 "access plus 1 year" + ExpiresByType font/woff "access plus 1 year" + ExpiresByType font/ttf "access plus 1 year" + ExpiresByType image/x-icon "access plus 1 year" + # 1 month for content images (book covers, uploaded media) + ExpiresByType image/jpeg "access plus 1 month" + ExpiresByType image/png "access plus 1 month" + ExpiresByType image/webp "access plus 1 month" + ExpiresByType image/gif "access plus 1 month" + ExpiresByType image/svg+xml "access plus 1 month" + + + + # Mark versioned assets as immutable — browsers skip revalidation until TTL. + # ?v= querystring busts the cache on every release, so this is safe. + + Header set Cache-Control "public, max-age=31536000, immutable" + + + Header set Cache-Control "public, max-age=2592000" + + +# === end Pinakes performance block === + +# Security Headers + + # Prevent MIME-type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Enable XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Prevent clickjacking + Header always set X-Frame-Options "SAMEORIGIN" + + # The application emits a per-response nonce-based Content-Security-Policy. + # Do not override it here: a static header would either break inline elements + # or require unsafe-inline for every response. + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Block access to sensitive files (defense in depth) +# Even though these files are outside webroot, this prevents misconfiguration attacks + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Audio/Video Streaming Support (for Digital Library plugin) + + # Enable Range Requests for audio/video streaming + # Required for seek functionality in audio players + + Header set Accept-Ranges bytes + # Allow CORS for audio/video files (required for cross-origin playback) + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + Header set Access-Control-Allow-Headers "Range, Content-Type" + Header set Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" + + + # CORS for digital content in uploads/digital/ directory + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + + + +# Correct MIME types for audio files + + AddType audio/mpeg .mp3 + AddType audio/mp4 .m4a + AddType audio/ogg .ogg + AddType audio/wav .wav + AddType audio/webm .weba + AddType video/mp4 .mp4 + AddType video/webm .webm + AddType video/ogg .ogv + AddType application/pdf .pdf + AddType application/epub+zip .epub + diff --git a/tests/fixtures/htaccess-history/0.8.6-rc.1.htaccess b/tests/fixtures/htaccess-history/0.8.6-rc.1.htaccess new file mode 100644 index 000000000..e23ad86c1 --- /dev/null +++ b/tests/fixtures/htaccess-history/0.8.6-rc.1.htaccess @@ -0,0 +1,198 @@ +# PHP upload limits (required for Digital Library plugin: eBook up to 100MB, audiobook up to 500MB) +# +# NOTE: these php_value directives ONLY take effect when PHP runs as an Apache +# module (mod_php). On php-fpm / FastCGI / CGI / suExec hosting — the common +# case on cPanel and most shared hosts — the blocks are +# skipped and these limits are IGNORED. There the server's own PHP config wins, +# so raise post_max_size and upload_max_filesize via php.ini / MultiPHP INI +# Editor / the php-fpm pool (php_admin_value[...]). The ~30MB admin-UI upgrade +# ZIP needs post_max_size >= 32M; if it's lower the upload is rejected and the +# app surfaces a clear 413 "file too large" message (see CsrfMiddleware). + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + php_value upload_max_filesize 512M + php_value post_max_size 520M + php_value max_execution_time 300 + php_value max_input_time 300 + + + Options -Indexes +FollowSymLinks + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # === Pinakes LiteSpeed privacy bypass === + # Application response headers are too late + # when LSCache can answer before PHP, so any mutation, Authorization header + # or visitor-specific cookie prevents a cache hit at the web-server layer. + # A sole validated locale cookie is safe and is varied independently at + # lookup time and by the application's X-LiteSpeed-Vary response header. + + # Enable LSCache request lookup/store for this vhost. Without it LSWS + # ignores the X-LiteSpeed-Cache-Control response headers and no page is + # ever served from edge cache. + CacheLookup on + + RewriteRule .* - [E=Cache-Vary:pinakes_locale] + + RewriteCond %{REQUEST_METHOD} !^(GET|HEAD)$ [NC] + RewriteRule .* - [E=Cache-Control:no-cache] + + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=Cache-Control:no-cache] + + RewriteCond %{HTTP:Cookie} !^$ + RewriteCond %{HTTP:Cookie} !^\s*pinakes_locale=[A-Za-z]{2}_[A-Za-z]{2}\s*;?\s*$ [NC] + RewriteRule .* - [E=Cache-Control:no-cache] + + # === end Pinakes LiteSpeed privacy bypass === + + # PRIVATE uploaded content — digital-library files, archive documents and + # generic storage — must NOT be served directly. Force them through + # index.php (even when the file exists on disk, so the !-f rule below would + # otherwise skip them) so PrivateModeMiddleware (and any future per-file + # auth) governs access (issue #160). See ProtectedUploadController. + # A relative RewriteRule pattern is matched against the per-directory + # stripped path, so it stays correct under a subdirectory install where + # %{REQUEST_URI} carries the extra prefix (an anchored ^/uploads/ would not + # match there and would leak the private files). Private rule first so its + # [L] wins before the public catch-all below. + # [NC]: on a case-insensitive disk /uploads/Digital/x is the same file as + # /uploads/digital/x, and must not slip past to the public rule below (#480). + RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [NC,QSA,L] + + # Allow direct access to the remaining PUBLIC static files in /uploads/ + # (covers, events, CMS images, branding). + RewriteRule ^uploads/ - [L] + + # Redirect all requests to index.php + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + +# === Pinakes performance block (auto-managed by Pinakes 0.7.13+) === +# HTTP compression + long-term caching for static assets. +# Asset URLs are version-busted via ?v=X.Y.Z (see HtmlHelper::asset), so +# cache invalidation happens automatically at every release — immutable+1y +# is safe for fingerprinted assets. +# Every directive is gated by so the file stays valid on +# hosts that don't ship mod_deflate / mod_expires / mod_headers / mod_brotli. + + + AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml \ + text/javascript application/javascript application/x-javascript \ + application/json application/ld+json application/xml application/rss+xml \ + application/atom+xml application/xhtml+xml \ + image/svg+xml font/ttf font/otf font/woff + # Don't re-compress already-compressed payloads + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|webp|ico|woff2?|gz|zip|epub|pdf|mp4|webm|mp3|m4a|ogg|wav)$ no-gzip dont-vary + + + + AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css \ + text/javascript application/javascript application/json \ + application/ld+json application/xml image/svg+xml font/ttf font/otf + + + + ExpiresActive On + # 1 year for versioned static assets + ExpiresByType text/css "access plus 1 year" + ExpiresByType application/javascript "access plus 1 year" + ExpiresByType text/javascript "access plus 1 year" + ExpiresByType font/woff2 "access plus 1 year" + ExpiresByType font/woff "access plus 1 year" + ExpiresByType font/ttf "access plus 1 year" + ExpiresByType image/x-icon "access plus 1 year" + # 1 month for content images (book covers, uploaded media) + ExpiresByType image/jpeg "access plus 1 month" + ExpiresByType image/png "access plus 1 month" + ExpiresByType image/webp "access plus 1 month" + ExpiresByType image/gif "access plus 1 month" + ExpiresByType image/svg+xml "access plus 1 month" + + + + # Mark versioned assets as immutable — browsers skip revalidation until TTL. + # ?v= querystring busts the cache on every release, so this is safe. + + Header set Cache-Control "public, max-age=31536000, immutable" + + + Header set Cache-Control "public, max-age=2592000" + + +# === end Pinakes performance block === + +# Security Headers + + # Prevent MIME-type sniffing + Header always set X-Content-Type-Options "nosniff" + + # Enable XSS protection + Header always set X-XSS-Protection "1; mode=block" + + # Prevent clickjacking + Header always set X-Frame-Options "SAMEORIGIN" + + # The application emits a per-response nonce-based Content-Security-Policy. + # Do not override it here: a static header would either break inline elements + # or require unsafe-inline for every response. + + # Referrer policy + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Block access to sensitive files (defense in depth) +# Even though these files are outside webroot, this prevents misconfiguration attacks + + + Require all denied + + + Order deny,allow + Deny from all + + + +# Audio/Video Streaming Support (for Digital Library plugin) + + # Enable Range Requests for audio/video streaming + # Required for seek functionality in audio players + + Header set Accept-Ranges bytes + # Allow CORS for audio/video files (required for cross-origin playback) + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + Header set Access-Control-Allow-Headers "Range, Content-Type" + Header set Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges" + + + # CORS for digital content in uploads/digital/ directory + + Header set Access-Control-Allow-Origin "*" + Header set Access-Control-Allow-Methods "GET, OPTIONS" + + + +# Correct MIME types for audio files + + AddType audio/mpeg .mp3 + AddType audio/mp4 .m4a + AddType audio/ogg .ogg + AddType audio/wav .wav + AddType audio/webm .weba + AddType video/mp4 .mp4 + AddType video/webm .webm + AddType video/ogg .ogv + AddType application/pdf .pdf + AddType application/epub+zip .epub + diff --git a/tests/htaccess-heal-apache-480.test.sh b/tests/htaccess-heal-apache-480.test.sh new file mode 100644 index 000000000..5c0eb825e --- /dev/null +++ b/tests/htaccess-heal-apache-480.test.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Issue #480 — every public/.htaccess Pinakes ever shipped, on a real Apache, before and after +# the 0.8.6 heal (App\Support\PrivateUploadsRewriteHeal). +# +# For each file in tests/fixtures/htaccess-history/ a throwaway Apache serves a site whose +# uploads/digital, uploads/archives/documents and uploads/copertine hold real files and whose +# index.php is a plain marker, at the domain root and in a subfolder. After the heal: +# - every private upload (digital, archive documents, the /uploads/Digital/ spelling) must +# reach index.php, never the file on disk; +# - a public upload (a cover) must still be served as a static file. +# "before" is printed for information: every format older than 0.8.6 leaks somewhere. +# +# Needs httpd with mod_rewrite (Homebrew: /opt/homebrew/bin/httpd). Uses port 18480. +# Run: bash tests/htaccess-heal-apache-480.test.sh +set -u +REPO="$(cd "$(dirname "$0")/.." && pwd)" +HTTPD="${HTTPD:-$(command -v httpd || true)}" +MAIN_CONF="${HTTPD_MAIN_CONF:-/opt/homebrew/etc/httpd/httpd.conf}" +PORT="${PORT:-18480}" +if [ -z "$HTTPD" ] || [ ! -f "$MAIN_CONF" ]; then + echo "SKIP: no httpd / $MAIN_CONF (set HTTPD and HTTPD_MAIN_CONF)" + exit 0 +fi +W="$(mktemp -d)" +trap '"$HTTPD" -f "$W/httpd.conf" -k stop >/dev/null 2>&1; rm -rf "$W"' EXIT +grep -E "^LoadModule" "$MAIN_CONF" > "$W/modules.conf" +SERVER_ROOT="$("$HTTPD" -V 2>/dev/null | sed -n 's/.*HTTPD_ROOT="\(.*\)"/\1/p')" +MIME="$(dirname "$MAIN_CONF")/mime.types" + +site() { # $1 docroot, $2 .htaccess + mkdir -p "$1/uploads/digital" "$1/uploads/archives/documents" "$1/uploads/copertine" + printf 'SECRET-DIGITAL' > "$1/uploads/digital/secret.pdf" + printf 'SECRET-ARCHIVE' > "$1/uploads/archives/documents/doc.pdf" + printf 'PUBLIC-COVER' > "$1/uploads/copertine/a.jpg" + printf 'ROUTED-TO-INDEX' > "$1/index.php" + cp "$2" "$1/.htaccess" +} +probe() { # $1 path -> LEAK | php | static | httpNNN + local body code + body="$(curl -s "http://127.0.0.1:$PORT$1")" + code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT$1")" + case "$body" in + SECRET-*) echo LEAK ;; + ROUTED-TO-INDEX) echo php ;; + PUBLIC-COVER) echo static ;; + *) echo "http$code" ;; + esac +} + +PRIVATE=(/uploads/digital/secret.pdf /uploads/Digital/secret.pdf /uploads/archives/documents/doc.pdf + /sub/uploads/digital/secret.pdf /sub/uploads/Digital/secret.pdf /sub/uploads/archives/documents/doc.pdf) +PUBLIC=(/uploads/copertine/a.jpg /sub/uploads/copertine/a.jpg) +fail=0 +checks=0 +for f in "$REPO"/tests/fixtures/htaccess-history/*.htaccess; do + v="$(basename "$f" .htaccess)" + php -r 'require $argv[1] . "/vendor/autoload.php"; echo App\Support\PrivateUploadsRewriteHeal::apply(file_get_contents($argv[2]));' "$REPO" "$f" > "$W/healed-$v" + for state in before after; do + src="$f"; [ "$state" = after ] && src="$W/healed-$v" + D="$W/site-$v-$state"; site "$D" "$src"; site "$D/sub" "$src" + cat > "$W/httpd.conf" < + AllowOverride All + Require all granted + +EOF + "$HTTPD" -f "$W/httpd.conf" -k start || { echo "FAIL $v: httpd did not start"; exit 1; } + sleep 0.6 + line="" + for p in "${PRIVATE[@]}"; do + r="$(probe "$p")"; line="$line $r" + if [ "$state" = after ]; then + checks=$((checks + 1)) + # On a case-sensitive disk /uploads/Digital/ is simply another, missing path. + if [ "$r" != php ] && ! { [[ "$p" == *Digital* ]] && [ "$r" = http404 ]; }; then + echo " FAIL $v after heal: $p -> $r"; fail=$((fail + 1)) + fi + fi + done + for p in "${PUBLIC[@]}"; do + r="$(probe "$p")"; line="$line $r" + if [ "$state" = after ]; then + checks=$((checks + 1)) + [ "$r" = static ] || { echo " FAIL $v after heal: $p -> $r (public file not served)"; fail=$((fail + 1)); } + fi + done + printf ' %-12s %-6s%s\n' "$v" "$state" "$line" + "$HTTPD" -f "$W/httpd.conf" -k stop >/dev/null 2>&1 + sleep 0.6 + done +done +echo +if [ "$fail" -eq 0 ]; then + echo "ALL $checks PASS (every shipped .htaccess, after the heal, on a real Apache)" +else + echo "$fail FAILED of $checks" +fi +exit $((fail > 0)) diff --git a/tests/htaccess-heal-history-480.unit.php b/tests/htaccess-heal-history-480.unit.php new file mode 100644 index 000000000..d796b6e8e --- /dev/null +++ b/tests/htaccess-heal-history-480.unit.php @@ -0,0 +1,342 @@ + array_values(array_filter(preg_split('/\r?\n/', $s) ?: [], + static fn (string $l): bool => trim($l) !== '' && !str_starts_with(ltrim($l), '#') + && !preg_match('/^\s*Rewrite(Rule|Cond)\b/', $l) && !str_contains($l, 'no-gzip'))); + +$files = glob(__DIR__ . '/fixtures/htaccess-history/*.htaccess') ?: []; +natsort($files); +$check(count($files) >= 13, count($files) . ' shipped versions of public/.htaccess'); +foreach ($files as $file) { + $version = basename($file, '.htaccess'); + $old = (string) file_get_contents($file); + $new = Heal::apply($old); + $hasUploads = str_contains($old, 'uploads'); + if (!$hasUploads) { + $check($new === $old, "{$version}: no /uploads/ rule at all, left as it is"); + continue; + } + $check(Heal::safe($new), "{$version}: private uploads go through index.php with [NC], before the public /uploads/ rule"); + $check(!preg_match('/^\s*RewriteCond %\{REQUEST_URI\} \^\/uploads\//m', $new), "{$version}: no anchored REQUEST_URI rule left (it never matches under a subfolder)"); + $check($rest($new) === $rest($old), "{$version}: every other directive is untouched"); + $check(Heal::apply($new) === $new, "{$version}: a second upgrade changes nothing"); + $check(str_contains($new, 'epub|pdf|mp4|webm|mp3|m4a|ogg|wav)$ no-gzip') || !str_contains($old, 'no-gzip'), "{$version}: PDF and WAV are never compressed"); +} + +// Saved on Windows: CRLF line ends are recognised, and kept in the lines the heal adds. +foreach ($files as $file) { + $version = basename($file, '.htaccess'); + $crlf = str_replace("\n", "\r\n", str_replace("\r\n", "\n", (string) file_get_contents($file))); + if (!str_contains($crlf, 'uploads')) { + continue; + } + $healed = Heal::apply($crlf); + $check(Heal::safe($healed) && preg_match('/(? str_replace($privateLine, " {$public}\n{$privateLine}", $healed); +$check(str_contains($healed, $privateLine) && Heal::safe($healed), 'the healed 0.7.71 file has the private line the next checks put rules above'); +foreach (['RewriteRule ^uploads/ - [L,NC]', 'RewriteRule ^uploads - [L]', 'RewriteRule ^uploads/ - [L]', 'rewriterule ^uploads/ - [l]', + 'RewriteRule ^/?uploads/ - [last]', 'RewriteRule ^uploads/.*$ - [END]', 'RewriteRule uploads/ - [QSA,L]', 'RewriteRule ^Uploads/ - [NC,L]', + 'RewriteRule "^uploads/" - [L]', 'RewriteRule ^uploads/(?!digital/) - [L]', 'RewriteRule ^uploads/ - [S=1]', + "RewriteCond %{REQUEST_URI} ^/uploads [NC]\n RewriteRule ^ - [L]", "RewriteCond %{HTTP_HOST} ^cdn\\. [NC]\n RewriteRule .* - [L]", + // Patterns that never name /uploads/ still reach every private file with that ending or shape. + 'RewriteRule \.epub$ - [L]', 'RewriteRule \.(pdf|mp3)$ - [L,NC]', 'RewriteRule ^(.*)$ - [L]', 'RewriteRule ^.*/a\.pdf$ - [END]', + 'RewriteRule \.(epub|mp3|m4a|ogg|wav)$ - [L]', 'RewriteRule \.(jpe?g|png|webp)$ - [NC,L]', 'RewriteRule ^uploads/digital/.+\.epub$ - [L]', + // A literal start that leads into a private folder, or one cut short by a quantifier. + 'RewriteRule ^uploads/digital - [L]', 'RewriteRule ^uploads/storage/public/ - [L]', 'RewriteRule ^uploadss?/ - [L]', 'RewriteRule ^UPLOADS/Digital/ - [L]', + // A safe-looking start with an unanchored branch after "|", at any depth. + 'RewriteRule ^assets/|uploads/ - [L]', 'RewriteRule ^thumbs/|\.pdf$ - [NC,L]', 'RewriteRule ^(assets|uploads)/ - [L]', + // Without NC, /uploads/Digital/ skips a lowercase exclusion on a case-insensitive disk. + 'RewriteRule !^uploads/ - [L]', + // Unprovable without reasoning about the regex, so reported: a lookahead is no proof. + 'RewriteRule ^uploads/(?!digital/|archives/documents/|storage/) - [NC,L]'] as $public) { + $check(!Heal::safe($publicAbove($public)), 'a public rule above the private one is not safe: ' . str_replace("\n ", ' + ', $public)); +} +foreach (['RewriteRule ^uploads/ - [NC]' => 'a passthrough without L goes on to the private rule below', + 'RewriteRule \.epub$ - [NC]' => 'a passthrough on an ending without L', + 'RewriteRule ^uploads/thumbs/ - [L]' => 'a passthrough on a public subfolder only', + 'RewriteRule ^uploads/digitalx/ - [L]' => 'a folder that only starts like a private one', + 'RewriteRule ^uploadsarchive/ - [L]' => 'a folder whose name only starts with "uploads"', + 'RewriteRule ^assets/.*\.css$ - [L]' => 'a passthrough outside /uploads/', + 'RewriteRule .* - [E=MINE:1]' => 'an E= rule like the shipped ones'] as $public => $why) { + $check(Heal::safe($publicAbove($public)), "{$why} is safe: {$public}"); +} + +// A rule the operator wrote is theirs. +$a = (string) file_get_contents(__DIR__ . '/fixtures/htaccess-history/0.7.13.htaccess'); +$edited = str_replace("RewriteCond %{REQUEST_URI} ^/uploads/ [NC]\n RewriteRule ^ - [L]", "RewriteCond %{REQUEST_URI} ^/uploads/ [NC]\n RewriteCond %{HTTP_HOST} ^cdn\\. [NC]\n RewriteRule ^ - [L]", $a); +file_put_contents($path, $edited); +$check(!Heal::heal($path, $dir . '/backups') && file_get_contents($path) !== false && !Heal::safe((string) file_get_contents($path)), + 'heal() declining an edited rule returns false, so the upgrade logs that private uploads may still leak'); +array_map('unlink', glob($dir . '/backups/*') ?: []); +@rmdir($dir . '/backups'); +@unlink($path); +@rmdir($dir); +$check($edited !== $a && !Heal::safe(Heal::apply($edited)) && preg_replace('/^.*no-gzip.*$/m', '', Heal::apply($edited)) === preg_replace('/^.*no-gzip.*$/m', '', $edited), + 'an /uploads/ rule the operator edited is left alone'); +$b = (string) file_get_contents(__DIR__ . '/fixtures/htaccess-history/0.7.21.htaccess'); +$half = preg_replace('/^\s*RewriteCond %\{REQUEST_URI\} \^\/uploads\/\(digital\|archives\/documents\|storage\)\/ \[NC\]\r?\n\s*RewriteRule \^ index\.php \[QSA,L\]\r?\n/m', '', $b); +$check($half !== $b && preg_replace('/^.*no-gzip.*$/m', '', Heal::apply((string) $half)) === preg_replace('/^.*no-gzip.*$/m', '', (string) $half), + 'a 0.7.18-style file missing its private half is not rewritten into an unsafe order'); + +// An operator RewriteCond ABOVE a shipped block binds to the first RewriteRule below it, across +// comments, blank lines and any other directive (only a RewriteRule takes the pending +// conditions). Splicing the private rule there would make it conditional (and on +// every other host the public rule serves private files from disk), so the block stays as it +// is and the file is reported as not safe. +$rules = static fn (string $s): array => array_values(preg_grep('/^\s*Rewrite(Rule|Cond)\b/', preg_split('/\r?\n/', $s) ?: []) ?: []); +$condAbove = static fn (string $s, string $anchor, string $between): string + => (string) preg_replace('/' . preg_quote($anchor, '/') . '/', " RewriteCond %{HTTP_HOST} ^cdn\\\\. [NC]\n" . $between . $anchor, $s, 1); +$healReports = static function (string $content): bool { + $d = sys_get_temp_dir() . '/htaccess-heal-' . bin2hex(random_bytes(4)); + mkdir($d); + file_put_contents($d . '/.htaccess', $content); + $ok = Heal::heal($d . '/.htaccess', $d . '/backups'); + array_map('unlink', glob($d . '/backups/*') ?: []); + @rmdir($d . '/backups'); + @unlink($d . '/.htaccess'); + @rmdir($d); + return $ok; +}; +$fixture = static fn (string $v): string => (string) file_get_contents(__DIR__ . "/fixtures/htaccess-history/{$v}.htaccess"); +$cases = [ + '0.7.13 (A)' => [$fixture('0.7.13'), " RewriteCond %{REQUEST_URI} ^/uploads/ [NC]\n RewriteRule ^ - [L]"], + '0.7.13 (A), above the comment' => [$fixture('0.7.13'), ' # Allow direct access to static files in /uploads/ directory'], + '0.7.21 (B passthrough)' => [$fixture('0.7.21'), " RewriteCond %{REQUEST_URI} ^/uploads/ [NC]\n RewriteCond %{REQUEST_URI} !^"], + '0.7.21 (B passthrough), above the comment' => [$fixture('0.7.21'), ' # Allow direct access to PUBLIC static files in /uploads/'], + '0.7.21 (B private pair)' => [$fixture('0.7.21'), ' RewriteCond %{REQUEST_URI} ^/uploads/(digital'], + '0.7.21 (B private pair), above the comment' => [$fixture('0.7.21'), ' # PRIVATE uploaded content'], + '0.7.71 (C)' => [$fixture('0.7.71'), ' RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [QSA,L]'], +]; +$betweens = [ + 'directly above' => '', + 'with a comment between' => " # operator note\n", + 'with a blank line between' => "\n", + 'with a SetEnvIf between' => " SetEnvIf Request_URI x foo=1\n", + 'with Options and RewriteBase between' => " Options -Indexes\n RewriteBase /\n", +]; +foreach ($cases as $label => [$base, $anchor]) { + foreach ($betweens as $how => $between) { + $edited = $condAbove($base, $anchor, $between); + $healed = Heal::apply($edited); + $check($edited !== $base && $rules($healed) === $rules($edited) && !Heal::safe($healed) && !$healReports($edited), + "{$label}: a RewriteCond {$how} is not moved onto the private rule, and heal() reports the file"); + } +} +// A heal whose rewritten rules would still not be safe (an operator passthrough above the +// private rule) keeps every rewrite rule as it was, but the no-gzip list does not depend on +// them: PDF and WAV still join it, and the second pass changes nothing. +$publicAbove = [ + '0.7.71 (C) with "^uploads/ - [L,NC]" above the private rule' => ['0.7.71', ' RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [QSA,L]', " RewriteRule ^uploads/ - [L,NC]\n"], + '0.7.71 (C) with "\.pdf$ - [L]" above the private rule' => ['0.7.71', ' RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [QSA,L]', " RewriteRule \\.pdf$ - [L]\n"], + '0.7.13 (A) with "^uploads/ - [L]" above the block' => ['0.7.13', ' # Allow direct access to static files in /uploads/ directory', " RewriteRule ^uploads/ - [L]\n"], +]; +foreach ($publicAbove as $label => [$v, $anchor, $insert]) { + $edited = (string) preg_replace('/' . preg_quote($anchor, '/') . '/', $insert . $anchor, $fixture($v), 1); + $healed = Heal::apply($edited); + $check($edited !== $fixture($v) && $rules($healed) === $rules($edited) && !Heal::safe($healed) + && str_contains($healed, 'epub|pdf|mp4|webm|mp3|m4a|ogg|wav)$ no-gzip') && Heal::apply($healed) === $healed, + "{$label}: the rewrite rules stay, PDF and WAV still join the no-gzip list"); +} +// On disk: that heal writes only the no-gzip line, keeps a backup, and still returns false so +// the caller logs the file; the next run finds nothing to do and writes nothing. +$d = sys_get_temp_dir() . '/htaccess-heal-' . bin2hex(random_bytes(4)); +mkdir($d); +[$v, $anchor, $insert] = reset($publicAbove); +$declined = (string) preg_replace('/' . preg_quote($anchor, '/') . '/', $insert . $anchor, $fixture($v), 1); +file_put_contents($d . '/.htaccess', $declined); +$first = Heal::heal($d . '/.htaccess', $d . '/backups'); +$written = (string) file_get_contents($d . '/.htaccess'); +$backups = count(glob($d . '/backups/*') ?: []); +$second = Heal::heal($d . '/.htaccess', $d . '/backups'); +$check(!$first && !$second && $rules($written) === $rules($declined) + && str_contains($written, 'epub|pdf|mp4|webm|mp3|m4a|ogg|wav)$ no-gzip') && !str_contains($declined, '|pdf|') + && $backups === 1 && count(glob($d . '/backups/*') ?: []) === 1 && file_get_contents($d . '/.htaccess') === $written, + 'a declined heal writes only the no-gzip line, still returns false, and the second run writes nothing'); +array_map('unlink', glob($d . '/backups/*') ?: []); +@rmdir($d . '/backups'); +@unlink($d . '/.htaccess'); +@rmdir($d); +$healed86 = Heal::apply($fixture('0.7.71')); +foreach ($betweens as $how => $between) { + $edited = $condAbove($healed86, ' RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [NC,QSA,L]', $between); + $check($edited !== $healed86 && !Heal::safe($edited) && !$healReports($edited) && $rules(Heal::apply($edited)) === $rules($edited), + "a healed 0.8.6 file with a RewriteCond {$how} the [NC] private rule is not safe"); +} +$check(Heal::safe($healed86) && Heal::safe($fixture('0.8.6-rc.1')), 'the E=HTTP_AUTHORIZATION RewriteRule or a comment above the private rule is no pending condition'); +// The other side of the same rule: a directive with no RewriteCond waiting behind it, or an +// operator condition already taken by its own RewriteRule, leaves nothing pending, so the +// shipped block still heals. +$directive = " SetEnvIf Request_URI x foo=1\n"; +$bound = " RewriteCond %{HTTP_HOST} ^cdn\\. [NC]\n RewriteRule ^ - [E=CDN:1]\n SetEnvIf Request_URI x foo=1\n"; +foreach (['0.7.13 (A)' => ['0.7.13', ' # Allow direct access to static files in /uploads/ directory'], + '0.7.21 (B)' => ['0.7.21', ' # Allow direct access to PUBLIC static files in /uploads/'], + '0.7.71 (C)' => ['0.7.71', ' RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [QSA,L]']] as $label => [$v, $anchor]) { + foreach (['a directive alone' => $directive, 'a condition bound to its own rule' => $bound] as $how => $insert) { + $edited = (string) preg_replace('/' . preg_quote($anchor, '/') . '/', $insert . $anchor, $fixture($v), 1); + $healed = Heal::apply($edited); + $check($edited !== $fixture($v) && $healed !== $edited && Heal::safe($healed) && $healReports($edited), + "{$label}: {$how} above the block leaves nothing pending, and the file heals"); + } +} + + +// A rule chained into the private one ([C]) makes it run only when that rule matched, so +// whatever the chained pattern leaves out skips it: any substitution, any pattern. +$base71 = Heal::apply($fixture('0.7.71')); +$chainAbove = static fn (string $rule): string => str_replace($privateLine, " {$rule}\n{$privateLine}", $base71); +$check(substr_count($base71, $privateLine) === 1 && Heal::safe($base71), 'the healed 0.7.71 file has one private line to chain into, and is safe'); +foreach (['RewriteRule ^uploads/thumbs/ - [C]', 'RewriteRule ^x$ index.php [chain]', 'RewriteRule ^assets/ - [QSA, c]'] as $chained) { + $check(!Heal::safe($chainAbove($chained)), "a rule chained into the private one is not safe: {$chained}"); +} +$c71 = $fixture('0.7.71'); +$chainedC = str_replace(' RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [QSA,L]', " RewriteRule ^assets/ - [C]\n RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [QSA,L]", $c71); +$check($chainedC !== $c71 && $rules(Heal::apply($chainedC)) === $rules($chainedC) && !Heal::safe(Heal::apply($chainedC)), + '0.7.71 (C) with a chained rule right above: the private rule is not rewritten into the chain, and the file is reported'); +$check(Heal::safe($chainAbove('RewriteRule ^x$ - [CO=seen:1:example.org]')), 'a cookie flag (CO=) is no chain: safe'); + +// END stops rewriting like L, so a private rule ending in END protects as well: a custom +// but safe file must not be reported on every maintenance window. +$endPrivate = str_replace($privateLine, str_replace('[NC,QSA,L]', '[NC,QSA,END]', $privateLine), $base71); +$check($endPrivate !== $base71 && Heal::safe($endPrivate), 'a private rule ending in END instead of L is safe'); + +// When PCRE gives up (backtrack or JIT limits), preg_* return null. Cast to a string, that +// null became an empty public/.htaccess, which heal() wrote and called acceptable. +$pcreLimits = [ini_get('pcre.backtrack_limit'), ini_get('pcre.jit')]; +$tmpDir = sys_get_temp_dir() . '/htaccess-heal-pcre-' . getmypid(); +@mkdir($tmpDir . '/backups', 0775, true); +$tmpFile = $tmpDir . '/.htaccess'; +file_put_contents($tmpFile, $fixture('0.7.71')); +try { + ini_set('pcre.jit', '0'); + ini_set('pcre.backtrack_limit', '1'); + $check(Heal::apply($fixture('0.7.71')) === $fixture('0.7.71'), 'when PCRE gives up, apply() keeps the file as it is, never an empty one'); + $check(!Heal::heal($tmpFile, $tmpDir . '/backups'), 'and heal() reports the file instead of calling it safe'); + clearstatcache(); + $check((string) file_get_contents($tmpFile) === $fixture('0.7.71'), 'and heal() leaves public/.htaccess untouched on disk'); + + // The 0.7.71 rule is found, but the replace that follows gives up on a later line whose + // trailing blanks make PCRE backtrack: apply() must keep the original, not write null. + $giveUp = $fixture('0.7.71') . 'RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [QSA,L]' . str_repeat(' ', 2000) . "x\n"; + file_put_contents($tmpFile, $giveUp); + ini_set('pcre.backtrack_limit', '1000'); + $check(preg_match('/^([ \t]*)RewriteRule \^uploads\/\(digital\|archives\/documents\|storage\)\/ index\.php \[QSA,L\][ \t]*(?=\r?$)/m', $giveUp) === 1, 'the fixture is recognised under the lowered limit'); + $check(Heal::apply($giveUp) === $giveUp, 'when the replace gives up after the match, apply() keeps the file as it is'); + Heal::heal($tmpFile, $tmpDir . '/backups'); + clearstatcache(); + $check((string) file_get_contents($tmpFile) === $giveUp, 'and heal() neither empties nor rewrites public/.htaccess'); + + // Only the no-gzip replace gives up: the rewrite heal already passed safe() and stays. + $noGzipGivesUp = $fixture('0.7.71') . 'SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png|webp|ico|woff2?|gz|zip|epub|mp4|webm|mp3|m4a|ogg)$ no-gzip dont-vary' . str_repeat(' ', 2000) . "x\n"; + $kept = Heal::apply($noGzipGivesUp); + $check($kept !== $noGzipGivesUp && Heal::safe($kept), 'when only the no-gzip replace gives up, apply() still returns the safe rewrite heal'); + + // The search for public rules above the private one gives up on a line whose trailing + // blanks make PCRE backtrack: that is no proof of "no public rule", so not safe. + $privateRule = ' RewriteRule ^uploads/(digital|archives/documents|storage)/ index.php [NC,QSA,L]'; + $healed71 = Heal::apply($fixture('0.7.71')); + $publicGivesUp = str_replace($privateRule, ' RewriteRule x -' . str_repeat(' ', 2000) . "y\n" . $privateRule, $healed71); + $check($publicGivesUp !== $healed71 && preg_match_all('/^[ \t]*RewriteRule[ \t]+("[^"\r\n]*"|[^ \t\r\n]+)[ \t]+-(?:[ \t]+\[([^\]\r\n]*)\])?[ \t]*(?=\r?$)/mi', $publicGivesUp) === false, + 'the search for public rules gives up on that file under the lowered limit'); + $check(!Heal::safe($publicGivesUp), 'when the search for public rules gives up, safe() does not call the file safe'); +} finally { + ini_set('pcre.backtrack_limit', (string) $pcreLimits[0]); + ini_set('pcre.jit', (string) $pcreLimits[1]); + array_map('unlink', glob($tmpDir . '/backups/*') ?: []); + @rmdir($tmpDir . '/backups'); + @unlink($tmpFile); + @rmdir($tmpDir); +} + +// Filesystem failures must never replace the operator's only copy of the rules. +$disk = sys_get_temp_dir() . '/htaccess-review-' . bin2hex(random_bytes(5)); +mkdir($disk); +try { + $missing = $disk . '/missing.htaccess'; + $check(Heal::heal($missing, $disk . '/backups') && !file_exists($missing) && !file_exists($disk . '/backups'), + 'absent .htaccess: no empty replacement and no needless backup directory'); + + $plain = "# nginx-only installation\nOptions -Indexes\n"; + file_put_contents($disk . '/plain', $plain); + $check(Heal::heal($disk . '/plain', $disk . '/backups') && file_get_contents($disk . '/plain') === $plain && !file_exists($disk . '/backups'), + 'a file without rewrite rules is accepted byte-for-byte without a backup'); + + // Apache reads directive names without case: a lower-case public rule is still a rule. + $lower = "rewriteengine on\nrewriterule ^uploads/ - [l]\n"; + file_put_contents($disk . '/lower', $lower); + $check(!Heal::heal($disk . '/lower', $disk . '/backups') && file_get_contents($disk . '/lower') === $lower, + 'a lower-case public rule with no private rule is reported, not taken for a file without rewrite rules'); + + $original = $fixture('0.7.71'); + file_put_contents($disk . '/blocked', $original); + file_put_contents($disk . '/not-a-directory', 'occupied'); + $check(!Heal::heal($disk . '/blocked', $disk . '/not-a-directory') && file_get_contents($disk . '/blocked') === $original && glob($disk . '/.pinakes-480-*') === [], + 'backup path occupied by a file: report failure, preserve the rules and leave no temporary file'); + + file_put_contents($disk . '/mode', $original); + chmod($disk . '/mode', 0640); + $healed = Heal::heal($disk . '/mode', $disk . '/backups'); + clearstatcache(true, $disk . '/mode'); + $check($healed && (fileperms($disk . '/mode') & 0777) === 0640 && Heal::safe((string) file_get_contents($disk . '/mode')), + 'atomic replacement keeps the operator\'s 0640 permissions while protecting private files'); + + $backupFiles = glob($disk . '/backups/*') ?: []; + $check(count($backupFiles) === 1 && file_get_contents($backupFiles[0]) === $original, + 'the upgrade backup contains the exact old file, so the operator can restore it'); +} finally { + foreach (glob($disk . '/backups/*') ?: [] as $backup) { unlink($backup); } + if (is_dir($disk . '/backups')) { rmdir($disk . '/backups'); } + foreach (glob($disk . '/*') ?: [] as $file) { unlink($file); } + rmdir($disk); +} + +echo "\n" . ($fail === 0 ? "ALL {$pass} PASS" : "{$fail} FAILED, {$pass} passed") . "\n"; +exit($fail > 0 ? 1 : 0); diff --git a/tests/plugin-schema-selfheal-fkcol.unit.php b/tests/plugin-schema-selfheal-fkcol.unit.php index 42e9faab5..b3ebe080a 100644 --- a/tests/plugin-schema-selfheal-fkcol.unit.php +++ b/tests/plugin-schema-selfheal-fkcol.unit.php @@ -106,6 +106,19 @@ function check(bool $cond, string $desc): void $hookCount = function (int $pluginId) use ($db): int { return (int) $db->query("SELECT COUNT(*) FROM plugin_hooks WHERE plugin_id={$pluginId}")->fetch_row()[0]; }; +// Drop prenotazione_id with whatever foreign key holds it, under its actual name: an +// install whose FK is not called ncip_transactions_ibfk_3 would keep both otherwise. +$dropPrenotazione = static function (mysqli $db): void { + $res = $db->query("SELECT DISTINCT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ncip_transactions' + AND COLUMN_NAME='prenotazione_id' AND REFERENCED_TABLE_NAME IS NOT NULL"); + foreach ($res ? $res->fetch_all() : [] as [$name]) { + $db->query('ALTER TABLE ncip_transactions DROP FOREIGN KEY `' . str_replace('`', '``', (string) $name) . '`'); + } + if ($db->query("SHOW COLUMNS FROM ncip_transactions LIKE 'prenotazione_id'")->num_rows > 0) { + $db->query('ALTER TABLE ncip_transactions DROP COLUMN prenotazione_id'); + } +}; $diskVersion = function (string $plugin): string { $meta = json_decode((string) @file_get_contents(__DIR__ . "/../storage/plugins/{$plugin}/plugin.json"), true); return (string) ($meta['version'] ?? '1.0.0'); @@ -251,6 +264,98 @@ function check(bool $cond, string $desc): void check($columnExists('libri', 'audio_url'), 'B06 idempotent: column intact after a second sync'); } +/* ===== Scenario C — an INACTIVE plugin's existing tables follow its version ===== */ +// An install upgraded from 0.7.x keeps ncip-server registered but switched off, with +// the ncip_transactions of that time: no prenotazione_id. The version bump used to sync +// the schema of active plugins only, so the column stayed missing until someone +// switched NCIP on. The bump now brings the tables an inactive plugin already has to +// its version, without registering any hook or switching it on. +$ncipC = $db->query("SELECT id, is_active, version FROM plugins WHERE name='ncip-server'")->fetch_assoc(); +if ($ncipC) { + $cId = (int) $ncipC['id']; + $cActive0 = (int) $ncipC['is_active']; + $cHooks0 = $hookCount($cId); + $cVersion0 = (string) $ncipC['version']; + // A failure halfway must not leave NCIP switched off without its hooks: put the + // activation state, the version and (for an active plugin) the hooks back. + $restores[] = function (mysqli $db) use ($cId, $cActive0, $cVersion0, $runSync): void { + $v = $db->real_escape_string($cVersion0); + $db->query("UPDATE plugins SET is_active={$cActive0}, version='{$v}' WHERE id={$cId}"); + if ($cActive0 === 1) { + $runSync(); + } + }; + $db->query("UPDATE plugins SET is_active=0 WHERE id={$cId}"); + $db->query("DELETE FROM plugin_hooks WHERE plugin_id={$cId}"); + $dropPrenotazione($db); + $db->query("UPDATE plugins SET version='0.0.1' WHERE id={$cId}"); + check(!$columnExists('ncip_transactions', 'prenotazione_id'), 'C01 setup: inactive ncip-server at an older version, ncip_transactions without prenotazione_id'); + + $runSync(); + check($columnExists('ncip_transactions', 'prenotazione_id'), 'C02 the version bump adds prenotazione_id to the inactive plugin\'s table'); + check($fkExists('ncip_transactions', 'prenotazione_id', 'prenotazioni'), 'C03 ...with its foreign key'); + $after = $db->query("SELECT is_active, version FROM plugins WHERE id={$cId}")->fetch_assoc(); + check((int) $after['is_active'] === 0 && $hookCount($cId) === 0, 'C04 the plugin stays switched off, with no hook registered'); + check((string) $after['version'] === $diskVersion('ncip-server'), 'C05 its version is the one on disk'); + + // The bump can be gone before this code ever runs: the previous release's Updater + // syncs the plugins with its own PluginManager still in memory, so an install + // upgraded from 0.7.16 arrives with NCIP already at the disk version and the + // column still missing. The sync has to fix that too. + $dropPrenotazione($db); + check(!$columnExists('ncip_transactions', 'prenotazione_id'), 'C07 setup: inactive ncip-server already at the disk version, prenotazione_id missing'); + $runSync(); + check($columnExists('ncip_transactions', 'prenotazione_id'), 'C08 the sync adds prenotazione_id with no version bump'); + check($fkExists('ncip_transactions', 'prenotazione_id', 'prenotazioni'), 'C09 ...with its foreign key'); + check((int) $db->query("SELECT is_active FROM plugins WHERE id={$cId}")->fetch_row()[0] === 0 && $hookCount($cId) === 0, 'C10 the plugin is still off, with no hook'); + + // Put the activation state back; an active plugin gets its hooks again from the sync. + $db->query("UPDATE plugins SET is_active={$cActive0} WHERE id={$cId}"); + if ($cActive0 === 1) { + $runSync(); + check($hookCount($cId) >= $cHooks0, 'C06 restored: ncip-server active again with its hooks'); + } +} + +/* ===== Scenario D — an inactive plugin that never created its tables is left alone ===== */ +$arch = $db->query("SELECT id, is_active, version FROM plugins WHERE name='archives'")->fetch_assoc(); +$archTable = static fn (): bool => (bool) $db->query("SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='archival_units'")->num_rows; +if ($arch && (int) $arch['is_active'] === 0 && !$archTable()) { + $dId = (int) $arch['id']; + $dVersion0 = $db->real_escape_string((string) $arch['version']); + $db->query("UPDATE plugins SET version='0.0.1' WHERE id={$dId}"); + $runSync(); + check(!$archTable(), 'D01 the version bump of an inactive, never-installed Archives creates no table'); + $db->query("UPDATE plugins SET version='{$dVersion0}' WHERE id={$dId}"); +} + +/* ===== Scenario E — the inactive sync never registers hooks or moves files ===== */ +// digital-library registers its hooks in setPluginId() whenever it has none (and moves +// its files there too). The schema-only instance of a switched-off plugin must skip +// that call, or a plugin the admin turned off gets hook rows behind their back. +if (isset($dlId)) { + $eHooks0 = $hookCount($dlId); + $restores[] = function (mysqli $db) use ($dlId, $dlActive0, $runSync): void { + $db->query("UPDATE plugins SET is_active={$dlActive0} WHERE id={$dlId}"); + if ($dlActive0 === 1) { + $runSync(); + } + }; + $db->query("UPDATE plugins SET is_active=0, version='0.0.1' WHERE id={$dlId}"); + $db->query("DELETE FROM plugin_hooks WHERE plugin_id={$dlId}"); + @$db->query("ALTER TABLE libri DROP COLUMN audio_url"); + check(!$columnExists('libri', 'audio_url') && $hookCount($dlId) === 0, 'E01 setup: inactive digital-library at an older version, no hooks, libri.audio_url missing'); + $runSync(); + check($columnExists('libri', 'audio_url'), 'E02 the version bump still syncs the inactive plugin\'s column'); + check($hookCount($dlId) === 0, 'E03 ...without registering any hook for the switched-off plugin'); + check((int) $db->query("SELECT is_active FROM plugins WHERE id={$dlId}")->fetch_row()[0] === 0, 'E04 ...and it stays switched off'); + $db->query("UPDATE plugins SET is_active={$dlActive0} WHERE id={$dlId}"); + if ($dlActive0 === 1) { + $runSync(); + check($hookCount($dlId) >= $eHooks0, 'E05 restored: digital-library active again with its hooks'); + } +} + // Belt and suspenders: if neither scenario ran a single assertion, the gate // would report success without verifying anything. Refuse to pass empty. if ($TESTNO === 0) {