diff --git a/.github/README.md b/.github/README.md
index f7f4cc6..a3db3c0 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -3,99 +3,439 @@
[](https://codecov.io/gh/GravityPDF/Upload)
[](https://opensource.org/licenses/MIT)
-This component simplifies file validation and uploading.
+A PHP library to validate and save uploaded files.
**Why was this library forked?**
* Original library was abandoned (untouched since 2018)
-* Adjusted namespace from \Upload to \GravityPdf\Upload
-* Bumped minimum PHP version to 7.3+
-* Sanitized filename and extension, and add UTF-8 filename support
+* Safe defaults: existing files are never overwritten, executable and markup extensions are
+ never written, stored files get mode `0640`, and `upload()` requires validation
+* `Validation\FileType` requires the extension and mimetype to match
+* Sanitizes filenames and extensions, with UTF-8 filename support
+* Stops path traversal, symlinked destinations, dotfiles and control characters in
+ filenames, and writes through a staged file moved into place rather than writing to the
+ destination directly
* Strict type checking
* Added `FileSystem::getDirectory()` and `FileInfo::setNameWithExtension()` methods
* Included unreleased code from upstream repo
+* Bumped minimum PHP version to 7.3+
* PSR-12 Code Formatting
* Automated tools: PHPUnit, PHPStan, PHPCS, and PHP Syntax Checker
-TODO: [PSR-7 and PSR-17 support (help wanted)](https://github.com/GravityPDF/Upload/issues/8)
-
## Installation
```
composer require gravitypdf/upload
```
+### Requirements
+
+PHP 7.3 to 8.5 and `ext-fileinfo`. Optional but recommended: `ext-mbstring` (or `symfony/polyfill-mbstring`) so filenames can be guaranteed UTF-8.
+
+Migrating from `codeguy/upload`? Version 3.x of this package is a drop-in replacement:
+update your imports from `\Upload\…` to `\GravityPdf\Upload\…`.
+
+Upgrading from 3.x? Version 4.0 turns new protections on by default. The
+[upgrade guide](https://github.com/GravityPDF/Upload/blob/main/UPGRADE.md) covers what
+changed and what to check.
+
## Usage
+### Single-file upload
+
Assume a file is uploaded with this HTML form:
```html
```
-When the HTML form is submitted, the server-side PHP code can validate and upload the file like this:
+Server-side, validate the upload, rename it, and store it:
```php
-$storage = new \GravityPdf\Upload\Storage\FileSystem('/path/to/directory');
-// To override existing files when uploading, pass `true` as the second parameter
-// $storage = new \GravityPdf\Upload\Storage\FileSystem('/path/to/directory', true);
-$file = new \GravityPdf\Upload\File('foo', $storage);
+use GravityPdf\Upload\File;
+use GravityPdf\Upload\Storage\FileSystem;
+use GravityPdf\Upload\Validation\FileType;
+use GravityPdf\Upload\Validation\Size;
-// Validate file upload
-// MimeType List => http://www.iana.org/assignments/media-types/media-types.xhtml
-$file->addValidations([
- // Ensure file is of type "image/png"
- new \GravityPdf\Upload\Validation\Mimetype('image/png'),
- new \GravityPdf\Upload\Validation\Extension('png'),
+// Store uploads where the web server will not execute or serve them directly
+$storage = new FileSystem('/path/to/uploads');
- //You can also add multi mimetype validation or extensions
- //new \GravityPdf\Upload\Validation\Mimetype(['image/png', 'image/gif'])
- //new \GravityPdf\Upload\Validation\Extension(['png', 'gif']),
+// Reads $_FILES['avatar']
+$file = new File('avatar', $storage);
- // Ensure file is no larger than 5M (use "B", "K", M", or "G")
- new \GravityPdf\Upload\Validation\Size('5M'),
+// upload() refuses to run unless at least one validation is added
+$file->addValidations([
+ new FileType('png', 'image/png'), // extension and file contents must both say PNG
+ new Size('2M'), // max 2 MiB ("B", "K", "M" or "G")
]);
-// Access data about the file
-// If upload accepts multiple files an array will be returned for each of these
+// isValid() also checks is_uploaded_file(), so call it before reading any metadata
+if ($file->isValid() === false) {
+ foreach ($file->getErrors() as $message) {
+ echo htmlspecialchars($message, ENT_QUOTES, 'UTF-8'), '
'; // always escape on output
+ }
+
+ return;
+}
+
+// Store under a random name; keep the client's (sanitized) name for display only
+$displayName = $file->getNameWithExtension();
+$file->setName(bin2hex(random_bytes(16)));
+
+try {
+ $file->upload();
+
+ $storedPath = $file->getUploadedFiles()[0];
+} catch (\Exception $e) {
+ // Validation has already passed, so this is a storage failure: the destination
+ // exists, the extension is blocked, or the disk is full
+}
+```
+
+`FileType` accepts any registered
+[IANA media type](https://www.iana.org/assignments/media-types/media-types.xhtml), such as
+`image/png` or `application/pdf`.
+
+### Reading file metadata
+
+```php
$data = [
- 'name' => $file->getNameWithExtension(),
- 'extension' => $file->getExtension(),
- 'mime' => $file->getMimetype(),
- 'size' => $file->getSize(),
- 'md5' => $file->getMd5(),
- 'dimensions' => $file->getDimensions(),
+ 'name' => $file->getNameWithExtension(), // sanitized client name; display only
+ 'extension' => $file->getExtension(),
+ 'mime' => $file->getMimetype(), // sniffed from the contents, not the client's claim
+ 'size' => $file->getSize(), // bytes, or false if the file is unreadable
+ 'hash' => $file->getHash(), // sha256 unless you pass another algorithm
+ 'dimensions' => $file->getDimensions(), // ['width' => int, 'height' => int]
];
+```
+
+These calls are forwarded to every file in the collection. With one file you get the value
+back, with several you get an array of values, and with none you get `null`. When a field
+accepts multiple files, read metadata per file instead.
+
+### Multi-file upload
+
+```html
+
+```
+
+The `$_FILES` key drops the brackets: `new File('photos', $storage)`. `File` acts as a
+collection of the individual files, so `count()`, `foreach` and array offsets all work. A
+file that failed to transfer (too large, nothing selected) is left out, and its error
+message is already in `getErrors()`.
+
+```php
+use GravityPdf\Upload\File;
+use GravityPdf\Upload\Storage\FileSystem;
+use GravityPdf\Upload\Validation\FileType;
+use GravityPdf\Upload\Validation\Size;
+
+$storage = new FileSystem('/path/to/uploads');
+$file = new File('photos', $storage);
+
+$file->addValidations([
+ // One format per call, otherwise every extension is paired with every media type
+ (new FileType(['jpg', 'jpeg'], 'image/jpeg'))
+ ->allow('png', 'image/png')
+ ->allow('webp', 'image/webp'),
+ new Size('10M'),
+]);
+
+// An empty collection has nothing to fail validation, so check the count as well
+if (count($file) === 0 || $file->isValid() === false) {
+ foreach ($file->getErrors() as $message) {
+ echo htmlspecialchars($message, ENT_QUOTES, 'UTF-8'), '
';
+ }
-// If you have an upload field that accepts multiple files you can access each file's info individually
-$firstFileName = $file[0]->getNameWithExtension();
-if(isset($file[1])) {
- $secondFileName = $file[1]->getNameWithExtension();
+ return;
}
-// or loop over all files for this key
-foreach($file as $i => $upload) {
- $name = $upload->getNameWithExtension();
- $upload->setName('file-'.$i);
+// Rename each file server-side, keeping the client names for display
+$manifest = [];
+foreach ($file as $photo) {
+ $displayName = $photo->getNameWithExtension();
+ $photo->setName(bin2hex(random_bytes(16)));
+
+ $manifest[] = [
+ 'display' => $displayName,
+ 'stored' => $photo->getNameWithExtension(),
+ 'hash' => $photo->getHash(),
+ ];
}
-// Try to upload file(s)
try {
- // Success!
$file->upload();
} catch (\Exception $e) {
- // Validation errors
- $errors = $file->getErrors();
- if(count($errors) === 0) {
- // Failed for another reason, like the file already exists
- $error = $e->getMessage();
+ // Multi-file uploads are not atomic: earlier files may already be on disk.
+ // getUploadedFiles() lists what was written so the batch can be rolled back.
+ foreach ($file->getUploadedFiles() as $uploadedPath) {
+ unlink($uploadedPath);
+ }
+
+ return;
+}
+```
+
+Individual files are also reachable by offset. Check with `isset($file[0])` first, because
+files that failed to transfer are missing from the collection.
+
+### Lifecycle callbacks
+
+Four optional hooks fire once per file, each receiving that file's `FileInfoInterface`:
+`beforeValidate`, `afterValidate`, `beforeUpload` and `afterUpload`. Use them for per-file
+work like renaming or audit logging without writing your own loops:
+
+```php
+use GravityPdf\Upload\FileInfoInterface;
+
+$file->beforeUpload(static function (FileInfoInterface $fileInfo): void {
+ $fileInfo->setName(bin2hex(random_bytes(16)));
+});
+
+$file->afterUpload(static function (FileInfoInterface $fileInfo): void {
+ error_log(sprintf('Stored upload as %s', $fileInfo->getNameWithExtension()));
+});
+```
+
+Renaming in `beforeUpload` cannot dodge validation. `upload()` re-runs every validation,
+and the storage deny-list is checked against the final name when the file is written.
+
+### Custom validation rules
+
+Implement `ValidationInterface` and throw `GravityPdf\Upload\Exception` to reject a file.
+The exception message is what `getErrors()` shows the end user:
+
+```php
+use GravityPdf\Upload\Exception;
+use GravityPdf\Upload\FileInfoInterface;
+use GravityPdf\Upload\ValidationInterface;
+
+class MaxDimensions implements ValidationInterface
+{
+ private $maxWidth;
+ private $maxHeight;
+
+ public function __construct(int $maxWidth, int $maxHeight)
+ {
+ $this->maxWidth = $maxWidth;
+ $this->maxHeight = $maxHeight;
+ }
+
+ public function validate(FileInfoInterface $fileInfo): void
+ {
+ $size = $fileInfo->getDimensions();
+
+ if ($size['width'] > $this->maxWidth || $size['height'] > $this->maxHeight) {
+ throw new Exception(
+ sprintf('Image must be no larger than %dx%d pixels', $this->maxWidth, $this->maxHeight),
+ $fileInfo
+ );
+ }
+ }
+}
+
+$file->addValidation(new MaxDimensions(2048, 2048));
+```
+
+Failures accumulate rather than abort: every validation runs against every file, and
+`getErrors()` reports them all at once. Throwing anything other than
+`GravityPdf\Upload\Exception` is caught too, but its message is dropped because PHP
+runtime messages can leak server paths.
+
+### Custom storage backends
+
+Implement `StorageInterface` to store files somewhere other than the local filesystem:
+read from `$fileInfo->getPathname()`, return the destination, and throw
+`GravityPdf\Upload\Exception` on failure.
+
+```php
+use GravityPdf\Upload\FileInfoInterface;
+use GravityPdf\Upload\StorageInterface;
+
+class ObjectStorage implements StorageInterface
+{
+ public function upload(FileInfoInterface $fileInfo): string
+ {
+ $key = 'uploads/' . $fileInfo->getNameWithExtension();
+
+ // ... stream $fileInfo->getPathname() to your object store ...
+
+ return $key;
}
}
```
+The protections described under "Security notes" (the extension deny-list, the
+`basename()` reduction, the symlink refusal, the staged write) live in
+`Storage\FileSystem`. A custom backend needs equivalents of its own.
+
+## Security notes
+
+**Prefer `FileType` over `Mimetype` and `Extension` separately.** Those two check independent
+lists, so content sniffed as `image/gif` stored as `avatar.png` satisfies both. `FileType`
+requires the extension and the contents to describe the same format.
+
+**Generate storage names server-side.** Sanitizing normalises client names, so `report.txt`
+and `report!.txt` both land on `report.txt`. A server-side name avoids the collision and the
+predictable destination:
+
+```php
+$file->setName(bin2hex(random_bytes(16))); // keep the client name as display metadata only
+```
+
+**Sanitizing is not escaping.** Unsafe characters are rewritten, not escaped. Escape on
+output and use parameterised queries. This applies to `getErrors()`.
+
+**Call `isValid()` before reading metadata.** It performs the `is_uploaded_file()` check;
+the metadata accessors do not. This matters where `$_FILES` is rebuilt by something other
+than the PHP SAPI (PSR-7 bridges, test harnesses, middleware).
+
+**Serve uploads from a directory the web server won't execute.** The storage defaults below
+are backstops, not a substitute for that.
+
+### Extensions blocked by default
+
+`FileSystem` refuses to write these, whatever the validations allowed. The check runs against
+the sanitized extension about to be written and throws `\GravityPdf\Upload\Exception` rather
+than recording a validation error.
+
+| Group | Extensions |
+|---|---|
+| PHP | `php` `php2` `php3` `php4` `php5` `php6` `php7` `php8` `phps` `phtml` `phtm` `phar` `pht` `inc` |
+| Server-side includes | `shtml` `shtm` `stm` |
+| CGI and scripts | `cgi` `fcgi` `pl` `py` `rb` `sh` `bash` `ps1` |
+| Java | `jsp` `jspx` `jspf` `jsw` `jsv` `jshtml` `jar` `war` |
+| ASP / ASP.NET | `asp` `aspx` `asa` `asax` `ascx` `ashx` `asmx` `cer` `cshtml` `vbhtml` |
+| Windows binaries | `exe` `dll` `com` `bat` `cmd` `msi` `scr` `vbs` `ws` `wsf` `hta` |
+| Server configuration | `htaccess` `htpasswd` `ini` `conf` `config` |
+| Markup and script | `html` `htm` `xhtml` `xht` `xhtm` `svg` `svgz` `xml` `xsl` `xslt` `js` `mjs` `swf` `mht` `mhtml` |
+
+The first seven groups are `FileSystem::EXECUTABLE_EXTENSIONS`, which a server runs. The last
+is `FileSystem::MARKUP_EXTENSIONS`, which a browser renders: serving one of those from your own
+origin is stored XSS, and SVG belongs there because it carries `