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:
- MIME validation: the type is determined by looking at the file's content (
finfo), not at the file extension. - Allowlist:
MimeTypeAllowlistis built into the core; modules cannot extend this list. - 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.jpgis stored asjpgunder a random name. - Deduplication by content hash: if the same content is uploaded a second time, the same asset is reused.
- Non-executable folder: files are written under
public/uploadsand the.htaccessthere 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.
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/.htaccessprotections 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;
originalNameis only for display. - Use
AssetUrlGeneratorfor the address and the thumbnail helper for size.