Skip to content

Improve image processing reliability, caching and admin workflows - #142

Merged
fabiodalez-dev merged 12 commits into
fabiodalez-dev:mainfrom
MiranoVerhoef:feat/combined-fork-improvements
Oct 27, 2026
Merged

fabiodalez-dev merged 12 commits into
fabiodalez-dev:mainfrom
MiranoVerhoef:feat/combined-fork-improvements

Conversation

@MiranoVerhoef

@MiranoVerhoef MiranoVerhoef commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Uploads and administrative edits need reliable background processing and cache invalidation, while large libraries make the current navigation, polling and repeated filesystem checks costly. This consolidates the remaining tested fork improvements on top of current main, including the first performance batch. Existing upstream lifecycle tests are retained.

Changes

  • Retry interrupted image jobs with capped backoff, serialize processing of each image, retry SQLite contention safely, and propagate CLI generation failures. Cover Apache, PHP-FPM and restricted process execution.
  • Show upload/variant progress and notify visitors when enhanced image versions become available. Add original-image details/downloads, keyboard navigation and collapsible media filters.
  • Revalidate changed pages and settings, invalidate affected page caches, preserve gallery previews after album deletion, and avoid cached public content bypassing maintenance mode.
  • Group admin navigation into sections and tabs; refine login branding and category navigation. Add album storage totals, uncategorized albums, independent hero text controls, a Classic homepage per-album limit, theme-specific logos, editable About navigation/permalink and local font uploads. Avoid duplicate Analytics Pro navigation.
  • Batch settings writes in one retried transaction, back off idle polling, and cache filesystem storage totals with database-change detection and timed reconciliation.
  • Pin the PHP 8.5.12 Apache runtime and Imagick source, preserve binary/text SQLite backup values, apply numeric migrations for prereleases, and keep prerelease Docker tags from replacing stable aliases.

Compatibility

Existing settings and single-setting callers remain supported. No external cron job or changed volume mapping is required. This keeps upstream's version and release history; fork-only release metadata and upgrade-image dependencies are excluded.

Validation

  • PHPStan and production frontend build.
  • Deterministic adaptive-polling and SQLite backup round-trip regression tests.
  • Full PHPUnit suite, including fresh installation and packaged updater lifecycle.
  • Docker/Apache and PHP-FPM HTTP uploads, restart recovery, restricted process execution, SQLite contention, queue retries and CLI failure handling.
  • Chromium admin/control checks and a retained-volume upgrade from the published upstream Docker baseline.

Verified runs: integrated fork suite (280 tests, 1,291 assertions; 13 environment-dependent skips) and this contribution branch, including the published upstream Docker upgrade. Both full integration workflows passed. Upstream CI also runs on this PR. Live-site deployment is left to the maintainer.

@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Too many files!

This PR contains 129 files, which is 29 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: bb548cb1-968b-4886-a564-515aabe2021c
Commits

Reviewing files that changed from the base of the PR and between 24659cd and f00cbd4.

Files ignored due to path filters: 1
  • package-lock.json is excluded by !**/package-lock.json
