Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Image variants

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: ImageVariant and ImageUsage (a Shared MappedSuperclass in app/Core/Base/Shared/System/Models/Entities/ + the Api and Admin layers), the enum ImageResizeMode (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 in AssetUsageRepository and saved by a single Api operation, saveAssignments — one transaction, FOR UPDATE on 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_original flag of the usage; the folder is always original and 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 function App\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; cover with height 0 is rejected with the code coverNeedsHeight (the form maps it to the maxHeight field). The recommended name: w1600.
  • VariantRenderer: maxWidth > 0 && maxHeight === 0 → resize($w, null, ShrinkOnly) (proportional, no enlarging); 0×0 stays a raw copy; the watermark and the image-bomb check are unchanged.
  • ⚠️ Behaviour change: the image library (system-images) and custom plans with maxWidth > 0 and maxHeight = 0 are now shrunk by width (before: a raw copy). assetVariants.neon has no such row (all 24 are 0/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 — turns srcset on, the default sizes="100vw";
  • sizes: '(min-width: 992px) 33vw, 100vw' — your own sizes, turns srcset on even without responsive.

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 by position, original always last (and only when keep_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.neon is 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 the assets log. 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.
  • VariantRenderer no 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:

$meta['usageKey'] = 'system-operator-photo';

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 the deleteVariant! signal (POST, #[Requires(methods: 'POST')]; the grid row action has ->setPost(), the button is submitted by an empty form outside gridForm), the Použití tab).
  • Assignment in the module settings (Blog, E-shop, Classifieds, Company catalogue, Discussion): the AssetUsageSettings component (app/UI/Admin/System/Components/AssetUsageSettingsComponent/) added by the trait App\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() creates assetUsageSettings_<moduleId> and AssetUsageSettings::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 PHP max_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) and api/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 CronLockManager lock named asset.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 gets DB_LOCKED and 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 as remaining.

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

  1. Write a class implementing AssetFormatProcessor: getExtensions(), getMimeTypes(), getKind() and process(), which only writes files and returns an AssetProcessingResult.
  2. Register it as a service in config/Shared/assetVariants.neon and add it to processors of the assets.formatRegistry service. Nothing else changes.
  3. In the managers where the format should pass, add its kind to $kinds. For SVG the administration form also needs addImageUpload(…, allowVector: true), otherwise the field validation rejects the file first.
  4. 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

  1. The manager uses AssetProcessingTrait.
  2. Define ownedAssetIds(object $entity): array: return the foreign keys of the images the entity owns (an id, an Asset entity or null). Example Blog\…\NewsManager::ownedAssetIds().
  3. Nothing else. DeleteTrait and TreeTrait start on their own based on this method. Images in a link table (gallery, intro panel) are the exception, see the warning below.
  4. Verify: delete a record and check that the row in cms_system_images and 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.

Cookie settings