diff --git a/UPGRADE.md b/UPGRADE.md
index 3b97fbc58..696bce8c3 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -15,6 +15,9 @@ Upgrading from v4.3.3
- `Prado\Web\TUri` is now a PSR-7 `UriInterface`: scheme/host are lower-cased, path/query/fragment are
percent-encoded, and `getPort()` returns `?int` (`null` when absent or scheme default).
- TDatePicker::InputMode changed to TDatePicker::DateInputMode - due to conflict with TWebControl::InputMode
+- TActiveFileUpload now validates by default: attached validators run client side before the upload starts, and the page
+ validates the ValidationGroup during the upload callback before OnFileUpload is raised. Set CausesValidation=false to
+ restore the previous behavior.
Upgrading from v4.3.2
---------------------
diff --git a/agents/framework/Web/UI/ActiveControls/TActiveFileUpload.md b/agents/framework/Web/UI/ActiveControls/TActiveFileUpload.md
index 0a5f7d116..16cef71e4 100644
--- a/agents/framework/Web/UI/ActiveControls/TActiveFileUpload.md
+++ b/agents/framework/Web/UI/ActiveControls/TActiveFileUpload.md
@@ -18,8 +18,22 @@ Async file upload using hidden iframe. Does postback in hidden iframe followed b
- `onFileUpload($param)` - Event raised when file upload completes
- `getFiles()` - Gets uploaded file items
- `getBusyImage()`, `getSuccessImage()`, `getErrorImage()` - Status indicator images
+- `getCausesValidation()` / `setCausesValidation($value)` - Validation integration on/off, default true (@since 4.4.0)
+- `getValidationGroup()` / `setValidationGroup($value)` - Group the page validates during the upload callback (@since 4.4.0)
- `getClientClassName()` - Returns `Prado.WebUI.TActiveFileUpload`
+## Validation Integration (@since 4.4.0)
+
+With `CausesValidation` (default true) and a [TFileValidator](../WebControls/TFileValidator.md)/[TImageValidator](../WebControls/TImageValidator.md) attached to the control:
+
+- **Client:** `fileChanged()` runs `manager.validateControl(inputID)` before the iframe submit; an invalid selection skips the upload and the validators display their messages. Guarded — pages without the validator script or a validation manager upload as before.
+- **Server:** `raiseCallbackEvent()` calls `$page->validate(ValidationGroup)` after `loadPostData()` and before raising `OnFileUpload`. The handler checks `$this->getPage()->getIsValid()` (or the upload's `IsValid`) before `saveAs()`. The temp files persist in `TempPath` during the callback, so `finfo`/`getimagesize` sniffing works.
+- **Timing trap:** validate during the upload callback, never on a later postback — a successful upload clears the input and `onUnload` deletes the temp files, so later validators see an empty selection and pass vacuously.
+- Give the upload and its validators a dedicated `ValidationGroup` so the upload callback does not validate unrelated page controls.
+- The status icons reflect transfer status only; server-side validation feedback is the `OnFileUpload` handler's responsibility (e.g. via an active label).
+
+Functional test: `tests/playwright/active-controls/ActiveFileUploadValidatorTestCase.spec.js` + `TActiveFileUploadValidatorTest.page`.
+
## See Also
- `TFileUpload`, [ICallbackEventHandler](./ICallbackEventHandler.md), [TActiveFileUploadItem](./TActiveFileUploadItem.md)
diff --git a/agents/framework/Web/UI/WebControls/INDEX.md b/agents/framework/Web/UI/WebControls/INDEX.md
index cf1822b85..0d43d8776 100644
--- a/agents/framework/Web/UI/WebControls/INDEX.md
+++ b/agents/framework/Web/UI/WebControls/INDEX.md
@@ -91,6 +91,8 @@ All validators extend `TBaseValidator`. Shared properties: `ControlToValidate`,
| `TCustomValidator` | Custom server-side (and optional client-side) logic |
| `TEmailAddressValidator` | Valid email format |
| `TDataTypeValidator` | Value is correct data type (integer, date, etc.) |
+| `TFileValidator` | Files selected in a `TFileUpload`: size, count, extension, MIME type (@since 4.4.0) |
+| `TImageValidator` | Image files selected in a `TFileUpload`: TFileValidator checks plus pixel dimensions and a readable-image check (@since 4.4.0) |
| `TValidationSummary` | Displays all errors in a group; `DisplayMode` (List/BulletList/SingleParagraph) |
## Semantic HTML5 Controls (@since 4.3.3)
diff --git a/agents/framework/Web/UI/WebControls/TFileUpload.md b/agents/framework/Web/UI/WebControls/TFileUpload.md
index eae0872eb..f31b0d2ad 100644
--- a/agents/framework/Web/UI/WebControls/TFileUpload.md
+++ b/agents/framework/Web/UI/WebControls/TFileUpload.md
@@ -14,6 +14,8 @@ Since Prado 4.0, TFileUpload supports multiple-file uploads via the `Multiple` p
TFileUpload automatically sets `enctype="multipart/form-data"` on the page form during `onPreRender` (via `TForm::setEnctype` or, in callback mode, via `TCallbackClient::setAttribute`).
+Since Prado 4.4.0, the `Accept` property renders the HTML5 `accept` attribute to filter the file types offered in the browser file picker, and [TFileValidator](./TFileValidator.md) validates the selected files (size, count, extension, MIME type) on both client and server.
+
## Inheritance
`TFileUpload` → `TWebControl` → `TControl` → `TComponent`
@@ -32,6 +34,8 @@ Implements: `IPostBackDataHandler`, `IValidatable`
|---|---|---|---|
| `Multiple` | bool | `false` | Enables multi-file upload. Adds `multiple` attribute and `[]` to the `name`. |
| `MaxFileSize` | int | `1048576` | Advisory maximum size (bytes). Written to a hidden field; enforced by PHP, not the browser. |
+| `Accept` | string | `''` | Comma-separated file type specifiers (`.jpg`, `image/png`, `image/*`) rendered as the `accept` attribute. Advisory; pair with `TFileValidator`. (@since 4.4.0) |
+| `Capture` | string | `''` | `user` or `environment`, rendered as the `capture` attribute so mobile browsers capture new media with that device. Desktop browsers ignore it. (@since 4.4.0) |
| `IsValid` | bool | `true` | Writeable by validators. |
## Key Methods
diff --git a/agents/framework/Web/UI/WebControls/TFileValidator.md b/agents/framework/Web/UI/WebControls/TFileValidator.md
new file mode 100644
index 000000000..62bc73d54
--- /dev/null
+++ b/agents/framework/Web/UI/WebControls/TFileValidator.md
@@ -0,0 +1,71 @@
+# Web/UI/WebControls/TFileValidator
+
+### Directories
+[framework](../../../INDEX.md) / [Web](../../INDEX.md) / [UI](../INDEX.md) / [WebControls](./INDEX.md) / **`TFileValidator`**
+
+## Class Info
+**Location:** `framework/Web/UI/WebControls/TFileValidator.php`
+**Namespace:** `Prado\Web\UI\WebControls`
+**Since:** 4.4.0 (issue #636)
+
+## Overview
+TFileValidator validates the files selected in a [TFileUpload](./TFileUpload.md) (or `TActiveFileUpload`) control. Each file is checked against size, extension, and MIME type restrictions; the file count is checked against min/max limits. With `EnableClientScript` (default true) the same checks run in the browser through the HTML5 File API before the files upload, avoiding the transfer of files the server would reject.
+
+Validation succeeds when no file is selected — pair with `TRequiredFieldValidator` to require a selection. A file with a PHP upload error code (`UPLOAD_ERR_FORM_SIZE`, `UPLOAD_ERR_PARTIAL`, …) fails validation.
+
+## Inheritance
+
+`TFileValidator` → `TBaseValidator` → `TLabel` → `TWebControl` → `TControl` → `TComponent`
+
+Client-side class: `Prado.WebUI.TFileValidator` (`framework/Web/Javascripts/source/prado/validator/validation3.js`).
+
+## Key Properties
+
+| Property | Type | Default | Description |
+|---|---|---|---|
+| `MaxFileSize` | int | `0` | Maximum bytes per file. `0` → falls back to the target's `TFileUpload::MaxFileSize` (1 MB default). |
+| `MinFileSize` | int | `0` | Minimum bytes per file. `0` disables the check. |
+| `TotalMaxFileSize` | int | `0` | Maximum combined bytes of all selected files. `0` disables the check. Helps a `Multiple` selection stay under `post_max_size`. (@since 4.4.0) |
+| `MaxFileCount` | int | `0` | Maximum number of files. `0` disables the check. |
+| `MinFileCount` | int | `0` | Minimum number of files when at least one is selected. `0` disables the check. |
+| `AllowedFileExtensions` | string | `''` | Comma/space separated extensions (`"jpg, png"` or `".jpg"`), case-insensitive. |
+| `AllowedFileTypes` | string | `''` | Comma/space separated MIME types; `image/*` matches every subtype. |
+| `CheckExtensionMimeType` | bool | `false` | Server-only. The `fileinfo`-sniffed content type must correspond to the file name extension (map in `static::$extensionMimeTypes`, extendable by subclasses). Detects renamed files. Unknown extensions, extensionless names, and unavailable sniffing pass. |
+| `InvalidFileNames` | string[] | `[]` | Read-only. Names of the files that failed the last validation. |
+
+## Matching Semantics
+
+| Configuration | Rule |
+|---|---|
+| `AllowedFileExtensions` and/or `AllowedFileTypes` set | The file must match every non-empty list (AND). |
+| Both empty, target has `Accept` property or `accept` attribute | Restrictions derive from the Accept tokens; the file must match any token (OR), mirroring the HTML file picker. `.jpg` tokens match the extension, MIME tokens match the type. |
+| Both empty, no Accept | Only size, count, and error-code checks apply. |
+
+## Server-side MIME Detection
+
+`getFileMimeType()` sniffs the uploaded file content with the `fileinfo` extension when available (`finfo_file` on `LocalName`), falling back to the untrusted browser-supplied `FileType`. The client side can only check the browser-reported `file.type`.
+
+## `{files}` ErrorMessage Token
+
+`ErrorMessage="Wrong type: {files}"` — both sides replace `{files}` with the comma-separated invalid file names (HTML-encoded server-side; `textContent` client-side). The client options keep the raw token (`getClientScriptOptions()` resets `ErrorMessage` to the unsubstituted value).
+
+## Patterns & Gotchas
+
+- **Target must be a TFileUpload** — `evaluateIsValid()` throws `TConfigurationException` (`filevalidator_fileupload_required`) otherwise.
+- **The client JS does not use `getValidationValue()`** — it reads `this.control.files` directly, so `TBaseValidator::$_clientClass` and the `getRawValidationValue()` switches stay untouched and `TRequiredFieldValidator` on file inputs keeps its `control.value` fakepath behavior.
+- **`MaxFileSize=0` still enforces a limit** — the target's `MaxFileSize` (default 1 MB) is the fallback; PHP enforces the same value through the `MAX_FILE_SIZE` hidden field anyway.
+- **Client `file.type` can be empty** for unknown types — a type-restricted validator then rejects the file client-side; the server re-checks with `fileinfo`.
+
+## Subclasses
+
+[TImageValidator](./TImageValidator.md) adds pixel dimension restrictions and a readable-image check.
+
+## TActiveFileUpload Integration (@since 4.4.0)
+
+`TActiveFileUpload` with `CausesValidation` (default true) runs this control's validators client-side before its auto-upload starts and validates the page server-side during the upload callback — see [TActiveFileUpload](../ActiveControls/TActiveFileUpload.md) for the timing trap (validate during the callback, not a later postback).
+
+## Tests
+
+- Unit: `tests/unit/Web/UI/WebControls/TFileValidatorTest.php`, `TFileUploadTest.php`
+- JS (vitest): `tests/js/validator/filevalidator.test.js`
+- Functional (Playwright): `tests/playwright/validators/FileValidatorTestCase.spec.js` + `tests/harness/validators/protected/pages/FileValidator.page`
diff --git a/agents/framework/Web/UI/WebControls/TImageValidator.md b/agents/framework/Web/UI/WebControls/TImageValidator.md
new file mode 100644
index 000000000..1bdd60c89
--- /dev/null
+++ b/agents/framework/Web/UI/WebControls/TImageValidator.md
@@ -0,0 +1,46 @@
+# Web/UI/WebControls/TImageValidator
+
+### Directories
+[framework](../../../INDEX.md) / [Web](../../INDEX.md) / [UI](../INDEX.md) / [WebControls](./INDEX.md) / **`TImageValidator`**
+
+## Class Info
+**Location:** `framework/Web/UI/WebControls/TImageValidator.php`
+**Namespace:** `Prado\Web\UI\WebControls`
+**Since:** 4.4.0
+
+## Overview
+TImageValidator extends [TFileValidator](./TFileValidator.md): every file restriction applies, and each file must additionally be a readable image whose pixel dimensions satisfy the bounds. Server side uses `getimagesize()`; a file that cannot be read as an image (or has no local temp file) fails closed.
+
+## Inheritance
+
+`TImageValidator` → `TFileValidator` → `TBaseValidator` → `TLabel` → …
+
+Client-side class: `Prado.WebUI.TImageValidator` (validation3.js), extends `Prado.WebUI.TFileValidator` via `$super` injection.
+
+## Key Properties
+
+| Property | Type | Default | Description |
+|---|---|---|---|
+| `MinImageWidth` / `MaxImageWidth` | int | `0` | Pixel width bounds. `0` disables either check. |
+| `MinImageHeight` / `MaxImageHeight` | int | `0` | Pixel height bounds. `0` disables either check. |
+
+## Client-side Asynchronous Decode
+
+Prado client validation is synchronous, so the JS class decodes dimensions out of band:
+
+- `onInit()` observes the input's `change` event; each selection change drops the cache (`_imageInfo`, keyed by `name|size|lastModified`) and decodes every file via `URL.createObjectURL` + `Image`.
+- `evaluateIsValid()` → `isValidFile()`: an undecoded (pending or uncached) file **passes** client-side; when a decode completes, `revalidate()` re-runs `validate()` + `updateSummary()` if results are already displayed (`this.visible`).
+- A submit that outruns the decode therefore posts back and the authoritative server-side validation catches the file. The Playwright tests accept either path.
+- Browsers without `URL.createObjectURL` skip the client dimension checks entirely (`canReadImages()` guard); jsdom tests seed `_imageInfo` directly.
+
+## Patterns & Gotchas
+
+- **Fails closed server-side** — a missing/unreadable temp file or a `getimagesize()` failure is invalid, unlike TFileValidator checks that pass when unverifiable.
+- **notImage detection** — client: `Image.onerror`; server: `getimagesize() === false`. Both fail the file.
+- Dimension property names carry the `Image` infix (`MinImageWidth`, not `MinWidth`) to avoid confusion with the validator control's own `Width`/`Height` style properties.
+
+## Tests
+
+- Unit: `tests/unit/Web/UI/WebControls/TImageValidatorTest.php` (crafts minimal GIF headers — `'GIF89a' . pack('v',$w) . pack('v',$h) . "\x00\x00\x00"` — which `getimagesize()` reads)
+- JS (vitest): `tests/js/validator/imagevalidator.test.js` (seeds the dimension cache; jsdom cannot decode)
+- Functional (Playwright): `tests/playwright/validators/ImageValidatorTestCase.spec.js` + `ImageValidator.page`, real PNGs built by `tests/playwright/validators/png.js`
diff --git a/framework/Exceptions/messages/messages.txt b/framework/Exceptions/messages/messages.txt
index 4aa21921b..3ff703ceb 100644
--- a/framework/Exceptions/messages/messages.txt
+++ b/framework/Exceptions/messages/messages.txt
@@ -427,6 +427,8 @@ basevalidator_forcontrol_unsupported = {0}.ForControl is not supported.
comparevalidator_controltocompare_invalid = TCompareValidator.ControlToCompare contains an invalid control ID path.
+filevalidator_fileupload_required = {0}.ControlToValidate must point to a TFileUpload control.
+
listcontrolvalidator_invalid_control = {0}.ControlToValidate contains an invalid TListControl ID path, "{1}" is a {2}.
repeater_template_required = TRepeater.{0} requires a template instance implementing ITemplate interface.
diff --git a/framework/Web/Javascripts/source/prado/activefileupload/activefileupload.js b/framework/Web/Javascripts/source/prado/activefileupload/activefileupload.js
index 1fca7f00e..112251ed9 100644
--- a/framework/Web/Javascripts/source/prado/activefileupload/activefileupload.js
+++ b/framework/Web/Javascripts/source/prado/activefileupload/activefileupload.js
@@ -23,6 +23,8 @@ Prado.WebUI.TActiveFileUpload = Prado.Class(Prado.WebUI.Control,
fileChanged() {
// ie11 fix
if(this.input.value=='') return;
+ // let the validators of the file input block an invalid selection
+ if(!this.validateFiles()) return;
// show the upload indicator, and hide the complete and error indicators (if they areSn't already).
this.flag.value = '1';
this.complete.style.display = 'none';
@@ -47,6 +49,21 @@ Prado.WebUI.TActiveFileUpload = Prado.Class(Prado.WebUI.Control,
this.form.enctype = this.oldFormEnctype;
},
+ /**
+ * Run the validators attached to the file input before the upload starts.
+ * Passes when causesValidation is off, the validation script is not loaded,
+ * or the form has no validation manager.
+ * @return {boolean} true when the selected files may upload.
+ */
+ validateFiles() {
+ if(!this.options.causesValidation || typeof(Prado.Validation) == "undefined")
+ return true;
+ const manager = Prado.Validation.managers[this.options.formID];
+ if(!manager)
+ return true;
+ return manager.validateControl(this.options.inputID);
+ },
+
finishUpload(options) {
if (this.options.targetID == options.targetID)
diff --git a/framework/Web/Javascripts/source/prado/validator/validation3.js b/framework/Web/Javascripts/source/prado/validator/validation3.js
index 1e071154f..57aadf86d 100644
--- a/framework/Web/Javascripts/source/prado/validator/validation3.js
+++ b/framework/Web/Javascripts/source/prado/validator/validation3.js
@@ -1975,3 +1975,327 @@ Prado.WebUI.TReCaptcha2Validator = Prado.Class(Prado.WebUI.TBaseValidator,
return(a != b);
}
});
+
+/**
+ * TFileValidator validates the files selected in a TFileUpload control
+ * through the HTML5 File API.
+ *
+ *
Each selected file is checked against the MinFileSize,
+ * MaxFileSize, AllowedFileExtensions and
+ * AllowedFileTypes options. The number of selected files is checked
+ * against MinFileCount and MaxFileCount. The
+ * {files} token of the error message is replaced with the names of
+ * the invalid files.
+ *
+ * @class Prado.WebUI.TFileValidator
+ * @extends Prado.WebUI.TBaseValidator
+ */
+Prado.WebUI.TFileValidator = Prado.Class(Prado.WebUI.TBaseValidator,
+{
+ /**
+ * Additional constructor options.
+ * @constructor initialize
+ * @param {object} options - Additional constructor options:
+ * @... {int} MaxFileSize - Maximum file size in bytes, 0 for no limit.
+ * @... {int} MinFileSize - Minimum file size in bytes, 0 for no minimum.
+ * @... {int} TotalMaxFileSize - Maximum combined size of the files in bytes, 0 for no limit.
+ * @... {int} MaxFileCount - Maximum number of files, 0 for no limit.
+ * @... {int} MinFileCount - Minimum number of files, 0 for no minimum.
+ * @... {string[]} AllowedFileExtensions - Allowed lower case file name extensions.
+ * @... {string[]} AllowedFileTypes - Allowed lower case MIME types, "image/*" matches every subtype.
+ * @... {boolean} MatchAnyType - True to accept a file matching any of the extension
+ * or MIME type lists, false to require a match in every non-empty list.
+ */
+
+ /**
+ * Evaluate validation state
+ * @function {boolean} ?
+ * @return True if every selected file satisfies the file restrictions.
+ */
+ evaluateIsValid() {
+ this.invalidFiles = [];
+ const files = this.getFileList();
+ if(!files || files.length <= 0)
+ return true;
+ let valid = true;
+ if(this.options.MaxFileCount > 0 && files.length > this.options.MaxFileCount)
+ valid = false;
+ if(this.options.MinFileCount > 0 && files.length < this.options.MinFileCount)
+ valid = false;
+ if(this.options.TotalMaxFileSize > 0 && this.getTotalFileSize(files) > this.options.TotalMaxFileSize)
+ valid = false;
+ for(const file of files)
+ {
+ if(!this.isValidFile(file))
+ {
+ this.invalidFiles.push(file.name);
+ valid = false;
+ }
+ }
+ this.updateErrorMessage();
+ return valid;
+ },
+
+ /**
+ * Get the files selected in the control to validate.
+ * @function {FileList} ?
+ * @return List of selected files, null if the File API is unavailable.
+ */
+ getFileList() {
+ return this.control.files || null;
+ },
+
+ /**
+ * Get the combined size of the selected files.
+ * @function {int} ?
+ * @param {FileList} files - Selected files.
+ * @return Combined size of the files in bytes.
+ */
+ getTotalFileSize(files) {
+ let total = 0;
+ for(const file of files)
+ total += file.size;
+ return total;
+ },
+
+ /**
+ * Check one file against the file size and file type restrictions.
+ * @function {boolean} ?
+ * @param {File} file - Selected file to check.
+ * @return True if the file satisfies the restrictions.
+ */
+ isValidFile(file) {
+ if(this.options.MaxFileSize > 0 && file.size > this.options.MaxFileSize)
+ return false;
+ if(this.options.MinFileSize > 0 && file.size < this.options.MinFileSize)
+ return false;
+ return this.isValidFileType(file);
+ },
+
+ /**
+ * Check the extension and MIME type of one file.
+ * With MatchAnyType the file must match any of the extension or
+ * MIME type lists, otherwise it must match every non-empty list.
+ * @function {boolean} ?
+ * @param {File} file - Selected file to check.
+ * @return True if the file satisfies the type restrictions.
+ */
+ isValidFileType(file) {
+ const extensions = this.options.AllowedFileExtensions || [];
+ const types = this.options.AllowedFileTypes || [];
+ if(extensions.length <= 0 && types.length <= 0)
+ return true;
+ const extensionValid = extensions.length > 0 && extensions.indexOf(this.getFileExtension(file)) != -1;
+ const typeValid = types.length > 0 && this.matchesAnyMimeType(file, types);
+ if(this.options.MatchAnyType)
+ return extensionValid || typeValid;
+ return (extensions.length <= 0 || extensionValid) && (types.length <= 0 || typeValid);
+ },
+
+ /**
+ * Get the lower case file name extension of a file.
+ * @function {string} ?
+ * @param {File} file - Selected file.
+ * @return Extension without the dot, empty string if the file name has no extension.
+ */
+ getFileExtension(file) {
+ const index = file.name.lastIndexOf('.');
+ return index < 0 ? '' : file.name.substring(index + 1).toLowerCase();
+ },
+
+ /**
+ * Check the MIME type of a file against a list of MIME type patterns.
+ * @function {boolean} ?
+ * @param {File} file - Selected file to check.
+ * @param {string[]} types - Lower case MIME type patterns, "image/*" matches every subtype.
+ * @return True if the file type matches any of the patterns.
+ */
+ matchesAnyMimeType(file, types) {
+ const type = (file.type || '').toLowerCase();
+ for(const pattern of types)
+ {
+ if(pattern == '*' || pattern == '*/*')
+ return true;
+ if(pattern.substring(pattern.length - 2) == '/*')
+ {
+ if(type.substring(0, pattern.length - 1) == pattern.substring(0, pattern.length - 1))
+ return true;
+ }
+ else if(type == pattern)
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Get the error message with the {files} token replaced by the names of
+ * the invalid files.
+ * @function {string} ?
+ * @return Validation error message.
+ */
+ getErrorMessage() {
+ const message = this.options.ErrorMessage;
+ if(typeof(message) == "string" && message.indexOf('{files}') != -1)
+ return message.replace('{files}', (this.invalidFiles || []).join(', '));
+ return message;
+ },
+
+ /**
+ * Update the validator message element when the error message uses the
+ * {files} token.
+ * @function ?
+ */
+ updateErrorMessage() {
+ if(this.message && typeof(this.options.ErrorMessage) == "string" && this.options.ErrorMessage.indexOf('{files}') != -1)
+ this.message.textContent = this.getErrorMessage();
+ }
+});
+
+/**
+ * TImageValidator validates the image files selected in a TFileUpload control.
+ *
+ * Every restriction of Prado.WebUI.TFileValidator applies, and each file
+ * must be a decodable image whose pixel dimensions satisfy the
+ * MinImageWidth, MaxImageWidth, MinImageHeight and
+ * MaxImageHeight options.
+ *
+ * Image decoding is asynchronous: the dimensions are read into a cache when
+ * the selection changes, and a file whose dimensions are not yet decoded
+ * passes the validation. The validator re-validates when the decoding
+ * completes. The server-side validation is authoritative.
+ *
+ * @class Prado.WebUI.TImageValidator
+ * @extends Prado.WebUI.TFileValidator
+ */
+Prado.WebUI.TImageValidator = Prado.Class(Prado.WebUI.TFileValidator,
+{
+ /**
+ * Additional constructor options.
+ * @constructor initialize
+ * @param {object} options - Additional constructor options:
+ * @... {int} MinImageWidth - Minimum image width in pixels, 0 for no minimum.
+ * @... {int} MaxImageWidth - Maximum image width in pixels, 0 for no limit.
+ * @... {int} MinImageHeight - Minimum image height in pixels, 0 for no minimum.
+ * @... {int} MaxImageHeight - Maximum image height in pixels, 0 for no limit.
+ */
+
+ /**
+ * Start decoding the image dimensions when the selection changes.
+ * @function ?
+ */
+ onInit() {
+ this._imageInfo = {};
+ if(this.control)
+ {
+ const validator = this;
+ this.observe(this.control, 'change', () => validator.preloadImageInfo());
+ this.preloadImageInfo();
+ }
+ },
+
+ /**
+ * Get the cache key identifying a selected file.
+ * @function {string} ?
+ * @param {File} file - Selected file.
+ * @return Cache key of the file.
+ */
+ fileKey(file) {
+ return [file.name, file.size, file.lastModified].join('|');
+ },
+
+ /**
+ * Check that the browser can decode selected files into images.
+ * @function {boolean} ?
+ * @return True if object URLs are available for image decoding.
+ */
+ canReadImages() {
+ return typeof(URL) != "undefined" && typeof(URL.createObjectURL) == "function";
+ },
+
+ /**
+ * Drop the stale dimension cache and decode the current selection.
+ * @function ?
+ */
+ preloadImageInfo() {
+ this._imageInfo = {};
+ const files = this.getFileList();
+ if(!files || !this.canReadImages())
+ return;
+ for(const file of files)
+ this.readImageInfo(file);
+ },
+
+ /**
+ * Decode the dimensions of one file into the cache and re-validate on
+ * completion.
+ * @function ?
+ * @param {File} file - Selected file to decode.
+ */
+ readImageInfo(file) {
+ const key = this.fileKey(file);
+ if(this._imageInfo[key])
+ return;
+ const info = this._imageInfo[key] = { pending: true, notImage: false, width: 0, height: 0 };
+ const url = URL.createObjectURL(file);
+ const image = new Image();
+ const validator = this;
+ const done = function(notImage) {
+ URL.revokeObjectURL(url);
+ info.pending = false;
+ info.notImage = notImage;
+ info.width = image.naturalWidth || image.width;
+ info.height = image.naturalHeight || image.height;
+ validator.revalidate();
+ };
+ image.onload = () => done(false);
+ image.onerror = () => done(true);
+ image.src = url;
+ },
+
+ /**
+ * Re-validate and update the summary after an asynchronous decode, once a
+ * validation has displayed results.
+ * @function ?
+ */
+ revalidate() {
+ if(this.visible)
+ {
+ this.validate();
+ if(this.manager)
+ this.manager.updateSummary(this.group);
+ }
+ },
+
+ /**
+ * Check one file against the parent restrictions and the image dimension
+ * restrictions. A file with undecoded dimensions passes.
+ * @function {boolean} ?
+ * @param {File} file - Selected file to check.
+ * @return True if the file satisfies the restrictions.
+ */
+ isValidFile($super, file) {
+ if(!$super(file))
+ return false;
+ if(!this.canReadImages())
+ return true;
+ const info = this._imageInfo[this.fileKey(file)];
+ if(!info)
+ {
+ this.readImageInfo(file);
+ return true;
+ }
+ if(info.pending)
+ return true;
+ if(info.notImage)
+ return false;
+ if(this.options.MinImageWidth > 0 && info.width < this.options.MinImageWidth)
+ return false;
+ if(this.options.MaxImageWidth > 0 && info.width > this.options.MaxImageWidth)
+ return false;
+ if(this.options.MinImageHeight > 0 && info.height < this.options.MinImageHeight)
+ return false;
+ if(this.options.MaxImageHeight > 0 && info.height > this.options.MaxImageHeight)
+ return false;
+ return true;
+ }
+});
diff --git a/framework/Web/UI/ActiveControls/TActiveFileUpload.php b/framework/Web/UI/ActiveControls/TActiveFileUpload.php
index 44c1c168e..c1b1ea55d 100644
--- a/framework/Web/UI/ActiveControls/TActiveFileUpload.php
+++ b/framework/Web/UI/ActiveControls/TActiveFileUpload.php
@@ -54,6 +54,24 @@
* {@see setMultiple Multiple} attribute to true. See the description of the parent class
* {@see \Prado\Web\UI\WebControls\TFileUpload} for further details.
*
+ * Since Prado 4.4.0 validators attached to the control, such as
+ * {@see \Prado\Web\UI\WebControls\TFileValidator} and
+ * {@see \Prado\Web\UI\WebControls\TImageValidator}, integrate with the upload when
+ * {@see setCausesValidation CausesValidation} is true (the default):
+ * - Client side, the validators of the control run before the upload starts and
+ * an invalid selection skips the upload while the validators display their messages.
+ * - Server side, the page validates the {@see setValidationGroup ValidationGroup}
+ * during the upload callback before {@see onFileUpload OnFileUpload} is raised.
+ * The event handler checks {@see \Prado\Web\UI\TPage::getIsValid()} or the control's
+ * {@see \Prado\Web\UI\WebControls\TFileUpload::getIsValid() IsValid} before saving the files.
+ *
+ * Validation happens during the upload callback. The selected files do not
+ * persist to a later postback: a successful upload clears the file input and the
+ * temporary files are removed when the callback ends. Validators evaluated on a
+ * later postback see an empty selection and succeed. Assign the control and its
+ * validators a dedicated {@see setValidationGroup ValidationGroup} to keep the
+ * upload callback from validating unrelated controls of the page.
+ *
* @author Bradley Booms
* @author Christophe Boulain
* @author LANDWEHR Computer und Software GmbH
@@ -182,6 +200,50 @@ public function setAutoPostBack($value)
$this->setViewState('AutoPostBack', TPropertyValue::ensureBoolean($value), true);
}
+ /**
+ * @return bool whether the validators of the control run before the upload starts and
+ * the page validates during the upload callback. Defaults to true.
+ * @since 4.4.0
+ */
+ public function getCausesValidation()
+ {
+ return $this->getViewState('CausesValidation', true);
+ }
+
+ /**
+ * Sets whether the upload performs validation. When true, the client-side
+ * validators of the control run before the upload starts and an invalid
+ * selection skips the upload. Server side, the page validates the
+ * {@see setValidationGroup ValidationGroup} during the upload callback
+ * before {@see onFileUpload OnFileUpload} is raised.
+ * @param bool $value whether the upload performs validation.
+ * @since 4.4.0
+ */
+ public function setCausesValidation($value)
+ {
+ $this->setViewState('CausesValidation', TPropertyValue::ensureBoolean($value), true);
+ }
+
+ /**
+ * @return string the group of validators the page validates during the upload callback. Defaults to ''.
+ * @since 4.4.0
+ */
+ public function getValidationGroup()
+ {
+ return $this->getViewState('ValidationGroup', '');
+ }
+
+ /**
+ * Sets the group of validators the page validates during the upload callback
+ * when {@see setCausesValidation CausesValidation} is true.
+ * @param string $value the validation group of the upload.
+ * @since 4.4.0
+ */
+ public function setValidationGroup($value)
+ {
+ $this->setViewState('ValidationGroup', TPropertyValue::ensureString($value), '');
+ }
+
/**
* @return string A chuck of javascript that will need to be called if {{@see getAutoPostBack AutoPostBack} is set to false}
*/
@@ -230,6 +292,10 @@ public function raiseCallbackEvent($param)
}
$this->loadPostData($key, null);
+ if ($this->getCausesValidation()) {
+ $this->getPage()->validate($this->getValidationGroup());
+ }
+
$this->raiseEvent('OnFileUpload', $this, $param);
}
}
@@ -420,6 +486,7 @@ protected function getClientOptions()
$options['completeID'] = $this->_success->getClientID();
$options['errorID'] = $this->_error->getClientID();
$options['autoPostBack'] = $this->getAutoPostBack();
+ $options['causesValidation'] = $this->getCausesValidation();
return $options;
}
diff --git a/framework/Web/UI/WebControls/TFileUpload.php b/framework/Web/UI/WebControls/TFileUpload.php
index dc32d816b..c4b7cee79 100644
--- a/framework/Web/UI/WebControls/TFileUpload.php
+++ b/framework/Web/UI/WebControls/TFileUpload.php
@@ -44,6 +44,12 @@
* TFileUpload raises {@see onFileUpload OnFileUpload} event if one or more files are
* uploaded (whether it succeeds or not).
*
+ * Since Prado 4.4.0 the {@see setAccept Accept} property renders the HTML5 "accept"
+ * attribute to filter the file types offered in the browser file picker, the
+ * {@see setCapture Capture} property renders the "capture" attribute to select a
+ * mobile capture device, and a {@see \Prado\Web\UI\WebControls\TFileValidator}
+ * can validate the selected files.
+ *
* @author Marcus Nyeholt , Qiang Xue
* @author LANDWEHR Computer und Software GmbH
* @since 3.0
@@ -93,6 +99,12 @@ protected function addAttributesToRender($writer)
$name .= '[]';
$writer->addAttribute('multiple', 'multiple');
}
+ if (($accept = $this->getAccept()) !== '') {
+ $writer->addAttribute('accept', $accept);
+ }
+ if (($capture = $this->getCapture()) !== '') {
+ $writer->addAttribute('capture', $capture);
+ }
$writer->addAttribute('name', $name);
$isEnabled = $this->getEnabled(true);
if (!$isEnabled && $this->getEnabled()) { // in this case parent will not render 'disabled'
@@ -143,6 +155,52 @@ public function setMaxFileSize($size)
$this->setViewState('MaxFileSize', TPropertyValue::ensureInteger($size), self::MAX_FILE_SIZE);
}
+ /**
+ * @return string the accept attribute value listing the file types the file picker accepts. Defaults to ''.
+ * @since 4.4.0
+ */
+ public function getAccept()
+ {
+ return $this->getViewState('Accept', '');
+ }
+
+ /**
+ * Sets the accept attribute of the file input. The value is a comma
+ * separated list of file type specifiers: extensions with a leading dot
+ * (".jpg"), MIME types ("image/png") or wildcard MIME types ("image/*").
+ * The browser uses the value to filter the files offered in its file
+ * picker. This is advisory; use a {@see \Prado\Web\UI\WebControls\TFileValidator}
+ * to validate the selected files.
+ * @param string $value comma separated list of accepted file type specifiers.
+ * @since 4.4.0
+ */
+ public function setAccept($value)
+ {
+ $this->setViewState('Accept', TPropertyValue::ensureString($value), '');
+ }
+
+ /**
+ * @return string the capture attribute value selecting the capture device. Defaults to ''.
+ * @since 4.4.0
+ */
+ public function getCapture()
+ {
+ return $this->getViewState('Capture', '');
+ }
+
+ /**
+ * Sets the capture attribute of the file input. The value "user" prefers the
+ * user-facing camera or microphone and "environment" prefers the outward-facing
+ * one. Mobile browsers then capture new media with that device in place of the
+ * file picker. The attribute applies to media types listed in
+ * {@see setAccept Accept} and desktop browsers ignore it.
+ * @param string $value "user", "environment" or '' to render no capture attribute.
+ */
+ public function setCapture($value)
+ {
+ $this->setViewState('Capture', TPropertyValue::ensureString($value), '');
+ }
+
/**
* For backward compatibility, the first file is used by default.
* @param int $index the index of the uploaded file, defaults to 0.
diff --git a/framework/Web/UI/WebControls/TFileValidator.php b/framework/Web/UI/WebControls/TFileValidator.php
new file mode 100644
index 000000000..32d2cda6d
--- /dev/null
+++ b/framework/Web/UI/WebControls/TFileValidator.php
@@ -0,0 +1,602 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+namespace Prado\Web\UI\WebControls;
+
+use Prado\Exceptions\TConfigurationException;
+use Prado\TPropertyValue;
+use Prado\Web\THttpUtility;
+use Prado\Web\TMediaType;
+
+/**
+ * TFileValidator class
+ *
+ * TFileValidator validates the files selected in a {@see \Prado\Web\UI\WebControls\TFileUpload}
+ * control. Each selected file is checked against the following restrictions:
+ * - {@see setMaxFileSize MaxFileSize} → maximum file size in bytes. The default 0
+ * uses the {@see TFileUpload::getMaxFileSize MaxFileSize} of the target control.
+ * - {@see setMinFileSize MinFileSize} → minimum file size in bytes; 0 disables the check.
+ * - {@see setAllowedFileExtensions AllowedFileExtensions} → comma separated list of
+ * file name extensions, compared case-insensitively; an empty list allows every extension.
+ * - {@see setAllowedFileTypes AllowedFileTypes} → comma separated list of MIME types;
+ * "image/*" matches every image subtype and an empty list allows every type.
+ *
+ * The number of selected files is checked against {@see setMinFileCount MinFileCount}
+ * and {@see setMaxFileCount MaxFileCount} when the target allows
+ * {@see TFileUpload::setMultiple Multiple} files; 0 disables either check. The
+ * combined size of the selected files is checked against
+ * {@see setTotalMaxFileSize TotalMaxFileSize}; 0 disables the check.
+ *
+ * With {@see setCheckExtensionMimeType CheckExtensionMimeType}, the MIME type
+ * sniffed from the file content must correspond to the file name extension.
+ * This detects files renamed to pass an extension restriction. The check runs
+ * server side only and requires the fileinfo extension; extensions absent from
+ * the known extension map pass unchecked.
+ *
+ * When AllowedFileExtensions and AllowedFileTypes are both empty, the type
+ * restrictions derive from the {@see TFileUpload::getAccept Accept} property or
+ * "accept" attribute of the target control. A file is then valid when it matches
+ * any accept token, following the HTML file picker semantics: ".jpg" tokens match
+ * the file name extension and MIME tokens match the file type.
+ *
+ * The validation succeeds when no file is selected. Use a
+ * {@see \Prado\Web\UI\WebControls\TRequiredFieldValidator} to require a file selection.
+ * A file failing the upload with a PHP error code, such as exceeding the server
+ * file size limits, fails the validation.
+ *
+ * When {@see TBaseValidator::setEnableClientScript EnableClientScript} is true
+ * (the default), the same checks run in the browser through the HTML5 File API
+ * before the files transfer to the server. Server side, the MIME type of each
+ * file is determined with the fileinfo extension when available, falling back
+ * to the browser supplied type.
+ *
+ * The "{files}" token in {@see TBaseValidator::setErrorMessage ErrorMessage} is
+ * replaced with the comma separated names of the invalid files.
+ *
+ * @author Brad Anderson
+ * @since 4.4.0
+ */
+class TFileValidator extends TBaseValidator
+{
+ /**
+ * @var string[] the names of the files that failed the last validation.
+ */
+ private $_invalidFileNames = [];
+
+ /**
+ * Map of lower case file name extension → sniffed MIME types that the
+ * content of a file with that extension can report. Used by
+ * {@see setCheckExtensionMimeType CheckExtensionMimeType}. Subclasses can
+ * extend the map.
+ * @var array
+ */
+ protected static $extensionMimeTypes = [
+ 'avif' => [TMediaType::AVIF],
+ 'bmp' => [TMediaType::BMP, 'image/x-ms-bmp'],
+ 'gif' => [TMediaType::GIF],
+ 'ico' => [TMediaType::ICON, 'image/vnd.microsoft.icon'],
+ 'jpeg' => [TMediaType::JPEG],
+ 'jpg' => [TMediaType::JPEG],
+ 'png' => [TMediaType::PNG],
+ 'svg' => [TMediaType::SVG, TMediaType::XML_TEXT, TMediaType::XML, TMediaType::PLAIN],
+ 'tif' => [TMediaType::TIFF],
+ 'tiff' => [TMediaType::TIFF],
+ 'webp' => [TMediaType::WEBP],
+ 'css' => [TMediaType::CSS, TMediaType::PLAIN],
+ 'csv' => [TMediaType::CSV, TMediaType::PLAIN, 'application/csv'],
+ 'htm' => [TMediaType::HTML],
+ 'html' => [TMediaType::HTML],
+ 'js' => [TMediaType::JAVASCRIPT, 'application/javascript', TMediaType::PLAIN],
+ 'json' => [TMediaType::JSON, TMediaType::PLAIN],
+ 'log' => [TMediaType::PLAIN],
+ 'md' => [TMediaType::MARKDOWN, TMediaType::PLAIN],
+ 'txt' => [TMediaType::PLAIN],
+ 'xml' => [TMediaType::XML, TMediaType::XML_TEXT],
+ 'bz2' => [TMediaType::BZIP2],
+ 'doc' => [TMediaType::DOC, 'application/vnd.ms-office', 'application/cdfv2'],
+ 'docx' => [TMediaType::DOCX, TMediaType::ZIP],
+ 'gz' => [TMediaType::GZIP, 'application/x-gzip'],
+ 'pdf' => [TMediaType::PDF],
+ 'ppt' => [TMediaType::PPT, 'application/vnd.ms-office', 'application/cdfv2'],
+ 'pptx' => [TMediaType::PPTX, TMediaType::ZIP],
+ 'rtf' => [TMediaType::RTF, 'text/rtf'],
+ 'tar' => [TMediaType::TAR],
+ 'xls' => [TMediaType::XLS, 'application/vnd.ms-office', 'application/cdfv2'],
+ 'xlsx' => [TMediaType::XLSX, TMediaType::ZIP],
+ 'xz' => [TMediaType::XZ],
+ 'zip' => [TMediaType::ZIP],
+ 'aac' => [TMediaType::AUDIO_AAC],
+ 'mp3' => [TMediaType::AUDIO_MPEG],
+ 'oga' => [TMediaType::AUDIO_OGG, 'application/ogg'],
+ 'ogg' => [TMediaType::AUDIO_OGG, 'application/ogg'],
+ 'wav' => [TMediaType::AUDIO_WAV, 'audio/x-wav'],
+ 'm4v' => [TMediaType::VIDEO_MP4],
+ 'mp4' => [TMediaType::VIDEO_MP4],
+ 'ogv' => [TMediaType::VIDEO_OGG, 'application/ogg'],
+ 'webm' => [TMediaType::VIDEO_WEBM, TMediaType::AUDIO_WEBM],
+ 'otf' => [TMediaType::OTF, 'font/sfnt', 'application/font-sfnt'],
+ 'ttf' => [TMediaType::TTF, 'font/sfnt', 'application/font-sfnt'],
+ 'woff' => [TMediaType::WOFF, 'application/font-woff'],
+ 'woff2' => [TMediaType::WOFF2],
+ ];
+
+ /**
+ * Gets the name of the javascript class responsible for performing validation for this control.
+ * This method overrides the parent implementation.
+ * @return string the javascript class name
+ */
+ protected function getClientClassName()
+ {
+ return 'Prado.WebUI.TFileValidator';
+ }
+
+ /**
+ * @return int the maximum file size in bytes. Defaults to 0, meaning the
+ * {@see TFileUpload::getMaxFileSize MaxFileSize} of the target control is used.
+ */
+ public function getMaxFileSize()
+ {
+ return $this->getViewState('MaxFileSize', 0);
+ }
+
+ /**
+ * Sets the maximum size in bytes allowed for each file.
+ * @param int $value the maximum file size in bytes, 0 to use the MaxFileSize of the target control.
+ */
+ public function setMaxFileSize($value)
+ {
+ $this->setViewState('MaxFileSize', TPropertyValue::ensureInteger($value), 0);
+ }
+
+ /**
+ * @return int the minimum file size in bytes. Defaults to 0, meaning no minimum.
+ */
+ public function getMinFileSize()
+ {
+ return $this->getViewState('MinFileSize', 0);
+ }
+
+ /**
+ * Sets the minimum size in bytes required for each file.
+ * @param int $value the minimum file size in bytes, 0 for no minimum.
+ */
+ public function setMinFileSize($value)
+ {
+ $this->setViewState('MinFileSize', TPropertyValue::ensureInteger($value), 0);
+ }
+
+ /**
+ * @return int the maximum combined size of the files in bytes. Defaults to 0, meaning no limit.
+ */
+ public function getTotalMaxFileSize()
+ {
+ return $this->getViewState('TotalMaxFileSize', 0);
+ }
+
+ /**
+ * Sets the maximum combined size in bytes allowed for all selected files
+ * together. This helps a {@see TFileUpload::setMultiple Multiple} selection
+ * stay under the "post_max_size" PHP limit.
+ * @param int $value the maximum combined file size in bytes, 0 for no limit.
+ */
+ public function setTotalMaxFileSize($value)
+ {
+ $this->setViewState('TotalMaxFileSize', TPropertyValue::ensureInteger($value), 0);
+ }
+
+ /**
+ * @return int the maximum number of files. Defaults to 0, meaning no limit.
+ */
+ public function getMaxFileCount()
+ {
+ return $this->getViewState('MaxFileCount', 0);
+ }
+
+ /**
+ * Sets the maximum number of files that can be selected at once.
+ * @param int $value the maximum number of files, 0 for no limit.
+ */
+ public function setMaxFileCount($value)
+ {
+ $this->setViewState('MaxFileCount', TPropertyValue::ensureInteger($value), 0);
+ }
+
+ /**
+ * @return int the minimum number of files. Defaults to 0, meaning no minimum.
+ */
+ public function getMinFileCount()
+ {
+ return $this->getViewState('MinFileCount', 0);
+ }
+
+ /**
+ * Sets the minimum number of files that must be selected at once.
+ * The check applies when at least one file is selected.
+ * @param int $value the minimum number of files, 0 for no minimum.
+ */
+ public function setMinFileCount($value)
+ {
+ $this->setViewState('MinFileCount', TPropertyValue::ensureInteger($value), 0);
+ }
+
+ /**
+ * @return string comma separated list of allowed file name extensions. Defaults to ''.
+ */
+ public function getAllowedFileExtensions()
+ {
+ return $this->getViewState('AllowedFileExtensions', '');
+ }
+
+ /**
+ * Sets the file name extensions allowed for the files, e.g. "jpg, png".
+ * Extensions are compared case-insensitively and a leading dot is ignored.
+ * @param string $value comma separated list of allowed extensions, '' to allow every extension.
+ */
+ public function setAllowedFileExtensions($value)
+ {
+ $this->setViewState('AllowedFileExtensions', TPropertyValue::ensureString($value), '');
+ }
+
+ /**
+ * @return string comma separated list of allowed MIME types. Defaults to ''.
+ */
+ public function getAllowedFileTypes()
+ {
+ return $this->getViewState('AllowedFileTypes', '');
+ }
+
+ /**
+ * Sets the MIME types allowed for the files, e.g. "image/png, image/jpeg".
+ * A type ending in "/*" matches every subtype, e.g. "image/*".
+ * @param string $value comma separated list of allowed MIME types, '' to allow every type.
+ */
+ public function setAllowedFileTypes($value)
+ {
+ $this->setViewState('AllowedFileTypes', TPropertyValue::ensureString($value), '');
+ }
+
+ /**
+ * @return bool whether the sniffed MIME type must correspond to the file name extension. Defaults to false.
+ */
+ public function getCheckExtensionMimeType()
+ {
+ return $this->getViewState('CheckExtensionMimeType', false);
+ }
+
+ /**
+ * Sets whether the MIME type sniffed from the file content must correspond
+ * to the file name extension, detecting files renamed to pass an extension
+ * restriction. The check runs server side only and requires the fileinfo
+ * extension. A file without an extension, an extension absent from the
+ * known extension map, or an unavailable sniffed type passes the check.
+ * @param bool $value true to cross-check the file content against the file name extension.
+ */
+ public function setCheckExtensionMimeType($value)
+ {
+ $this->setViewState('CheckExtensionMimeType', TPropertyValue::ensureBoolean($value), false);
+ }
+
+ /**
+ * @return string[] the names of the files that failed the last validation.
+ */
+ public function getInvalidFileNames()
+ {
+ return $this->_invalidFileNames;
+ }
+
+ /**
+ * Returns the error message with the "{files}" token replaced by the HTML
+ * encoded, comma separated names of the invalid files.
+ * @return string the error message.
+ */
+ public function getErrorMessage()
+ {
+ $message = parent::getErrorMessage();
+ if (strpos($message, '{files}') !== false) {
+ $names = array_map([THttpUtility::class, 'htmlEncode'], $this->getInvalidFileNames());
+ $message = str_replace('{files}', implode(', ', $names), $message);
+ }
+ return $message;
+ }
+
+ /**
+ * Returns the target {@see \Prado\Web\UI\WebControls\TFileUpload} of the validator.
+ * @throws TConfigurationException if the target control is not a TFileUpload.
+ * @return TFileUpload the file upload control to validate.
+ */
+ protected function getFileUploadTarget()
+ {
+ $control = $this->getValidationTarget();
+ if (!($control instanceof TFileUpload)) {
+ throw new TConfigurationException('filevalidator_fileupload_required', $this::class);
+ }
+ return $control;
+ }
+
+ /**
+ * This method overrides the parent's implementation.
+ * The validation succeeds if every selected file satisfies the file count,
+ * file size and file type restrictions. The validation succeeds when no
+ * file is selected.
+ * @return bool whether the validation succeeds
+ */
+ protected function evaluateIsValid()
+ {
+ $this->_invalidFileNames = [];
+ $control = $this->getFileUploadTarget();
+ $files = array_filter($control->getFiles(), fn ($file) => $file->getErrorCode() !== UPLOAD_ERR_NO_FILE);
+ if (count($files) === 0) {
+ return true;
+ }
+ $valid = true;
+ if (($max = $this->getMaxFileCount()) > 0 && count($files) > $max) {
+ $valid = false;
+ }
+ if (($min = $this->getMinFileCount()) > 0 && count($files) < $min) {
+ $valid = false;
+ }
+ if (($total = $this->getTotalMaxFileSize()) > 0) {
+ $totalSize = array_sum(array_map(fn ($file) => $file->getFileSize(), $files));
+ if ($totalSize > $total) {
+ $valid = false;
+ }
+ }
+ foreach ($files as $file) {
+ if (!$this->validateFile($file)) {
+ $this->_invalidFileNames[] = $file->getFileName();
+ $valid = false;
+ }
+ }
+ return $valid;
+ }
+
+ /**
+ * Checks one uploaded file against the error code, file size and file type restrictions.
+ * @param TFileUploadItem $file the uploaded file to check.
+ * @return bool whether the file satisfies the restrictions.
+ */
+ protected function validateFile($file)
+ {
+ if ($file->getErrorCode() !== UPLOAD_ERR_OK) {
+ return false;
+ }
+ if (($max = $this->getEffectiveMaxFileSize()) > 0 && $file->getFileSize() > $max) {
+ return false;
+ }
+ if (($min = $this->getMinFileSize()) > 0 && $file->getFileSize() < $min) {
+ return false;
+ }
+ if ($this->getCheckExtensionMimeType() && !$this->validateExtensionMimeType($file)) {
+ return false;
+ }
+ return $this->validateFileType($file);
+ }
+
+ /**
+ * Checks that the MIME type sniffed from the content of an uploaded file
+ * corresponds to its file name extension. The check passes when the file
+ * has no extension, the extension is absent from the extension map, or no
+ * sniffed type is available.
+ * @param TFileUploadItem $file the uploaded file to check.
+ * @return bool whether the file content corresponds to the file name extension.
+ */
+ protected function validateExtensionMimeType($file)
+ {
+ $extension = $this->getFileExtension($file);
+ if ($extension === '' || !isset(static::$extensionMimeTypes[$extension])) {
+ return true;
+ }
+ if (($mimeType = $this->getSniffedMimeType($file)) === null) {
+ return true;
+ }
+ return in_array($mimeType, static::$extensionMimeTypes[$extension], true);
+ }
+
+ /**
+ * Checks the extension and MIME type of one uploaded file.
+ * With explicit {@see setAllowedFileExtensions AllowedFileExtensions} or
+ * {@see setAllowedFileTypes AllowedFileTypes}, the file must match every
+ * non-empty list. With restrictions derived from the target's Accept value,
+ * the file must match any of the tokens.
+ * @param TFileUploadItem $file the uploaded file to check.
+ * @return bool whether the file satisfies the type restrictions.
+ */
+ protected function validateFileType($file)
+ {
+ $extensions = $this->getEffectiveFileExtensions();
+ $types = $this->getEffectiveFileTypes();
+ if (count($extensions) === 0 && count($types) === 0) {
+ return true;
+ }
+ $extensionValid = count($extensions) > 0 && in_array($this->getFileExtension($file), $extensions, true);
+ $typeValid = count($types) > 0 && $this->matchesAnyMimeType($this->getFileMimeType($file), $types);
+ if ($this->getMatchAnyType()) {
+ return $extensionValid || $typeValid;
+ }
+ return (count($extensions) === 0 || $extensionValid) && (count($types) === 0 || $typeValid);
+ }
+
+ /**
+ * Returns the lower case file name extension of an uploaded file.
+ * @param TFileUploadItem $file the uploaded file.
+ * @return string the extension without the dot, '' if the file name has no extension.
+ */
+ protected function getFileExtension($file)
+ {
+ return strtolower(pathinfo($file->getFileName(), PATHINFO_EXTENSION));
+ }
+
+ /**
+ * Returns the MIME type of an uploaded file, sniffed from the file content,
+ * falling back to the browser supplied
+ * {@see TFileUploadItem::getFileType FileType}.
+ * @param TFileUploadItem $file the uploaded file.
+ * @return string the lower case MIME type of the file.
+ */
+ protected function getFileMimeType($file)
+ {
+ if (($mimeType = $this->getSniffedMimeType($file)) !== null) {
+ return $mimeType;
+ }
+ return strtolower($file->getFileType());
+ }
+
+ /**
+ * Returns the MIME type sniffed from the content of an uploaded file with
+ * the fileinfo extension.
+ * @param TFileUploadItem $file the uploaded file.
+ * @return ?string the lower case sniffed MIME type, null when the fileinfo
+ * extension or the local file is unavailable.
+ */
+ protected function getSniffedMimeType($file)
+ {
+ $localName = $file->getLocalName();
+ if (function_exists('finfo_open') && $localName !== '' && is_file($localName)) {
+ if (($mimeType = @finfo_file(finfo_open(FILEINFO_MIME_TYPE), $localName)) !== false) {
+ return strtolower($mimeType);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Checks a MIME type against a list of MIME type patterns.
+ * @param string $mimeType the lower case MIME type to check.
+ * @param string[] $patterns the lower case MIME type patterns; "image/*" matches every image subtype.
+ * @return bool whether the MIME type matches any of the patterns.
+ */
+ protected function matchesAnyMimeType($mimeType, $patterns)
+ {
+ foreach ($patterns as $pattern) {
+ if ($pattern === '*' || $pattern === '*/*') {
+ return true;
+ }
+ if (substr($pattern, -2) === '/*') {
+ if (strncmp($mimeType, $pattern, strlen($pattern) - 1) === 0) {
+ return true;
+ }
+ } elseif ($mimeType === $pattern) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns the maximum file size to enforce, falling back to the
+ * {@see TFileUpload::getMaxFileSize MaxFileSize} of the target control
+ * when {@see setMaxFileSize MaxFileSize} is 0.
+ * @return int the maximum file size in bytes.
+ */
+ protected function getEffectiveMaxFileSize()
+ {
+ if (($max = $this->getMaxFileSize()) > 0) {
+ return $max;
+ }
+ return $this->getFileUploadTarget()->getMaxFileSize();
+ }
+
+ /**
+ * Returns the file name extensions to enforce, derived from the target's
+ * Accept value when {@see setAllowedFileExtensions AllowedFileExtensions}
+ * and {@see setAllowedFileTypes AllowedFileTypes} are empty.
+ * @return string[] lower case extensions without dots.
+ */
+ protected function getEffectiveFileExtensions()
+ {
+ if (!$this->getMatchAnyType()) {
+ return array_map(fn ($extension) => ltrim($extension, '.'), $this->splitList($this->getAllowedFileExtensions()));
+ }
+ $extensions = [];
+ foreach ($this->splitList($this->getTargetAccept()) as $token) {
+ if (strncmp($token, '.', 1) === 0) {
+ $extensions[] = ltrim($token, '.');
+ }
+ }
+ return $extensions;
+ }
+
+ /**
+ * Returns the MIME type patterns to enforce, derived from the target's
+ * Accept value when {@see setAllowedFileExtensions AllowedFileExtensions}
+ * and {@see setAllowedFileTypes AllowedFileTypes} are empty.
+ * @return string[] lower case MIME type patterns.
+ */
+ protected function getEffectiveFileTypes()
+ {
+ if (!$this->getMatchAnyType()) {
+ return $this->splitList($this->getAllowedFileTypes());
+ }
+ $types = [];
+ foreach ($this->splitList($this->getTargetAccept()) as $token) {
+ if (strpos($token, '/') !== false) {
+ $types[] = $token;
+ }
+ }
+ return $types;
+ }
+
+ /**
+ * Returns whether a file matching any of the extension or MIME type lists
+ * is valid. This is true when the type restrictions derive from the
+ * target's Accept value, following the HTML file picker semantics.
+ * @return bool whether any list match validates the file type.
+ */
+ protected function getMatchAnyType()
+ {
+ return $this->getAllowedFileExtensions() === '' && $this->getAllowedFileTypes() === '';
+ }
+
+ /**
+ * Returns the Accept value of the target control, from the
+ * {@see TFileUpload::getAccept Accept} property or the "accept" attribute.
+ * @return string the accept value of the target control, '' when not set.
+ */
+ protected function getTargetAccept()
+ {
+ $control = $this->getFileUploadTarget();
+ if (($accept = $control->getAccept()) !== '') {
+ return $accept;
+ }
+ return (string) $control->getAttribute('accept');
+ }
+
+ /**
+ * Splits a comma or space separated list into lower case items, ignoring
+ * empty items.
+ * @param string $value comma or space separated list.
+ * @return string[] lower case list items.
+ */
+ protected function splitList($value)
+ {
+ return preg_split('/[\s,]+/', strtolower($value), -1, PREG_SPLIT_NO_EMPTY) ?: [];
+ }
+
+ /**
+ * Returns an array of javascript validator options.
+ * The ErrorMessage option keeps the raw "{files}" token so the client-side
+ * validator can substitute the invalid file names.
+ * @return array javascript validator options.
+ */
+ protected function getClientScriptOptions()
+ {
+ $options = parent::getClientScriptOptions();
+ $options['ErrorMessage'] = parent::getErrorMessage();
+ $options['MaxFileSize'] = $this->getEffectiveMaxFileSize();
+ $options['MinFileSize'] = $this->getMinFileSize();
+ $options['TotalMaxFileSize'] = $this->getTotalMaxFileSize();
+ $options['MaxFileCount'] = $this->getMaxFileCount();
+ $options['MinFileCount'] = $this->getMinFileCount();
+ $options['AllowedFileExtensions'] = $this->getEffectiveFileExtensions();
+ $options['AllowedFileTypes'] = $this->getEffectiveFileTypes();
+ $options['MatchAnyType'] = $this->getMatchAnyType();
+ return $options;
+ }
+}
diff --git a/framework/Web/UI/WebControls/TImageValidator.php b/framework/Web/UI/WebControls/TImageValidator.php
new file mode 100644
index 000000000..fd4d5187d
--- /dev/null
+++ b/framework/Web/UI/WebControls/TImageValidator.php
@@ -0,0 +1,188 @@
+
+ * @link https://github.com/pradosoft/prado
+ * @license https://github.com/pradosoft/prado/blob/master/LICENSE
+ */
+
+namespace Prado\Web\UI\WebControls;
+
+use Prado\TPropertyValue;
+
+/**
+ * TImageValidator class
+ *
+ * TImageValidator validates the image files selected in a
+ * {@see \Prado\Web\UI\WebControls\TFileUpload} control. Every restriction of
+ * {@see \Prado\Web\UI\WebControls\TFileValidator} applies, and each file must be a
+ * readable image whose dimensions satisfy the following restrictions; 0 disables
+ * either check:
+ * - {@see setMinImageWidth MinImageWidth} / {@see setMaxImageWidth MaxImageWidth} → pixel width bounds.
+ * - {@see setMinImageHeight MinImageHeight} / {@see setMaxImageHeight MaxImageHeight} → pixel height bounds.
+ *
+ * A file that getimagesize() cannot read as an image fails the validation.
+ *
+ * Client side, the image dimensions are read asynchronously through the File
+ * API when the selection changes. A file whose dimensions are not yet decoded
+ * passes the client-side validation and the validator re-validates when the
+ * decoding completes. The server-side validation is authoritative.
+ *
+ * @author Brad Anderson
+ * @since 4.4.0
+ */
+class TImageValidator extends TFileValidator
+{
+ /**
+ * Gets the name of the javascript class responsible for performing validation for this control.
+ * This method overrides the parent implementation.
+ * @return string the javascript class name
+ */
+ protected function getClientClassName()
+ {
+ return 'Prado.WebUI.TImageValidator';
+ }
+
+ /**
+ * @return int the minimum image width in pixels. Defaults to 0, meaning no minimum.
+ */
+ public function getMinImageWidth()
+ {
+ return $this->getViewState('MinImageWidth', 0);
+ }
+
+ /**
+ * Sets the minimum width in pixels required for each image.
+ * @param int $value the minimum image width in pixels, 0 for no minimum.
+ */
+ public function setMinImageWidth($value)
+ {
+ $this->setViewState('MinImageWidth', TPropertyValue::ensureInteger($value), 0);
+ }
+
+ /**
+ * @return int the maximum image width in pixels. Defaults to 0, meaning no limit.
+ */
+ public function getMaxImageWidth()
+ {
+ return $this->getViewState('MaxImageWidth', 0);
+ }
+
+ /**
+ * Sets the maximum width in pixels allowed for each image.
+ * @param int $value the maximum image width in pixels, 0 for no limit.
+ */
+ public function setMaxImageWidth($value)
+ {
+ $this->setViewState('MaxImageWidth', TPropertyValue::ensureInteger($value), 0);
+ }
+
+ /**
+ * @return int the minimum image height in pixels. Defaults to 0, meaning no minimum.
+ */
+ public function getMinImageHeight()
+ {
+ return $this->getViewState('MinImageHeight', 0);
+ }
+
+ /**
+ * Sets the minimum height in pixels required for each image.
+ * @param int $value the minimum image height in pixels, 0 for no minimum.
+ */
+ public function setMinImageHeight($value)
+ {
+ $this->setViewState('MinImageHeight', TPropertyValue::ensureInteger($value), 0);
+ }
+
+ /**
+ * @return int the maximum image height in pixels. Defaults to 0, meaning no limit.
+ */
+ public function getMaxImageHeight()
+ {
+ return $this->getViewState('MaxImageHeight', 0);
+ }
+
+ /**
+ * Sets the maximum height in pixels allowed for each image.
+ * @param int $value the maximum image height in pixels, 0 for no limit.
+ */
+ public function setMaxImageHeight($value)
+ {
+ $this->setViewState('MaxImageHeight', TPropertyValue::ensureInteger($value), 0);
+ }
+
+ /**
+ * Checks one uploaded file against the parent restrictions and the image
+ * restrictions.
+ * @param TFileUploadItem $file the uploaded file to check.
+ * @return bool whether the file satisfies the restrictions.
+ */
+ protected function validateFile($file)
+ {
+ return parent::validateFile($file) && $this->validateImage($file);
+ }
+
+ /**
+ * Checks that an uploaded file is a readable image whose dimensions satisfy
+ * the image restrictions. A file that cannot be read as an image fails.
+ * @param TFileUploadItem $file the uploaded file to check.
+ * @return bool whether the file is an image satisfying the restrictions.
+ */
+ protected function validateImage($file)
+ {
+ if (($size = $this->getImageSize($file)) === null) {
+ return false;
+ }
+ [$width, $height] = $size;
+ if ($width <= 0 || $height <= 0) {
+ return false;
+ }
+ if (($min = $this->getMinImageWidth()) > 0 && $width < $min) {
+ return false;
+ }
+ if (($max = $this->getMaxImageWidth()) > 0 && $width > $max) {
+ return false;
+ }
+ if (($min = $this->getMinImageHeight()) > 0 && $height < $min) {
+ return false;
+ }
+ if (($max = $this->getMaxImageHeight()) > 0 && $height > $max) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Returns the pixel dimensions of an uploaded image file.
+ * @param TFileUploadItem $file the uploaded file.
+ * @return ?array the [width, height] of the image, null when the local file
+ * is unavailable or is not a readable image.
+ */
+ protected function getImageSize($file)
+ {
+ $localName = $file->getLocalName();
+ if ($localName === '' || !is_file($localName)) {
+ return null;
+ }
+ if (($info = @getimagesize($localName)) === false) {
+ return null;
+ }
+ return [$info[0], $info[1]];
+ }
+
+ /**
+ * Returns an array of javascript validator options.
+ * @return array javascript validator options.
+ */
+ protected function getClientScriptOptions()
+ {
+ $options = parent::getClientScriptOptions();
+ $options['MinImageWidth'] = $this->getMinImageWidth();
+ $options['MaxImageWidth'] = $this->getMaxImageWidth();
+ $options['MinImageHeight'] = $this->getMinImageHeight();
+ $options['MaxImageHeight'] = $this->getMaxImageHeight();
+ return $options;
+ }
+}
diff --git a/framework/classes.php b/framework/classes.php
index 2efb84f36..607e4d0b5 100644
--- a/framework/classes.php
+++ b/framework/classes.php
@@ -773,6 +773,7 @@
'TFigureCaptionOrder' => 'Prado\Web\UI\WebControls\TFigureCaptionOrder',
'TFileUpload' => 'Prado\Web\UI\WebControls\TFileUpload',
'TFileUploadItem' => 'Prado\Web\UI\WebControls\TFileUploadItem',
+'TFileValidator' => 'Prado\Web\UI\WebControls\TFileValidator',
'TFlushOutput' => 'Prado\Web\UI\WebControls\TFlushOutput',
'TFont' => 'Prado\Web\UI\WebControls\TFont',
'TFooter' => 'Prado\Web\UI\WebControls\TFooter',
@@ -801,6 +802,7 @@
'TImageClickEventParameter' => 'Prado\Web\UI\WebControls\TImageClickEventParameter',
'TImageMap' => 'Prado\Web\UI\WebControls\TImageMap',
'TImageMapEventParameter' => 'Prado\Web\UI\WebControls\TImageMapEventParameter',
+'TImageValidator' => 'Prado\Web\UI\WebControls\TImageValidator',
'TInlineFrame' => 'Prado\Web\UI\WebControls\TInlineFrame',
'TInlineFrameAlign' => 'Prado\Web\UI\WebControls\TInlineFrameAlign',
'TInlineFrameScrollBars' => 'Prado\Web\UI\WebControls\TInlineFrameScrollBars',
diff --git a/tests/harness/active-controls/protected/pages/TActiveFileUploadValidatorTest.page b/tests/harness/active-controls/protected/pages/TActiveFileUploadValidatorTest.page
new file mode 100644
index 000000000..6def29641
--- /dev/null
+++ b/tests/harness/active-controls/protected/pages/TActiveFileUploadValidatorTest.page
@@ -0,0 +1,22 @@
+
+
+ TActiveFileUpload Validator Functional Test
+
+
+
+
+
+
+
+
diff --git a/tests/harness/active-controls/protected/pages/TActiveFileUploadValidatorTest.php b/tests/harness/active-controls/protected/pages/TActiveFileUploadValidatorTest.php
new file mode 100644
index 000000000..fa7e05457
--- /dev/null
+++ b/tests/harness/active-controls/protected/pages/TActiveFileUploadValidatorTest.php
@@ -0,0 +1,18 @@
+
+ */
+class TActiveFileUploadValidatorTest extends TPage
+{
+ public function uploadComplete($sender, $param)
+ {
+ $valid = $this->getIsValid() ? 'valid' : 'invalid';
+ $this->label1->setText($sender->getFileName() . ' ' . $valid);
+ }
+}
diff --git a/tests/harness/validators/protected/pages/FileValidator.page b/tests/harness/validators/protected/pages/FileValidator.page
new file mode 100644
index 000000000..0d27847dd
--- /dev/null
+++ b/tests/harness/validators/protected/pages/FileValidator.page
@@ -0,0 +1,85 @@
+
+Prado FileValidator Tests
+
+
File of at most 100 bytes
+
+
+
+
+
+
jpg or png files only
+
+
+
+
+
+
At most 2 files
+
+
+
+
+
+
Server side only, txt files only
+
+
+
+
+
+
Accept derived restrictions
+
+
+
+
+
+
At most 150 bytes in total
+
+
+
+
+
+
Server side content check, png only
+
+
+
+
+
+
+
+
diff --git a/tests/harness/validators/protected/pages/ImageValidator.page b/tests/harness/validators/protected/pages/ImageValidator.page
new file mode 100644
index 000000000..740efe39f
--- /dev/null
+++ b/tests/harness/validators/protected/pages/ImageValidator.page
@@ -0,0 +1,31 @@
+
+Prado ImageValidator Tests
+
+
Images of at most 100x100 pixels
+
+
+
+
+
+
Server side only, images of at least 10x10 pixels
+
+
+
+
+
+
+
+
diff --git a/tests/js/activecontrols/activefileupload.test.js b/tests/js/activecontrols/activefileupload.test.js
index da75b20ee..9b3d4724b 100644
--- a/tests/js/activecontrols/activefileupload.test.js
+++ b/tests/js/activecontrols/activefileupload.test.js
@@ -354,6 +354,79 @@ describe('TActiveFileUpload fileChanged', () => {
});
});
+// ─── fileChanged validation gate ─────────────────────────────────────────────
+
+describe('TActiveFileUpload validation gate', () => {
+ let dom;
+
+ beforeEach(() => {
+ clearRegistry(); clearControls();
+ dom = buildDOM();
+ });
+
+ afterEach(() => {
+ restoreMocks();
+ destroyDOM(dom);
+ delete global.Prado.Validation;
+ });
+
+ function selectFile() {
+ Object.defineProperty(dom.fileInput, 'value', {
+ get: () => 'file.txt',
+ configurable: true,
+ });
+ }
+
+ function stubValidation(result) {
+ const validateControl = vi.fn().mockReturnValue(result);
+ global.Prado.Validation = { managers: { [IDS.formID]: { validateControl } } };
+ return validateControl;
+ }
+
+ it('skips the upload when a validator of the input fails', () => {
+ const validateControl = stubValidation(false);
+ const ctrl = new TActiveFileUpload({ ...IDS, causesValidation: true });
+ selectFile();
+ ctrl.fileChanged();
+ expect(validateControl).toHaveBeenCalledWith(IDS.inputID);
+ expect(dom.form.submit).not.toHaveBeenCalled();
+ expect(dom.flag.value).toBe('');
+ });
+
+ it('uploads when the validators of the input pass', () => {
+ const validateControl = stubValidation(true);
+ const ctrl = new TActiveFileUpload({ ...IDS, causesValidation: true });
+ selectFile();
+ ctrl.fileChanged();
+ expect(validateControl).toHaveBeenCalledWith(IDS.inputID);
+ expect(dom.form.submit).toHaveBeenCalled();
+ });
+
+ it('uploads without consulting validation when causesValidation is false', () => {
+ const validateControl = stubValidation(false);
+ const ctrl = new TActiveFileUpload({ ...IDS, causesValidation: false });
+ selectFile();
+ ctrl.fileChanged();
+ expect(validateControl).not.toHaveBeenCalled();
+ expect(dom.form.submit).toHaveBeenCalled();
+ });
+
+ it('uploads when the validation script is not loaded', () => {
+ const ctrl = new TActiveFileUpload({ ...IDS, causesValidation: true });
+ selectFile();
+ ctrl.fileChanged();
+ expect(dom.form.submit).toHaveBeenCalled();
+ });
+
+ it('uploads when the form has no validation manager', () => {
+ global.Prado.Validation = { managers: {} };
+ const ctrl = new TActiveFileUpload({ ...IDS, causesValidation: true });
+ selectFile();
+ ctrl.fileChanged();
+ expect(dom.form.submit).toHaveBeenCalled();
+ });
+});
+
// ─── finishUpload ─────────────────────────────────────────────────────────────
describe('TActiveFileUpload finishUpload', () => {
diff --git a/tests/js/validator/filevalidator.test.js b/tests/js/validator/filevalidator.test.js
new file mode 100644
index 000000000..655596303
--- /dev/null
+++ b/tests/js/validator/filevalidator.test.js
@@ -0,0 +1,259 @@
+/**
+ * Behavioural tests for Prado.WebUI.TFileValidator.
+ *
+ * Source: framework/Web/Javascripts/source/prado/validator/validation3.js
+ *
+ * Strategy: build a minimal jsdom form + file input + span, override the
+ * input's read-only `files` property with plain File arrays, and drive
+ * evaluateIsValid() with different option sets.
+ */
+
+import { Validation, ValidationManager, WebUI } from '../adapters/validator.js';
+
+// ─── Shared DOM helpers ───────────────────────────────────────────────────────
+
+function makeEnv(formId, inputId, spanId) {
+ const form = document.createElement('form');
+ form.id = formId;
+
+ const input = document.createElement('input');
+ input.id = inputId;
+ input.type = 'file';
+
+ const span = document.createElement('span');
+ span.id = spanId;
+
+ form.appendChild(input);
+ form.appendChild(span);
+ document.body.appendChild(form);
+ new ValidationManager({ FormID: formId });
+
+ return { form, input, span };
+}
+
+function teardown(form, formId) {
+ document.body.removeChild(form);
+ delete Validation.managers[formId];
+}
+
+function setFiles(input, files) {
+ Object.defineProperty(input, 'files', { configurable: true, value: files });
+}
+
+function file(name, content = 'abc', type = '') {
+ return new File([content], name, { type });
+}
+
+describe('TFileValidator', () => {
+ let env;
+
+ beforeEach(() => {
+ env = makeEnv('fvForm', 'fvInput', 'fvSpan');
+ });
+
+ afterEach(() => teardown(env.form, 'fvForm'));
+
+ function makeValidator(extra = {}) {
+ return new WebUI.TFileValidator({
+ ID: 'fvSpan',
+ FormID: 'fvForm',
+ ControlToValidate: 'fvInput',
+ ErrorMessage: 'invalid file',
+ Enabled: true,
+ MaxFileSize: 0,
+ MinFileSize: 0,
+ TotalMaxFileSize: 0,
+ MaxFileCount: 0,
+ MinFileCount: 0,
+ AllowedFileExtensions: [],
+ AllowedFileTypes: [],
+ MatchAnyType: false,
+ ...extra,
+ });
+ }
+
+ describe('empty selection', () => {
+ it('is valid with no files selected', () => {
+ const v = makeValidator({ MaxFileSize: 1, AllowedFileExtensions: ['txt'] });
+ setFiles(env.input, []);
+ expect(v.evaluateIsValid()).toBe(true);
+ });
+
+ it('is valid when the File API is unavailable', () => {
+ const v = makeValidator({ MaxFileSize: 1 });
+ Object.defineProperty(env.input, 'files', { configurable: true, value: undefined });
+ expect(v.evaluateIsValid()).toBe(true);
+ });
+ });
+
+ describe('file size', () => {
+ it('accepts a file within MaxFileSize', () => {
+ const v = makeValidator({ MaxFileSize: 100 });
+ setFiles(env.input, [file('a.txt', 'short')]);
+ expect(v.evaluateIsValid()).toBe(true);
+ });
+
+ it('rejects a file over MaxFileSize and records its name', () => {
+ const v = makeValidator({ MaxFileSize: 3 });
+ setFiles(env.input, [file('a.txt', 'longer content')]);
+ expect(v.evaluateIsValid()).toBe(false);
+ expect(v.invalidFiles).toEqual(['a.txt']);
+ });
+
+ it('rejects a file under MinFileSize', () => {
+ const v = makeValidator({ MinFileSize: 100 });
+ setFiles(env.input, [file('a.txt', 'tiny')]);
+ expect(v.evaluateIsValid()).toBe(false);
+ });
+ });
+
+ describe('file extensions', () => {
+ it('matches extensions case-insensitively', () => {
+ const v = makeValidator({ AllowedFileExtensions: ['jpg', 'png'] });
+ setFiles(env.input, [file('photo.JPG')]);
+ expect(v.evaluateIsValid()).toBe(true);
+ });
+
+ it('rejects a file with a disallowed extension', () => {
+ const v = makeValidator({ AllowedFileExtensions: ['jpg', 'png'] });
+ setFiles(env.input, [file('anim.gif')]);
+ expect(v.evaluateIsValid()).toBe(false);
+ expect(v.invalidFiles).toEqual(['anim.gif']);
+ });
+
+ it('rejects a file without an extension when extensions are restricted', () => {
+ const v = makeValidator({ AllowedFileExtensions: ['txt'] });
+ setFiles(env.input, [file('README')]);
+ expect(v.evaluateIsValid()).toBe(false);
+ });
+ });
+
+ describe('MIME types', () => {
+ it('accepts an exact MIME type match', () => {
+ const v = makeValidator({ AllowedFileTypes: ['text/plain'] });
+ setFiles(env.input, [file('a.txt', 'abc', 'text/plain')]);
+ expect(v.evaluateIsValid()).toBe(true);
+ });
+
+ it('rejects a MIME type not in the list', () => {
+ const v = makeValidator({ AllowedFileTypes: ['text/plain'] });
+ setFiles(env.input, [file('a.png', 'abc', 'image/png')]);
+ expect(v.evaluateIsValid()).toBe(false);
+ });
+
+ it('matches wildcard subtypes', () => {
+ const v = makeValidator({ AllowedFileTypes: ['image/*'] });
+ setFiles(env.input, [file('a.png', 'abc', 'image/png')]);
+ expect(v.evaluateIsValid()).toBe(true);
+ setFiles(env.input, [file('a.txt', 'abc', 'text/plain')]);
+ expect(v.evaluateIsValid()).toBe(false);
+ });
+
+ it('matches every type with "*"', () => {
+ const v = makeValidator({ AllowedFileTypes: ['*'] });
+ setFiles(env.input, [file('a.bin', 'abc', 'application/octet-stream')]);
+ expect(v.evaluateIsValid()).toBe(true);
+ });
+
+ it('requires both lists to match without MatchAnyType', () => {
+ const v = makeValidator({
+ AllowedFileExtensions: ['txt'],
+ AllowedFileTypes: ['text/plain'],
+ });
+ setFiles(env.input, [file('a.txt', 'abc', 'text/plain')]);
+ expect(v.evaluateIsValid()).toBe(true);
+ setFiles(env.input, [file('a.txt', 'abc', 'image/png')]);
+ expect(v.evaluateIsValid()).toBe(false);
+ });
+
+ it('accepts any list match with MatchAnyType', () => {
+ const v = makeValidator({
+ AllowedFileExtensions: ['txt'],
+ AllowedFileTypes: ['image/png'],
+ MatchAnyType: true,
+ });
+ setFiles(env.input, [file('a.txt', 'abc', 'application/octet-stream')]);
+ expect(v.evaluateIsValid()).toBe(true);
+ setFiles(env.input, [file('b.png', 'abc', 'image/png')]);
+ expect(v.evaluateIsValid()).toBe(true);
+ setFiles(env.input, [file('c.exe', 'abc', 'application/x-msdownload')]);
+ expect(v.evaluateIsValid()).toBe(false);
+ });
+ });
+
+ describe('total file size', () => {
+ it('rejects a selection whose combined size exceeds TotalMaxFileSize', () => {
+ const v = makeValidator({ TotalMaxFileSize: 8 });
+ setFiles(env.input, [file('a.txt', 'aaaaa'), file('b.txt', 'bbbbb')]);
+ expect(v.evaluateIsValid()).toBe(false);
+ });
+
+ it('accepts a selection within TotalMaxFileSize', () => {
+ const v = makeValidator({ TotalMaxFileSize: 12 });
+ setFiles(env.input, [file('a.txt', 'aaaaa'), file('b.txt', 'bbbbb')]);
+ expect(v.evaluateIsValid()).toBe(true);
+ });
+ });
+
+ describe('file counts', () => {
+ it('rejects more files than MaxFileCount', () => {
+ const v = makeValidator({ MaxFileCount: 2 });
+ setFiles(env.input, [file('a.txt'), file('b.txt'), file('c.txt')]);
+ expect(v.evaluateIsValid()).toBe(false);
+ });
+
+ it('rejects fewer files than MinFileCount', () => {
+ const v = makeValidator({ MinFileCount: 2 });
+ setFiles(env.input, [file('a.txt')]);
+ expect(v.evaluateIsValid()).toBe(false);
+ setFiles(env.input, [file('a.txt'), file('b.txt')]);
+ expect(v.evaluateIsValid()).toBe(true);
+ });
+ });
+
+ describe('{files} error message token', () => {
+ it('substitutes the invalid file names in getErrorMessage()', () => {
+ const v = makeValidator({
+ AllowedFileExtensions: ['txt'],
+ ErrorMessage: 'Invalid files: {files}',
+ });
+ setFiles(env.input, [file('bad.gif'), file('worse.exe')]);
+ expect(v.evaluateIsValid()).toBe(false);
+ expect(v.getErrorMessage()).toBe('Invalid files: bad.gif, worse.exe');
+ });
+
+ it('updates the message element text content', () => {
+ const v = makeValidator({
+ AllowedFileExtensions: ['txt'],
+ ErrorMessage: 'Invalid: {files}',
+ });
+ setFiles(env.input, [file('bad.gif')]);
+ v.evaluateIsValid();
+ expect(env.span.textContent).toBe('Invalid: bad.gif');
+ });
+
+ it('leaves the message element alone without the token', () => {
+ env.span.textContent = 'static message';
+ const v = makeValidator({ AllowedFileExtensions: ['txt'] });
+ setFiles(env.input, [file('bad.gif')]);
+ v.evaluateIsValid();
+ expect(env.span.textContent).toBe('static message');
+ });
+ });
+
+ describe('validate() integration', () => {
+ it('marks the validator invalid and shows the message', () => {
+ const v = makeValidator({ AllowedFileExtensions: ['txt'] });
+ setFiles(env.input, [file('bad.gif')]);
+ expect(v.validate()).toBe(false);
+ expect(v.isValid).toBe(false);
+ });
+
+ it('passes with a valid file', () => {
+ const v = makeValidator({ AllowedFileExtensions: ['txt'], MaxFileSize: 100 });
+ setFiles(env.input, [file('good.txt')]);
+ expect(v.validate()).toBe(true);
+ expect(v.isValid).toBe(true);
+ });
+ });
+});
diff --git a/tests/js/validator/imagevalidator.test.js b/tests/js/validator/imagevalidator.test.js
new file mode 100644
index 000000000..cb80908e8
--- /dev/null
+++ b/tests/js/validator/imagevalidator.test.js
@@ -0,0 +1,200 @@
+/**
+ * Behavioural tests for Prado.WebUI.TImageValidator.
+ *
+ * Source: framework/Web/Javascripts/source/prado/validator/validation3.js
+ *
+ * Strategy: jsdom cannot decode images, so the asynchronous dimension reads
+ * never complete here. The dimension logic is driven by seeding the
+ * validator's `_imageInfo` cache with decoded entries and stubbing
+ * `canReadImages()`.
+ */
+
+import { Validation, ValidationManager, WebUI } from '../adapters/validator.js';
+
+// ─── Shared DOM helpers ───────────────────────────────────────────────────────
+
+function makeEnv(formId, inputId, spanId) {
+ const form = document.createElement('form');
+ form.id = formId;
+
+ const input = document.createElement('input');
+ input.id = inputId;
+ input.type = 'file';
+
+ const span = document.createElement('span');
+ span.id = spanId;
+
+ form.appendChild(input);
+ form.appendChild(span);
+ document.body.appendChild(form);
+ new ValidationManager({ FormID: formId });
+
+ return { form, input, span };
+}
+
+function teardown(form, formId) {
+ document.body.removeChild(form);
+ delete Validation.managers[formId];
+}
+
+function setFiles(input, files) {
+ Object.defineProperty(input, 'files', { configurable: true, value: files });
+}
+
+function file(name, content = 'abc', type = 'image/png') {
+ return new File([content], name, { type });
+}
+
+describe('TImageValidator', () => {
+ let env;
+
+ beforeEach(() => {
+ env = makeEnv('ivForm', 'ivInput', 'ivSpan');
+ });
+
+ afterEach(() => teardown(env.form, 'ivForm'));
+
+ function makeValidator(extra = {}) {
+ return new WebUI.TImageValidator({
+ ID: 'ivSpan',
+ FormID: 'ivForm',
+ ControlToValidate: 'ivInput',
+ ErrorMessage: 'invalid image',
+ Enabled: true,
+ MaxFileSize: 0,
+ MinFileSize: 0,
+ TotalMaxFileSize: 0,
+ MaxFileCount: 0,
+ MinFileCount: 0,
+ AllowedFileExtensions: [],
+ AllowedFileTypes: [],
+ MatchAnyType: false,
+ MinImageWidth: 0,
+ MaxImageWidth: 0,
+ MinImageHeight: 0,
+ MaxImageHeight: 0,
+ ...extra,
+ });
+ }
+
+ /**
+ * Seed the dimension cache with a decoded entry for the file and force
+ * canReadImages() so the dimension checks run under jsdom.
+ */
+ function seedInfo(v, f, info) {
+ v.canReadImages = () => true;
+ v.readImageInfo = () => {};
+ v._imageInfo[v.fileKey(f)] = { pending: false, notImage: false, width: 0, height: 0, ...info };
+ }
+
+ it('extends TFileValidator', () => {
+ expect(makeValidator() instanceof WebUI.TFileValidator).toBe(true);
+ });
+
+ describe('dimension checks with decoded entries', () => {
+ it('accepts an image within the bounds', () => {
+ const v = makeValidator({ MinImageWidth: 10, MaxImageWidth: 200, MinImageHeight: 10, MaxImageHeight: 200 });
+ const f = file('a.png');
+ setFiles(env.input, [f]);
+ seedInfo(v, f, { width: 100, height: 100 });
+ expect(v.evaluateIsValid()).toBe(true);
+ });
+
+ it('rejects an image over MaxImageWidth', () => {
+ const v = makeValidator({ MaxImageWidth: 100 });
+ const f = file('wide.png');
+ setFiles(env.input, [f]);
+ seedInfo(v, f, { width: 150, height: 50 });
+ expect(v.evaluateIsValid()).toBe(false);
+ expect(v.invalidFiles).toEqual(['wide.png']);
+ });
+
+ it('rejects an image under MinImageHeight', () => {
+ const v = makeValidator({ MinImageHeight: 100 });
+ const f = file('short.png');
+ setFiles(env.input, [f]);
+ seedInfo(v, f, { width: 150, height: 50 });
+ expect(v.evaluateIsValid()).toBe(false);
+ });
+
+ it('rejects a file that failed to decode as an image', () => {
+ const v = makeValidator();
+ const f = file('fake.png');
+ setFiles(env.input, [f]);
+ seedInfo(v, f, { notImage: true });
+ expect(v.evaluateIsValid()).toBe(false);
+ });
+ });
+
+ describe('undecoded files', () => {
+ it('passes a file whose decode is still pending', () => {
+ const v = makeValidator({ MaxImageWidth: 1 });
+ const f = file('a.png');
+ setFiles(env.input, [f]);
+ seedInfo(v, f, { pending: true });
+ expect(v.evaluateIsValid()).toBe(true);
+ });
+
+ it('passes a file with no cache entry and requests its decode', () => {
+ const v = makeValidator({ MaxImageWidth: 1 });
+ const f = file('a.png');
+ setFiles(env.input, [f]);
+ v.canReadImages = () => true;
+ const requested = [];
+ v.readImageInfo = (fileArg) => requested.push(fileArg.name);
+ expect(v.evaluateIsValid()).toBe(true);
+ expect(requested).toEqual(['a.png']);
+ });
+
+ it('skips the dimension checks when images cannot be read', () => {
+ const v = makeValidator({ MaxImageWidth: 1 });
+ v.canReadImages = () => false;
+ setFiles(env.input, [file('a.png')]);
+ expect(v.evaluateIsValid()).toBe(true);
+ });
+ });
+
+ describe('inherited TFileValidator checks', () => {
+ it('rejects a file over MaxFileSize before the dimension checks', () => {
+ const v = makeValidator({ MaxFileSize: 2 });
+ const f = file('a.png', 'longer content');
+ setFiles(env.input, [f]);
+ seedInfo(v, f, { width: 10, height: 10 });
+ expect(v.evaluateIsValid()).toBe(false);
+ });
+
+ it('rejects a disallowed extension before the dimension checks', () => {
+ const v = makeValidator({ AllowedFileExtensions: ['png'] });
+ const f = file('a.gif', 'abc', 'image/gif');
+ setFiles(env.input, [f]);
+ seedInfo(v, f, { width: 10, height: 10 });
+ expect(v.evaluateIsValid()).toBe(false);
+ });
+ });
+
+ describe('selection changes', () => {
+ it('drops the stale dimension cache when the selection changes', () => {
+ const v = makeValidator();
+ const f = file('a.png');
+ setFiles(env.input, [f]);
+ seedInfo(v, f, { width: 10, height: 10 });
+ v.canReadImages = () => false;
+ env.input.dispatchEvent(new Event('change'));
+ expect(v._imageInfo).toEqual({});
+ });
+ });
+
+ describe('revalidate()', () => {
+ it('re-validates after a decode completes once results are displayed', () => {
+ const v = makeValidator({ MaxImageWidth: 100 });
+ const f = file('wide.png');
+ setFiles(env.input, [f]);
+ seedInfo(v, f, { pending: true });
+ expect(v.validate()).toBe(true);
+ // the decode completes: the cache entry resolves to an oversized image
+ v._imageInfo[v.fileKey(f)] = { pending: false, notImage: false, width: 150, height: 50 };
+ v.revalidate();
+ expect(v.isValid).toBe(false);
+ });
+ });
+});
diff --git a/tests/playwright/active-controls/ActiveFileUploadValidatorTestCase.spec.js b/tests/playwright/active-controls/ActiveFileUploadValidatorTestCase.spec.js
new file mode 100644
index 000000000..900453b51
--- /dev/null
+++ b/tests/playwright/active-controls/ActiveFileUploadValidatorTestCase.spec.js
@@ -0,0 +1,37 @@
+import { test } from '@playwright/test';
+import { genericHelper } from '../helpers.js';
+import { pngBuffer } from '../validators/png.js';
+
+function textFile(name, content = 'plain text content') {
+ return { name, mimeType: 'text/plain', buffer: Buffer.from(content) };
+}
+
+test('ActiveFileUploadValidatorTestCase', async ({ page }) => {
+ const h = genericHelper(page);
+ const base = 'ctl0_Content_';
+ await h.url('active-controls/index.php?page=TActiveFileUploadValidatorTest');
+ await h.assertSourceContains('TActiveFileUpload Validator Functional Test');
+
+ await h.assertText(`${base}label1`, 'No upload');
+ await h.assertNotVisible(`${base}validator1`);
+
+ // an invalid extension is blocked client side before the upload starts
+ await page.setInputFiles(`#${base}uploader`, textFile('fake.png'));
+ await h.assertVisible(`${base}validator1`);
+ await h.assertText(`${base}validator1`, 'Wrong type: fake.png');
+ await h.assertText(`${base}label1`, 'No upload');
+
+ // a valid selection uploads and the server-side validation passes
+ await page.setInputFiles(`#${base}uploader`, textFile('ok.txt'));
+ await h.assertNotVisible(`${base}validator1`);
+ await h.assertText(`${base}label1`, 'ok.txt valid');
+
+ // PNG content renamed to .txt passes the client gate but fails the
+ // server-side CheckExtensionMimeType validation during the callback
+ await page.setInputFiles(`#${base}uploader`, {
+ name: 'trick.txt',
+ mimeType: 'text/plain',
+ buffer: pngBuffer(4, 4),
+ });
+ await h.assertText(`${base}label1`, 'trick.txt invalid');
+});
diff --git a/tests/playwright/validators/FileValidatorTestCase.spec.js b/tests/playwright/validators/FileValidatorTestCase.spec.js
new file mode 100644
index 000000000..de7673bb6
--- /dev/null
+++ b/tests/playwright/validators/FileValidatorTestCase.spec.js
@@ -0,0 +1,137 @@
+import { test } from '@playwright/test';
+import { genericHelper } from '../helpers.js';
+import { pngFile } from './png.js';
+
+function makeFile(name, mimeType, size = 10) {
+ return { name, mimeType, buffer: Buffer.alloc(size, 'a') };
+}
+
+test.describe('FileValidatorTestCase', () => {
+ test('testMaxFileSize', async ({ page }) => {
+ const h = genericHelper(page);
+ const base = 'ctl0_Content_';
+ await h.url('validators/index.php?page=FileValidator');
+ await h.assertSourceContains('Prado FileValidator Tests');
+
+ // the Capture property renders the capture attribute
+ await h.assertAttribute(`${base}upload1@capture`, 'environment');
+
+ await h.assertNotVisible(`${base}validator1`);
+ await page.setInputFiles(`#${base}upload1`, makeFile('big.txt', 'text/plain', 200));
+ await h.assertNotVisible(`${base}validator1`);
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertVisible(`${base}validator1`);
+ await page.setInputFiles(`#${base}upload1`, makeFile('small.txt', 'text/plain', 50));
+ await h.assertNotVisible(`${base}validator1`);
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertNotVisible(`${base}validator1`);
+ });
+
+ test('testAllowedFileExtensions', async ({ page }) => {
+ const h = genericHelper(page);
+ const base = 'ctl0_Content_';
+ await h.url('validators/index.php?page=FileValidator');
+
+ await h.assertNotVisible(`${base}validator2`);
+ await page.setInputFiles(`#${base}upload2`, makeFile('anim.gif', 'image/gif'));
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertVisible(`${base}validator2`);
+ // the {files} token is replaced with the invalid file name
+ await h.assertText(`${base}validator2`, 'Wrong type: anim.gif');
+ await page.setInputFiles(`#${base}upload2`, makeFile('photo.jpg', 'image/jpeg'));
+ await h.assertNotVisible(`${base}validator2`);
+ });
+
+ test('testMaxFileCount', async ({ page }) => {
+ const h = genericHelper(page);
+ const base = 'ctl0_Content_';
+ await h.url('validators/index.php?page=FileValidator');
+
+ await h.assertNotVisible(`${base}validator3`);
+ await page.setInputFiles(`#${base}upload3`, [
+ makeFile('a.txt', 'text/plain'),
+ makeFile('b.txt', 'text/plain'),
+ makeFile('c.txt', 'text/plain'),
+ ]);
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertVisible(`${base}validator3`);
+ await page.setInputFiles(`#${base}upload3`, [
+ makeFile('a.txt', 'text/plain'),
+ makeFile('b.txt', 'text/plain'),
+ ]);
+ await h.assertNotVisible(`${base}validator3`);
+ });
+
+ test('testServerSideValidation', async ({ page }) => {
+ const h = genericHelper(page);
+ const base = 'ctl0_Content_';
+ await h.url('validators/index.php?page=FileValidator');
+
+ // validator4 has EnableClientScript=false: the invalid file posts back
+ // and the server-side validation shows the message after the reload.
+ await h.assertNotVisible(`${base}validator4`);
+ await page.setInputFiles(`#${base}upload4`, makeFile('report.pdf', 'application/pdf'));
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertVisible(`${base}validator4`);
+
+ await page.setInputFiles(`#${base}upload4`, makeFile('report.txt', 'text/plain'));
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertNotVisible(`${base}validator4`);
+ });
+
+ test('testTotalMaxFileSize', async ({ page }) => {
+ const h = genericHelper(page);
+ const base = 'ctl0_Content_';
+ await h.url('validators/index.php?page=FileValidator');
+
+ await h.assertNotVisible(`${base}validator6`);
+ await page.setInputFiles(`#${base}upload6`, [
+ makeFile('a.txt', 'text/plain', 100),
+ makeFile('b.txt', 'text/plain', 100),
+ ]);
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertVisible(`${base}validator6`);
+ await page.setInputFiles(`#${base}upload6`, [
+ makeFile('a.txt', 'text/plain', 60),
+ makeFile('b.txt', 'text/plain', 60),
+ ]);
+ await h.assertNotVisible(`${base}validator6`);
+ });
+
+ test('testCheckExtensionMimeType', async ({ page }) => {
+ const h = genericHelper(page);
+ const base = 'ctl0_Content_';
+ await h.url('validators/index.php?page=FileValidator');
+
+ // validator7 is server side only: a text file renamed to .png passes the
+ // extension restriction but fails the sniffed content cross-check.
+ await h.assertNotVisible(`${base}validator7`);
+ await page.setInputFiles(`#${base}upload7`, makeFile('fake.png', 'image/png', 50));
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertVisible(`${base}validator7`);
+
+ await page.setInputFiles(`#${base}upload7`, pngFile('real.png', 4, 4));
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertNotVisible(`${base}validator7`);
+ });
+
+ test('testAcceptDerivedRestrictions', async ({ page }) => {
+ const h = genericHelper(page);
+ const base = 'ctl0_Content_';
+ await h.url('validators/index.php?page=FileValidator');
+
+ // the Accept property renders the accept attribute
+ await h.assertAttribute(`${base}upload5@accept`, '.txt, image/png');
+
+ await h.assertNotVisible(`${base}validator5`);
+ await page.setInputFiles(`#${base}upload5`, makeFile('setup.exe', 'application/x-msdownload'));
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertVisible(`${base}validator5`);
+ // a file matching the extension token is valid
+ await page.setInputFiles(`#${base}upload5`, makeFile('notes.txt', 'text/plain'));
+ await h.assertNotVisible(`${base}validator5`);
+ // a file matching the MIME token is valid
+ await page.setInputFiles(`#${base}upload5`, makeFile('logo.png', 'image/png'));
+ await h.assertNotVisible(`${base}validator5`);
+ });
+});
diff --git a/tests/playwright/validators/ImageValidatorTestCase.spec.js b/tests/playwright/validators/ImageValidatorTestCase.spec.js
new file mode 100644
index 000000000..51b02002c
--- /dev/null
+++ b/tests/playwright/validators/ImageValidatorTestCase.spec.js
@@ -0,0 +1,58 @@
+import { test } from '@playwright/test';
+import { genericHelper } from '../helpers.js';
+import { pngFile } from './png.js';
+
+test.describe('ImageValidatorTestCase', () => {
+ test('testMaxImageDimensions', async ({ page }) => {
+ const h = genericHelper(page);
+ const base = 'ctl0_Content_';
+ await h.url('validators/index.php?page=ImageValidator');
+ await h.assertSourceContains('Prado ImageValidator Tests');
+
+ // An oversized image fails: client side once the asynchronous decode
+ // completes, or server side when the submit outruns the decode.
+ await h.assertNotVisible(`${base}validator1`);
+ await page.setInputFiles(`#${base}upload1`, pngFile('wide.png', 200, 50));
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertVisible(`${base}validator1`);
+
+ await page.setInputFiles(`#${base}upload1`, pngFile('small.png', 50, 50));
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertNotVisible(`${base}validator1`);
+ });
+
+ test('testNotAnImage', async ({ page }) => {
+ const h = genericHelper(page);
+ const base = 'ctl0_Content_';
+ await h.url('validators/index.php?page=ImageValidator');
+
+ await h.assertNotVisible(`${base}validator1`);
+ await page.setInputFiles(`#${base}upload1`, {
+ name: 'fake.png',
+ mimeType: 'image/png',
+ buffer: Buffer.from('plain text pretending to be an image'),
+ });
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertVisible(`${base}validator1`);
+
+ await page.setInputFiles(`#${base}upload1`, pngFile('real.png', 20, 20));
+ await h.assertNotVisible(`${base}validator1`);
+ });
+
+ test('testServerSideMinImageDimensions', async ({ page }) => {
+ const h = genericHelper(page);
+ const base = 'ctl0_Content_';
+ await h.url('validators/index.php?page=ImageValidator');
+
+ // validator2 has EnableClientScript=false: the undersized image posts
+ // back and the server-side validation shows the message after reload.
+ await h.assertNotVisible(`${base}validator2`);
+ await page.setInputFiles(`#${base}upload2`, pngFile('tiny.png', 5, 5));
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertVisible(`${base}validator2`);
+
+ await page.setInputFiles(`#${base}upload2`, pngFile('ok.png', 20, 20));
+ await h.byXPath("//input[@type='submit' and @value='Test']").click();
+ await h.assertNotVisible(`${base}validator2`);
+ });
+});
diff --git a/tests/playwright/validators/png.js b/tests/playwright/validators/png.js
new file mode 100644
index 000000000..b0bedf0ed
--- /dev/null
+++ b/tests/playwright/validators/png.js
@@ -0,0 +1,71 @@
+/**
+ * Minimal PNG builder for validator functional tests.
+ *
+ * Produces a real, decodable 8-bit RGB PNG of the requested pixel dimensions
+ * so both the browser (client-side TImageValidator) and PHP's getimagesize()
+ * (server-side) read the same width and height.
+ */
+
+import zlib from 'zlib';
+
+const CRC_TABLE = (() => {
+ const table = new Int32Array(256);
+ for (let n = 0; n < 256; n++) {
+ let c = n;
+ for (let k = 0; k < 8; k++) {
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ }
+ table[n] = c;
+ }
+ return table;
+})();
+
+function crc32(buf) {
+ let crc = -1;
+ for (let i = 0; i < buf.length; i++) {
+ crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
+ }
+ return (crc ^ -1) >>> 0;
+}
+
+function chunk(type, data) {
+ const length = Buffer.alloc(4);
+ length.writeUInt32BE(data.length);
+ const typeAndData = Buffer.concat([Buffer.from(type, 'latin1'), data]);
+ const crc = Buffer.alloc(4);
+ crc.writeUInt32BE(crc32(typeAndData));
+ return Buffer.concat([length, typeAndData, crc]);
+}
+
+/**
+ * @param {number} width pixel width
+ * @param {number} height pixel height
+ * @returns {Buffer} a complete PNG file
+ */
+export function pngBuffer(width, height) {
+ const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(width, 0);
+ ihdr.writeUInt32BE(height, 4);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 2; // color type: truecolor RGB
+ // one filter byte per scanline followed by black RGB pixels
+ const raw = Buffer.alloc((width * 3 + 1) * height);
+ const idat = zlib.deflateSync(raw);
+ return Buffer.concat([
+ signature,
+ chunk('IHDR', ihdr),
+ chunk('IDAT', idat),
+ chunk('IEND', Buffer.alloc(0)),
+ ]);
+}
+
+/**
+ * @param {string} name file name for the upload
+ * @param {number} width pixel width
+ * @param {number} height pixel height
+ * @returns {{name: string, mimeType: string, buffer: Buffer}} a Playwright setInputFiles payload
+ */
+export function pngFile(name, width, height) {
+ return { name, mimeType: 'image/png', buffer: pngBuffer(width, height) };
+}
diff --git a/tests/unit/Web/UI/ActiveControls/TActiveFileUploadTest.php b/tests/unit/Web/UI/ActiveControls/TActiveFileUploadTest.php
new file mode 100644
index 000000000..8c6803712
--- /dev/null
+++ b/tests/unit/Web/UI/ActiveControls/TActiveFileUploadTest.php
@@ -0,0 +1,49 @@
+assertInstanceOf(\Prado\Web\UI\WebControls\TFileUpload::class, $upload);
+ }
+
+ public function testCausesValidationDefaultsToTrue()
+ {
+ $upload = new TActiveFileUpload();
+ $this->assertTrue($upload->getCausesValidation());
+ }
+
+ public function testSetCausesValidation()
+ {
+ $upload = new TActiveFileUpload();
+ $upload->setCausesValidation(false);
+ $this->assertFalse($upload->getCausesValidation());
+ }
+
+ public function testValidationGroupDefaultsToEmpty()
+ {
+ $upload = new TActiveFileUpload();
+ $this->assertEquals('', $upload->getValidationGroup());
+ }
+
+ public function testSetValidationGroup()
+ {
+ $upload = new TActiveFileUpload();
+ $upload->setValidationGroup('uploadGroup');
+ $this->assertEquals('uploadGroup', $upload->getValidationGroup());
+ }
+
+ public function testInheritsAcceptAndCapture()
+ {
+ $upload = new TActiveFileUpload();
+ $upload->setAccept('image/*');
+ $upload->setCapture('user');
+ $this->assertEquals('image/*', $upload->getAccept());
+ $this->assertEquals('user', $upload->getCapture());
+ }
+}
diff --git a/tests/unit/Web/UI/WebControls/TFileUploadTest.php b/tests/unit/Web/UI/WebControls/TFileUploadTest.php
new file mode 100644
index 000000000..ab0e193c6
--- /dev/null
+++ b/tests/unit/Web/UI/WebControls/TFileUploadTest.php
@@ -0,0 +1,105 @@
+setID('upload1');
+ $page->getControls()->add($upload);
+ return [$page, $upload];
+ }
+
+ // ================================================================================
+ // Accept Property Tests
+ // ================================================================================
+
+ public function testAcceptDefaultsToEmpty()
+ {
+ $upload = new TFileUpload();
+ $this->assertEquals('', $upload->getAccept());
+ }
+
+ public function testSetAccept()
+ {
+ $upload = new TFileUpload();
+ $upload->setAccept('.jpg, image/png, image/*');
+ $this->assertEquals('.jpg, image/png, image/*', $upload->getAccept());
+ }
+
+ public function testRenderAcceptAttribute()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $upload->setAccept('image/*');
+
+ $html = $this->renderBeginTag($upload);
+
+ $this->assertStringContainsString('type="file"', $html);
+ $this->assertStringContainsString('accept="image/*"', $html);
+ }
+
+ public function testRenderWithoutAcceptAttribute()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+
+ $html = $this->renderBeginTag($upload);
+
+ $this->assertStringNotContainsString('accept=', $html);
+ }
+
+ // ================================================================================
+ // Capture Property Tests
+ // ================================================================================
+
+ public function testCaptureDefaultsToEmpty()
+ {
+ $upload = new TFileUpload();
+ $this->assertEquals('', $upload->getCapture());
+ }
+
+ public function testSetCapture()
+ {
+ $upload = new TFileUpload();
+ $upload->setCapture('environment');
+ $this->assertEquals('environment', $upload->getCapture());
+ }
+
+ public function testRenderCaptureAttribute()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $upload->setAccept('image/*');
+ $upload->setCapture('user');
+
+ $html = $this->renderBeginTag($upload);
+
+ $this->assertStringContainsString('capture="user"', $html);
+ }
+
+ public function testRenderWithoutCaptureAttribute()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+
+ $html = $this->renderBeginTag($upload);
+
+ $this->assertStringNotContainsString('capture=', $html);
+ }
+
+ public function testRenderMultipleAttribute()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $upload->setMultiple(true);
+
+ $html = $this->renderBeginTag($upload);
+
+ $this->assertStringContainsString('multiple="multiple"', $html);
+ $this->assertStringContainsString('[]', $html);
+ }
+}
diff --git a/tests/unit/Web/UI/WebControls/TFileValidatorTest.php b/tests/unit/Web/UI/WebControls/TFileValidatorTest.php
new file mode 100644
index 000000000..da872ad66
--- /dev/null
+++ b/tests/unit/Web/UI/WebControls/TFileValidatorTest.php
@@ -0,0 +1,639 @@
+_tempFiles as $file) {
+ if (is_file($file)) {
+ unlink($file);
+ }
+ }
+ $this->_tempFiles = [];
+ parent::tearDown();
+ }
+
+ private function createPageWithUpload()
+ {
+ $page = new \Prado\Web\UI\TPage();
+ $upload = new TFileUpload();
+ $upload->setID('upload1');
+ $page->getControls()->add($upload);
+ return [$page, $upload];
+ }
+
+ private function createValidator($page, $target, $id = 'v1')
+ {
+ $validator = new TFileValidator();
+ $validator->setID($id);
+ $validator->setControlToValidate($target->getID());
+ $page->getControls()->add($validator);
+ return $validator;
+ }
+
+ private function setFiles($upload, array $files)
+ {
+ PradoUnit::setProp($upload, '_files', $files);
+ }
+
+ private function makeFile($name, $size, $type = '', $errorCode = UPLOAD_ERR_OK, $localName = '')
+ {
+ return new TFileUploadItem($name, $size, $type, $errorCode, $localName);
+ }
+
+ private function makeTempFile($content)
+ {
+ $file = tempnam(sys_get_temp_dir(), 'tfv');
+ file_put_contents($file, $content);
+ $this->_tempFiles[] = $file;
+ return $file;
+ }
+
+ private function invokeEvaluateIsValid($validator)
+ {
+ return PradoUnit::invoke($validator, 'evaluateIsValid');
+ }
+
+ // ================================================================================
+ // Constructor and Default State Tests
+ // ================================================================================
+
+ public function testSetForeColorToRed()
+ {
+ $validator = new TFileValidator();
+ $this->assertEquals('red', $validator->getForeColor());
+ }
+
+ public function testExtendsTBaseValidator()
+ {
+ $validator = new TFileValidator();
+ $this->assertInstanceOf(\Prado\Web\UI\WebControls\TBaseValidator::class, $validator);
+ }
+
+ public function testDefaultPropertyValues()
+ {
+ $validator = new TFileValidator();
+ $this->assertEquals(0, $validator->getMaxFileSize());
+ $this->assertEquals(0, $validator->getMinFileSize());
+ $this->assertEquals(0, $validator->getTotalMaxFileSize());
+ $this->assertEquals(0, $validator->getMaxFileCount());
+ $this->assertEquals(0, $validator->getMinFileCount());
+ $this->assertFalse($validator->getCheckExtensionMimeType());
+ $this->assertEquals('', $validator->getAllowedFileExtensions());
+ $this->assertEquals('', $validator->getAllowedFileTypes());
+ $this->assertEquals([], $validator->getInvalidFileNames());
+ }
+
+ public function testPropertySettersAndGetters()
+ {
+ $validator = new TFileValidator();
+ $validator->setTotalMaxFileSize(8192);
+ $this->assertEquals(8192, $validator->getTotalMaxFileSize());
+ $validator->setCheckExtensionMimeType(true);
+ $this->assertTrue($validator->getCheckExtensionMimeType());
+ $validator->setMaxFileSize(2048);
+ $this->assertEquals(2048, $validator->getMaxFileSize());
+ $validator->setMinFileSize(16);
+ $this->assertEquals(16, $validator->getMinFileSize());
+ $validator->setMaxFileCount(5);
+ $this->assertEquals(5, $validator->getMaxFileCount());
+ $validator->setMinFileCount(2);
+ $this->assertEquals(2, $validator->getMinFileCount());
+ $validator->setAllowedFileExtensions('jpg, png');
+ $this->assertEquals('jpg, png', $validator->getAllowedFileExtensions());
+ $validator->setAllowedFileTypes('image/*');
+ $this->assertEquals('image/*', $validator->getAllowedFileTypes());
+ }
+
+ public function testGetClientClassName()
+ {
+ $validator = new TFileValidator();
+ $this->assertEquals('Prado.WebUI.TFileValidator', PradoUnit::invoke($validator, 'getClientClassName'));
+ }
+
+ // ================================================================================
+ // Validation Target Tests
+ // ================================================================================
+
+ public function testNonFileUploadTargetThrowsException()
+ {
+ $page = new \Prado\Web\UI\TPage();
+ $textbox = new TTextBox();
+ $textbox->setID('text1');
+ $page->getControls()->add($textbox);
+ $validator = $this->createValidator($page, $textbox);
+
+ $this->expectException(TConfigurationException::class);
+ $this->invokeEvaluateIsValid($validator);
+ }
+
+ // ================================================================================
+ // Empty Selection Tests
+ // ================================================================================
+
+ public function testNoFilesIsValid()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testNoFileErrorCodeIsFilteredOut()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setMinFileCount(1);
+ $this->setFiles($upload, [$this->makeFile('', 0, '', UPLOAD_ERR_NO_FILE)]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ // ================================================================================
+ // File Size Tests
+ // ================================================================================
+
+ public function testFileWithinMaxFileSize()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setMaxFileSize(1000);
+ $this->setFiles($upload, [$this->makeFile('a.txt', 500)]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testFileOverMaxFileSize()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setMaxFileSize(100);
+ $this->setFiles($upload, [$this->makeFile('a.txt', 500)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ $this->assertEquals(['a.txt'], $validator->getInvalidFileNames());
+ }
+
+ public function testMaxFileSizeFallsBackToTargetMaxFileSize()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $upload->setMaxFileSize(400);
+ $validator = $this->createValidator($page, $upload);
+ $this->setFiles($upload, [$this->makeFile('a.txt', 500)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+
+ $this->setFiles($upload, [$this->makeFile('a.txt', 300)]);
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testFileUnderMinFileSize()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setMinFileSize(50);
+ $this->setFiles($upload, [$this->makeFile('a.txt', 10)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ // ================================================================================
+ // Upload Error Code Tests
+ // ================================================================================
+
+ public function testFormSizeErrorCodeIsInvalid()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $this->setFiles($upload, [$this->makeFile('a.txt', 0, '', UPLOAD_ERR_FORM_SIZE)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testPartialErrorCodeIsInvalid()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $this->setFiles($upload, [$this->makeFile('a.txt', 10, '', UPLOAD_ERR_PARTIAL)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ // ================================================================================
+ // File Extension Tests
+ // ================================================================================
+
+ public function testAllowedExtensionMatchesCaseInsensitively()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileExtensions('jpg, png');
+ $this->setFiles($upload, [$this->makeFile('photo.JPG', 10)]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testDisallowedExtensionIsInvalid()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileExtensions('jpg, png');
+ $this->setFiles($upload, [$this->makeFile('anim.gif', 10)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ $this->assertEquals(['anim.gif'], $validator->getInvalidFileNames());
+ }
+
+ public function testExtensionListAcceptsLeadingDots()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileExtensions('.png');
+ $this->setFiles($upload, [$this->makeFile('logo.png', 10)]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testFileWithoutExtensionIsInvalidWithExtensionList()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileExtensions('txt');
+ $this->setFiles($upload, [$this->makeFile('README', 10)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ // ================================================================================
+ // MIME Type Tests
+ // ================================================================================
+
+ public function testAllowedMimeTypeMatches()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileTypes('text/plain');
+ $this->setFiles($upload, [$this->makeFile('a.txt', 10, 'text/plain')]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testDisallowedMimeTypeIsInvalid()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileTypes('text/plain');
+ $this->setFiles($upload, [$this->makeFile('a.png', 10, 'image/png')]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testWildcardMimeTypeMatchesSubtypes()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileTypes('image/*');
+ $this->setFiles($upload, [$this->makeFile('a.png', 10, 'image/png')]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+
+ $this->setFiles($upload, [$this->makeFile('a.txt', 10, 'text/plain')]);
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testMimeTypeSniffedFromFileContent()
+ {
+ if (!function_exists('finfo_open')) {
+ $this->markTestSkipped('The fileinfo extension is not available.');
+ }
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileTypes('text/plain');
+ $localName = $this->makeTempFile('plain text content');
+ $this->setFiles($upload, [$this->makeFile('a.txt', 18, 'application/octet-stream', UPLOAD_ERR_OK, $localName)]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testExplicitExtensionAndTypeListsBothApply()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileExtensions('txt');
+ $validator->setAllowedFileTypes('text/plain');
+ $this->setFiles($upload, [$this->makeFile('a.txt', 10, 'text/plain')]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+
+ $this->setFiles($upload, [$this->makeFile('a.txt', 10, 'image/png')]);
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ // ================================================================================
+ // Accept Fallback Tests
+ // ================================================================================
+
+ public function testAcceptPropertyFallbackMatchesAnyToken()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $upload->setAccept('.txt, image/png');
+ $validator = $this->createValidator($page, $upload);
+
+ $this->setFiles($upload, [$this->makeFile('a.txt', 10, 'application/octet-stream')]);
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+
+ $this->setFiles($upload, [$this->makeFile('b.png', 10, 'image/png')]);
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+
+ $this->setFiles($upload, [$this->makeFile('c.exe', 10, 'application/x-msdownload')]);
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testAcceptAttributeFallback()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $upload->setAttribute('accept', '.csv');
+ $validator = $this->createValidator($page, $upload);
+
+ $this->setFiles($upload, [$this->makeFile('data.csv', 10)]);
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+
+ $this->setFiles($upload, [$this->makeFile('data.xml', 10)]);
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testExplicitListsOverrideAccept()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $upload->setAccept('.png');
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileExtensions('txt');
+
+ $this->setFiles($upload, [$this->makeFile('a.png', 10)]);
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+
+ $this->setFiles($upload, [$this->makeFile('a.txt', 10)]);
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ // ================================================================================
+ // File Count Tests
+ // ================================================================================
+
+ public function testMaxFileCountExceeded()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $upload->setMultiple(true);
+ $validator = $this->createValidator($page, $upload);
+ $validator->setMaxFileCount(2);
+ $this->setFiles($upload, [
+ $this->makeFile('a.txt', 10),
+ $this->makeFile('b.txt', 10),
+ $this->makeFile('c.txt', 10),
+ ]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testMinFileCountNotReached()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $upload->setMultiple(true);
+ $validator = $this->createValidator($page, $upload);
+ $validator->setMinFileCount(2);
+ $this->setFiles($upload, [$this->makeFile('a.txt', 10)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+
+ $this->setFiles($upload, [$this->makeFile('a.txt', 10), $this->makeFile('b.txt', 10)]);
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ // ================================================================================
+ // Total File Size Tests
+ // ================================================================================
+
+ public function testTotalMaxFileSizeExceeded()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $upload->setMultiple(true);
+ $validator = $this->createValidator($page, $upload);
+ $validator->setTotalMaxFileSize(150);
+ $this->setFiles($upload, [$this->makeFile('a.txt', 100), $this->makeFile('b.txt', 100)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testTotalMaxFileSizeWithinLimit()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $upload->setMultiple(true);
+ $validator = $this->createValidator($page, $upload);
+ $validator->setTotalMaxFileSize(150);
+ $this->setFiles($upload, [$this->makeFile('a.txt', 60), $this->makeFile('b.txt', 60)]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ // ================================================================================
+ // Extension MIME Type Cross-Check Tests
+ // ================================================================================
+
+ public function testCheckExtensionMimeTypeCatchesRenamedFile()
+ {
+ if (!function_exists('finfo_open')) {
+ $this->markTestSkipped('The fileinfo extension is not available.');
+ }
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setCheckExtensionMimeType(true);
+ $localName = $this->makeTempFile('plain text pretending to be an image');
+ $this->setFiles($upload, [$this->makeFile('photo.png', 36, 'image/png', UPLOAD_ERR_OK, $localName)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testCheckExtensionMimeTypeAcceptsMatchingFile()
+ {
+ if (!function_exists('finfo_open')) {
+ $this->markTestSkipped('The fileinfo extension is not available.');
+ }
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setCheckExtensionMimeType(true);
+ $localName = $this->makeTempFile('plain text content');
+ $this->setFiles($upload, [$this->makeFile('notes.txt', 18, 'text/plain', UPLOAD_ERR_OK, $localName)]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testCheckExtensionMimeTypePassesUnknownExtension()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setCheckExtensionMimeType(true);
+ $localName = $this->makeTempFile('arbitrary content');
+ $this->setFiles($upload, [$this->makeFile('data.xyz', 17, '', UPLOAD_ERR_OK, $localName)]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testCheckExtensionMimeTypePassesWithoutLocalFile()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setCheckExtensionMimeType(true);
+ $this->setFiles($upload, [$this->makeFile('photo.png', 10, 'image/png')]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testCheckExtensionMimeTypeDisabledByDefault()
+ {
+ if (!function_exists('finfo_open')) {
+ $this->markTestSkipped('The fileinfo extension is not available.');
+ }
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $localName = $this->makeTempFile('plain text pretending to be an image');
+ $this->setFiles($upload, [$this->makeFile('photo.png', 36, 'image/png', UPLOAD_ERR_OK, $localName)]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ // ================================================================================
+ // ErrorMessage Token Tests
+ // ================================================================================
+
+ public function testErrorMessageFilesTokenSubstitution()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileExtensions('txt');
+ $validator->setErrorMessage('Invalid files: {files}');
+ $this->setFiles($upload, [$this->makeFile('bad.gif', 10), $this->makeFile('worse.exe', 10)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ $this->assertEquals('Invalid files: bad.gif, worse.exe', $validator->getErrorMessage());
+ }
+
+ public function testErrorMessageFilesTokenIsHtmlEncoded()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileExtensions('txt');
+ $validator->setErrorMessage('{files}');
+ $this->setFiles($upload, [$this->makeFile('.gif', 10)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ $this->assertEquals('<b>.gif', $validator->getErrorMessage());
+ }
+
+ // ================================================================================
+ // Validate Method Integration Tests
+ // ================================================================================
+
+ public function testValidateMethodMarksTargetInvalid()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileExtensions('txt');
+ $this->setFiles($upload, [$this->makeFile('bad.gif', 10)]);
+
+ $this->assertFalse($validator->validate());
+ $this->assertFalse($validator->getIsValid());
+ $this->assertFalse($upload->getIsValid());
+ }
+
+ public function testDisabledValidatorIsAlwaysValid()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileExtensions('txt');
+ $validator->setEnabled(false);
+ $this->setFiles($upload, [$this->makeFile('bad.gif', 10)]);
+
+ $this->assertTrue($validator->validate());
+ $this->assertTrue($validator->getIsValid());
+ }
+
+ // ================================================================================
+ // Client Script Options Tests
+ // ================================================================================
+
+ public function testGetClientScriptOptions()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $form = new TForm();
+ $page->getControls()->add($form);
+ $page->setForm($form);
+ $validator = $this->createValidator($page, $upload);
+ $validator->setMaxFileSize(2048);
+ $validator->setMinFileSize(16);
+ $validator->setTotalMaxFileSize(9000);
+ $validator->setMaxFileCount(3);
+ $validator->setMinFileCount(1);
+ $validator->setAllowedFileExtensions('.JPG, png');
+ $validator->setAllowedFileTypes('Image/*');
+
+ $options = PradoUnit::invoke($validator, 'getClientScriptOptions');
+
+ $this->assertEquals(2048, $options['MaxFileSize']);
+ $this->assertEquals(16, $options['MinFileSize']);
+ $this->assertEquals(9000, $options['TotalMaxFileSize']);
+ $this->assertEquals(3, $options['MaxFileCount']);
+ $this->assertEquals(1, $options['MinFileCount']);
+ $this->assertEquals(['jpg', 'png'], $options['AllowedFileExtensions']);
+ $this->assertEquals(['image/*'], $options['AllowedFileTypes']);
+ $this->assertFalse($options['MatchAnyType']);
+ }
+
+ public function testGetClientScriptOptionsWithAcceptFallback()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $form = new TForm();
+ $page->getControls()->add($form);
+ $page->setForm($form);
+ $upload->setAccept('.txt, image/*');
+ $upload->setMaxFileSize(4096);
+ $validator = $this->createValidator($page, $upload);
+
+ $options = PradoUnit::invoke($validator, 'getClientScriptOptions');
+
+ $this->assertEquals(4096, $options['MaxFileSize']);
+ $this->assertEquals(['txt'], $options['AllowedFileExtensions']);
+ $this->assertEquals(['image/*'], $options['AllowedFileTypes']);
+ $this->assertTrue($options['MatchAnyType']);
+ }
+
+ public function testGetClientScriptOptionsKeepsRawErrorMessageToken()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $form = new TForm();
+ $page->getControls()->add($form);
+ $page->setForm($form);
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileExtensions('txt');
+ $validator->setErrorMessage('Invalid: {files}');
+ $this->setFiles($upload, [$this->makeFile('bad.gif', 10)]);
+ $validator->validate();
+
+ $options = PradoUnit::invoke($validator, 'getClientScriptOptions');
+
+ $this->assertEquals('Invalid: {files}', $options['ErrorMessage']);
+ $this->assertEquals('Invalid: bad.gif', $validator->getErrorMessage());
+ }
+}
diff --git a/tests/unit/Web/UI/WebControls/TImageValidatorTest.php b/tests/unit/Web/UI/WebControls/TImageValidatorTest.php
new file mode 100644
index 000000000..40f78f357
--- /dev/null
+++ b/tests/unit/Web/UI/WebControls/TImageValidatorTest.php
@@ -0,0 +1,217 @@
+_tempFiles as $file) {
+ if (is_file($file)) {
+ unlink($file);
+ }
+ }
+ $this->_tempFiles = [];
+ parent::tearDown();
+ }
+
+ private function createPageWithUpload()
+ {
+ $page = new \Prado\Web\UI\TPage();
+ $upload = new TFileUpload();
+ $upload->setID('upload1');
+ $page->getControls()->add($upload);
+ return [$page, $upload];
+ }
+
+ private function createValidator($page, $target, $id = 'v1')
+ {
+ $validator = new TImageValidator();
+ $validator->setID($id);
+ $validator->setControlToValidate($target->getID());
+ $page->getControls()->add($validator);
+ return $validator;
+ }
+
+ private function setFiles($upload, array $files)
+ {
+ PradoUnit::setProp($upload, '_files', $files);
+ }
+
+ private function makeTempFile($content)
+ {
+ $file = tempnam(sys_get_temp_dir(), 'tiv');
+ file_put_contents($file, $content);
+ $this->_tempFiles[] = $file;
+ return $file;
+ }
+
+ private function makeImageFile($name, $width, $height)
+ {
+ $localName = $this->makeTempFile('GIF89a' . pack('v', $width) . pack('v', $height) . "\x00\x00\x00");
+ return new TFileUploadItem($name, filesize($localName), 'image/gif', UPLOAD_ERR_OK, $localName);
+ }
+
+ private function invokeEvaluateIsValid($validator)
+ {
+ return PradoUnit::invoke($validator, 'evaluateIsValid');
+ }
+
+ // ================================================================================
+ // Constructor and Default State Tests
+ // ================================================================================
+
+ public function testExtendsTFileValidator()
+ {
+ $validator = new TImageValidator();
+ $this->assertInstanceOf(\Prado\Web\UI\WebControls\TFileValidator::class, $validator);
+ }
+
+ public function testDefaultPropertyValues()
+ {
+ $validator = new TImageValidator();
+ $this->assertEquals(0, $validator->getMinImageWidth());
+ $this->assertEquals(0, $validator->getMaxImageWidth());
+ $this->assertEquals(0, $validator->getMinImageHeight());
+ $this->assertEquals(0, $validator->getMaxImageHeight());
+ }
+
+ public function testPropertySettersAndGetters()
+ {
+ $validator = new TImageValidator();
+ $validator->setMinImageWidth(10);
+ $this->assertEquals(10, $validator->getMinImageWidth());
+ $validator->setMaxImageWidth(200);
+ $this->assertEquals(200, $validator->getMaxImageWidth());
+ $validator->setMinImageHeight(20);
+ $this->assertEquals(20, $validator->getMinImageHeight());
+ $validator->setMaxImageHeight(400);
+ $this->assertEquals(400, $validator->getMaxImageHeight());
+ }
+
+ public function testGetClientClassName()
+ {
+ $validator = new TImageValidator();
+ $this->assertEquals('Prado.WebUI.TImageValidator', PradoUnit::invoke($validator, 'getClientClassName'));
+ }
+
+ // ================================================================================
+ // Image Dimension Tests
+ // ================================================================================
+
+ public function testNoFilesIsValid()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testImageWithinBounds()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setMinImageWidth(10);
+ $validator->setMaxImageWidth(200);
+ $validator->setMinImageHeight(10);
+ $validator->setMaxImageHeight(200);
+ $this->setFiles($upload, [$this->makeImageFile('a.gif', 100, 100)]);
+
+ $this->assertTrue($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testImageOverMaxImageWidth()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setMaxImageWidth(100);
+ $this->setFiles($upload, [$this->makeImageFile('wide.gif', 150, 50)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ $this->assertEquals(['wide.gif'], $validator->getInvalidFileNames());
+ }
+
+ public function testImageUnderMinImageHeight()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setMinImageHeight(100);
+ $this->setFiles($upload, [$this->makeImageFile('short.gif', 150, 50)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testNonImageFileIsInvalid()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $localName = $this->makeTempFile('not an image at all');
+ $this->setFiles($upload, [new TFileUploadItem('fake.gif', 18, 'image/gif', UPLOAD_ERR_OK, $localName)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testMissingLocalFileIsInvalid()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $this->setFiles($upload, [new TFileUploadItem('a.gif', 10, 'image/gif', UPLOAD_ERR_OK, '')]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ // ================================================================================
+ // Inherited Restriction Tests
+ // ================================================================================
+
+ public function testInheritedExtensionRestrictionApplies()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setAllowedFileExtensions('png');
+ $this->setFiles($upload, [$this->makeImageFile('a.gif', 50, 50)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ public function testInheritedMaxFileSizeApplies()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $validator = $this->createValidator($page, $upload);
+ $validator->setMaxFileSize(5);
+ $this->setFiles($upload, [$this->makeImageFile('a.gif', 50, 50)]);
+
+ $this->assertFalse($this->invokeEvaluateIsValid($validator));
+ }
+
+ // ================================================================================
+ // Client Script Options Tests
+ // ================================================================================
+
+ public function testGetClientScriptOptions()
+ {
+ [$page, $upload] = $this->createPageWithUpload();
+ $form = new TForm();
+ $page->getControls()->add($form);
+ $page->setForm($form);
+ $validator = $this->createValidator($page, $upload);
+ $validator->setMinImageWidth(10);
+ $validator->setMaxImageWidth(200);
+ $validator->setMinImageHeight(20);
+ $validator->setMaxImageHeight(400);
+
+ $options = PradoUnit::invoke($validator, 'getClientScriptOptions');
+
+ $this->assertEquals(10, $options['MinImageWidth']);
+ $this->assertEquals(200, $options['MaxImageWidth']);
+ $this->assertEquals(20, $options['MinImageHeight']);
+ $this->assertEquals(400, $options['MaxImageHeight']);
+ }
+}