Files selected for processing: 129
  • .dockerignore
  • .github/workflows/docker-publish.yml
  • .github/workflows/image-jobs.yml
  • .github/workflows/verify-published-image.yml
  • .gitignore
  • CHANGELOG.md
  • DOCKER.md
  • Dockerfile
  • app/Config/routes.php
  • app/Controllers/Admin/AlbumsController.php
  • app/Controllers/Admin/AuthController.php
  • app/Controllers/Admin/MediaController.php
  • app/Controllers/Admin/PagesController.php
  • app/Controllers/Admin/SettingsController.php
  • app/Controllers/Admin/TypographyController.php
  • app/Controllers/Admin/UploadController.php
  • app/Controllers/Frontend/DownloadController.php
  • app/Controllers/Frontend/MediaController.php
  • app/Controllers/Frontend/PageController.php
  • app/Middlewares/AuthMiddleware.php
  • app/Middlewares/CacheMiddleware.php
  • app/Services/AlbumStorageService.php
  • app/Services/AnalyticsService.php
  • app/Services/CacheWarmService.php
  • app/Services/ContainerMigrationService.php
  • app/Services/HomeImageService.php
  • app/Services/ImageGenerationRevision.php
  • app/Services/ImageJobQueue.php
  • app/Services/Imaging/ImageEngine.php
  • app/Services/SettingsService.php
  • app/Services/TwigGlobalsCache.php
  • app/Services/TypographyService.php
  • app/Services/UploadService.php
  • app/Services/VariantMaintenanceService.php
  • app/Support/Database.php
  • app/Support/ImageProcessingBusy.php
  • app/Support/ImageProcessingLock.php
  • app/Support/SqliteRetry.php
  • app/Support/Updater.php
  • app/Tasks/ImagesGenerateCommand.php
  • app/Tasks/ImagesGenerateVariantsCommand.php
  • app/Tasks/ImagesWorkCommand.php
  • app/Views/admin/_layout.twig
  • app/Views/admin/_navigation.twig
  • app/Views/admin/albums/create.twig
  • app/Views/admin/albums/edit.twig
  • app/Views/admin/albums/index.twig
  • app/Views/admin/categories/index.twig
  • app/Views/admin/login.twig
  • app/Views/admin/media/_filters.twig
  • app/Views/admin/media/_grid.twig
  • app/Views/admin/media/index.twig
  • app/Views/admin/pages/about.twig
  • app/Views/admin/pages/home.twig
  • app/Views/admin/settings.twig
  • app/Views/admin/typography/index.twig
  • app/Views/frontend/_download_runtime.twig
  • app/Views/frontend/_gallery_runtime.twig
  • app/Views/frontend/_layout.twig
  • app/Views/frontend/_layout_modern.twig
  • app/Views/frontend/_pwa_runtime.twig
  • app/Views/frontend/_site_logo.twig
  • app/Views/frontend/album.twig
  • app/Views/frontend/galleries.twig
  • app/Views/frontend/home/_hero.twig
  • app/Views/frontend/home/_preloader.twig
  • app/Views/frontend/home_modern.twig
  • bin/build-release.sh
  • bin/console
  • bin/image-worker.sh
  • docker/entrypoint.sh
  • package.json
  • phpstan-baseline.neon
  • plugins/cimaise-analytics-pro/plugin.php
  • plugins/custom-templates-pro/plugin.php
  • plugins/maintenance-mode/templates/maintenance.php
  • public/assets/.vite/manifest.json
  • public/assets/admin-CoDgvQhn.js
  • public/assets/admin-DDt8AqQk.js
  • public/assets/admin-app-CMyQXTjg.css
  • public/assets/admin-app-DBUbIwW0.css
  • public/assets/app-CXV8McJM.css
  • public/assets/app-DXthpaXb.css
  • public/assets/app.css
  • public/assets/image-job-progress.js
  • public/assets/js/admin-navigation.js
  • public/assets/js/image-version-notice.js
  • public/index.php
  • public/sw.js
  • resources/admin-app.css
  • resources/admin.js
  • resources/app.css
  • resources/js/utils/upload-fit.js
  • storage/translations/en.json
  • storage/translations/en_admin.json
  • storage/translations/fr.json
  • storage/translations/fr_admin.json
  • storage/translations/it.json
  • storage/translations/it_admin.json
  • tests/Controllers/AlbumDeletionCacheTest.php
  • tests/Controllers/AlbumShowEquipmentTest.php
  • tests/Docker/EntrypointMigrationsTest.php
  • tests/Docker/ImageWorkerTest.php
  • tests/Lifecycle/UpdateTest.php
  • tests/Middlewares/ImageGenerationValidatorTest.php
  • tests/Services/AlbumStorageCacheTest.php
  • tests/Services/AlbumStorageServiceTest.php
  • tests/Services/AnalyticsDeviceDetectionTest.php
  • tests/Services/ContainerMigrationServiceTest.php
  • tests/Services/ImageGenerationFallbacksTest.php
  • tests/Services/ImageJobQueueTest.php
  • tests/Services/ImagesGenerateCommandTest.php
  • tests/Services/OriginalImageTest.php
  • tests/Services/SettingsBatchTest.php
  • tests/Services/SettingsPostWriteCacheTest.php
  • tests/Support/UpdaterPrereleaseMigrationTest.php
  • tests/Support/UpdaterSqliteBackupTest.php
  • tests/Views/AdminSidebarTranslationsTest.php
  • tests/Views/TranslationCoverageTest.php
  • tests/integration/Dockerfile.fpm
  • tests/integration/admin-options.php
  • tests/integration/admin-ui.cjs
  • tests/integration/image-job-polling.cjs
  • tests/integration/image-jobs.php
  • tests/integration/image-version-notice.cjs
  • tests/integration/prime-template-cache.php
  • tests/integration/setup-web.php
  • tests/integration/upload-fit.cjs
  • tests/integration/web-upload.sh

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

