Image variants#
One uploaded image yields several resized copies ("variants"). What is made and how large is read from the database and edited in the administration. For the site administrator the same is in Image variants.
Tables and entities#
Migration sql/2026-10-08_01_system_asset_variants_tables.sql (seed _02, Api RBAC _03,
pages and Admin RBAC _04; rollbacks _51–_54).
The width-only variant and the usage_key column came with the migrations
sql/2026-10-09_01_system_asset_variants_height_zero.sql (a CHECK change, rollback _51) and
sql/2026-10-09_02_system_images_usage_key.sql (rollback _52) — see
A width-only variant and The image record.
| Table | What it is | Notes |
|---|---|---|
cms_system_asset_variants |
a variant = technical data: name, max_width, max_height (0 = no height limit), resize_mode (fit / cover), watermark, position, activated |
name is UNIQUE and immutable after creation; also UNIQUE on (max_width, max_height, resize_mode, watermark); CHECK chk_asset_variant_dims (max_width >= 1, max_height >= 0) |
cms_system_asset_usages |
a usage = the former "asset type" (usage_key, e.g. blog-articles), module_id, keep_original, position |
created only by a migration; the label comes from the translation admin.system.assetUsages.<usage_key> |
cms_system_asset_usage_variants |
which variants are made for a usage: usage_id, variant_id, alias, position |
UNIQUE (usage_id, alias); the FK to the variant is RESTRICT, to the usage CASCADE |
- Entities:
ImageVariantandImageUsage(a SharedMappedSuperclassinapp/Core/Base/Shared/System/Models/Entities/+ the Api and Admin layers), the enumImageResizeMode(fit=NetteImage::ShrinkOnly,cover=NetteImage::Cover). - The assignment (
cms_system_asset_usage_variants) is not an ORM entity. It is read and written with raw DBAL inAssetUsageRepositoryand saved by a single Api operation,saveAssignments— one transaction,FOR UPDATEon the usage row, DELETE + INSERT. The reason: collections through the ApiBridge have known pitfalls (see Hydrators). - The original is not a variant. It is the
keep_originalflag of the usage; the folder is alwaysoriginaland it has no alias. For video the original is always stored. - The rules (alias
^[a-z0-9-]{1,32}$, uniqueness of alias and folder, mandatory aliases per usage — see Required aliases per usage, width 1–8000 px, height 0–8000 px) are held by the pure functionApp\Core\Utils\Helpers\AssetVariantPlan. The Admin uses it for early validation; the Api is the authority and validates again.
A width-only variant#
max_height = 0 means "no height limit". The column is smallint unsigned and the UNIQUE
index accepts zero; only the CHECK chk_asset_variant_dims changed (migration 2026-10-09_01,
repeatable: DROP CONSTRAINT IF EXISTS + ADD; rollback _51 deletes the variants with
height 0 and their assignments).
AssetVariantPlan::validateVariant()accepts a height of 0..8000;coverwith height 0 is rejected with the codecoverNeedsHeight(the form maps it to themaxHeightfield). The recommended name:w1600.VariantRenderer:maxWidth > 0 && maxHeight === 0→resize($w, null, ShrinkOnly)(proportional, no enlarging);0×0stays a raw copy; the watermark and the image-bomb check are unchanged.- ⚠️ Behaviour change: the image library (
system-images) and custom plans withmaxWidth > 0andmaxHeight = 0are now shrunk by width (before: a raw copy).assetVariants.neonhas no such row (all 24 are0/0).
Rendering: srcset from the image record#
The responsive <img srcset sizes> is built from the real widths in the image record, not
from maxWidth (that is only an upper bound, and a w descriptor with a false width would
mislead the browser).
The Asset entity (app/Core/Base/Shared/System/Models/Entities/Asset.php):
| Method | What it returns |
|---|---|
getVariantFiles() |
the variant files without touching the disk: folder, variant, alias, width, height, maxWidth, original; width/height only from meta.variants[folder], null without meta |
getVariantsSortedByWidth() |
resized copies ascending by real width (else maxWidth), the original always last |
getVariantFallbackOrder(?string $wanted) |
folders in the order they replace a missing file (see the rendering rules below); the width of an unknown folder is derived from the name WxH or wN |
getThumbnailPath() |
the preview path: thumb → smallest resized copy → original → full path; video: the thumb poster only when posters exist, otherwise the video file |
App\Core\Utils\Helpers\AssetSrcset (candidates(), build()) is pure logic without a disk:
no original, only a real width, an equal width only once (the smaller maxWidth wins), fewer
than two candidates = an empty result (a plain <img> is rendered); a space and a comma in the
URL are encoded.
The front AssetRenderer ({control frontSystemAssetRenderer $asset, …}) has two parameters:
responsive: true— turnssrcseton, the defaultsizes="100vw";sizes: '(min-width: 992px) 33vw, 100vw'— your ownsizes, turnssrcseton even withoutresponsive.
A call with only variant: 'thumb' and no sizes (most lists) does not change: a plain <img>.
The template emits <picture><img src srcset sizes></picture> (the wrapper stays because template
styles depend on it, the <source media> elements are gone); on a load error asset-fallback.js
removes srcset and sizes too.
AssetRenderer handles a missing file in two steps:
| Step | Who | What it does |
|---|---|---|
| server | AssetPlaceholderHelper::exists() |
is_file() on the path derived from the public URL; if the file is missing it takes the next folder in the fallback order, finally the placeholder image. When the path cannot be derived it renders a normal <img> (an existing image is never hidden) |
| client | data-asset-fallback + asset-fallback.js |
on error (404, stale meta, file outside the web server) puts the placeholder image into src |
The placeholder is an image, not an icon box: only src is swapped, so the list styles
(object-fit, hover zoom, box height) keep working. A video is not replaced: without the file a
placeholder <img> is rendered instead of the player. The call
{control frontSystemAssetRenderer null, alt: $name} gives the placeholder right away (an item
without an image); inside {foreach} call {multicontrol …}.
A call without variant (most lists) first targets a file directly in the asset folder, which only
the old upload created; the current pipeline stores files only in variant folders. When it is
missing, the resized variants are tried ascending and the original only last, so a small list slot
does not get the full photo.
Rendering rules:
| Topic | Rule |
|---|---|
Original in srcset |
The original does not go into srcset (it has no size cap); without at least two resized copies of a known width the output is a plain <img>. |
Enabling srcset |
srcset only on request: responsive: true or a passed sizes. |
Default sizes |
The default sizes with responsive: true is 100vw. |
src |
src = the requested variant, otherwise big, otherwise the largest resized copy. |
width / height |
No width/height on the <img> (it would change the layout of lists; a separate step). |
| Missing variant | Replacing a missing variant: requested → the nearest larger resized copy → smaller descending → original → placeholder image. |
| Administration preview | The preview in the administration is the thumb folder, otherwise the smallest resized copy, otherwise the original. |
A variant without an alias (in a usage) is made into a folder named after the variant and gets
into srcset as another width; the usage's required aliases (usually big and thumb) stay mandatory.
Reading: AssetVariantProvider#
App\UI\Api\System\Models\Helpers\AssetVariantProvider::getVariants(string $type) returns
the same shape as before, with a few extra keys:
array<string /* folder */, array{
maxWidth: int, maxHeight: int, resizeFlags: list<int>, useWatermark: bool,
variant?: string|null, alias?: string|null, original?: bool
}>
- Folder =
alias ?? variant name; ordered byposition,originalalways last (and only whenkeep_original = 1). Inactive variants are left out. - The plan is loaded with one query and held in memory for the request (no Redis, no invalidation).
assetVariants.neonis now only the default set and a fallback for the 24 usages of the other modules. It is used when the tables are missing (code deployed before the migration), the query fails, or the usage has no active assignment in the DB; the provider then writes to theassetslog. A change of sizes in the neon file has no effect on a site with a migrated DB. The five usages of the System module (Site identity, Operator, image library) have no neon fallback — their plan is read from the DB only.VariantRendererno longer injects the provider, it gets%watermarkPath%directly (otherwise the DI would form a cycle).
The former exceptions are gone
The blog gallery and category, the classifieds and discussion category, and the e-shop
product/set used to read sizes from the module settings (variantFromSettings(),
EshopAssetTrait). All of them now read the provider under their own usage. Site
identity, Operator and the image library are in the tables too, see
Images of the System module.
The old settings keys are no longer read
productImage*, productThumbnail*, productResizeType*, gallery*, article* and
category.image.* / category.thumb.* are read by no code and no form. They still
remain in cms_system_settings (no cleanup migration was written). Do not put them
back into forms — saveSettingForm() writes every listed key.
The image record#
A cms_system_images row carries in variants, for each variant besides name (= the
folder), the sizes, the watermark and resizeFlags, also:
{"name":"big","maxWidth":1200,"maxHeight":900,"resizeFlags":[],"useWatermark":false,
"variant":"1200x900","alias":"big"}
variant is the variant's name, alias the alias (or null); the original has
"original": true. The real file sizes stay in meta.variants[<folder>]. An image uploaded
before the tables were introduced has no variant key — readers that treat name as the
folder work unchanged. For fit, resizeFlags is [] (some types had [1] before; the
effective behaviour is the same).
The usage_key column#
cms_system_images.usage_key (varchar(64) NULL, index idx_system_images_usage_key, deliberately
without an FK; migration 2026-10-09_02, rollback _52) holds the usage key under which the image
was uploaded — preparation for regenerating variants. It is written by
AssetProcessingTrait::saveAssetVariants() from getAssetUsageKey() (default =
getAssetVariantType()); ImageManager overrides it and returns the key of the usage being processed
(see Images of the System module). In the entity Asset::$usageKey is
#[ExternalReadonly] — a client cannot overwrite it. Old records are NULL; regeneration fills them in, see below.
Required aliases per usage#
Templates ask for some folders by name (the favicon <head>, the operator card), so the
required aliases are not the same for every usage. They are held by the constant
AssetVariantPlan::REQUIRED_ALIASES_BY_USAGE and read by
AssetVariantPlan::requiredAliasesFor(string $usageKey): list<string>; a usage outside the
constant gets REQUIRED_ALIASES (big, thumb).
Usage (usage_key) |
Required aliases |
|---|---|
system-identity-favicon |
icon32, icon180, icon512 |
system-identity-logo |
thumb |
system-operator-photo |
avatar, thumb |
system-images |
thumb |
system-identity-og-image and all others |
big, thumb |
The function is used by validateAssignments() (an optional 4th parameter $usageKey; empty
= big + thumb, so old calls are unchanged), AssetUsageManager::saveAssignments(), the
AssetUsageSettings component (early validation in the Admin), AssetVariantManager::validate()
and AssetVariantFormTrait (a variant that carries a required alias of some usage cannot be
turned off — error requiredInUse). The missingAlias error carries the name of the missing alias.
A new usage with its own required aliases = add a row to the constant, otherwise the pair
big + thumb applies.
Images of the System module#
Site identity, Operator and the image library (formerly variants hard-coded in the form traits)
take their plan from the tables like every other usage. Migration
sql/2026-10-17_01_system_asset_variants_system_usages.sql (rollback _51_…_rollback.sql;
repeatable, no DELIMITER, the deploy runs it itself) creates five usages of the System
module (module_id 1), the missing variants (deduplicated by size, mode and watermark),
the assignments with NOT EXISTS, and fills in usage_key on existing images.
usage_key |
Label in the administration | Default assignment (alias: size) |
|---|---|---|
system-identity-favicon |
Identita webu — favicona | icon32: 32×32, icon180: 180×180, icon512: 512×512 (cover) + original |
system-identity-og-image |
Identita webu — sdílecí obrázek | big: 1200×630 (cover), thumb: 400×210 (cover) + original |
system-identity-logo |
Identita webu — logo | thumb: 400×200 (fit) + original |
system-operator-photo |
Provozovatel — fotka | avatar: 240×240 (cover), thumb: 400×400 (fit) + original |
system-images |
Knihovna obrázků | big: 1200×900, thumb: 400×300 (fit) + original |
The logo covers all image fields of the Identity except the favicon and the sharing image.
The Api decides the plan, not the form. The Admin traits (SiteIdentityFormTrait,
OperatorFormTrait, ImageFormTrait) send no variants; they put only usageKey into $meta:
App\UI\Api\System\Models\Managers\ImageManager accepts only the keys of the USAGE_KEYS
whitelist (the five above); any other, missing or non-string key falls back to system-images,
so an Api client cannot invent a foreign usage. It takes the plan from AssetVariantProvider
and writes the key to cms_system_images.usage_key; when a file is replaced, usage_key is
copied onto the edited record together with variants. When the plan is missing in the DB
(these five usages have no neon fallback), it returns Result::failure(ResultCode::NOT_AVAILABLE)
instead of an image without folders. The
Api no longer forces the original folder — the usage's keep_original controls it, as for
the others. $meta flows Admin → Api unfiltered (internal transport); on the external path
filterExternalMeta() drops it.
No neon fallback. config/Shared/assetVariants.neon does not contain these five types —
the variant tables in the DB are the only source of truth. When the plan is missing (a table
outage, a usage with no assigned variants), the upload is refused: the administration (Site
identity, Operator, Images) shows a clear message, nothing is saved and the current image stays.
The image library. Entering variants per image (the Multiplier) is gone. A new upload
takes the plan of the system-images usage; the edit form (Image/edit.latte) shows a
read-only overview of variants from Asset::getVariantFiles() and saving without a new file
leaves the variants record unchanged.
Contract. php tests/Manual/System/asset_variants_system_contract.php compares the effective
plan of the five usages with the hard-coded values (tests/Fixtures/asset-variants-system-before.json),
checks the required aliases and the usageKey whitelist; output 5/5 OK, otherwise a diff and
exit 1. The snapshot of the original 24 types is still guarded by asset_variants_contract.php.
Unit tests of the required aliases: tests/Unit/System/AssetVariantPlanTest.php.
Administration and RBAC#
- The screen
Nastavení → Varianty obrázků:AssetVariantPresenter(list, add, edit, delete through thedeleteVariant!signal (POST,#[Requires(methods: 'POST')]; the grid row action has->setPost(), the button is submitted by an empty form outsidegridForm), the Použití tab). - Assignment in the module settings (Blog, E-shop, Classifieds, Company catalogue,
Discussion): the
AssetUsageSettingscomponent (app/UI/Admin/System/Components/AssetUsageSettingsComponent/) added by the traitApp\Core\Traits\Admin\Presenters\AssetUsageSettingsTrait; it has its own form and button and is not saved with the settings form. - The Použití tab builds one component per module:
AssetVariantPresenter::createComponent()createsassetUsageSettings_<moduleId>andAssetUsageSettings::setSection()controls the heading above the first one. A single shared form would have about 1700 fields (29 usages × 19 variants × 3 fields) and would exceed PHPmax_input_vars(default 1000) — the submission would be silently discarded. The largest form is now about 420 fields; watch any new usage with many variants. - Api presenters
api/system/asset-variant(CRUD) andapi/system/asset-usage(get-assignments,save-assignments,get-regeneration-stats,regenerate-variants,delete-originals,delete-outdated-variants).
| Layer | Keys |
|---|---|
| Admin | :Admin:System:AssetVariant:show, :add, :edit, :del, :regenerate, :deleteOriginals, :deleteOutdated |
| Api — variants | :Api:System:AssetVariant:get, getAll, getBy, save, delete, updateColumns, deleteAll |
| Api — assignments | :Api:System:AssetUsage:getAssignments, :saveAssignments, :getRegenerationStats, :regenerateVariants, :deleteOriginals, :deleteOutdatedVariants |
Assignment in a module's settings requires :Admin:<Module>:Setting:show; the Použití
tab (incl. the Company module) requires :Admin:System:AssetVariant:edit. The variant Api
deleteAll is rejected (it would bypass the in-use check); updateColumns is validated like
a save.
Regeneration and deleting originals#
Old images have only the folders the usage plan prescribed at upload time. Regeneration fills
them in and can delete stored originals. Migrations sql/2026-10-10_01 (the
variants_attempts column), _02 (Api RBAC) and _03 (Admin RBAC); deleting variants outside the setting: sql/2026-10-13_01 (Api RBAC
deleteOutdatedVariants) and _02 (Admin RBAC deleteOutdated); rollbacks 2026-10-10_51–_53,
2026-10-13_51–_52; all repeatable, the deploy runs them itself. For the administrator:
Older images: regeneration.
| Part | What it does |
|---|---|
App\Core\Utils\Helpers\AssetVariantBackfill |
pure logic without disk and DB: decide() (plan × record × files on disk → create / reconcile / skipped + reason tooLarge, noSource, watermarkedSource; state complete / pending / blocked / exhausted), classify() (the three counts per usage), expectedSize() / staleFolders() / withOverwrite() (regenerating existing files), outdatedFolders() / withoutFolders() / canDeleteOutdated() (variants outside the setting), applyToRecord(), canDeleteOriginal(), withoutOriginal(), usageKeyFromPath(), likePatterns(); MAX_ATTEMPTS = 3, PROTECTED_FOLDERS |
App\UI\Api\System\Models\Managers\AssetVariantRegenerationManager |
process orchestrator outside Services/: findStats(), regenerate(?usageId, batch, overwrite, retry), deleteOriginals(usageId), deleteOutdatedVariants(usageId); image rows go through raw DBAL via the chain AssetManager → AssetService → AssetMapper → AssetRepository, new folders are rendered by the same VariantRenderer as an upload |
AssetUsagePresenter (Api) |
get-regeneration-stats, regenerate-variants (overwrite, retry), delete-originals, delete-outdated-variants; the writing ones POST only (GET = 405), usageId and the flags through RequestParamShape (a bad value = 400) |
AssetVariantRegeneratePresenter (Cron) |
/cron/system/asset-variant-regenerate?token=…[&limit=] |
AssetVariantPresenter (Admin) |
signals handleRegenerate(usageId) and handleDeleteOriginals(usageId) on the Usages tab, both #[Requires(methods: 'POST')] with their own gate |
Cron#
/cron/system/asset-variant-regenerate?token=…[&limit=25]: first fills in usage_key of old
images, then processes at most limit images per each usage (default
DEFAULT_BATCH = 25, cap MAX_BATCH = 500). The response carries the counts (processed,
created, overwritten, failed, skipped, remaining). The cron never overwrites existing
files (overwriting existing files is only the action for a single usage in the administration, i.e. the Api
regenerate-variants with overwrite). After a deploy repeat it until remaining is
0; afterwards a nightly run is enough. The frequency is in the presenter docblock, so it
shows up in the overview of scheduled jobs.
Lock and batches#
- The
CronLockManagerlock namedasset.variants.regenerate(TTL 300 s, extended after each image) is shared by the cron, the button and the deleting of originals, so batches never run concurrently. The other side getsDB_LOCKEDand the administration says it is already running. - The time budget of one batch is 45 s; deleting originals handles at most 500 files per
click (
DELETE_BATCH) and returns the rest asremaining.
Attempts: variants_attempts#
The column cms_system_images.variants_attempts (tinyint unsigned, #[ExternalReadonly]).
A failure (unreadable source, exception while rendering) raises it; after 3 attempts the
image is exhausted and skipped. A successful processing of the image resets it
(saveVariantRecord(…, resetAttempts: true)), "cannot be regenerated" does not change it. The
Try again button (regenerate-variants with retry) first resets the attempts of the usage
(resetVariantsAttempts()) and then runs a batch. attempts > 0 alone is the state "Could not
be generated". A separate column is deliberate: thumbnails_attempts belongs to the video catch-up
cron and every upload raises it for images.
Filling in usage_key from the path#
Old records have usage_key = NULL. Every batch run fills them with one UPDATE … CASE WHEN
path LIKE … by path (AssetVariantBackfill::PATH_USAGE, assetDir roots from
parameters.neon). What cannot be determined unambiguously stays NULL and is skipped; a
guess is never written. ⚠️ A new usage with its own root belongs in PATH_USAGE too,
otherwise its old images stay without a key.
Regeneration and deletion rules#
| Topic | Rule |
|---|---|
| Source | The source of regeneration is the original. Without it only variants with a smaller or equal width are made from the largest existing copy without a watermark (height is not considered); a watermarked source = blocked (watermarkedSource). |
| Image usage | The usage of an image = usage_key; for NULL it is filled in from the path, ambiguous ones are skipped. |
| Filling in | Only adds missing folders (missing = the file is not on disk). An existing file is never overwritten. A folder whose file is on disk but the record does not know it (an interrupted run), and an unknown original/, are just written to the record (reconcile) together with meta from getimagesize. Video and SVG are skipped. |
| Batch | A batch of 25 images per usage; GET_LOCK, 3 attempts. |
| Delete originals | "Delete originals": raster only, only when both the record and the disk have original and big. The record is written first (without original in variants and meta), then the file is deleted. A usage with keep_original = 1 is refused by the Api (INVALID_STATE), the UI does not show the button. Its own key, POST only. |
| Counts per usage | Three numbers per usage — counts of images, an image can be in more than one: missing current variants (missing: the plan has a folder the record does not know, the file is not on disk and can be made), could not be generated (failed: the record lists the folder and the file is missing, or variants_attempts > 0), cannot be regenerated (unfixable: the file is missing and there is no source — without an original the target is larger than the largest reduction without a watermark). Only a missing record of an existing file is counted nowhere. Pure function AssetVariantBackfill::classify(). |
| Also regenerate existing | "Also regenerate existing" (overwrite): overwrites files of plan folders whose actual dimensions (from meta, otherwise getimagesize) differ from what would be made from the original today (expectedSize() computes it like VariantRenderer — Nette Image::calculateSize(), incl. Cover); only with an existing original, respects the attempt limit. Atomic write: render to .regen-<random>-<name>.<extension> in the same folder, rename() after all are rendered, finally cleans temporary files. Updates variants and meta. The watermark is not examined. |
| Variants outside the setting | "Delete variants outside the current setting": folders the record has and the usage plan does not. Never big / thumb / original — both the folder and the alias are protected (PROTECTED_FOLDERS). Only raster and only with the original on disk; a plan without a reduction cleans nothing. Record first (variants and meta through withoutFolders()), then the file + rmdir of an empty folder (shared by other images of the path). At most 500 images per click. Its own Api and Admin key, POST only. |
RBAC#
| Layer | Keys |
|---|---|
| Api | :Api:System:AssetUsage:getRegenerationStats, :regenerateVariants, :deleteOriginals, :deleteOutdatedVariants (groups System:AssetUsage:show, regenerate, deleteOriginals, deleteOutdatedVariants; admin role only) |
| Admin | :Admin:System:AssetVariant:regenerate, :deleteOriginals, :deleteOutdated (its own key for the irreversible deletion) |
After a deploy clear temp/cache (RBAC). Check that GET on the signals gives 405 and that
NonstandardActionPrivilegeGateTest guards both the gate and #[Requires(methods: 'POST')].
Upload formats#
Only one place knows which formats can be uploaded: AssetFormatRegistry. It collects
extensions and MIME types from the processors (AssetFormatProcessor); each processor serves
one media kind (AssetFormatKind). Callers never list extensions, only the kinds they
accept in that place.
| Processor | Kind | Extensions | What it does |
|---|---|---|---|
RasterImageProcessor |
Raster |
jpg, jpeg, png, gif, webp (jfif, jpe, jif are renamed to jpg) |
checks with ImageUploadPolicy, variants via VariantRenderer (resize, watermark) |
VectorImageProcessor |
Vector |
svg |
sanitizes with SvgSanitizer, then writes the same bytes to every variant |
VideoProcessor |
Video |
mp4, mov |
verifies the content with ffprobe, the original goes to original/, variants are posters from the first frame |
What applies where lives in the manager code ($kinds before calling saveAssetVariants()):
Blog, Company and e-shop images take only Raster, Classifieds and Business directory category
icons Raster + Vector, the "Images" administration (ImageManager) all three. Without an
argument AssetProcessingTrait takes only Raster (saveAssetVariants(),
resolveAssetExtension()); a dropzone gallery takes getAssetFormatKinds(), by default
Raster + Video.
A variant without a transformation (maxWidth and maxHeight 0, no watermark) is written as a
raw copy of the bytes, not through GD. Only when some variant needs decoding are the header
dimensions checked against the ImageUploadPolicy limit, so a small file with huge dimensions
never reaches GD and cannot exhaust memory.
Procedure: adding a new format
- Write a class implementing
AssetFormatProcessor:getExtensions(),getMimeTypes(),getKind()andprocess(), which only writes files and returns anAssetProcessingResult. - Register it as a service in
config/Shared/assetVariants.neonand add it toprocessorsof theassets.formatRegistryservice. Nothing else changes. - In the managers where the format should pass, add its kind to
$kinds. For SVG the administration form also needsaddImageUpload(…, allowVector: true), otherwise the field validation rejects the file first. - Clear
temp/cache(DI container) and upload a file everywhere it should pass, and also where it should not.
A processor does not create an Asset or touch the database
The caller creates and stores the image row inside the save transaction. A processor that writes to the DB itself bypasses the rollback: a failed save leaves a row that belongs to nothing.
Two processors with the same extension break the container
The registry throws a LogicException when it is built, and every page that uses uploads
fails. Extensions must be disjoint across processors.
An unknown extension is not renamed
resolveExtension() returns null and the caller discards the file. Do not try to "rescue"
an unknown extension by renaming it to .jpg: an SVG would be stored as a .jpg with markup
inside.
SVG#
SVG is XML that can run scripts. Files are served straight from www/, so an SVG opened on its
own URL runs as a document on the domain where the administrator has a session. That is why
SvgSanitizer cleans it with a whitelist:
| Rule | Why |
|---|---|
an element or attribute outside ALLOWED_ELEMENTS / ALLOWED_ATTRIBUTES is removed, on* always |
a blacklist always forgets something |
<image> and <a> are not on the list |
an icon does not need them and both can link outside |
href / xlink:href only #anchor |
<use href="#icon"> works, an outside link does not |
the DANGEROUS_CSS mask (url() other than #, @import, expression(, javascript: …) runs on every attribute and on <style> |
presentation attributes (fill, filter) take url() just like CSS |
| CSS escapes are decoded and comments dropped before the mask | a browser reads @\69 mport as @import |
invalid XML, a root other than <svg> or an internal DTD = rejected |
entity bombs and XXE |
A dangerous attribute is removed on its own, not the whole element: the icon then renders with
the default fill instead of losing its shape. url(#grad) stays, exported icons use it all the
time.
SVG is not resized but it is copied into every variant folder
(VariantRenderer::copyToVariants()): the variant plan is always stored in Asset.variants and
templates build thumb/<name>.svg from it.
Vector only where an administrator uploads
Sanitization is a defence, not a permission. A context where a front-end user uploads
(attachments, listing photos) does not get Vector: the victim of a stored script would be
the administrator who opens the file.
Video#
Content verification is not best effort: a file without an ISO BMFF header, a server without
ffprobe, or content that ffprobe does not accept as MP4/MOV with a video track is rejected
and nothing is written to disk. Verification runs on a temporary file outside the webroot and
only the verified video is moved into place.
The poster, on the other hand, is best effort. Without ffmpeg, or when extraction fails, the
image is created with thumbnailsGenerated = false and the cron
/cron/system/asset-thumbnail-catchup/generate (daily at 4:00) fills in the variants through
AssetManager::backfillThumbnails(). It calls the same VideoProcessor::renderPosterVariants()
as the upload. Without ffmpeg the cron ends as skipped.
After two failed attempts the cron gives up on a video
Every failure increments thumbnails_attempts (including a missing file or a missing variant
plan), and whatever reaches AssetManager::MAX_ATTEMPTS (2) drops out of the selection for
good. After fixing the server (installing ffmpeg), reset thumbnails_attempts for videos
with thumbnails_generated = 0, otherwise they stay without a poster.
Releasing the old image#
The cms_system_images row and the image files do not disappear by themselves after a
replacement, a removal or a delete of the owner. AssetProcessingTrait releases them, and
where it does so depends on the situation:
| Situation | Call | When it runs |
|---|---|---|
| replacing or removing a single image (logo, photo) | releaseAsset($old, $newId) |
in afterSave, inside the transaction, only after a successful updateColumn() of the foreign key |
| images dropped from a gallery or form | releaseDroppedAssets($before, $kept) |
in afterSave, after the relations and the main image are written |
deleting the entity through delete() |
completedAssetCleanup() |
after the commit, on its own from DeleteTrait |
deleting a subtree through deleteNode() |
captureOwnedAssetIds() before the delete, releaseOwnedAssets() after it |
on its own from TreeTrait |
soft delete (deleted = 1) |
nothing | the record can be restored, the image must stay |
The replacement runs in the transaction on purpose: deleting the row is atomic with switching the foreign key, and the reference check runs on the same connection, so it sees the key just written. Deleting an entity, on the other hand, runs only after the commit, because inside the transaction the foreign key to the image would still be alive.
Whether an image may be deleted is decided not by the trait but by
AssetManager::releaseAssetRow(). It looks for references through information_schema (all
columns with a foreign key to cms_system_images), and when something still points to the
image or it is not sure, it returns null and the image stays with its files.
Procedure: a manager whose images should be released after a delete
- The manager uses
AssetProcessingTrait. - Define
ownedAssetIds(object $entity): array: return the foreign keys of the images the entity owns (an id, anAssetentity ornull). ExampleBlog\…\NewsManager::ownedAssetIds(). - Nothing else.
DeleteTraitandTreeTraitstart on their own based on this method. Images in a link table (gallery, intro panel) are the exception, see the warning below. - Verify: delete a record and check that the row in
cms_system_imagesand the files are gone, and that a shared image stayed.
Capture images from a link table in beforeDelete()
ownedAssetIds() runs only after the commit, when the cascade
(cms_mod_blog_gallery_image_relations, cms_mod_blog_intro_image_relations) has already
deleted the links. The entity preloaded by DeleteTrait does not carry the link
collection: it is lazy and the UoW is cleared after reading. beforeDelete() therefore
loads the entity again with the include (find($id, ['assetRels'])), stores the image ids
in a manager property and ownedAssetIds() only returns them from there
(Blog\…\GalleryManager, IntroManager). When ownedAssetIds() reads the collection
from the entity it receives, it returns nothing and the rows and files of all the images
stay orphaned.
releaseAsset() only after the foreign key was written successfully
Called earlier, it deletes an image the record still points to: when anything fails
afterwards, the database refers to a row that no longer exists. For the same reason,
single-image channels only scan the old files (listAssetFilesIn()) and delete them only
after success (deleteAssetFiles()).
releaseAsset() returns false for a shared image
Then do not delete the old files in the folder ($stalePaths) either: they belong to an
image another record points to.
A rollback knows nothing about files
The variants of a new image are written to disk inside the transaction. When the save
ends in failure, delete what you wrote on the failure path:
deleteAssetFiles($this->assetVariantFilePaths($absBase, $fileName, $variantConfigs)).
The lookup is by the random fileName, so another image in the same folder is not touched.
The cleanup is fail-soft: an error is only written to the assets log and neither the save
nor the delete fails. An orphaned row is a smaller defect than a broken edit.
Upload size limit#
App\Core\Security\Upload\ImageUploadSizeLimit::bytes() decides the upper limit for the
image uploader of classifieds and the company catalogue (front and admin): the
uploader parameter's maxSize (MB) > the module setting maxImageSize (MB) > 50 MB. The
file uploader is unchanged.