Skip to content

Media and storage

Media is one of the most attacked surfaces of a CMF: a user uploads a file and the server serves it. CPalius ties the upload pipeline to a single gate and hardens every step.

Upload: a single gate

No code writes files directly; AssetManager::upload() is the only gateway for writing. Every upload goes through these checks:

  1. MIME validation: the type is determined by looking at the file's content (finfo), not at the file extension.
  2. Allowlist: MimeTypeAllowlist is built into the core; modules cannot extend this list.
  3. Extension derivation: the extension on disk is derived on the server from the validated MIME, not from the file name the client sent. A file arriving as evil.php.jpg is stored as jpg under a random name.
  4. Deduplication by content hash: if the same content is uploaded a second time, the same asset is reused.
  5. Non-executable folder: files are written under public/uploads and the .htaccess there blocks script execution, MIME sniffing and content embedding with a layered protection.

Allowed types

Family Types
Image JPEG, PNG, GIF, WebP, AVIF, BMP, TIFF, HEIC/HEIF, ICO
Document PDF only
Video MP4, WebM, OGG video, QuickTime (MOV), Matroska (MKV)
Audio Deliberately excluded: until explicitly added to the map

SVG, HTML, archives and application/octet-stream are deliberately left out. SVG can carry scripts, HTML runs like a page, archives get around content inspection.

If you need an extra type

The allowlist belongs to the core. A module can request narrowing in its own call (restrictedTo([...]), $allowedMimePrefixes, $maxBytes); it cannot extend the allowlist.

Media library

The Media module (Studio → Media) lists and searches uploaded files and offers a media picker. The picker is called from other screens:

// single selection
window.CPaliusMediaPicker.open((asset) => { /* asset.id, asset.url, asset.originalName */ });

// multiple selection
window.CPaliusMediaPicker.open((assets) => assets.forEach(add), { multiple: true });

For an asset's preview information: GET /admin/media/api/assets/{id}/meta → {id, url, name, mime}.

Image/file fields use this picker automatically with the field-media script; see Field system.

Image thumbnails

ImageProcessor produces the image at the requested size on the first request and writes it under public/uploads/cache/. On every later request the web server serves the file directly, and PHP is not involved. The modes are crop (crop) and fit.

In templates the cp_thumb filter is used: {{ asset|cp_thumb(400, 300) }} (default crop) or {{ asset|cp_thumb(400, 300, 'fit') }}. The source can be an asset object, its id or a path. When an asset is deleted, the derivatives are cleaned with purge.

CDN

The CDN key follows a very simple contract: cdn.base_url replaces only the /uploads prefix.

Setup cdn.base_url
A pull zone in front of your own server https://cdn.example.com/uploads
A public bucket (R2 / S3 / Spaces) https://pub-xxxx.r2.dev

The CDN key is independent of remote storage. A pull zone requires no uploads: it pulls files not in its cache from your server. Imposing a bucket requirement would make the cheapest and most common setup impossible to express. cdn.images_only (on by default) makes only images be served from the CDN.

File addresses are always generated with AssetUrlGenerator; do not write "/uploads/".$key in your module.

Remote storage: S3, R2, FTP

Three targets can be defined in the AACP → Maintenance → Storage screen:

Target Settings (highlights)
S3-compatible endpoint, region (default eu-central-1), bucket, key/secret key, prefix, path_style
Cloudflare R2 region (auto), bucket, key/secret key, prefix
FTP host, port (21), user/password, path, passive mode, SSL, timeout

The target interface is deliberately minimal: put, has, delete, test. CPalius does not read remotely; this is a deliberate decision to avoid creating a dependency of "the remote file must be readable on every request".

Live write test

A target is not used until it passes a live write test with the current configuration. Credentials that look reasonable are not proof: a bucket policy that grants only ListBucket but forbids PutObject, an FTP account with a read-only home directory or a misspelled endpoint can only be caught by a test that writes a file and reads it back. The Save and test button does this; the result is stored.

Media offload

If remote storage is enabled, uploaded media is copied (not moved) to the remote target:

  • The local file stays and remains the primary copy. Image processing reads from disk with GD; a remote primary would mean downloading from the network for every thumbnail production.
  • The public/uploads/.htaccess protections do not travel to the bucket together with the bytes; there, protection is a bucket policy managed by the operator.
  • The local copy is not deleted so that the file backup does not stop including the media.

Result: the remote copy is a delivery optimization, not a move. That is why every error is recoverable: offload() does not throw an exception, it returns a bool; an S3 outage does not turn a working image upload into a 500.

Setting Default
storage.media.sweep_enabled off
storage.media.sweep_batch 200

The sweep scheduled task (core.media_offload_sweep, daily at 03:40) sends files that have not yet gone remote in bulk; it is the way to move the existing library when first switching on.

Storage for backups

The same targets are also used for backup delivery; for details and the different behaviour (there errors are loud) see Backup and maintenance.

For the module author

// Upload: the single authorized gate
$asset = $assetManager->upload($uploadedFile, allowedMimePrefixes: ['image/'], maxBytes: 5 * 1024 * 1024);

// Address: always the generator
$url = $assetUrlGenerator->forKey($asset->getStorageKey());
  • Do not write the upload yourself, use AssetManager.
  • Narrow the file type restriction in the call (like image/).
  • Do not use what the user sent as the file name; originalName is only for display.
  • Use AssetUrlGenerator for the address and the thumbnail helper for size.
Last updated: 13.10.2026