A digest-pinned php:8.5.12 base and a git-commit Imagick build break the
security-rebuild path used for 1.4.27 (re-run the publish workflow with
pull: true on php:8.5-apache-trixie): every PHP patch would need a PR.

- Dockerfile: php:8.5-apache-trixie and the released imagick extension.
- Version checks accept PHP 8.5 >= 8.5.12 (FPM test host: 8.2 >= 8.2.35)
  instead of an exact version that fails on the next patch release.
- image-jobs.yml: same SHA-pinned Node 24 actions as the other workflows,
  push runs only for main, and it also runs the visitor watcher test.
MAX_ATTEMPTS only capped the backoff exponent, so a job that can never
succeed was retried hourly forever, the Docker worker booted bin/console
every 15 s for backed-off jobs, and every admin request (including the
progress poll) scheduled a drain.

- After MAX_ATTEMPTS failures a job is parked as {id}.failed and logged
  as an error; enqueueing the image again starts it afresh.
- bin/image-worker.sh starts PHP only for jobs whose next_at has passed.
- AuthMiddleware drains only when a job is due, never from the
  /admin/api/image-jobs poll.
- runSynchronously() waits at most 20 s for a running drain, then leaves
  the job queued instead of blocking the upload request.
- Lock files of deleted images are pruned under the worker lock.
- images:generate regenerates everything again unless --missing is given
  (--missing had become a no-op); --force still wins.
- GD fallbacks decode WebP and GIF originals, as the old command did.
- JPEG-XL variant dimensions are capped at the original width.
- The cjxl route (full-size Imagick decode + external process) honours
  CIMAISE_DISABLE_IMAGICK and runs only from the CLI; libvips+libjxl
  still works in any SAPI.
- A blur batch skips an image held by the worker instead of aborting the
  whole album.
…nding

Every open photo page polled /api/image-generation-revision every 30 s
forever, each poll booting the full app. The layout now says whether any
image job is queued: without one the page does not poll, otherwise it
watches for at most 10 minutes and stops once the pill is shown.

The early ETag is derived from the page-cache entry, not from the HTML,
so it now includes the generation state; otherwise visitors kept a stale
"pending" flag through 304 responses.
- Albums: drop the album.N.show_equipment settings write. The column is
  the source of truth; the row was written inside the album transaction
  (refreshing the shared settings cache before commit) and added one row
  per album to the settings hashed into every HTML validator.
- TwigGlobalsCache: bump the prefix to v6 for site_logo_dark and add its
  default.
- SQLite backup: keep text readable through quote(); only text holding a
  NUL byte is hex-encoded.
- French: translate the 49 new admin keys and 6 frontend keys; a test now
  checks fr/it cover every English key.
- .gitignore: whitelist the new test files.
- CHANGELOG: document upload compression removal, job parking and the
  bounded visitor watcher.
@fabiodalez-dev

Copy link
Copy Markdown
Owner

Thanks a lot for this, Mirano. It is a big and careful piece of work, and the test coverage (Apache, PHP-FPM, restricted exec, SQLite contention, Chromium) made it much easier to review. I went through the whole diff, ran the suites and the Docker build, and pushed the fixes for everything I found directly to this branch so you don't have to do another round. Here is what I changed and why.

Rebase on 1.4.27. I merged main into the branch. The conflicts were in docker-publish.yml (I kept both your OCI labels and the pull: true added in 1.4.27) and CHANGELOG.md (your notes stay under [Unreleased], above 1.4.27). docker/entrypoint.sh also listed storage/image-jobs twice; that is fixed, and the SERVER_MODE handling from #143 is preserved.

