10 — Media library and form builder#
10.1 Media library#
Media in the sidebar, and the same component opens as a modal from any image or gallery field.
One implementation, two placements.
| Feature | Detail |
|---|---|
| Upload | drag and drop onto the grid, or a file picker; multiple files; per-file progress |
| Folders | media_folders, nestable, drag a file onto a folder to move it; a breadcrumb at the top |
| Views | grid (default) and list with size, dimensions, type, upload date, uploader |
| Search | original name, title, alt text, caption |
| Filters | type (image / document / video), folder, date, unused |
| Detail panel | preview, alt text, title, caption, dimensions, size, URL, Used on N pages, replace, delete |
| Replace | uploads a new file in place, keeping the media.id, so every page using it updates at once |
| Unused | files with no usage index entry; listed so the owner can clean up, never auto-deleted |
| Bulk | move to folder, delete (with a usage warning naming the pages) |
Alt text#
The detail panel asks for alt text with a short explanation of why. An image inserted without alt
text is flagged in the editor outline with a soft warning, and Dashboard -> Needs attention counts
images missing alt text. Decorative images get an explicit This image is decorative toggle, which
emits alt="" — so a missing alt is always a mistake, never a choice we cannot distinguish.
Upload limits and validation#
| Check | Rule |
|---|---|
| Extensions allowed | jpg, jpeg, png, gif, webp, avif, svg, pdf, doc, docx, xls, xlsx, ppt, pptx, zip, mp4, webm, mp3 |
| Extensions blocked | anything else, explicitly including php, phtml, php3..8, phar, sh, exe, bat, cgi, pl, py, htaccess, htm, html |
| MIME | checked with finfo against the extension; a mismatch is rejected |
| Images | re-encoded through the image pipeline, which destroys embedded payloads |
| SVG | sanitised (scripts, event handlers, external references and <foreignObject> removed) before storage; if sanitisation changes nothing suspicious it is kept, otherwise rejected with a message |
| Size | default 8 MB per file, configurable, capped by upload_max_filesize — the screen shows the server limit, because on shared hosting it is usually the real constraint |
| Filename | slugified, de-duplicated with a numeric suffix; original kept in original_name |
| Storage path | media/{year}/{month}/{slug}.{ext} on the public disk |
Uploads are stored outside the webroot-executable path only by virtue of being on the public disk
under storage/app/public, served via the public/storage symlink. The deploy checklist verifies
that the webserver does not execute PHP under storage/, and the installer writes a defensive
.htaccess (php_flag engine off, RemoveHandler, ForceType text/plain) into the media
directory for Apache/LiteSpeed hosts.
Image pipeline#
upload
-> validate (extension, mime, size)
-> read with the detected driver (imagick, else gd)
-> strip EXIF except orientation; apply orientation
-> resize down if wider than `media.max_width` (default 2560)
-> encode primary as WebP (quality 82); keep the original file as well
-> generate variants: thumb 320w, medium 768w, large 1536w (WebP, skipping sizes larger than the source)
-> write media row with conversions{} and dimensions
- Variants are generated inline on upload, one file at a time, with the request kept small enough for shared-hosting limits. A batch of uploads processes sequentially with visible progress, so a timeout loses at most the file in flight — already-processed files are committed.
kodepress:media-optimizere-processes files that predate a settings change, in chunks, driven by cron.- Output markup:
<img loading="lazy" decoding="async" width height srcset sizes>with a<picture>wrapper offering WebP and the original type. Width and height are always emitted, so there is no layout shift. - The first image of the first section (typically a hero) is emitted with
loading="eager"andfetchpriority="high"; everything else is lazy. The renderer knows the position, so this needs no user decision.
10.2 Form builder#
Settings -> Forms lists forms; the builder edits one. The form block on a page selects a form by
id, so the same form can appear on several pages and all submissions land in one place.
Field types#
| Type | Stored value | Validation offered |
|---|---|---|
text |
string | required, min, max |
textarea |
string | required, min, max |
email |
string | required, email format |
phone |
string | required, pattern (Bangladesh pattern offered as a preset) |
number |
number | required, min, max |
select |
string | required, one of the options |
radio |
string | required, one of the options |
checkbox |
bool | required (must be checked) |
checkbox_group |
array | required, min/max selections |
date |
date | required, min, max |
file |
media id | required, allowed types, max size |
hidden |
string | — (page URL, UTM values, a campaign id) |
heading |
— | layout only |
paragraph |
— | layout only |
consent |
bool | required; the label is richtext so it can link a privacy page |
Each field has: key (auto from the label, editable, unique), label, placeholder, help,
required, width (full / half / third), options (for choice types), default, and
conditional (show this field when another field equals a value).
Form settings#
| Setting | Default |
|---|---|
| Submit button label | translated Send |
| On success | show a message (default) or redirect to a page |
| Success message | translated default, editable richtext |
| Notify emails | the admin email |
| Email subject and body | templates with {{field_key}} placeholders |
| Reply-to | the form's email field, when one exists |
| Store submissions | on |
| Honeypot | on — a hidden field plus a minimum fill time of 2 seconds |
| Rate limit | 5 submissions per IP per 10 minutes, per form |
| reCAPTCHA / hCaptcha | optional, keys in Settings; off by default (no third-party calls unless asked) |
Submission flow#
POST /_kp/forms/{form}
-> rate limit (per IP + form) and honeypot / fill-time check -> silent 200 on failure for bots
-> validate against forms.fields (server-side, always; client-side hints too)
-> handle file fields: validate, run the image pipeline if it is an image, create media rows
-> store a form_submissions row (when store_submissions)
-> send notification email (queue connection is sync, so this happens inline)
-> audit log entry
-> respond: Livewire swaps in the success message, or redirects
- Email sending inline is a deliberate trade: with no queue worker available, the alternative is a
cron delay of up to a minute on a contact form. Failures are caught, the submission is still
stored, the user still sees success, and the failure is logged and surfaced in
Dashboard -> Needs attention. A submission is never lost because SMTP was down. - Spam marking: a submission flagged by the honeypot path that still gets stored (because the
heuristic is soft) is marked
is_spamand hidden from the default list.
Submissions screen#
Per form: a table of submissions with the first three fields as columns, read/unread state, a detail
drawer showing every field and the uploaded files, Mark as read, Delete, and Export CSV
(current filter, UTF-8 with a BOM so Excel opens Bengali correctly). Unread counts appear as a badge
on the sidebar Settings item and on the Dashboard.
Retention: forms.settings.retain_days (default null = forever). When set, a nightly command deletes
older submissions and their uploaded files, and the setting screen states what will be deleted.
10.3 Newsletter block#
A thin wrapper: it creates (on first use) a form with a single email field and a consent checkbox,
and renders inline. It is a separate block only because it appears in footers constantly and should
be one click, not six.
Integrations with external mailing services are out of scope for core; the submission is stored and can be exported. A plugin may push to a provider.
10.4 Backup and restore (Phase 4)#
Settings -> Backup offers a one-click backup producing a ZIP containing a mysqldump-equivalent
SQL file (written with PHP, since mysqldump may be unavailable) and the media directory.
Shared hosting makes this the most failure-prone feature in the product, so:
- The backup runs in chunks across cron ticks: schema, then tables in batches of rows, then media in batches of files, appending to the archive and recording progress in a state file. A timeout resumes rather than restarting.
- Progress is visible on the screen (
Backing up: media 340/1200) with a resumable state after a browser close. - The archive is written to
storage/app/backupswith a random filename, is never reachable from the web, and is downloaded through an authenticated streaming route. - Restore asks the owner to type the site name to confirm, takes a safety backup of the current database first, and restores in the same chunked manner. It refuses an archive whose manifest version is newer than the running code.
- Retention: keep the last N (default 3) local backups; the screen shows the disk space used, because filling a shared-hosting quota breaks the live site.
10.5 Tests for this document#
| Test | Asserts |
|---|---|
php_upload_rejected |
by extension and by a spoofed MIME |
svg_with_script_rejected_or_sanitised |
no <script> survives storage |
image_converted_to_webp_with_variants |
three variants, correct dimensions, originals kept |
oversized_image_is_downscaled |
to max_width |
img_tag_has_dimensions_and_lazy |
and the hero image is eager |
replacing_media_updates_every_page |
id stable, pages re-rendered |
deleting_used_media_warns_with_page_list |
and requires confirmation |
form_validates_server_side |
invalid POST returns errors even with client checks bypassed |
honeypot_and_fill_time_block_bots |
silent 200, nothing stored |
form_rate_limit_per_ip_and_form |
the 6th submission in 10 minutes is refused, and a different form is unaffected |
submission_stored_when_mail_fails |
mail exception swallowed, row present, failure logged |
csv_export_has_bom_and_bengali_intact |
round-trips through a UTF-8 read |
backup_resumes_after_interruption |
state file drives a resume, archive is complete |
KodePress documentation · generated from the Markdown sources by
tools/build-docs-site.py · internal preview, not indexed.