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']); + } +}