Docker base image no longer pinned. The Dockerfile used php:8.5.12-apache-trixie@sha256:… and a git commit of Imagick. That breaks the way I ship PHP security fixes: in 1.4.27 I re-ran the publish workflow on the floating php:8.5-apache-trixie tag with pull: true, and a digest pin would turn every PHP patch into a PR. I went back to the floating tag and the released imagick extension; the image builds and runs PHP 8.5.12 with ImageMagick 7.1.1. The checks in image-jobs.yml and verify-published-image.yml now require PHP 8.5 at version 8.5.12 or later, instead of exactly 8.5.12, so they keep passing after the next patch release. Same for the PHP-FPM test host (8.2, at least 8.2.35).

Failing image jobs were retried forever. MAX_ATTEMPTS only capped the backoff exponent, so a job that can never succeed (a corrupt original, an encoder the host lacks) was retried every hour indefinitely. Meanwhile bin/image-worker.sh booted bin/console every 15 seconds, because it counted backed-off jobs as work. And AuthMiddleware scheduled a drain on every admin request, including the /admin/api/image-jobs poll. Now:

  • after 10 failures the job is parked as {id}.failed and logged as an error; enqueueing the image again (re-upload, regenerate) starts it afresh;
  • the worker only starts PHP for jobs that are actually due (next_at reached);
  • the admin drain only runs when a job is due, and never from the progress poll.

Synchronous uploads could hang behind a drain. With image.variants_async off, runSynchronously() blocked on LOCK_EX behind a drain that can last 240 s. It now waits at most 20 s; after that the job stays queued for the running drain and the upload returns.

Per-image locks. generateBlurredVariantsForAlbum() stopped at the first image held by the worker, so one locked image aborted blur generation for the whole album. It now skips that image and continues. Lock files of images that no longer exist are removed during a drain, under the worker lock. I kept your rule of never unlinking a lock that could still be contended.

show_equipment settings row. store()/update() also wrote album.N.show_equipment through SettingsService::set(), inside the album transaction. Nothing reads it, since the column is the source of truth. The write refreshed the shared settings cache before commit, and it added one row per album to the settings that CacheMiddleware hashes into every HTML validator. I removed it.

Visitors' version watcher. Every visitor with a photo page open polled /api/image-generation-revision every 30 s, forever, and each poll boots the full app. On shared hosting with many visitors that adds up. The layout now tells the script whether any image is queued. With nothing queued it does not poll at all; otherwise it watches for at most 10 minutes and stops once it has shown the pill. While testing this in a browser I also found that the early ETag in CacheMiddleware is derived from the page-cache entry, not from the HTML. Visitors therefore got a 304 and kept a stale "pending" flag, so I added the generation state to htmlContentId().

images:generate contract. With the command now extending images:generate-variants, --missing had become a no-op and the default no longer regenerated anything. I restored the historical behaviour: no options regenerates everything, --missing only fills gaps, --force still wins. The GD fallbacks also decoded only JPEG/PNG, so WebP and GIF originals produced no variants on GD-only hosts. The old command handled both, and so do the fallbacks again. JPEG-XL variants recorded the breakpoint width even when the original was smaller; they are now capped at the source width, as before.

JPEG-XL in web requests. The cjxl route decodes the full original through Imagick and spawns cjxl, and it ignored CIMAISE_DISABLE_IMAGICK. It now honours that flag and runs only from the CLI (the Docker worker / images:work). libvips with libjxl, which is cheap, still works everywhere. I updated the comments in ImageEngine and VariantMaintenanceService that still said "CLI only".

Smaller things.

  • TwigGlobalsCache gained site_logo_dark without bumping the cache prefix that its own comment asks for, and without a default. Both are fixed (v6).
  • The SQLite backup hex-encoded every text cell, which doubled the dump and made it unreadable. Text now goes through quote(), and only text containing a NUL byte is hex-encoded. Your round-trip test still passes, and a new assertion checks that plain text stays readable.
  • fr_admin.json was missing 49 of the new keys and fr.json 6; they are translated now.
  • The new test files were committed but still ignored by .gitignore, which whitelists test files one by one, so I added them.
  • image-jobs.yml now uses the same SHA-pinned Node 24 actions as the other workflows. On push it runs only for main; pull requests still run it.

One behaviour change I kept, documented. Removing @uppy/compressor means uploads are no longer re-encoded in the browser at 85 % JPEG with a 4000 px cap, and large PNGs are no longer converted to JPEG. For a photography portfolio, getting the untouched original (and its metadata) on the server is the right call, but it was not mentioned anywhere, so I added it to the changelog.

