From b4027fc355133ef6bbfae1f058ef48f72bee824c Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sun, 24 May 2026 14:22:21 +0200 Subject: [PATCH] feat(form): Add support for PSR-7 file upload to V3 FileVariable and ImageVariable When a ServerRequestInterface is passed to BaseForm and files are uploaded, the uploaded files are now extracted via ServerRequestInterface::getUploadedFiles() and injected into variables implementing a new FileUploadAware interface. This eliminates the $_FILES and $GLOBALS['browser'] dependency on the PSR-7 path. These globals however are still supported for legacy fallback. Input via array|Horde_Variables|Variables is unchanged. FormVariables that don't receive an injected UploadedFileInterface continue to read from $_FILES directly. The bridge from lib/ Horde_Form is unaffected since it never calls setUploadedFile(). New: FileUploadAware interface, FileUploadPsr7Test along with 10 tests. Removed: doc/V3-MIGRATION-GUIDE.md obsoleted by UPGRADING.md. --- composer.json | 1 + doc/UPGRADING.md | 114 +++- doc/V3-MIGRATION-GUIDE.md | 798 -------------------------- src/V3/BaseForm.php | 103 +++- src/V3/FileUploadAware.php | 51 ++ src/V3/FileVariable.php | 137 ++++- src/V3/ImageVariable.php | 122 ++-- test/unit/Type/FileUploadPsr7Test.php | 223 +++++++ 8 files changed, 689 insertions(+), 860 deletions(-) delete mode 100644 doc/V3-MIGRATION-GUIDE.md create mode 100644 src/V3/FileUploadAware.php create mode 100644 test/unit/Type/FileUploadPsr7Test.php diff --git a/composer.json b/composer.json index 8fd7615..47504e4 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ "horde/token": "^3 || dev-FRAMEWORK_6_0", "horde/translation": "^3 || dev-FRAMEWORK_6_0", "horde/util": "^3 || dev-FRAMEWORK_6_0", + "psr/http-message": "^2", "ext-json": "*" }, "require-dev": { diff --git a/doc/UPGRADING.md b/doc/UPGRADING.md index 6203dac..527b158 100644 --- a/doc/UPGRADING.md +++ b/doc/UPGRADING.md @@ -1118,7 +1118,117 @@ transparently across the form API: --- -## Known limitations (2026-04) +## File uploads via PSR-7 + +### Overview + +When a `ServerRequestInterface` is passed to BaseForm's constructor, +uploaded files are extracted automatically via `getUploadedFiles()` and +injected into `FileVariable` and `ImageVariable` instances before +`validate()` and `getInfo()` run. No `$_FILES` access or +`$GLOBALS['browser']` dependency is needed on the PSR-7 path. + +### Usage + +```php +use Horde\Form\V3\BaseForm; + +// PSR-7 request from your middleware / router +$form = new BaseForm($request, 'Upload Document'); +$form->addVariable('Title', 'title', 'text', true); +$form->addVariable('Document', 'document', 'file', true); + +if ($form->validate()) { + $info = $form->getInfo(); + // $info['document'] contains: + // 'name' => 'report.pdf' (client filename) + // 'type' => 'application/pdf' (client media type) + // 'size' => 45678 (bytes) + // 'tmp_name' => '/tmp/horde_form_upload_abc123' (on-disk temp file) + // 'file' => same as tmp_name + // 'error' => UPLOAD_ERR_OK + // 'uploaded_file' => UploadedFileInterface instance + rename($info['document']['tmp_name'], $permanentPath); +} +``` + +### Important: move_uploaded_file() does NOT work + +The temp file is created by writing the PSR-7 stream to disk. PHP's +`move_uploaded_file()` rejects it because PHP did not create it via +its upload mechanism. Use `rename()` or the `UploadedFileInterface` +object's `moveTo()` method instead: + +```php +// Option A: rename (simple, works on same filesystem) +rename($info['document']['tmp_name'], $dest); + +// Option B: PSR-7 moveTo (works cross-filesystem, preferred) +$info['document']['uploaded_file']->moveTo($dest); +``` + +### Legacy fallback + +When form data is provided as an array or `Horde_Variables` (i.e., not +a full PSR-7 request), no uploaded files are extracted from the request. +`FileVariable` and `ImageVariable` fall back to reading `$_FILES` and +using `$GLOBALS['browser']->wasFileUploaded()`. Existing code using +this pattern is unchanged. + +### Explicit file injection + +When you decompose the PSR-7 request before passing form data: + +```php +$formVars = $request->getParsedBody(); +$form = new BaseForm($formVars, 'Upload'); +$form->setUploadedFiles($request->getUploadedFiles()); +``` + +This is equivalent to passing the full request. + +### Custom file-type variables + +To create a custom variable type that participates in PSR-7 file +injection, implement `FileUploadAware`: + +```php +use Horde\Form\V3\BaseVariable; +use Horde\Form\V3\FileUploadAware; +use Psr\Http\Message\UploadedFileInterface; + +class AvatarVariable extends BaseVariable implements FileUploadAware +{ + private ?UploadedFileInterface $uploadedFile = null; + + public function setUploadedFile(?UploadedFileInterface $file): void + { + $this->uploadedFile = $file; + } + + protected function isValid($vars, $value): bool + { + if ($this->uploadedFile !== null) { + if ($this->uploadedFile->getError() !== UPLOAD_ERR_OK) { + return $this->invalid('Upload failed.'); + } + if ($this->uploadedFile->getSize() > 2_000_000) { + return $this->invalid('Avatar must be under 2MB.'); + } + return true; + } + // Legacy fallback... + } +} +``` + +BaseForm resolves the upload by matching the variable's name against the +request's uploaded files tree. For `ImageVariable`, the key is +`{varname}[new]` to match the image upload widget's HTML structure. + +--- + +## Known limitations (2026-05) - **Sub-forms**: Replaced by `FieldGroup` / `Section` model (see "FieldGroup and Section" below). Groups provide structural variable @@ -1131,8 +1241,6 @@ transparently across the form API: future scope item. - **Asset collection**: `AssetManager` collects JS/CSS paths but has no integration with Horde's page output yet. -- **File uploads**: `renderFile()` produces `` - but file processing in `getInfo()` is not fully wired. - **validate() / getInfo() still wrap Horde_Variables**: These submission-time paths still construct `new Horde_Variables($array)`. Rendering paths have been migrated to `resolveValue()`. diff --git a/doc/V3-MIGRATION-GUIDE.md b/doc/V3-MIGRATION-GUIDE.md deleted file mode 100644 index 9ca579f..0000000 --- a/doc/V3-MIGRATION-GUIDE.md +++ /dev/null @@ -1,798 +0,0 @@ -# Horde_Form: lib/ vs src/V3 Developer Guide - -> **Superseded**: This document was written 2026-03-05 when BaseForm, -> Actions, and Renderer were not yet implemented. All three now exist. -> See **[UPGRADING.md](UPGRADING.md)** for the current migration guide. -> This file is kept for historical reference only. - -**Version**: 3.0.0-beta3+ -**Date**: 2026-03-05 -**Audience**: Horde application developers -**Status**: OUTDATED — BaseForm, Actions, and Renderer are now implemented - ---- - -## Quick Decision Guide - -**Should I use lib/ or src/V3?** - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Are you maintaining an existing Horde 5/6 application? │ -│ ├─ YES → Use lib/ (legacy) │ -│ └─ NO → Continue ↓ │ -│ │ -│ Do you need Actions (reload, submit, etc)? │ -│ ├─ YES → Use lib/ (V3 Actions not ported yet) │ -│ └─ NO → Continue ↓ │ -│ │ -│ Do you need custom Renderer? │ -│ ├─ YES → Use lib/ (V3 Renderer not ported yet) │ -│ └─ NO → Continue ↓ │ -│ │ -│ Are you starting a NEW application from scratch? │ -│ ├─ YES → Use V3 (modern, future-proof) │ -│ └─ NO → Use lib/ (safer for existing code) │ -└─────────────────────────────────────────────────────────────┘ -``` - -**Summary**: Use **lib/** for existing/legacy apps. Use **V3** for new greenfield projects that don't need Actions/Renderer yet. - ---- - -## Overview - -Horde_Form has two parallel implementations: - -1. **lib/** - Legacy implementation (Horde 5/6 era) - - Complete: Form, Variable, Type, Action, Renderer - - Ancient patterns: Reference passing, no type hints - - Stable, widely used, will be maintained through H6 - -2. **src/V3/** - Modern implementation (Horde 7+ future) - - Incomplete: BaseVariable ✅, BaseForm ❌, Actions ❌, Renderer ❌ - - Modern patterns: Type hints, namespaces, no references - - Under development, breaking changes possible - -**Important**: These implementations **cannot be mixed** in the same form. Choose one or the other. - ---- - -## When to Use lib/ (Legacy) - -### ✅ Use lib/ When: - -1. **Maintaining existing applications** - ```php - // Existing Horde app code - $form = new Horde_Form($vars, 'Edit Task'); - $form->addVariable('Name', 'name', 'text', true); - ``` - -2. **Need Actions system** - ```php - // Actions not in V3 yet - $var->setAction(Horde_Form_Action::factory('reload')); - ``` - -3. **Need Renderer system** - ```php - // Renderer not in V3 yet - $renderer = $form->getRenderer(); - $renderer->render(); - ``` - -4. **Mixed with other lib/-style code** - ```php - // Your app uses lib/ Horde_Form elsewhere - // Keep consistency - ``` - -5. **Need stability for production** - - lib/ is complete, tested, stable - - V3 is under development - -### ❌ Don't Use lib/ For: - -1. **New greenfield applications** (use V3 if possible) -2. **Code that will need PHP 9+ support** (references deprecated) -3. **Code requiring modern IDE support** (no type hints in lib/) - ---- - -## When to Use src/V3/ (Modern) - -### ✅ Use V3 When: - -1. **Starting new applications from scratch** - ```php - // New app, no legacy code - use Horde\Form\V3\BaseForm; - $form = new BaseForm($vars, 'New Form'); - ``` - -2. **Full application conversions** - - Converting entire app from lib/ to V3 - - All-or-nothing per form - -3. **Want modern PHP patterns** - ```php - // Type hints, return types - public function validate(): bool { } - public function getInfo($vars = null): array { } - ``` - -4. **Don't need Actions/Renderer yet** - - Simple forms with validation only - - Can wait for Actions/Renderer porting - -5. **Want future-proof code** - - V3 is the direction for Horde 7+ - - lib/ will be deprecated eventually - -### ❌ Don't Use V3 For: - -1. **Production apps** (V3 BaseForm not complete yet) -2. **Forms needing Actions** (not ported yet) -3. **Forms needing custom Renderer** (not ported yet) -4. **Mixed with lib/ code** (incompatible interfaces) - ---- - -## Migration Path: lib/ → V3 - -### Can I Mix lib/ and V3? - -**NO.** lib/ and V3 have incompatible interfaces and cannot be mixed in the same form. - -**Incompatibilities:** - -| Aspect | lib/ | V3 | Compatible? | -|--------|------|-----|-------------| -| Variable class | `Horde_Form_Variable` | `Horde\Form\V3\Variable` | ❌ No | -| Type classes | Separate `Horde_Form_Type_*` | Merged into `*Variable` | ❌ No | -| Validation signature | `isValid($var, $vars, $value, $message)` | `isValid($vars, $value): bool` | ❌ No | -| Error handling | `$message` by reference | `invalid()` method | ❌ No | -| getInfo() | Modifies `&$info` parameter | Returns array | ❌ No | - -### Migration is Per-Form, All-or-Nothing - -**Option 1: Keep lib/ (no changes needed)** -```php -// Existing code - works fine -$form = new Horde_Form($vars, 'Edit Task'); -$form->addVariable('Name', 'name', 'text', true); -$form->addVariable('Email', 'email', 'email', true); -if ($form->validate()) { - $form->getInfo($vars, $info); -} -``` - -**Option 2: Convert to V3 (full rewrite)** -```php -// V3 version - when BaseForm is ready -use Horde\Form\V3\BaseForm; -$form = new BaseForm($vars, 'Edit Task'); -$form->addVariable('Name', 'name', 'text', true); -$form->addVariable('Email', 'email', 'email', true); -if ($form->validate()) { - $info = $form->getInfo($vars); // Returns array! -} -``` - -**You cannot do this:** -```php -// ❌ WRONG - mixing lib/ Form with V3 Variable -$form = new Horde_Form($vars); // lib/ -$var = new Horde\Form\V3\TextVariable('Name', 'name', true); // V3 -$form->addVariable($var); // ERROR: Incompatible! -``` - -### Migration Steps (When V3 BaseForm is Ready) - -1. **Identify forms to migrate** - - Start with simple forms (no Actions, no custom Renderer) - - Leave complex forms on lib/ for now - -2. **Update class names** - ```php - // Before - use Horde_Form; - $form = new Horde_Form($vars, 'Title'); - - // After - use Horde\Form\V3\BaseForm; - $form = new BaseForm($vars, 'Title'); - ``` - -3. **Update getInfo() calls** - ```php - // Before (lib/) - $form->getInfo($vars, $info); - // $info is modified by reference - - // After (V3) - $info = $form->getInfo($vars); - // Returns array - ``` - -4. **Test thoroughly** - - V3 validation is different internally - - Error messages may differ - - Behavior should match but verify - -5. **Keep Actions/Renderer on lib/ for now** - - Only migrate simple forms to V3 - - Complex forms stay on lib/ until V3 Actions/Renderer ready - ---- - -## Architecture Comparison - -### lib/ (Legacy) Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ Horde_Form │ -│ - Constructor: __construct($vars, $title, $name) │ -│ - addVariable(...) → creates Horde_Form_Variable │ -│ - validate() → calls Variable::validate() │ -│ - getInfo() → extracts values │ -└─────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ Horde_Form_Variable │ -│ - Constructor: __construct($name, $varName, $type, ...)│ -│ - type: Horde_Form_Type object (separate) │ -│ - validate() → calls $this->type->isValid() │ -└─────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ Horde_Form_Type_text (etc) │ -│ - init($regex, $size, $maxlength) │ -│ - isValid($var, $vars, $value, $message) │ -│ - $message passed by REFERENCE (modified) │ -└─────────────────────────────────────────────────────────┘ -``` - -**Key characteristics:** -- **3 objects**: Form, Variable, Type (separate) -- **Reference passing**: `$this->_vars = &$vars`, `$message` by reference -- **No type hints**: Dynamic typing throughout -- **Ancient patterns**: Singleton, global namespace - -### V3 (Modern) Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ Horde\Form\V3\BaseForm │ -│ - Constructor: __construct($vars, string $title, ...) │ -│ - addVariable(...): Variable → creates *Variable │ -│ - validate(): bool → typed return │ -│ - getInfo($vars = null): array → returns array │ -└─────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ Horde\Form\V3\TextVariable │ -│ - Extends: BaseVariable │ -│ - NO separate Type object (merged!) │ -│ - init(...$params) → variadic │ -│ - validate($vars): bool → typed return │ -│ - isValid($vars, $value): bool → internal │ -│ - invalid($message): bool → error handling │ -└─────────────────────────────────────────────────────────┘ -``` - -**Key characteristics:** -- **2 objects**: Form, Variable (Type merged into Variable) -- **No reference parameters**: Clean signatures -- **Type hints everywhere**: `bool`, `array`, `string` -- **Modern patterns**: Namespaces, PSR-4 - ---- - -## Breaking Changes: lib/ → V3 - -### 1. Type/Variable Merge - -**lib/ (separate):** -```php -$var = new Horde_Form_Variable('Name', 'name', $type, true); -$type = $var->getType(); // Returns Horde_Form_Type_text -$type->isValid($var, $vars, $value, $message); -``` - -**V3 (merged):** -```php -$var = new TextVariable('Name', 'name', true); -// No separate Type object! -$var->validate($vars); // Variable IS the type -$var->getType(); // Returns $this (with deprecation warning) -``` - -**Impact**: Internal only - `addVariable()` API stays the same. - -### 2. Validation Signature - -**lib/:** -```php -// In Horde_Form_Type -public function isValid($var, $vars, $value, $message) -{ - // Modify $message by reference - $this->message = 'Error'; - return false; -} -``` - -**V3:** -```php -// In *Variable -protected function isValid(Horde_Variables $vars, $value): bool -{ - // Use invalid() method - return $this->invalid('Error message'); -} -``` - -**Impact**: Custom type implementations need rewrite. - -### 3. getInfo() Signature - -**lib/:** -```php -$form->getInfo($vars, $info); -// $info modified by reference -echo $info['name']; -``` - -**V3:** -```php -$info = $form->getInfo($vars); -// Returns array -echo $info['name']; -``` - -**Impact**: All getInfo() calls need update. - -### 4. Error Handling - -**lib/:** -```php -$var->type->isValid($var, $vars, $value, $message); -// $message set by reference -echo $message; -``` - -**V3:** -```php -$var->validate($vars); -echo $var->getMessage(); // Getter instead -``` - -**Impact**: Error retrieval pattern changes. - -### 5. Validation Method Parameter - -**lib/:** -```php -// validate() takes $message parameter -$var->validate($vars, $message); -``` - -**V3:** -```php -// validate() no longer takes $message -$var->validate($vars); // Deprecation warning if you pass $message -``` - -**Impact**: Remove $message parameter from all validate() calls. - ---- - -## Gotchas: Odd lib/ Behavior in PHP 8.4+ - -### 1. Non-Static Singleton Called Statically - -**Problem:** -```php -// In Horde_Form_Action -public function singleton($action) // Non-static method -{ - static $instances = []; - // ... -} - -// Called statically -$action = Horde_Form_Action::singleton('reload'); // ERROR in PHP 8.4 -``` - -**Error**: "Non-static method Horde_Form_Action::singleton() cannot be called statically" - -**Workaround**: Use `factory()` instead: -```php -$action = Horde_Form_Action::factory('reload'); // Works -``` - -**V3 Fix**: Remove singleton pattern entirely. - -### 2. Reference Passing Deprecated - -**Problem:** -```php -// lib/ uses references everywhere -$this->_vars = &$vars; -$this->_hiddenVariables[] = &$var; -public function getInfo($vars, &$info) { } -``` - -**Issue**: Reference passing may be deprecated in PHP 9+ - -**Workaround**: None for lib/, avoid in new code - -**V3 Fix**: Minimal reference usage, return values instead - -### 3. No Type Hints = No IDE Support - -**Problem:** -```php -// lib/ has no type hints -public function validate($vars) { } -public function getInfo($vars, $info) { } -``` - -**Issue**: -- No IDE autocomplete -- No static analysis -- Hard to refactor - -**V3 Fix**: Full type hints everywhere -```php -public function validate($vars): bool { } -public function getInfo($vars = null): array { } -``` - -### 4. $message Reference Parameter - -**Problem:** -```php -// Easy to forget to pass by reference -$type->isValid($var, $vars, $value, $message); // $message not declared -// $message stays empty, no error shown! -``` - -**Workaround**: Always declare `$message = ''` before calling - -**V3 Fix**: No $message parameter, use `invalid()` method - -### 5. Polymorphic Signatures - -**Problem:** -```php -// addVariable has 4-7 parameters depending on usage -public function addVariable($humanName, $varName, $type, $required, - $readonly = false, $description = null, $params = []) -``` - -**Issue**: Easy to pass wrong number/order of parameters - -**V3 Fix**: Same signature but with type hints for safety - ---- - -## API Compatibility Matrix - -### Form API - -| Method | lib/ Signature | V3 Signature | Compatible? | -|--------|----------------|--------------|-------------| -| Constructor | `__construct($vars, $title, $name)` | `__construct($vars, string $title, ?string $name)` | ✅ Yes (types added) | -| addVariable | `addVariable(...)` → `Horde_Form_Variable` | `addVariable(...): Variable` | ✅ Yes (return type) | -| validate | `validate()` → bool | `validate(): bool` | ✅ Yes (type hint) | -| getInfo | `getInfo($vars, &$info)` | `getInfo($vars = null): array` | ❌ **NO** (signature change) | -| setError | `setError($var, $message)` | `setError($var, string $message)` | ✅ Yes (type hint) | -| getError | `getError($var)` | `getError($var): ?string` | ✅ Yes (type hint) | - -### Variable API - -| Method | lib/ Signature | V3 Signature | Compatible? | -|--------|----------------|--------------|-------------| -| Constructor | `__construct($name, $varName, $type, ...)` | `__construct($name, $varName, $required, ...)` | ❌ **NO** (no $type param) | -| validate | `validate($vars, $message)` | `validate($vars): bool` | ❌ **NO** ($message removed) | -| getValue | `getValue($vars, $index)` | `getValue($vars, $index = null)` | ✅ Yes (default added) | -| getType | `getType()` → `Horde_Form_Type` | `getType()` → `$this` | ⚠️ Different (deprecation) | -| getMessage | N/A | `getMessage(): string` | ➕ New in V3 | - -### Type API - -| Method | lib/ Signature | V3 Signature | Compatible? | -|--------|----------------|--------------|-------------| -| init | `init(...$params)` | `init(...$params)` | ✅ Yes | -| isValid | `isValid($var, $vars, $value, $message)` | `isValid($vars, $value): bool` | ❌ **NO** (parameters) | -| getInfo | `getInfo($vars, $var, &$info)` | `getInfoV3($vars): mixed` | ❌ **NO** (signature) | -| about | `about()` → array | `about(): array` | ✅ Yes (type hint) | - -**Legend:** -- ✅ Compatible (works with type hints added) -- ⚠️ Different (works but behavior differs) -- ❌ **NO** (breaking change, incompatible) -- ➕ New (V3 addition) - ---- - -## Real-World Examples - -### Example 1: Simple Form (Both APIs) - -**lib/ version:** -```php -addVariable('Your Name', 'name', 'text', true); -$form->addVariable('Email', 'email', 'email', true); -$form->addVariable('Message', 'message', 'longtext', true); - -if ($form->validate()) { - $form->getInfo($vars, $info); - // Process $info - sendEmail($info['name'], $info['email'], $info['message']); -} - -$renderer = $form->getRenderer(); -$renderer->render(); -``` - -**V3 version (when BaseForm ready):** -```php -addVariable('Your Name', 'name', 'text', true); -$form->addVariable('Email', 'email', 'email', true); -$form->addVariable('Message', 'message', 'longtext', true); - -if ($form->validate()) { - $info = $form->getInfo($vars); // Returns array! - sendEmail($info['name'], $info['email'], $info['message']); -} - -// Renderer not available in V3 yet -// Manual rendering or wait for V3 Renderer -``` - -**Key differences:** -1. Import statement: `use Horde\Form\V3\BaseForm` -2. getInfo() returns array: `$info = $form->getInfo($vars)` -3. No Renderer in V3 yet - -### Example 2: Form with Actions (lib/ only) - -```php -addVariable( - 'Category', - 'category', - 'enum', - true, - false, - null, - [['tech' => 'Technology', 'science' => 'Science']] -); - -// Reload form when category changes -$categoryVar->setAction(Horde_Form_Action::factory('reload')); - -// Conditional field (shown based on category) -if ($vars->get('category') == 'tech') { - $form->addVariable('Technology Type', 'tech_type', 'enum', true, false, null, [ - ['web' => 'Web', 'mobile' => 'Mobile'] - ]); -} - -if ($form->validate()) { - $form->getInfo($vars, $info); - processForm($info); -} -``` - -**Cannot do in V3 yet** - Actions not ported. - -### Example 3: Custom Variable Type - -**lib/ custom type:** -```php -_country = $country; - } - - public function isValid($var, $vars, $value, $message) - { - if ($var->isRequired() && empty($value)) { - $this->message = 'ZIP code is required'; - return false; - } - - if ($this->_country == 'US' && !preg_match('/^\d{5}(-\d{4})?$/', $value)) { - $this->message = 'Invalid US ZIP code'; - return false; - } - - return true; - } - - public function about() - { - return [ - 'name' => 'ZIP Code', - 'params' => [ - 'country' => ['label' => 'Country', 'type' => 'text'] - ] - ]; - } -} - -// Usage -$form->addVariable('ZIP Code', 'zipcode', 'customapp:zipcode', true, false, null, ['US']); -``` - -**V3 custom type (when BaseForm ready):** -```php -_country = $params[0] ?? 'US'; - } - - protected function isValid(Horde_Variables $vars, $value): bool - { - if ($this->isRequired() && empty($value)) { - return $this->invalid('ZIP code is required'); - } - - if ($this->_country == 'US' && !preg_match('/^\d{5}(-\d{4})?$/', $value)) { - return $this->invalid('Invalid US ZIP code'); - } - - return true; - } - - public function about(): array - { - return [ - 'name' => 'ZIP Code', - 'params' => [ - 'country' => ['label' => 'Country', 'type' => 'text'] - ] - ]; - } -} - -// Usage -$form->addVariable('ZIP Code', 'zipcode', 'customapp:zipcode', true, false, null, ['US']); -``` - -**Key differences:** -1. Namespace: `CustomApp\Form\V3` -2. Extends: `BaseVariable` (not separate Type) -3. Signature: `isValid($vars, $value): bool` (typed) -4. Error: `$this->invalid('message')` (not $message reference) -5. Return type: `about(): array` (typed) - ---- - -## FAQ - -### Q: When will V3 be complete? - -**A**: Unknown. BaseForm, Actions, and Renderer need implementation. Estimated 4-6 weeks of development work. No committed timeline. - -### Q: Will lib/ be removed? - -**A**: Not in Horde 6. lib/ will be maintained through H6 lifecycle. May be deprecated in Horde 7+ once V3 is complete and stable. - -### Q: Can I start using V3 now? - -**A**: Only for experimental/testing. BaseForm is not implemented yet. Wait for 3.0.0 stable release. - -### Q: How do I test my app with both lib/ and V3? - -**A**: You can't mix them in the same form. You'd need to maintain two versions of each form and test separately. - -### Q: What if I have a custom Renderer? - -**A**: Use lib/. V3 Renderer not started yet. Custom renderers will need to be rewritten for V3 when available. - -### Q: Will my lib/ code break in PHP 9? - -**A**: Maybe. Reference passing and non-static methods called statically may cause issues. Plan to migrate to V3 before PHP 9. - -### Q: Where can I see V3 examples? - -**A**: Test files in `test/v3/` show V3 usage patterns. Real apps will need to wait for BaseForm implementation. - -### Q: How do I report V3 bugs? - -**A**: GitHub issues at https://github.com/horde/Form/issues - mark with "V3" label. - -### Q: Can I contribute to V3? - -**A**: Yes! BaseForm implementation is straightforward. See `horde-development/horde-form-v3-completeness-analysis.md` for implementation guide. - ---- - -## Version History - -| Version | Date | Changes | -|---------|------|---------| -| 3.0.0-beta3 | 2025-07-05 | V3 Variables complete, BaseForm stub | -| 3.0.0-beta2 | 2025-07-02 | V3 type initializer fixes | -| 3.0.0-beta1 | 2025-07-02 | Initial V3 namespace | -| 3.0.0-alpha8 | 2025-06-22 | V3 design start | -| 2.0.19 | 2019-01-06 | Last stable lib/ release | - ---- - -## Summary - -**Use lib/** for: -- ✅ Existing/maintenance work -- ✅ Production applications -- ✅ Forms with Actions -- ✅ Forms with custom Renderers -- ✅ Stability required - -**Use V3** for: -- ✅ New greenfield apps (when BaseForm ready) -- ✅ Future-proof code -- ✅ Modern PHP patterns -- ⏳ Wait for 3.0.0 stable release - -**Cannot mix** lib/ and V3 in same form - choose one or the other. - -**Migration** will be all-or-nothing per form when V3 is complete. - ---- - -## Additional Resources - -- **V3 Completeness Analysis**: `horde-development/horde-form-v3-completeness-analysis.md` -- **V3 Test Coverage**: `horde-development/horde-form-v3-test-coverage.md` -- **V3 vs lib/ Comparison**: `horde-development/horde-form-v3-analysis.md` -- **GitHub Issues**: https://github.com/horde/Form/issues -- **Horde Documentation**: https://www.horde.org/libraries/Horde_Form - ---- - -**Document Status**: Complete, ready for review -**Next Steps**: Implement BaseForm (issue #19), update this doc when complete diff --git a/src/V3/BaseForm.php b/src/V3/BaseForm.php index 283fc44..ffb4d60 100644 --- a/src/V3/BaseForm.php +++ b/src/V3/BaseForm.php @@ -24,25 +24,41 @@ use Horde\Token\Token; use Horde\Token\Exception\TokenException; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UploadedFileInterface; /** * Base implementation of the Form interface for Horde Form V3. * * This is a modernized implementation of Horde_Form with: * - Type/Variable merge (no separate Type objects) - * - PSR-7 ServerRequest support + * - PSR-7 ServerRequest support (including file uploads) * - Named parameters throughout * - Strict typing * - Modern PHP patterns (no singleton, minimal reference passing) * + * ## Input types + * * V3 accepts multiple input types for backward compatibility: * - Horde_Variables (legacy Horde apps) - * - PSR-7 ServerRequest (modern apps) + * - PSR-7 ServerRequest (modern apps — recommended) * - Plain arrays (testing, simple apps) * * All inputs are normalized to array internally for consistent operation. * - * PSR-4 implementation. + * ## File uploads + * + * When a PSR-7 ServerRequestInterface is passed to the constructor, + * uploaded files are extracted via getUploadedFiles() and stored + * internally. During validate() and getInfo(), any variable implementing + * FileUploadAware receives the corresponding UploadedFileInterface + * before its validation or extraction logic runs. + * + * This eliminates the need for $_FILES and $GLOBALS['browser'] when + * using the PSR-7 input path. The legacy fallback remains active when + * form data is provided as an array or Horde_Variables. + * + * Files can also be set explicitly via setUploadedFiles() when the + * request was decomposed before reaching the form. * * @see Horde_Form PSR-0 legacy equivalent in lib/Horde/Form.php * @@ -82,6 +98,13 @@ class BaseForm implements \Horde\Form\Form */ private array $vars; + /** + * Uploaded files from PSR-7 request. + * + * @var array + */ + private array $uploadedFiles = []; + /** * Submit button labels. * @@ -211,7 +234,9 @@ private function normalizeVars( if (is_array($vars)) { return $vars; } - // PSR-7: read from the source matching the HTTP method. + // PSR-7: extract uploaded files alongside form data. + $this->uploadedFiles = $vars->getUploadedFiles(); + // Read from the source matching the HTTP method. // POST/PUT/PATCH carry data in the body; GET/DELETE/HEAD in query. return match ($vars->getMethod()) { 'POST', 'PUT', 'PATCH' => $vars->getParsedBody() ?? [], @@ -247,6 +272,66 @@ public function setVars(Horde_Variables|ServerRequestInterface|array $vars): voi $this->vars = $this->normalizeVars($vars); } + /** + * Set uploaded files for file-type form variables. + * + * When a PSR-7 ServerRequestInterface is passed to the constructor, + * uploaded files are extracted automatically. Use this method when + * form data is provided as an array or Horde_Variables but uploaded + * files are available separately (e.g., from a PSR-7 request that + * was decomposed before reaching the form). + * + * The array structure mirrors ServerRequestInterface::getUploadedFiles(): + * keys are field names, values are UploadedFileInterface instances or + * nested arrays thereof. + * + * @param array $uploadedFiles + * + * @api + */ + public function setUploadedFiles(array $uploadedFiles): void + { + $this->uploadedFiles = $uploadedFiles; + } + + /** + * Resolve the uploaded file for a given variable name. + * + * Navigates the uploadedFiles tree using bracket notation: + * - Simple: "photo" → $uploadedFiles['photo'] + * - Nested: "object[photo][new]" → $uploadedFiles['object']['photo']['new'] + */ + private function getUploadedFileFor(Variable $var): ?UploadedFileInterface + { + if (empty($this->uploadedFiles)) { + return null; + } + + $name = $var->getVarName(); + + // For ImageVariable, the upload field is varname[new] + if ($var instanceof ImageVariable) { + $name .= '[new]'; + } + + if (!str_contains($name, '[')) { + $file = $this->uploadedFiles[$name] ?? null; + return $file instanceof UploadedFileInterface ? $file : null; + } + + // Navigate nested: "field[sub][key]" → ['field']['sub']['key'] + $parts = explode('[', str_replace(']', '', $name)); + $current = $this->uploadedFiles; + foreach ($parts as $part) { + if (!is_array($current) || !isset($current[$part])) { + return null; + } + $current = $current[$part]; + } + + return $current instanceof UploadedFileInterface ? $current : null; + } + /** * Get a single variable value. * @@ -927,6 +1012,9 @@ public function validate($vars = null): bool // Validate all variables in enabled groups foreach ($this->getVariables(flat: true, withHidden: false, enabledOnly: true) as $var) { + if ($var instanceof FileUploadAware) { + $var->setUploadedFile($this->getUploadedFileFor($var)); + } if (!$var->validate($varsObject)) { $this->errors[$var->getVarName()] = $var->getMessage(); } @@ -934,6 +1022,9 @@ public function validate($vars = null): bool // Validate hidden variables foreach ($this->hiddenVariables as $var) { + if ($var instanceof FileUploadAware) { + $var->setUploadedFile($this->getUploadedFileFor($var)); + } if (!$var->validate($varsObject)) { $this->errors[$var->getVarName()] = $var->getMessage(); } @@ -1092,6 +1183,10 @@ private function getInfoFromVariables(array $variables, array $vars): array continue; } + if ($var instanceof FileUploadAware) { + $var->setUploadedFile($this->getUploadedFileFor($var)); + } + $varName = $var->getVarName(); // Handle array values (field names ending with []) diff --git a/src/V3/FileUploadAware.php b/src/V3/FileUploadAware.php new file mode 100644 index 0000000..cd16dbc --- /dev/null +++ b/src/V3/FileUploadAware.php @@ -0,0 +1,51 @@ +uploadedFile = $file; + * } + * + * protected function isValid($vars, $value): bool + * { + * if ($this->uploadedFile !== null) { + * // Use $this->uploadedFile->getError(), getSize(), etc. + * } + * // ... + * } + * } + * ``` + * + * BaseForm resolves the uploaded file by matching the variable's name + * against the ServerRequest's uploaded files tree. + */ +interface FileUploadAware +{ + public function setUploadedFile(?UploadedFileInterface $file): void; +} diff --git a/src/V3/FileVariable.php b/src/V3/FileVariable.php index 1c1358a..ffac605 100644 --- a/src/V3/FileVariable.php +++ b/src/V3/FileVariable.php @@ -1,39 +1,97 @@ createServerRequest('POST', '/upload'); + * $request = $request->withUploadedFiles(['document' => $uploadedFile]); + * $request = $request->withParsedBody(['title' => 'Report']); + * + * $form = new BaseForm($request, 'Upload'); + * $form->addVariable('Doc', 'document', 'file', true); + * + * if ($form->validate()) { + * $info = $form->getInfo(); + * // $info['document']['name'] — client filename + * // $info['document']['type'] — client media type + * // $info['document']['size'] — file size in bytes + * // $info['document']['tmp_name'] — temp file path (stream written to disk) + * // $info['document']['file'] — same as tmp_name + * // $info['document']['error'] — UPLOAD_ERR_OK + * // $info['document']['uploaded_file'] — original UploadedFileInterface + * } + * ``` + * + * The file content is written to a temp file so that consumers can use + * rename() to move it. Note: move_uploaded_file() will NOT work on this + * temp file because PHP did not create it via its upload mechanism. + * Use rename() or the UploadedFileInterface::moveTo() method instead. + * + * ## Legacy path (backward compatibility) + * + * When no UploadedFileInterface is injected (e.g., form data passed as + * array or Horde_Variables), the variable falls back to reading $_FILES + * and using $GLOBALS['browser']->wasFileUploaded() for validation. + * This path is unchanged from previous behavior. * * @see Horde_Form_Type_file PSR-0 legacy equivalent in lib/Horde/Form/Type.php */ -class FileVariable extends BaseVariable +class FileVariable extends BaseVariable implements FileUploadAware { + private ?UploadedFileInterface $uploadedFile = null; + + public function setUploadedFile(?UploadedFileInterface $file): void + { + $this->uploadedFile = $file; + } + /** * Validates file upload field. * - * Checks if a file was successfully uploaded using the browser's upload - * detection. Required fields must have a file uploaded. Optional fields - * pass validation even without a file. + * @param Horde_Variables|Variables $vars Form variables + * @param mixed $value Field value + * @return bool * - * @param Horde_Variables $vars Form variables - * @param mixed $value Field value (not used; checks $_FILES directly) - * - * @return bool True if valid, false with error message set if required file missing - * - * @api + * @api */ public function isValid(Horde_Variables|Variables $vars, $value): bool { + if ($this->uploadedFile !== null) { + $error = $this->uploadedFile->getError(); + if ($error === UPLOAD_ERR_NO_FILE) { + if ($this->isRequired()) { + return $this->invalid(Horde_Form_Translation::t("This field is required.")); + } + return true; + } + if ($error !== UPLOAD_ERR_OK) { + return $this->invalid(self::uploadErrorMessage($error)); + } + return true; + } + + // Legacy fallback: $_FILES via Horde_Browser if ($this->isRequired()) { try { $GLOBALS['browser']->wasFileUploaded($this->getVarName()); @@ -46,18 +104,40 @@ public function isValid(Horde_Variables|Variables $vars, $value): bool return true; } - //TODO: Rename back to getInfo() after the V3 transition + /** + * @api + */ protected function getInfoV3($vars) { + if ($this->uploadedFile !== null) { + if ($this->uploadedFile->getError() !== UPLOAD_ERR_OK) { + return null; + } + $tmpFile = Horde::getTempFile('form_upload', false); + $stream = $this->uploadedFile->getStream(); + $stream->rewind(); + $dest = fopen($tmpFile, 'wb'); + while (!$stream->eof()) { + fwrite($dest, $stream->read(8192)); + } + fclose($dest); + return [ + 'name' => $this->uploadedFile->getClientFilename(), + 'type' => $this->uploadedFile->getClientMediaType(), + 'tmp_name' => $tmpFile, + 'file' => $tmpFile, + 'size' => $this->uploadedFile->getSize(), + 'error' => UPLOAD_ERR_OK, + 'uploaded_file' => $this->uploadedFile, + ]; + } + + // Legacy fallback: $_FILES via Horde_Browser $name = $this->getVarName(); try { $GLOBALS['browser']->wasFileUploaded($name); return [ - /** - * WARNING: Horde_Util::dispelMagicQuotes() removed in PSR-4 version - * Magic quotes are obsolete in PHP 8+. Remove this call. - */ - 'name' => Horde_Util::dispelMagicQuotes($_FILES[$name]['name']), + 'name' => $_FILES[$name]['name'], 'type' => $_FILES[$name]['type'], 'tmp_name' => $_FILES[$name]['tmp_name'], 'file' => $_FILES[$name]['tmp_name'], @@ -72,11 +152,24 @@ protected function getInfoV3($vars) /** * Return info about field type. - * - * @api + * + * @api */ public function about(): array { - return [ 'name' => Horde_Form_Translation::t("File upload") ]; + return ['name' => Horde_Form_Translation::t("File upload")]; + } + + public static function uploadErrorMessage(int $code): string + { + return match ($code) { + UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => Horde_Form_Translation::t("The file was larger than the maximum allowed size."), + UPLOAD_ERR_PARTIAL => Horde_Form_Translation::t("The file was only partially uploaded."), + UPLOAD_ERR_NO_FILE => Horde_Form_Translation::t("No file was uploaded."), + UPLOAD_ERR_NO_TMP_DIR => Horde_Form_Translation::t("Server temporary directory missing."), + UPLOAD_ERR_CANT_WRITE => Horde_Form_Translation::t("Failed to write file to disk."), + UPLOAD_ERR_EXTENSION => Horde_Form_Translation::t("A PHP extension stopped the upload."), + default => Horde_Form_Translation::t("Unknown upload error."), + }; } } diff --git a/src/V3/ImageVariable.php b/src/V3/ImageVariable.php index 95176a5..dc0ac6f 100644 --- a/src/V3/ImageVariable.php +++ b/src/V3/ImageVariable.php @@ -1,5 +1,7 @@ wasFileUploaded(). + * + * ## Session-based image persistence + * + * Both paths use $GLOBALS['session'] to persist image data between + * requests (for preview/modify workflows). The session dependency is + * orthogonal to the upload source — it handles previously uploaded + * images, not the current upload. + * * @property bool $show_upload Show the upload button * @property bool $show_keeporig Show the option to upload also original non-modified image * @property int|null $max_filesize Limit the file size - - * - * PSR-4 implementation. * * @see Horde_Form_Type_image PSR-0 legacy equivalent in lib/Horde/Form/Type.php */ -class ImageVariable extends BaseVariable +class ImageVariable extends BaseVariable implements FileUploadAware { + private ?UploadedFileInterface $uploadedFile = null; + + public function setUploadedFile(?UploadedFileInterface $file): void + { + $this->uploadedFile = $file; + } /** * Has a file been uploaded on this form submit? * @@ -248,13 +277,27 @@ private function _getUpload($vars) } /* Check if file has been uploaded. */ - try { - $new = $varname . '[new]'; - - $GLOBALS['browser']->wasFileUploaded($new); - $this->_uploaded = true; - } catch (Horde_Browser_Exception $e) { - $this->_uploaded = $e; + if ($this->uploadedFile !== null) { + // PSR-7 path: use injected UploadedFileInterface + $error = $this->uploadedFile->getError(); + if ($error === UPLOAD_ERR_OK) { + $this->_uploaded = true; + } else { + $this->_uploaded = new Horde_Browser_Exception( + FileVariable::uploadErrorMessage($error), + $error + ); + } + } else { + // Legacy path: $_FILES via Horde_Browser + try { + $new = $varname . '[new]'; + + $GLOBALS['browser']->wasFileUploaded($new); + $this->_uploaded = true; + } catch (Horde_Browser_Exception $e) { + $this->_uploaded = $e; + } } if ($this->_uploaded === true) { @@ -274,36 +317,49 @@ private function _getUpload($vars) $tmp_file = Horde::getTempFile('Horde', false); } - /* Get the other parts of the upload. */ - $keys = ArrayUtils::getFieldParts($new); - - /* Get the temporary file name. */ - $file = ArrayUtils::getElement($_FILES, $keys, 'tmp_name'); - - /* Move the browser created temp file to the new temp file. */ - move_uploaded_file($file, $tmp_file); + if ($this->uploadedFile !== null) { + // PSR-7 path: write stream to temp file + $stream = $this->uploadedFile->getStream(); + $dest = fopen($tmp_file, 'wb'); + while (!$stream->eof()) { + fwrite($dest, $stream->read(8192)); + } + fclose($dest); - /* Get the name value. */ - $name = ArrayUtils::getElement($_FILES, $keys, 'name'); + $name = $this->uploadedFile->getClientFilename(); + $type = $this->uploadedFile->getClientMediaType(); + $size = $this->uploadedFile->getSize(); - /* Get the file type. */ - $type = ArrayUtils::getElement($_FILES, $keys, 'type'); - if ($type === null || $type === '' || $type === 'application/octet-stream') { - /* Type wasn't set on upload, try analysing the upload. */ - $type = Horde_Mime_Magic::analyzeFile($tmp_file, $GLOBALS['conf']['mime']['magic_db'] ?? null); - if ($type === false) { - /* Work out the type from the file name. */ - $type = Horde_Mime_Magic::filenameToMime($name); + if ($type === null || $type === '' || $type === 'application/octet-stream') { + $type = Horde_Mime_Magic::analyzeFile($tmp_file, $GLOBALS['conf']['mime']['magic_db'] ?? null); + if ($type === false) { + $type = Horde_Mime_Magic::filenameToMime($name ?? ''); + } } - - /* Set the type. */ - ArrayUtils::setElement($_FILES, $keys, $type, 'type'); + } else { + // Legacy path: read from $_FILES + $new = $varname . '[new]'; + $keys = ArrayUtils::getFieldParts($new); + + $file = ArrayUtils::getElement($_FILES, $keys, 'tmp_name'); + move_uploaded_file($file, $tmp_file); + + $name = ArrayUtils::getElement($_FILES, $keys, 'name'); + $type = ArrayUtils::getElement($_FILES, $keys, 'type'); + if ($type === null || $type === '' || $type === 'application/octet-stream') { + $type = Horde_Mime_Magic::analyzeFile($tmp_file, $GLOBALS['conf']['mime']['magic_db'] ?? null); + if ($type === false) { + $type = Horde_Mime_Magic::filenameToMime($name); + } + ArrayUtils::setElement($_FILES, $keys, $type, 'type'); + } + $size = ArrayUtils::getElement($_FILES, $keys, 'size'); } $img = [ 'type' => $type, 'name' => $name, - 'size' => ArrayUtils::getElement($_FILES, $keys, 'size'), + 'size' => $size, 'file' => basename($tmp_file), ]; } else { diff --git a/test/unit/Type/FileUploadPsr7Test.php b/test/unit/Type/FileUploadPsr7Test.php new file mode 100644 index 0000000..153d5a5 --- /dev/null +++ b/test/unit/Type/FileUploadPsr7Test.php @@ -0,0 +1,223 @@ +createStream($content); + return new UploadedFile( + $stream, + $streamFactory, + $filename, + $mediaType, + $error, + strlen($content) + ); + } + + private function createNoFileUpload(): UploadedFileInterface + { + $streamFactory = new StreamFactory(); + $stream = $streamFactory->createStream(''); + return new UploadedFile( + $stream, + $streamFactory, + '', + '', + UPLOAD_ERR_NO_FILE, + 0 + ); + } + + // ======================================================================== + // FileVariable with injected UploadedFileInterface + // ======================================================================== + + public function testIsValidReturnsTrueForSuccessfulUpload(): void + { + $upload = $this->createUploadedFile('PDF content', 'report.pdf', 'application/pdf'); + + $var = new FileVariable('Attachment', 'attachment', true); + $var->setUploadedFile($upload); + + $vars = new \Horde_Variables([]); + $this->assertTrue($var->isValid($vars, null)); + } + + public function testIsValidReturnsFalseWhenRequiredAndNoFile(): void + { + $upload = $this->createNoFileUpload(); + + $var = new FileVariable('Attachment', 'attachment', true); + $var->setUploadedFile($upload); + + $vars = new \Horde_Variables([]); + $this->assertFalse($var->isValid($vars, null)); + } + + public function testIsValidReturnsTrueWhenOptionalAndNoFile(): void + { + $upload = $this->createNoFileUpload(); + + $var = new FileVariable('Attachment', 'attachment', false); + $var->setUploadedFile($upload); + + $vars = new \Horde_Variables([]); + $this->assertTrue($var->isValid($vars, null)); + } + + public function testGetInfoReturnsFileMetadataFromPsr7Upload(): void + { + $content = 'fake PDF bytes here'; + $upload = $this->createUploadedFile($content, 'doc.pdf', 'application/pdf'); + + $var = new FileVariable('Document', 'document', false); + $var->setUploadedFile($upload); + + $vars = new \Horde_Variables([]); + $info = $var->getInfo($vars); + + $this->assertIsArray($info); + $this->assertSame('doc.pdf', $info['name']); + $this->assertSame('application/pdf', $info['type']); + $this->assertSame(strlen($content), $info['size']); + $this->assertSame(UPLOAD_ERR_OK, $info['error']); + $this->assertInstanceOf(UploadedFileInterface::class, $info['uploaded_file']); + // tmp_name should be a real file with the content + $this->assertFileExists($info['tmp_name']); + $this->assertSame($content, file_get_contents($info['tmp_name'])); + } + + public function testGetInfoReturnsNullWhenNoFileUploaded(): void + { + $upload = $this->createNoFileUpload(); + + $var = new FileVariable('Document', 'document', false); + $var->setUploadedFile($upload); + + $vars = new \Horde_Variables([]); + $info = $var->getInfo($vars); + + $this->assertNull($info); + } + + public function testUploadErrorMessageIsTranslated(): void + { + $streamFactory = new StreamFactory(); + $stream = $streamFactory->createStream(''); + $upload = new UploadedFile($stream, $streamFactory, 'big.zip', 'application/zip', UPLOAD_ERR_INI_SIZE, 0); + + $var = new FileVariable('File', 'file', true); + $var->setUploadedFile($upload); + + $vars = new \Horde_Variables([]); + $this->assertFalse($var->isValid($vars, null)); + $this->assertNotEmpty($var->getMessage()); + } + + // ======================================================================== + // FileUploadAware interface check + // ======================================================================== + + public function testFileVariableImplementsFileUploadAware(): void + { + $var = new FileVariable('File', 'file', false); + $this->assertInstanceOf(FileUploadAware::class, $var); + } + + // ======================================================================== + // BaseForm integration with PSR-7 request + // ======================================================================== + + public function testBaseFormExtractsUploadedFilesFromPsr7Request(): void + { + $content = 'image data'; + $upload = $this->createUploadedFile($content, 'photo.jpg', 'image/jpeg'); + + $request = new ServerRequest('POST', '/submit'); + $request = $request->withParsedBody(['title' => 'My Photo']); + $request = $request->withUploadedFiles(['photo' => $upload]); + + $form = new BaseForm($request, 'Upload Form'); + $form->addVariable('Title', 'title', 'text', true); + $form->addVariable('Photo', 'photo', 'file', true); + + $this->assertTrue($form->validate()); + + $info = $form->getInfo(); + $this->assertSame('My Photo', $info['title']); + $this->assertIsArray($info['photo']); + $this->assertSame('photo.jpg', $info['photo']['name']); + $this->assertSame('image/jpeg', $info['photo']['type']); + $this->assertFileExists($info['photo']['tmp_name']); + $this->assertSame($content, file_get_contents($info['photo']['tmp_name'])); + } + + public function testBaseFormValidationFailsForMissingRequiredFile(): void + { + $upload = $this->createNoFileUpload(); + + $request = new ServerRequest('POST', '/submit'); + $request = $request->withParsedBody(['title' => 'No Photo']); + $request = $request->withUploadedFiles(['photo' => $upload]); + + $form = new BaseForm($request, 'Upload Form'); + $form->useToken(false); + $form->addVariable('Title', 'title', 'text', true); + $form->addVariable('Photo', 'photo', 'file', true); + + $this->assertFalse($form->validate()); + $this->assertNotNull($form->getError('photo')); + } + + public function testBaseFormLegacyPathUnaffectedWhenArrayInput(): void + { + // When input is a plain array, no uploaded files are extracted — + // the legacy $_FILES path remains active (tested in FileTypeTest) + $form = new BaseForm(['name' => 'test'], 'Simple Form'); + $form->useToken(false); + $form->addVariable('Name', 'name', 'text', true); + + $this->assertTrue($form->validate()); + $info = $form->getInfo(); + $this->assertSame('test', $info['name']); + } +}