Browse Source

Performance: OPcache sizing, asset compression/caching, cover transcoder, disk-read transcoding (#5704)

* Opcache: keep stale cache files from filling the cache

The docker-compose php.ini capped opcache at 72 MB, below PHP's own
128 MB default. UNA's hash-named cache/*.php files (db, menu, template
caches) are regenerated constantly and were unlinked without
opcache_invalidate(), so every deleted file left a dead entry behind:
opcache never evicts, and once the file is gone opcache_invalidate()
can no longer resolve its path. After 11 days a local dev cache held
778 cache/ entries against 196 files on disk, was full with 14% wasted
(below the 20% restart threshold) and recompiled 110 of the home page's
397 scripts on every request: 0.82 s median TTFB against 0.27 s once
everything fits.

- scripts/docker-compose/php.ini: opcache.memory_consumption 72 -> 256 MB,
  opcache.interned_strings_buffer 16 -> 32 MB. validate_timestamps and
  revalidate_freq stay as they are; measured, they make no difference.
- BxDolCacheFile: invalidate the opcache entry before unlinking a cache
  file (delData, removeAllByPrefix, TTL expiry) so its memory becomes
  reclaimable wasted space instead of a dead entry. Inherited by
  BxDolCacheFileHtml and used by the Studio "clear cache" actions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Perf: compress and cache static assets, add font-display

Lighthouse on the local stack flagged three delivery problems that are
server-config, not application, issues. The HTML document was served
uncompressed (238 KB), static assets carried no Cache-Control/Expires,
and the Font Awesome faces had no font-display.

- scripts/docker-compose/nginx.conf: enable gzip for text responses and
  add a 30-day cache for real static files. Virtual asset-looking paths
  (/s/<obj>/<file>.png handled by storage.php, or missing files routed to
  r.php) are sent to a @dynamic fallback via try_files, so storage and the
  r.php rewrite keep working. The document now gzips 238 KB -> 29 KB.
- .htaccess: mod_deflate + mod_expires equivalents for the Apache
  deployment, both IfModule-guarded so they no-op where the modules are
  absent. text/html is deliberately excluded from expires.
- scripts/railway/Dockerfile: a2enmod deflate expires so the .htaccess
  directives are active on Railway (rewrite was already enabled).
- modules/boonex/fontawesome/template/css/fonts-{all,light,duotone}.css:
  font-display: swap on every @font-face so icon text is not blocked on
  the webfont.

Local Lighthouse (desktop) 85 -> 88, LCP ~1.8s; the compression,
cache-lifetime, render-blocking and font-display audits now pass. The
remaining opportunities (unused JS/CSS, cover image format) need larger
build-side changes and are left for a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Perf: optimize the site cover transcoder output

The homepage cover is produced by the sys_cover transcoder, whose only
filter forced the output type to PNG:

  Resize {w:1920, h:720, force_type: png}

force_type does not re-encode, it only renames the file after the resize
(Intervention Image picks the encoder from the temp file's extension,
which is still the source extension at that point). So a JPEG cover came
out as JPEG bytes (GD, quality 90) with a .png name, served as image/png
at 417 KB - larger than needed and with the wrong mime. Module covers
such as bx_posts_cover have no force_type and keep the compressed source
format, which is why only the site cover stood out.

Two changes:
- BxDolTranscoder::applyFilter_Resize now honours an optional `quality`
  filter param, applied via BxDolImageResize::setJpegQuality and restored
  afterwards (the resizer is a singleton reused for every image in the
  request). BxDolImageResize gains a getJpegQuality getter for the
  restore. No behaviour change for transcoders that do not set `quality`.
- install/sql/system.sql: sys_cover Resize filter is now
  {w:1920, h:720, quality:82} - force_type dropped so a JPEG source is
  served as image/jpeg, and quality 82 re-encodes the hero. Measured on
  the sample cover: 417 KB -> 285 KB (-32%), same 1372x514 dimensions.

Existing installs (the seed only covers fresh ones):
  UPDATE sys_transcoder_filters
     SET filter_params='a:3:{s:1:"w";s:4:"1920";s:1:"h";s:3:"720";s:7:"quality";s:2:"82";}'
   WHERE transcoder_object='sys_cover';
  UPDATE sys_objects_transcoder SET ts=UNIX_TIMESTAMP() WHERE object='sys_cover';
The ts bump makes each cached cover regenerate once at the new quality.

sys_cover_unit_profile and sys_cover_preview carry the same force_type
pattern and can get the same treatment in a follow-up; left out here to
keep the change to the audited homepage cover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Perf: add static-asset cache headers to the Caddyfile

scripts/Caddyfile compressed responses (encode zstd br gzip) but set no
Cache-Control on static files, so FrankenPHP/Caddy deployments missed the
long-lived caching nginx and Apache now get. Add a matcher for the static
asset extensions with a 30-day public Cache-Control. Storage URLs (/s/*)
are excluded because storage.php sets its own headers, including shorter,
private ones for private files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Perf: optimize the profile cover transcoder output

Follow-up to the sys_cover fix: sys_cover_unit_profile (profile page
cover) carried the same force_type=png, which only renames and does not
re-encode, so JPEG covers were served as oversized image/png. Switch its
Resize filter to {w:640, h:240, quality:82}, matching sys_cover.

Existing installs:
  UPDATE sys_transcoder_filters
     SET filter_params='a:3:{s:1:"w";s:3:"640";s:1:"h";s:3:"240";s:7:"quality";s:2:"82";}'
   WHERE transcoder_object='sys_cover_unit_profile';
  UPDATE sys_objects_transcoder SET ts=UNIX_TIMESTAMP() WHERE object='sys_cover_unit_profile';

sys_cover_preview (120x45) already forces jpg and is left as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fix + perf: transcode local-storage sources from disk, not over HTTP

The image transcoder fetched its source file over HTTP from the storage
object's public URL (BX_DOL_URL_ROOT) even when that file sits on the
local disk. Every thumbnail/cover/avatar therefore made a self-referential
HTTP round trip, and in any environment where the app cannot reach its own
public URL from inside (the classic containerised dev setup where the app
talks to the web server by service name, not localhost:<published-port>)
transcoding failed outright - uploads stored fine but their thumbnails
came back 404, so profile pictures and covers showed as broken images.

- BxDolStorage: new getFileLocalPath($mixedHandler) returning false by
  default (remote engines cannot answer).
- BxDolStorageLocal: override returning the file's absolute on-disk path
  (getObjectBaseDir + stored path), or false if it is not present.
- BxDolTranscoder::storeFileLocally_Storage: copy the source straight from
  disk when getFileLocalPath resolves, and fall back to the previous HTTP
  fetch for remote storage engines or if the local copy fails.

Verified locally (php container cannot reach localhost:8088): bx_persons_thumb,
bx_persons_cover and bx_persons_avatar_big now transcode to real JPEGs
instead of 404. Removing the round trip is also a small speed-up in
production for the common local-storage case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Review cleanups: upgrade migration, JS mime coverage, base iconset font-display

- upgrade/files/15.0.0.RC1-15.0.0.RC2/sql.sql: migrate existing installs to
  the new sys_cover / sys_cover_unit_profile Resize filters (quality 82, no
  force_type) and bump their ts so every cached cover regenerates once.
  Verified locally: the site cover regenerates through transcode() from
  disk at 285 KB.
- .htaccess: list text/javascript alongside application/javascript for
  both mod_deflate and mod_expires (Debian-based Apache images map .js to
  text/javascript), and switch mod_expires off for sw.js.php, which PHP
  emits as text/javascript and which must not get a 30-day lifetime.
- scripts/docker-compose/nginx.conf: add text/javascript to gzip_types so
  PHP-emitted JS (sw.js.php) is compressed as well.
- template/css/icons.css: font-display: swap on the base Font Awesome
  iconset faces, matching the bx_fontawesome module files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Sonar: declare getJpegQuality visibility

SonarCloud failed the quality gate on the new code's reliability rating
because of php:S1784 - getJpegQuality() had no explicit visibility. Mark
it public (it is called from BxDolTranscoder::applyFilter_Resize).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Tests: make _t() available to unit tests

The global _t() is declared at the bottom of BxDolLanguages.php, so it
exists only once that class file has been loaded. On CI (fresh install via
install/cmd.php, phpunit 12) nothing in the PHPUnit bootstrap loaded it,
and BxDolServiceMacroTest::testCallMacroMalformed, which compares against
_t('_sys_macros_malformed'), errored five times with "Call to undefined
function _t()". Every master run has failed this way since the
integration tests landed on 2026-09-15.

Load BxDolLanguages explicitly in tests/bootstrap.php, as api.php does.
The full Units suite passes locally before and after (419 tests).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* CI: create PR preview environments by duplicating a template

Railway services are project-scoped. mariadb and una were created by the
first preview and survive environment teardown, so in every later PR
environment `railway add --service una` refused ("already exists"), the
environment held no service instances, `railway variable set` reported
"ServiceInstance not found" and `railway up` failed with 502 before any
build. Every PR preview since #5679 failed this way.

The deploy job now creates the PR environment with `railway environment
new --duplicate pr-template`, and recreates it when a leftover from a
failed run has no una instance (`railway service status` is the probe).
pr-template is a duplicate of a working preview environment: a deployed
mariadb plus an empty una instance carrying the MYSQL* connection
variables and UNA_DB_ENGINE / UNA_HASH_SECRET / UNA_DB_SOCK, so a second
push into the same environment no longer hits the marker-header fatal.
Setting the una variables doubles as the readiness check for the
duplicated instance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* remove changes in sql upgrade script

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Aleksandr Trofimov <at@unacms.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Andrey Yasko 2 weeks ago
parent
commit
2537bc2e7a

+ 26 - 16
.github/workflows/ci.yml

@@ -98,6 +98,7 @@ jobs:
       RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }}
       RAILWAY_PROJECT_ID: ${{ secrets.RAILWAY_PROJECT_ID }}
       RAILWAY_BASE_ENVIRONMENT: ${{ secrets.RAILWAY_ENVIRONMENT }}
+      RAILWAY_TEMPLATE_ENVIRONMENT: pr-template
       RAILWAY_PR_ENVIRONMENT: pr-${{ github.event.pull_request.number }}
     steps:
     - uses: actions/checkout@v4
@@ -128,25 +129,34 @@ jobs:
           exit 1
         fi
         base="${RAILWAY_BASE_ENVIRONMENT:-production}"
+        template="${RAILWAY_TEMPLATE_ENVIRONMENT:-pr-template}"
         railway link --project "$RAILWAY_PROJECT_ID" --environment "$base" --json
-        environments=$(railway environment list --json)
-        if printf '%s\n' "$environments" | jq -e --arg name "$RAILWAY_PR_ENVIRONMENT" '.environments[] | select(.name == $name)' >/dev/null; then
-          echo "Reusing Railway environment $RAILWAY_PR_ENVIRONMENT"
-        else
-          railway environment new "$RAILWAY_PR_ENVIRONMENT" --duplicate "$base" --json
+
+        # Railway services are project-scoped: mariadb and una already exist, so `railway add`
+        # cannot create them again and a plain new environment holds no instances of them
+        # (every preview after the first failed this way). A PR environment therefore gets
+        # its instances by duplicating the template environment. An environment left over
+        # from a failed run has no una instance; recreate it.
+        if ! railway service status --service una --environment "$RAILWAY_PR_ENVIRONMENT" >/dev/null 2>&1; then
+          railway environment delete "$RAILWAY_PR_ENVIRONMENT" -y >/dev/null 2>&1 || true
+          railway environment new "$RAILWAY_PR_ENVIRONMENT" --duplicate "$template" --json
+          # a duplicate may carry the template's una deployment; the artefact below replaces it
+          railway down --service una --environment "$RAILWAY_PR_ENVIRONMENT" -y >/dev/null 2>&1 || true
         fi
         railway link --project "$RAILWAY_PROJECT_ID" --environment "$RAILWAY_PR_ENVIRONMENT" --json
-        railway variable set --service mariadb --environment "$RAILWAY_PR_ENVIRONMENT" --skip-deploys \
-          MARIADB_ROOT_PASSWORD=una \
-          MARIADB_DATABASE=una \
-          MARIADB_USER=una \
-          MARIADB_PASSWORD=una
-        railway variable set --service una --environment "$RAILWAY_PR_ENVIRONMENT" --skip-deploys \
-          MYSQLHOST=mariadb.railway.internal \
-          MYSQLPORT=3306 \
-          MYSQLDATABASE=una \
-          MYSQLUSER=una \
-          MYSQLPASSWORD=una
+
+        # The duplicated una instance is what `railway up` deploys into. Setting its variables
+        # doubles as the readiness check: it fails with "ServiceInstance not found" until it exists.
+        for i in $(seq 1 12); do
+          railway variable set --service una --environment "$RAILWAY_PR_ENVIRONMENT" --skip-deploys \
+            MYSQLHOST=mariadb.railway.internal \
+            MYSQLPORT=3306 \
+            MYSQLDATABASE=una \
+            MYSQLUSER=una \
+            MYSQLPASSWORD=una \
+            && break
+          sleep 5
+        done
         railway domain --service una --environment "$RAILWAY_PR_ENVIRONMENT" || true
 
     - name: Deploy artefact

+ 33 - 4
.htaccess

@@ -12,10 +12,39 @@ Options -MultiViews -Indexes
     RewriteRule ^page/(.*)$  page.php?i=$1 [QSA,L]
     RewriteRule ^s/([a-zA-Z0-9_]+)/([a-zA-Z0-9\.]+)  storage.php?o=$1&f=$2   [QSA,L]
 
-    RewriteCond %{REQUEST_FILENAME} -f [OR]
-    RewriteCond %{REQUEST_FILENAME} -d
-    RewriteRule .+ - [L]
-    RewriteRule ^(.+)$ r.php?_q=$1 [QSA,L]
+    RewriteCond %{REQUEST_FILENAME} -f [OR]
+    RewriteCond %{REQUEST_FILENAME} -d
+    RewriteRule .+ - [L]
+    RewriteRule ^(.+)$ r.php?_q=$1 [QSA,L]
 </IfModule>
 
 AddType image/svg+xml svg
+
+# Compress text responses (guarded: no effect unless mod_deflate is enabled).
+<IfModule mod_deflate.c>
+    AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml application/javascript text/javascript application/json application/xml image/svg+xml application/manifest+json
+</IfModule>
+
+# Long-lived caching for static assets (guarded: no effect unless mod_expires is enabled).
+# text/html is intentionally omitted so dynamic pages are never cached.
+<IfModule mod_expires.c>
+    ExpiresActive On
+    ExpiresByType text/css                  "access plus 30 days"
+    ExpiresByType application/javascript    "access plus 30 days"
+    ExpiresByType text/javascript           "access plus 30 days"
+    ExpiresByType image/svg+xml             "access plus 30 days"
+    ExpiresByType image/png                 "access plus 30 days"
+    ExpiresByType image/jpeg                "access plus 30 days"
+    ExpiresByType image/gif                 "access plus 30 days"
+    ExpiresByType image/webp                "access plus 30 days"
+    ExpiresByType image/x-icon              "access plus 30 days"
+    ExpiresByType font/woff2                "access plus 365 days"
+    ExpiresByType font/woff                 "access plus 365 days"
+    ExpiresByType font/ttf                  "access plus 365 days"
+    ExpiresByType application/vnd.ms-fontobject "access plus 365 days"
+
+    # The service worker is generated by PHP as text/javascript and must stay fresh.
+    <Files "sw.js.php">
+        ExpiresActive Off
+    </Files>
+</IfModule>

+ 24 - 3
inc/classes/BxDolCacheFile.php

@@ -78,7 +78,7 @@ class BxDolCacheFile extends BxDolCache
     function delData($sKey)
     {
         $sFile = $this->sPath . $sKey;
-        return !file_exists($sFile) || @unlink($sFile);
+        return !file_exists($sFile) || $this->_unlinkFile($sFile);
     }
 
     /**
@@ -93,7 +93,7 @@ class BxDolCacheFile extends BxDolCache
         $l = strlen($s);
         while (($sFile = readdir($rHandler)) !== false)
             if (0 === strncmp($sFile, $s, $l))
-                @unlink ($this->sPath . $sFile);
+                $this->_unlinkFile($this->sPath . $sFile);
 
         closedir($rHandler);
 
@@ -129,12 +129,33 @@ class BxDolCacheFile extends BxDolCache
     {
         $iTimeDiff = time() - filectime($sFile);
         if ($iTimeDiff > $iTTL) {
-            @unlink ($sFile);
+            $this->_unlinkFile($sFile);
             return true;
         } else {
             return false;
         }
     }
+
+    /**
+     * Remove a cache file together with its opcache entry.
+     *
+     * Cache files are PHP scripts read with include(), so opcache keeps a compiled copy of each of them.
+     * It never evicts entries on its own, and once a file is gone opcache_invalidate() can no longer resolve
+     * its path, so a file removed with a plain unlink() leaves a dead entry behind for good. With hash-named
+     * cache files being regenerated all the time such entries pile up until the cache is full and every
+     * script that no longer fits is recompiled on every request. Invalidating before the unlink turns the
+     * entry into wasted memory instead, which opcache reclaims with its next restart.
+     *
+     * @param  string $sFile - full path to the file
+     * @return true if the file is deleted, false otherwise
+     */
+    protected function _unlinkFile ($sFile)
+    {
+        if (function_exists('opcache_invalidate'))
+            opcache_invalidate($sFile, true);
+
+        return @unlink($sFile);
+    }
 }
 
 /** @} */

+ 5 - 0
inc/classes/BxDolImageResize.php

@@ -138,6 +138,11 @@ class BxDolImageResize extends BxDolFactory implements iBxDolSingleton
         $this->_iJpegQuality = $i;
     }
 
+    public function getJpegQuality ()
+    {
+        return $this->_iJpegQuality;
+    }
+
     function setSquareResize ($b)
     {
         $this->_isSquareResize = ($b ? true : false);

+ 9 - 0
inc/classes/BxDolStorage.php

@@ -772,6 +772,15 @@ abstract class BxDolStorage extends BxDolFactory implements iBxDolFactoryObject
      */
     public function getFileUrlById($iFileId) { }
 
+    /**
+     * Get the absolute local filesystem path of a stored file. Only local storage
+     * engines can answer this; other engines return false and callers fall back to
+     * the file URL.
+     * @param $mixedHandler file id or remote id
+     * @return string absolute path on success, false otherwise
+     */
+    public function getFileLocalPath($mixedHandler) { return false; }
+
     /**
      * Get file info array by file id.
      * @param $iFileId file id

+ 16 - 0
inc/classes/BxDolStorageLocal.php

@@ -131,6 +131,22 @@ class BxDolStorageLocal extends BxDolStorage
         return true;
     }
 
+    /**
+     * Get the absolute local filesystem path of a stored file, or false when it is
+     * not available on disk.
+     * @param $mixedHandler file id or remote id
+     * @return string absolute path on success, false otherwise
+     */
+    public function getFileLocalPath ($mixedHandler)
+    {
+        $aFile = $this->getFile($mixedHandler);
+        if (!$aFile || empty($aFile['path']))
+            return false;
+
+        $sPath = $this->getObjectBaseDir($aFile['private']) . $aFile['path'];
+        return file_exists($sPath) ? $sPath : false;
+    }
+
     protected function getObjectBaseDir ($isPrivate = false)
     {
         return BX_DIRECTORY_STORAGE . $this->_aObject['object'] . '/';

+ 25 - 2
inc/classes/BxDolTranscoder.php

@@ -720,7 +720,20 @@ class BxDolTranscoder extends BxDolFactory implements iBxDolFactoryObject
         else
             $o->setSquareResize (false);
 
-        if (IMAGE_ERROR_SUCCESS == $o->resize($sFile))
+        // Optional per-transcoder JPEG quality. BxDolImageResize is a singleton reused for every
+        // image in the request, so the previous quality is restored afterwards.
+        $iQualityPrev = null;
+        if (isset($aParams['quality']) && (int)$aParams['quality'] > 0) {
+            $iQualityPrev = $o->getJpegQuality();
+            $o->setJpegQuality((int)$aParams['quality']);
+        }
+
+        $iResult = $o->resize($sFile);
+
+        if ($iQualityPrev !== null)
+            $o->setJpegQuality($iQualityPrev);
+
+        if (IMAGE_ERROR_SUCCESS == $iResult)
             return true;
 
         bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: applyFilter_Resize failed for file ({$sFile}): " . $o->getError(), BX_LOG_ERR);
@@ -847,6 +860,17 @@ class BxDolTranscoder extends BxDolFactory implements iBxDolFactoryObject
             return false;
         }
 
+        $sTmpFile = $this->getTmpFilename ($aFile['file_name']);
+
+        // Prefer reading a local-engine source straight from disk. It avoids a
+        // self-referential HTTP round trip for every transcode (a small speed-up),
+        // and it works where the app cannot reach its own public URL from inside
+        // (e.g. a container whose BX_DOL_URL_ROOT is not routable locally).
+        $sLocalPath = $oStorageOriginal->getFileLocalPath($mixedHandler);
+        if ($sLocalPath && @copy($sLocalPath, $sTmpFile))
+            return $sTmpFile;
+
+        // Fallback: fetch over HTTP (remote storage engines, or if the disk copy failed).
         $sUrl = $oStorageOriginal->getFileUrlById($mixedHandler);
         if (!$sUrl) {
             bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeFileLocally_Storage failed, file({$mixedHandler}) wasn't found in source storage ({$this->_aObject['source_params']['object']})", BX_LOG_ERR);
@@ -859,7 +883,6 @@ class BxDolTranscoder extends BxDolFactory implements iBxDolFactoryObject
             return false;
         }
 
-        $sTmpFile = $this->getTmpFilename ($aFile['file_name']);
         if (!file_put_contents($sTmpFile, $sFileData)) {
             bx_log('sys_transcoder', "[{$this->_aObject['object']}] ERROR: storeFileLocally_Storage failed, file save failed ({$sTmpFile})", BX_LOG_ERR);
             return false;

+ 2 - 2
install/sql/system.sql

@@ -5958,8 +5958,8 @@ INSERT INTO `sys_transcoder_filters` (`transcoder_object`, `filter`, `filter_par
 ('sys_icon_android_splash', 'Resize', 'a:3:{s:1:"w";s:3:"512";s:1:"h";s:3:"512";s:13:"square_resize";s:1:"1";}', '0'),
 ('sys_icon_facebook', 'Resize', 'a:4:{s:1:"w";s:3:"100";s:1:"h";s:3:"100";s:13:"square_resize";s:1:"1";s:10:"force_type";s:3:"png";}', '0'),
 ('sys_icon_favicon', 'Resize', 'a:4:{s:1:"w";s:2:"16";s:1:"h";s:2:"16";s:13:"square_resize";s:1:"1";s:10:"force_type";s:3:"png";}', '0'),
-('sys_cover', 'Resize', 'a:3:{s:1:"w";s:4:"1920";s:1:"h";s:3:"720";s:10:"force_type";s:3:"png";}', '0'),
-('sys_cover_unit_profile', 'Resize', 'a:3:{s:1:"w";s:3:"640";s:1:"h";s:3:"240";s:10:"force_type";s:3:"png";}', '0'),
+('sys_cover', 'Resize', 'a:3:{s:1:"w";s:4:"1920";s:1:"h";s:3:"720";s:7:"quality";s:2:"82";}', '0'),
+('sys_cover_unit_profile', 'Resize', 'a:3:{s:1:"w";s:3:"640";s:1:"h";s:3:"240";s:7:"quality";s:2:"82";}', '0'),
 ('sys_cover_preview', 'Resize', 'a:3:{s:1:"w";s:3:"120";s:1:"h";s:2:"45";s:10:"force_type";s:3:"jpg";}', '0'),
 ('sys_builder_page_preview', 'Resize', 'a:4:{s:1:"w";s:3:"128";s:1:"h";s:3:"128";s:13:"square_resize";s:1:"1";s:10:"force_type";s:3:"jpg";}', '0'),
 ('sys_builder_page_embed', 'ResizeVar', '', '0'),

+ 4 - 0
modules/boonex/fontawesome/template/css/fonts-all.css

@@ -1,4 +1,5 @@
 @font-face {
+  font-display: swap;
   font-family: 'sys-user';
   font-style: normal;
   font-weight: 900;
@@ -13,6 +14,7 @@
 }
 
 @font-face {
+  font-display: swap;
   font-family: 'sys-user-far';
   font-style: normal;
   font-weight: 400;
@@ -27,6 +29,7 @@
 }
 
 @font-face {
+  font-display: swap;
     font-family: 'sys-user-fab';
     font-style: normal;
     font-weight: normal;
@@ -40,6 +43,7 @@
 }
 
 @font-face {
+  font-display: swap;
     font-family: 'sys-user-fal';
     font-style: normal;
     font-weight: 300;

+ 2 - 0
modules/boonex/fontawesome/template/css/fonts-duotone.css

@@ -37,6 +37,7 @@
 }
 
 @font-face {
+  font-display: swap;
     font-family: 'sys-user-fab';
     font-style: normal;
     font-weight: normal;
@@ -50,6 +51,7 @@
 }
 
 @font-face {
+  font-display: swap;
     font-family: 'sys-user-fal';
     font-style: normal;
     font-weight: 300;

+ 2 - 0
modules/boonex/fontawesome/template/css/fonts-light.css

@@ -1,4 +1,5 @@
 @font-face {
+  font-display: swap;
     font-family: 'sys-user-fal';
     font-style: normal;
     font-weight: 300;
@@ -15,6 +16,7 @@
 }
 
 @font-face {
+  font-display: swap;
     font-family: 'sys-user-fab';
     font-style: normal;
     font-weight: normal;

+ 9 - 0
scripts/Caddyfile

@@ -9,6 +9,15 @@
 	# Enable compression (optional)
 	encode zstd br gzip
 
+	# Long-lived caching for real static assets. Storage (/s/*) serves files
+	# through storage.php, which sets its own headers (private files included),
+	# so it is excluded here.
+	@static {
+		path *.woff2 *.woff *.ttf *.otf *.eot *.svg *.png *.jpg *.jpeg *.gif *.webp *.avif *.ico *.css *.js *.mjs *.map
+		not path /s/*
+	}
+	header @static Cache-Control "public, max-age=2592000"
+
 	# Add trailing slash for directory requests
 	@canonicalPath {
 		file {path}/index.php

+ 27 - 6
scripts/docker-compose/nginx.conf

@@ -1,26 +1,47 @@
-#
-
 server {
     listen 80 default_server;
     root /opt/una;
-
     client_max_body_size 200M;
 
+    # Compress text responses. HTML is compressed whenever gzip is on; the
+    # types below add CSS/JS/JSON/SVG. Already-compressed responses (the
+    # gzip_loader.php bundles, which set Content-Encoding themselves, and
+    # woff2 fonts) are left untouched by nginx.
+    gzip on;
+    gzip_vary on;
+    gzip_proxied any;
+    gzip_comp_level 5;
+    gzip_min_length 256;
+    gzip_types text/plain text/css text/xml application/javascript text/javascript application/json application/xml image/svg+xml application/manifest+json application/wasm;
+
     location / {
         index index.html index.htm index.php;
-
         rewrite "^/page/(.*)$" /page.php?i=$1 last;
         rewrite "^/m/(.*)$" /modules/index.php?r=$1 last;
         rewrite "^/s/([a-zA-Z0-9_]+)/([a-zA-Z0-9\.]+)" /storage.php?o=$1&f=$2 last;
-
         if (!-e $request_filename) {
             rewrite  ^/(.+)$  /r.php?_q=$1  last;
             break;
         }
     }
 
-    index index.php index.html index.htm;
+    # Long-lived caching for real static files. Virtual paths that merely end
+    # in an asset extension (/s/<obj>/<file>.png served by storage.php, or a
+    # missing file routed through r.php) are not files on disk, so try_files
+    # hands them to @dynamic instead of 404ing.
+    location ~* \.(?:woff2?|ttf|otf|eot|svg|png|jpe?g|gif|webp|avif|ico|css|js|mjs|map)$ {
+        try_files $uri @dynamic;
+        expires 30d;
+        add_header Cache-Control "public";
+        access_log off;
+    }
 
+    location @dynamic {
+        rewrite "^/s/([a-zA-Z0-9_]+)/([a-zA-Z0-9\.]+)" /storage.php?o=$1&f=$2 last;
+        rewrite ^/(.+)$ /r.php?_q=$1 last;
+    }
+
+    index index.php index.html index.htm;
     location ~ \.php$ {
         fastcgi_pass php:9000;
         fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

+ 2 - 2
scripts/docker-compose/php.ini

@@ -15,9 +15,9 @@ opcache.enable=1
 opcache.revalidate_freq=0
 opcache.validate_timestamps=1
 opcache.max_accelerated_files=100000
-opcache.memory_consumption=72
+opcache.memory_consumption=256
 opcache.max_wasted_percentage=20
-opcache.interned_strings_buffer=16
+opcache.interned_strings_buffer=32
 opcache.fast_shutdown=1
 
 [xdebug]

+ 1 - 1
scripts/railway/Dockerfile

@@ -7,7 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
         libzip-dev \
     && docker-php-ext-configure gd --with-freetype --with-jpeg \
     && docker-php-ext-install -j$(nproc) gd mysqli pdo_mysql zip exif opcache \
-    && a2enmod rewrite \
+    && a2enmod rewrite deflate expires \
     && rm -rf /var/lib/apt/lists/*
 
 RUN printf '%s\n' \

+ 3 - 0
template/css/icons.css

@@ -4,6 +4,7 @@
  */
 
 @font-face {
+  font-display: swap;
   font-family: 'sys-user';
   font-style: normal;
   font-weight: 900;
@@ -18,6 +19,7 @@
 }
 
 @font-face {
+  font-display: swap;
   font-family: 'sys-user-far';
   font-style: normal;
   font-weight: 400;
@@ -31,6 +33,7 @@
 }
 
 @font-face {
+  font-display: swap;
   font-family: 'sys-user-fab';
   font-style: normal;
   font-weight: normal;

+ 6 - 0
tests/bootstrap.php

@@ -19,6 +19,12 @@ if (!file_exists($sHeaderPath))
 
 require_once($sHeaderPath);
 
+// The global _t() is declared at the bottom of BxDolLanguages.php, so it only exists once that class
+// file has been loaded. A web request loads it while bootstrapping; the installer-generated header
+// used by CI does not, and unit tests that compare against _t() strings failed there with
+// "Call to undefined function _t()". Load it up front, as api.php does.
+bx_import('BxDolLanguages');
+
 class BxDolTestCase extends \PHPUnit\Framework\TestCase
 {
     /**