Tests. I added a regression test for each fix: queue parking, due-only wakeups, bounded synchronous wait, lock pruning, worker back-off, poll path, ETag, globals shape, GD WebP/GIF, the cjxl gate, JXL dimensions, the blur batch, show_equipment, the images:generate options, French coverage and the visitor watcher in Node. I checked that each of them fails on the previous commit of this branch and passes now. Results:

  • PHPStan is clean.
  • PHPUnit: 306 tests, 1380 assertions, 10 skipped (all environment-dependent).
  • Node tests pass, and the production build passes.
  • I also ran the whole image-jobs.yml pipeline locally against the Docker image: PHPUnit inside the image, the queue and admin option scripts, Apache upload and restart recovery, the Chromium checks, and PHP-FPM uploads with and without exec. Every step passed.

Splitting future changes of this size into a few PRs would help a lot, mostly so that CodeRabbit (capped at 100 files) can review them too. Thanks again!

@MiranoVerhoef

Copy link
Copy Markdown
Contributor Author

I will be pushing them in smaller chunks for now on!

I'm just editing the Docker to get away from Adobe Portfolio haha, thank you for all your work!

- Uploads: a file the server would reject (over the 40 MP pixel cap or
  PHP's upload size) is downscaled in the browser instead of failing;
  every other file is still sent byte for byte. 45-61 MP camera
  originals were accepted before only because of the old compressor.
- Per-image lock: the CLI waits up to 120 s for the holder; web requests
  get ImageProcessingBusy at once. An on-demand variant request no longer
  records a failure marker while the worker holds the image, and a busy
  image is postponed in the queue without counting an attempt.
- Lock files live in storage/image-jobs/locks, so the per-request job
  listing no longer scans one file per image; orphan locks are pruned at
  most once a day.
- A synchronous generation failure gets the same backoff and progress
  state as a drain failure. A missing placeholder is logged, not retried:
  the variants exist and the image is usable.
- SQLite busy_timeout is back to 30 s: transactions and direct PDO
  statements are not covered by SqliteRetry.
- Upload widget and progress panel treat parked ("failed") and retrying
  jobs as settled instead of spinning forever; new en/it/fr strings.
- iOS is matched on whole tokens ("Studios" is not iOS).
- Media library previews of public images are static files again;
  protected previews are cached privately for 5 minutes.
- File page cache ETag uses the file's stat instead of hashing it, and
  typography_version hashes the typography settings instead of
  generating the stylesheet on every request.
@fabiodalez-dev
fabiodalez-dev merged commit b10d475 into fabiodalez-dev:main Oct 27, 2026
3 checks passed
@fabiodalez-dev

Copy link
Copy Markdown
Owner

Thanks a lot, Mirano! I'm really glad you're choosing Cimaise to move away from Adobe Portfolio, that genuinely made my day. If anything in the Docker setup gets in your way during the migration, just open an issue and I'll take a look.

I merged this PR and it's out in 1.4.28, together with the new Docker image (fabiodalez/cimaise:1.4.28 and latest, still on PHP 8.5.12). Before merging I did one more review pass and pushed a few fixes on top of your branch:

  • Large originals: without the browser compression, originals from 45–61 MP cameras were rejected by the 40 MP limit or by PHP's upload size. Only those files are now downscaled in the browser; everything else is still uploaded byte for byte.
  • Per-image lock: a visitor requesting a variant while the worker held the image recorded a failure marker, and the queue counted that as a failed attempt. Web requests now get a "busy" signal that is not treated as a failure, the queue postpones the job without counting it, and the CLI waits up to 120 s for the holder. The lock files moved to storage/image-jobs/locks, so the job listing done on every request no longer scans one file per image.
  • SQLite busy_timeout is back to 30 s, since transactions and direct PDO statements are not covered by SqliteRetry.
  • Queue bookkeeping: a failure during synchronous generation now gets the same backoff as one in a drain, and a missing placeholder is logged instead of retrying a job whose variants all exist.
  • Admin UI: parked and retrying jobs no longer keep the upload spinner running, and the progress panel reports failed images.
  • Smaller things: the iOS check matches whole tokens only, public media library previews are static files again, and the typography version no longer regenerates the stylesheet on every request.

Smaller PRs from now on sound perfect. Thanks again for all the work!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants