diff --git a/CHANGELOG.md b/CHANGELOG.md index 6419dbfe..034e1717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - RSS and Atom feeds and the Categories widget use the Smarty block cache instead of having to go through the Smarty compiler every time. ([#864](https://github.com/flatpressblog/flatpress/pull/864)) - If APCu is available, the Smarty cache fragments are stored in the APCu cache instead of on disk. ([#865](https://github.com/flatpressblog/flatpress/pull/865)) - The sitemap returns an image from a post, if available. ([#940](https://github.com/flatpressblog/flatpress/pull/940)) +- Post navigation now uses secure, absolute offset anchors with request, APCu, and file cache fallbacks ([#944](https://github.com/flatpressblog/flatpress/pull/944)) - Admin area: - No typographic quotes in the plugin management. ([#874](https://github.com/flatpressblog/flatpress/pull/874)) @@ -40,6 +41,7 @@ - includes jQuery 4.0.0 and jQueryUI 1.14.2 - Archives plugin update to version 1.1.2 ([#896](https://github.com/flatpressblog/flatpress/pull/896)) - jQuery / JS legacy code have been updated + - The archives cache has been made language- and character set-safe ([#944](https://github.com/flatpressblog/flatpress/pull/944)) - Comment Center plugin update to version 1.1.5 ([#896](https://github.com/flatpressblog/flatpress/pull/896)) - jQuery / JS legacy code have been updated - CookieBanner plugin update to version 1.0.4 ([#896](https://github.com/flatpressblog/flatpress/pull/896)) diff --git a/docs/FlatPress_APCu_Cache_Overview.md b/docs/FlatPress_APCu_Cache_Overview.md index 64c9edbe..12e26ff2 100644 --- a/docs/FlatPress_APCu_Cache_Overview.md +++ b/docs/FlatPress_APCu_Cache_Overview.md @@ -263,6 +263,11 @@ Medium. All category link generation benefits, but categories change rarely. - `@apcu_set($ckey, $lang, 0);` (no expiry). +**Locale setup performance note:** + +- `set_locale()` now tries the configured locale/charset candidates directly with PHP `setlocale()`. +- Language selection, FlatPress language files, configured output charset, and the existing `setlocale()` fallback sequence remain independent of APCu. + **Impact:** High on multi-language setups; otherwise medium. @@ -345,6 +350,138 @@ Medium. Saves an include+parse of `settings.conf.php` on every request when `gen --- + +### 2.10 Main Stream Offset Anchors – `fp:fpdb:offset-anchors:v1:*` + +**Logical APCu prefix:** `fp:fpdb:offset-anchors:v1:` +**Files:** + +- `fp-includes/core/core.fpdb.class.php` +- `fp-includes/core/core.entry.php` +- `fp-includes/core/core.fileio.php` +- `fp-includes/core/core.apcu.php` + +**Purpose:** + +- Accelerates deep pages of the normal chronological entry stream. +- Stores only structural B+ tree navigation data: + - absolute entry-index offset, + - key at that offset, + - preceding key required to preserve `FPDB_Query::getPrevId()` semantics. +- It does **not** cache entry content, rendered HTML, comments, language output, or Smarty results. + +**Why absolute offsets are used:** + +`admin.php?p=config` can change `general.maxentries`, and callers can pass an explicit `count` or `start`. +The cache therefore never binds an anchor to a page number. + +Examples: + +- `page=9, count=5` -> absolute start offset `40` +- `page=3, count=20` -> absolute start offset `40` +- `start=40` -> absolute start offset `40` + +All three can safely reuse the same structural anchor. + +**Eligibility:** + +The optimization is deliberately limited to the unfiltered main entry stream: + +- main index/category `0`, +- no entry-ID query, +- no random query, +- no year/month/day filter, +- no category filter, +- no exclude index, +- positive `count`, +- start offset at or beyond the current anchor step. + +All other query forms continue through the original B+ tree walker unchanged. + +**Anchor spacing:** + +- Default fixed step: `128`. +- Optional host override: `FP_FPDB_ANCHOR_STEP`. +- Accepted range is clamped to `16..4096`. +- For very large indexes the effective step grows automatically to keep the fixed anchor map at roughly no more than `8192` anchors. +- Optional switch `FP_FPDB_OFFSET_ANCHORS=0|false|off|no` disables the optimization completely. + +**Validity signature:** + +The main-index signature contains: + +- cache schema version, +- `%%fpdb-index-generation.tmp` generation token, +- main index file mtime, +- main index file size, +- current B+ tree length. + +`entry_index_generation_bump()` rotates the token only when the ordered main-index key set changes: + +- a new main-index key is inserted, +- a main-index key is deleted. + +Title-only and category-only edits do not move stream offsets and therefore do not force an unnecessary generation change. + +The token is written through `io_write_file()`. If it cannot be created or safely rotated, offset-anchor reuse is disabled and FlatPress stays on the original walker. + +**Backend and fallback order:** + +```text +request-local anchor map + | + v +APCu namespaced hot cache + | + v +fp-content/cache/%%fpdb-offset-anchors-v1.json + | + v +original B+ tree walker +``` + +The pipes are intentionally aligned to make the fallback direction explicit. + +- APCu is optional and uses the normal `is_apcu_on()`, `apcu_get()`, and `apcu_set()` wrappers. +- APCu TTL: `3600` seconds. +- The JSON fallback is regenerable runtime data and is written atomically through `io_write_file()`. +- Cache-file reads use `io_load_file_uncached()` because the anchor layer already provides its own request/APCu hierarchy and generation validation. +- If APCu is missing, full, disabled, or rejects a store, the file fallback remains available. +- If the file cache is missing, corrupt, read-only, or cannot be written, the B+ tree walker remains authoritative. +- Cache failures can reduce performance only; they must not change query results or entry write success. + +**Warm-up behavior:** + +- The first deep request for a new index generation still walks the original B+ tree and learns fixed-step anchors on that path. +- Following requests can start at the nearest verified anchor and walk only the remaining distance. +- Shallow pages below the anchor step do not perform generation or anchor-file lookups. + +**Write and concurrency safety:** + +- The B+ tree format and its insert/delete/rebalance implementation are unchanged. +- An anchor is accepted only when its key is still the exact walker key and the index signature remains unchanged after positioning. +- Newly learned anchors are persisted only if the signature still matches immediately before the write. +- The generation/cache files are not sources of truth and are safe to delete during cache maintenance. + +**Language / charset safety:** + +Offset anchors contain only index keys and offsets. They are independent of: + +- FlatPress language, +- output charset, +- date/month translations, +- theme, +- Smarty, +- rendered content. + +Changing language or charset therefore does not require anchor invalidation. + +**Impact:** +Low on shallow pages by design; high on deep entry-stream pagination of large blogs after warm-up. + +--- + + ## 3. Plugin and Template Infrastructure Caches ### 3.1 Plugin Discovery and Status – `fp:plugin:*`, `fp:plugins:*` @@ -481,8 +618,8 @@ Medium. Particularly useful when image metadata is frequently queried. **Prefixes:** - `fp:archives:v` -- `fp:archives:list:vN:` -- `fp:archives:html:vN:` +- `fp:archives:list:vN:loc-:` +- `fp:archives:html:vN:loc-:` **File:** `fp-plugins/archives/plugin.archives.php` @@ -493,6 +630,13 @@ Medium. Particularly useful when image metadata is frequently queried. Both store BLOG_BASEURL as a placeholder `%BLOG_BASEURL%` and expand it on read. +**Language and charset isolation:** + +- The APCu key contains `sha1(lowercase(lang) . '|' . lowercase(charset))`. +- The request-local archive cache uses the same locale/charset context. +- Switching the configured FlatPress language or output charset therefore cannot reuse a previously rendered month list from another locale. +- This matters because archive labels contain translated month names from the active FlatPress language data. + **Invalidation:** - Namespaced by `fp:archives:v` (integer version in APCu). @@ -1113,6 +1257,7 @@ The following table summarizes each logical cache group: | Base URL Config | `fp:config:settings:*` | No | File mtime/size via `stat()`, TTL 1h | Medium | | File I/O | `fp:io:*` | No | File mtime/size, TTL (default 1h) | High | | Entries | `fp:entry:parsed:*` | No | Entry file mtime/size | High | +| Stream offset anchors | `fp:fpdb:offset-anchors:v1:*` | No | Main-index generation + mtime/size/length | High on deep pages | | Comments | `fp:comments:list:*`, `fp:comments:count:*` | No | Comment dir mtime, TTL 300s (APCu) + file fallback | Medium–High | | Static pages | `fp:statics:list:*` | No | Static dir mtime/size, TTL 600s | Medium | | Categories | `fp:cats:list:*`, `fp:cats:encoded:*` | No | Categories file mtime/size, TTL 600s | Medium | @@ -1125,7 +1270,7 @@ The following table summarizes each logical cache group: | Smarty plugin index | `fp:spi:*` | No | Dir+token hash, TTL 300s | Medium | | Search | `fp:search:rev`, `fp:search:v*` | No | Content rev + TTL (5s / 900s) | Medium | | BBCode | `fp:bbcode:*` | No | Parser/img/meta mtimes, TTL 300–7200s | Medium–High | -| Archives | `fp:archives:v`, `fp:archives:list*`, `fp:archives:html*` | **Yes** | `plugin_archives_cache_bump()` + PrettyURLs bump | Medium | +| Archives | `fp:archives:v`, `fp:archives:list*`, `fp:archives:html*` | **Yes** | Generation/PrettyURLs bump + language/charset key | Medium | | Calendar | `fp:calendar:v`, `calendar:*:vN` | **Yes** | `plugin_calendar_cache_bump()` + PrettyURLs bump | Medium–High | | Storage plugin | `fp:storage:v`, `fp:storage:aggregate*`, `fp:storage:dirsize*`, `fp:storage:quota*` | No | Post-success hooks + generation bump + JSON purge/TTL | Medium–High | | Mastodon instance snapshot | `fp:mastodon:instance_document:` | No | TTL 900s, `instance_url` change, snapshot refresh | Low–Medium | @@ -1160,6 +1305,7 @@ For completeness, the following logical prefixes are used by FlatPress `1.6.dev` - `fp:comments:count:` - `fp:config:settings:` - `fp:entry:parsed:` +- `fp:fpdb:offset-anchors:v1:` - `fp:https:v2:` - `fp:ini:` - `fp:io:` diff --git a/fp-includes/core/core.entry.php b/fp-includes/core/core.entry.php index 89421c75..fae433e7 100755 --- a/fp-includes/core/core.entry.php +++ b/fp-includes/core/core.entry.php @@ -1,5 +1,68 @@ open(); } - } class entry_index { @@ -153,6 +215,7 @@ function add($id, $entry, $del = array(), $update_title = true) { $main = & $this->get_index(); $seek = null; + $main_key_existed = ($main->has_key($key) !== false); // title must not be updated, let's get the offset value from has_key if (!$update_title) { @@ -212,6 +275,12 @@ function add($id, $entry, $del = array(), $update_title = true) { } } + // Only insertion/removal of an ordered main-index key changes offsets. + // Title/category-only edits keep existing offset anchors valid. + if (!$main_key_existed) { + entry_index_generation_bump(); + } + return $this->_lock_release(); } @@ -237,9 +306,9 @@ function delete($id, $entry) { } } + entry_index_generation_bump(); return $this->_lock_release(); } - } class entry_archives extends fs_filelister { @@ -297,7 +366,6 @@ function getList() { function getCount() { return $this->_count; } - } /** diff --git a/fp-includes/core/core.fpdb.class.php b/fp-includes/core/core.fpdb.class.php index ab54f1f1..1ea21af1 100644 --- a/fp-includes/core/core.fpdb.class.php +++ b/fp-includes/core/core.fpdb.class.php @@ -1,5 +1,194 @@ APCu -> regenerable file cache. + */ +class FPDB_OffsetAnchorCache { + + private static $local = array(); + + /** + * Allows hosts to disable the optimization without changing FlatPress. + * Default: enabled. + * + * @return bool + */ + static function enabled() { + $value = getenv('FP_FPDB_OFFSET_ANCHORS'); + if ($value === false && isset($_ENV ['FP_FPDB_OFFSET_ANCHORS'])) { + $value = $_ENV ['FP_FPDB_OFFSET_ANCHORS']; + } + if ($value === false || $value === '') { + return true; + } + + $value = strtolower(trim((string)$value)); + return !in_array($value, array('0', 'false', 'off', 'no'), true); + } + + /** + * Returns an anchor spacing that stays bounded for very large indexes. + * The environment override is optional and clamped to a safe range. + * + * @param int $index_length + * @return int + */ + static function step($index_length = 0) { + $step = 128; + $value = getenv('FP_FPDB_ANCHOR_STEP'); + if ($value === false && isset($_ENV ['FP_FPDB_ANCHOR_STEP'])) { + $value = $_ENV ['FP_FPDB_ANCHOR_STEP']; + } + if ($value !== false && is_numeric($value)) { + $step = (int)$value; + } + + $step = max(16, min(4096, $step)); + $index_length = max(0, (int)$index_length); + + // Keep the persistent map at roughly <= 8192 fixed anchors. + if ($index_length > 0) { + $bounded = (int)ceil($index_length / 8192); + $step = max($step, $bounded); + } + + return $step; + } + + /** + * Builds the validity signature for the ordered main index. + * + * The generation token handles same-second/same-size mutations through + * FlatPress. mtime/size/length additionally detect most external changes. + * + * @param BPlusTree $entry_index + * @return string|false + */ + static function signature(&$entry_index) { + if (!self::enabled()) { + return false; + } + + $generation = entry_index_generation_read(); + if ($generation === false || $generation === '') { + return false; + } + + $file = INDEX_DIR . 'index-0.dat'; + clearstatcache(true, $file); + $mtime = @filemtime($file); + $size = @filesize($file); + $length = $entry_index->length(); + + if ($mtime === false || $size === false || $length === false) { + return false; + } + + return 'v1:' . $generation . ':' . (int)$mtime . ':' . (int)$size . ':' . (int)$length; + } + + /** + * @param mixed $anchors + * @return array + */ + private static function normalize_anchors($anchors) { + $normalized = array(); + if (!is_array($anchors)) { + return $normalized; + } + + foreach ($anchors as $offset => $anchor) { + if (!is_numeric($offset) || (int)$offset < 0 || !is_array($anchor)) { + continue; + } + $key = isset($anchor ['key']) && is_string($anchor ['key']) ? $anchor ['key'] : ''; + $prev = isset($anchor ['prev']) && is_string($anchor ['prev']) ? $anchor ['prev'] : ''; + if ($key === '') { + continue; + } + $normalized [(int)$offset] = array( + 'key' => $key, + 'prev' => $prev + ); + } + + ksort($normalized, SORT_NUMERIC); + return $normalized; + } + + /** + * @param string $signature + * @return array + */ + static function load($signature) { + if (isset(self::$local [$signature])) { + return self::$local [$signature]; + } + + $key = 'fp:fpdb:offset-anchors:v1:' . sha1($signature); + if (function_exists('is_apcu_on') && is_apcu_on()) { + $hit = false; + $payload = apcu_get($key, $hit); + if ($hit && is_array($payload) && isset($payload ['signature']) && $payload ['signature'] === $signature) { + $anchors = self::normalize_anchors(isset($payload ['anchors']) ? $payload ['anchors'] : array()); + return self::$local [$signature] = $anchors; + } + } + + $file = CACHE_DIR . '%%fpdb-offset-anchors-v1.json'; + $raw = io_load_file_uncached($file); + if (is_string($raw) && $raw !== '') { + $payload = json_decode($raw, true); + if (is_array($payload) && isset($payload ['version'], $payload ['signature']) && + (int)$payload ['version'] === 1 && $payload ['signature'] === $signature) { + $anchors = self::normalize_anchors(isset($payload ['anchors']) ? $payload ['anchors'] : array()); + self::$local [$signature] = $anchors; + if (function_exists('is_apcu_on') && is_apcu_on()) { + apcu_set($key, array( + 'version' => 1, + 'signature' => $signature, + 'anchors' => $anchors + ), 3600); + } + return $anchors; + } + } + + return self::$local [$signature] = array(); + } + + /** + * @param string $signature + * @param array $anchors + * @return void + */ + static function store($signature, $anchors) { + $anchors = self::normalize_anchors($anchors); + self::$local [$signature] = $anchors; + + $payload = array( + 'version' => 1, + 'signature' => $signature, + 'anchors' => $anchors + ); + + if (function_exists('is_apcu_on') && is_apcu_on()) { + apcu_set('fp:fpdb:offset-anchors:v1:' . sha1($signature), $payload, 3600); + } + + $json = json_encode($payload); + if (is_string($json)) { + // Best effort only: failure leaves the B+ tree path fully functional. + io_write_file(CACHE_DIR . '%%fpdb-offset-anchors-v1.json', $json . "\n"); + } + } +} + + class FPDB_QueryParams { var $id = null; @@ -138,7 +327,6 @@ function parse_string($str) { $params = utils_kexplode(strtolower($str), ',:', false); $this->validate_array($params); } - } class FPDB_Query { @@ -184,6 +372,14 @@ class FPDB_Query { var $preventry = array(); + var $offset_anchor_signature = null; + + var $offset_anchor_step = 0; + + var $offset_anchor_map = array(); + + var $offset_anchor_dirty = false; + function __construct($params, $ID) { global $current_query; @@ -298,6 +494,131 @@ function _prepare_single(&$entry_index) { } } + /** + * Only the unfiltered chronological main stream is anchor-safe. + * + * @return bool + */ + function _offset_anchor_eligible() { + $qp = &$this->params; + + return FPDB_OffsetAnchorCache::enabled() && + !$this->single && + !$qp->id && + !$qp->random && + !$qp->y && + !$qp->m && + !$qp->d && + (int)$qp->category === 0 && + !$qp->exclude && + (int)$qp->start > 0 && + (int)$qp->count > 0; + } + + /** + * Repositions the walker to the nearest verified absolute-offset anchor. + * + * @param BPlusTree $entry_index + * @param int $index_count + * @return void + */ + function _prepare_offset_anchor(&$entry_index, $index_count) { + if (!$this->_offset_anchor_eligible()) { + return; + } + + $this->offset_anchor_step = FPDB_OffsetAnchorCache::step($index_count); + $target = (int)$this->params->start; + + // Shallow pages are already faster with the original walker and should + // not pay generation/file-cache lookup overhead. + if ($target < $this->offset_anchor_step) { + return; + } + + $signature = FPDB_OffsetAnchorCache::signature($entry_index); + if ($signature === false) { + return; + } + + $this->offset_anchor_signature = $signature; + $this->offset_anchor_map = FPDB_OffsetAnchorCache::load($signature); + $best_offset = -1; + $best_anchor = null; + + foreach ($this->offset_anchor_map as $offset => $anchor) { + $offset = (int)$offset; + if ($offset > 0 && $offset <= $target && $offset > $best_offset) { + $best_offset = $offset; + $best_anchor = $anchor; + } + } + + if ($best_offset <= 0 || !is_array($best_anchor) || empty($best_anchor ['key'])) { + return; + } + + $anchor_key = (string)$best_anchor ['key']; + $anchor_walker = &$entry_index->walker($anchor_key, true, null, null); + + if (!$anchor_walker->valid || $anchor_walker->current_key() !== $anchor_key) { + return; + } + + // Recheck after positioning so an index mutation cannot validate an + // anchor against a generation that changed while this request ran. + if (FPDB_OffsetAnchorCache::signature($entry_index) !== $signature) { + return; + } + + $this->walker = &$anchor_walker; + $this->pointer = $best_offset; + + $prev_key = isset($best_anchor ['prev']) && is_string($best_anchor ['prev']) ? $best_anchor ['prev'] : ''; + $this->currentid = $prev_key !== '' ? entry_keytoid($prev_key) : ''; + } + + /** + * Records a fixed-step anchor encountered by the original walker. + * + * @param string $key + * @return void + */ + function _capture_offset_anchor($key) { + if (!is_string($this->offset_anchor_signature) || $this->offset_anchor_signature === '' || + $this->offset_anchor_step <= 0 || $this->pointer <= 0 || + ($this->pointer % $this->offset_anchor_step) !== 0 || + isset($this->offset_anchor_map [$this->pointer])) { + return; + } + + $prev_key = $this->currentid !== '' ? entry_idtokey($this->currentid) : ''; + $this->offset_anchor_map [$this->pointer] = array( + 'key' => $key, + 'prev' => $prev_key + ); + $this->offset_anchor_dirty = true; + } + + /** + * Persists newly learned anchors only if the index generation is unchanged. + * + * @return void + */ + function _flush_offset_anchors() { + if (!$this->offset_anchor_dirty || !is_string($this->offset_anchor_signature) || + $this->offset_anchor_signature === '' || !$this->main_idx) { + return; + } + + $current_signature = FPDB_OffsetAnchorCache::signature($this->main_idx); + if ($current_signature === $this->offset_anchor_signature) { + FPDB_OffsetAnchorCache::store($this->offset_anchor_signature, $this->offset_anchor_map); + } + + $this->offset_anchor_dirty = false; + } + function _prepare_list(&$entry_index) { $qp = &$this->params; @@ -338,6 +659,8 @@ function _prepare_list(&$entry_index) { $index_count = $qp->start = $qp->count = 0; } } + + $this->_prepare_offset_anchor($entry_index, $index_count); } function _get_random_id(&$entry_index) { @@ -477,6 +800,7 @@ function &peekEntry() { $this->previd = $this->currentid; $key = $this->walker->current_key(); + $this->_capture_offset_anchor($key); $id = $this->currentid = entry_keytoid($key); if ($this->single) { @@ -489,6 +813,13 @@ function &peekEntry() { $this->pointer++; } + // A requested offset can itself be a fixed-step anchor. The loop above + // stops before processing that key, so capture it explicitly. + if ($this->walker->valid && $this->pointer === (int)$qp->start) { + $this->_capture_offset_anchor($this->walker->current_key()); + } + $this->_flush_offset_anchors(); + // if there is a secondary (not) idx if ($this->secondary_idx) { @@ -660,7 +991,6 @@ function getPrevPage() { ); } } - } class FPDB_CommentList { @@ -707,7 +1037,6 @@ function &getComment() { ); return $couplet; } - } class FPDB { @@ -813,7 +1142,6 @@ function &getQuery($queryId = 0) { } return $o; } - } class FPDB_transaction { @@ -837,7 +1165,6 @@ function __construct($id_cat = 0) { $this->_tree = new caching_SBPT(fopen($this->_cachefile . '.dat', 'rb'), fopen($this->_cachefile . '.strings.dat', 'rb'), 256, $this->_offset, $this->_chunksize, $this->_keysize); } - } // SMARTY FUNCTIONS ---------------------------------------------------- diff --git a/fp-includes/core/core.language.php b/fp-includes/core/core.language.php index ac6099da..5904fe3b 100644 --- a/fp-includes/core/core.language.php +++ b/fp-includes/core/core.language.php @@ -161,7 +161,6 @@ function _checkFile($directory, $file) { return 0; } - } function lang_list() { @@ -182,13 +181,13 @@ function lang_list() { * Features: * - Validates and applies locale settings using combinations of `locale names` and `charsets`. * - Logs debug messages if the `DEBUG` constant is defined and enabled. - * - Uses `locale -a` to verify available locales when supported. + * - Tries configured locale/charset candidates directly with `setlocale()`. * - Provides a fallback mechanism to `en_US.UTF-8` if no suitable locale is found. * - Ensures robust error handling and compatibility across different server setups. * * Requirements: * - `lang.conf.php` must define locale and charset mappings. - * - If `locale -a` is unavailable, the function attempts direct locale setting. + * - No external locale-discovery process is required; unavailable candidates fail safely. * * Debugging: * - Enable detailed logging by defining the `DEBUG` constant as `true` in the configuration. @@ -289,16 +288,6 @@ function set_locale() { error_log('set_locale -> Adding charset variations. Current charset: ' . $charset); } - $supportedLocales = []; - /** @phpstan-ignore-next-line */ - if (DIRECTORY_SEPARATOR !== '\\' && function_exists('shell_exec') && is_callable('shell_exec') && stripos(ini_get('disable_functions'), 'shell_exec') === false) { - // Checks the supported locales with locale -a and only uses valid combinations - $output = shell_exec('locale -a 2>/dev/null'); - if ($output !== null) { - $supportedLocales = explode("\n", trim($output)); - } - } - // Check whether a locale is already set $currentLocale = setlocale(LC_TIME, '0'); if ($debug) { @@ -315,45 +304,25 @@ function set_locale() { if (defined('LC_NUMERIC')) $localeCategories [] = LC_NUMERIC; if (defined('LC_COLLATE')) $localeCategories [] = LC_COLLATE; - // If supportedLocales is not empty, validate against it - if (!empty($supportedLocales)) { - foreach ($localeVariantsWithCharsets as $variant) { - foreach ($localeCategories as $category) { - $currentLocale = @setlocale($category, $variant); - if ($currentLocale === false) { - if ($debug) { - error_log('set_locale -> Failed to set locale for category ' . $category . ': ' . $variant); - } - } else { - if ($debug) { - error_log('set_locale -> Locale set for category ' . $category . ': ' . $currentLocale); - } - $selectedLocale = $currentLocale; + // Try candidates directly. setlocale() is the authoritative and portable + // capability check; probing the host with an external `locale -a` process + // adds latency and is unavailable on many shared hosts. + foreach ($localeVariantsWithCharsets as $variant) { + foreach ($localeCategories as $category) { + $currentLocale = @setlocale($category, $variant); + if ($currentLocale === false) { + if ($debug) { + error_log('set_locale -> No set locale for category ' . $category . ': ' . $variant); } - } - if ($selectedLocale) { - break; - } - } - } else { - // If locale -a is not available, try setting directly - foreach ($localeVariantsWithCharsets as $variant) { - foreach ($localeCategories as $category) { - $currentLocale = @setlocale($category, $variant); - if ($currentLocale === false) { - if ($debug) { - error_log('set_locale -> No set locale for category ' . $category . ': ' . $variant); - } - } else { - if ($debug) { - error_log('set_locale -> Locale set for category ' . $category . ': ' . $variant); - } - $selectedLocale = $variant; + } else { + if ($debug) { + error_log('set_locale -> Locale set for category ' . $category . ': ' . $currentLocale); } + $selectedLocale = $currentLocale; } - if ($selectedLocale) { - break; - } + } + if ($selectedLocale) { + break; } } diff --git a/fp-plugins/archives/plugin.archives.php b/fp-plugins/archives/plugin.archives.php index 99a0e8a1..8837824d 100644 --- a/fp-plugins/archives/plugin.archives.php +++ b/fp-plugins/archives/plugin.archives.php @@ -229,27 +229,46 @@ function plugin_archives_cache_prepare_for_store($value) { return $value; } -function plugin_archives_cached_list() { - static $local = null; - if ($local !== null) { - return $local; - } +/** + * Cache-key context for localized archive output. + * + * Archive labels contain translated month names. Charset is included as well + * because FlatPress can serve legacy output encodings configured per locale. + * + * @return string + */ +function plugin_archives_cache_locale_context() { global $fp_config; + + $lang = isset($fp_config ['locale'] ['lang']) ? strtolower((string)$fp_config ['locale'] ['lang']) : 'en-us'; + $charset = isset($fp_config ['locale'] ['charset']) ? strtolower((string)$fp_config ['locale'] ['charset']) : 'utf-8'; + + return ':loc-' . sha1($lang . '|' . $charset); +} + +function plugin_archives_cached_list() { + static $local = array(); + $apcu_on = function_exists('is_apcu_on') ? is_apcu_on() : false; $ns = plugin_archives_cache_ns(); + $locale_context = plugin_archives_cache_locale_context(); $sig = plugin_archives_mtime_sig_cached(); - $key = 'fp:archives:list' . $ns . ':' . $sig; + $local_key = $locale_context . ':' . $sig; + if (isset($local [$local_key])) { + return $local [$local_key]; + } + $key = 'fp:archives:list' . $ns . $locale_context . ':' . $sig; if ($apcu_on) { $hit = false; $val = apcu_get($key, $hit); if ($hit && is_array($val)) { $val = plugin_archives_cache_expand_baseurl_list($val); - return $local = $val; + return $local [$local_key] = $val; } } $obj = new plugin_archives_monthlist(); $list = $obj->getList(); - $local = $list; + $local [$local_key] = $list; if ($apcu_on) { @apcu_set($key, plugin_archives_cache_prepare_for_store($list), 900); } @@ -257,26 +276,28 @@ function plugin_archives_cached_list() { } function plugin_archives_cached_html() { - static $local = null; - if ($local !== null) { - return $local; - } - global $fp_config; + static $local = array(); + $apcu_on = function_exists('is_apcu_on') ? is_apcu_on() : false; $ns = plugin_archives_cache_ns(); + $locale_context = plugin_archives_cache_locale_context(); $sig = plugin_archives_mtime_sig_cached(); - $key = 'fp:archives:html' . $ns . ':' . $sig; + $local_key = $locale_context . ':' . $sig; + if (isset($local [$local_key])) { + return $local [$local_key]; + } + $key = 'fp:archives:html' . $ns . $locale_context . ':' . $sig; if ($apcu_on) { $hit = false; $val = apcu_get($key, $hit); if ($hit && is_string($val)) { $val = plugin_archives_cache_expand_baseurl_html($val); - return $local = $val; + return $local [$local_key] = $val; } } $obj = new plugin_archives_monthlist(); $html = $obj->getHtmlList(); - $local = $html; + $local [$local_key] = $html; if ($apcu_on) { @apcu_set($key, plugin_archives_cache_prepare_for_store($html), 900); }