umityatarkalkmaz / upload
Validates and stores a single uploaded file, with separate visitor and operator error messages
Requires
- php: >=8.2
- ext-fileinfo: *
- umityatarkalkmaz/string-helper: ^2.0
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Validates and stores a single uploaded file. Every rejection produces two messages: a short one for the visitor and the underlying cause for you.
Requirements
PHP 8.2 or newer, with ext-fileinfo.
Installation
composer require umityatarkalkmaz/upload
Usage
use UmitYatarkalkmaz\Upload; $uploader = new Upload( targetDir: __DIR__ . '/../storage/uploads', maxFileSize: 2 * 1024 * 1024, allowedExtensions: ['jpg', 'jpeg', 'png', 'gif'], ); $filename = $uploader->upload('photo'); if ($filename === null) { // Safe to show the visitor. foreach ($uploader->getErrors() as $message) { echo htmlspecialchars($message), "\n"; } // Never show this; send it to your log. error_log(implode(' | ', $uploader->getDebugErrors())); return; } echo 'Stored as ', $filename;
upload() returns the stored filename, or null when the file was rejected.
Where to store, and how to serve
The target directory belongs outside the document root. A file the web server can reach directly is a file the web server may decide to execute or to render inline, and neither is something the uploader can prevent from here.
Serve stored files through a script that decides what the browser is allowed to do with them:
$filename = Upload::sanitizeFilename($_GET['file'] ?? ''); $path = __DIR__ . '/../storage/uploads/' . $filename; if (!is_file($path)) { http_response_code(404); return; } header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('X-Content-Type-Options: nosniff'); readfile($path);
Content-Disposition: attachment keeps the response out of the page's origin,
and nosniff stops the browser from guessing a type the header did not claim.
If the directory has to live inside the document root, disable PHP execution in
it — php_admin_flag engine off in the Apache directory block, or simply never
routing that path to PHP-FPM in nginx.
The two error channels
getErrors() returns messages written for the visitor: File is too large.,
File type not allowed., The file could not be stored. They say what went
wrong without revealing anything about the server.
getDebugErrors() returns the matching operator-facing detail for the same
failure, in the same order: which php.ini directive was exceeded, what MIME
type the content actually had, which path could not be written. Log it. Putting
it in a response hands an attacker a map of your configuration.
Both arrays are cleared at the start of every upload() call.
What is checked, in order
- The
$_FILESentry exists, is well-formed, and is not a multi-file field. - The PHP upload error code is
UPLOAD_ERR_OK. is_uploaded_file()confirms the temporary file really came from this request.- The size is within
maxFileSize. - The extension is in the allowlist.
- The content matches that extension, sniffed with
finfo— a PHP script renamed to.pngis rejected here.
The stored name is built from scratch: the original base name is slugified, truncated to 60 characters, and given a random 16-character suffix plus the validated extension. The name is then claimed with an exclusive create, so an upload never overwrites a file that is already there; if the random name is taken it is regenerated, and after five collisions the upload is rejected through the normal error channels. Nothing the visitor puts in the filename can escape the target directory.
Configuration is checked at construction
The constructor throws InvalidArgumentException when the target directory is
missing or unwritable, when maxFileSize is not positive, when the allowlist is
empty, or when it names an extension the class has no MIME mapping for:
new Upload(__DIR__ . '/uploads', allowedExtensions: ['exe']); // InvalidArgumentException: Unsupported extension: exe
Supported extensions: jpg, jpeg, png, gif, webp, bmp, pdf, txt,
csv, zip, doc, docx, xls, xlsx.
Filenames outside an upload
sanitizeFilename() names files that never went through upload(). It
slugifies the base name, caps it at 60 characters unless you pass a shorter
limit, and keeps the extension only when it is one of the supported types
listed above. Anything else is dropped, extension and all:
Upload::sanitizeFilename('Şeker Ağacı.JPG'); // 'seker-agaci.jpg' Upload::sanitizeFilename('Şeker Ağacı.jpg', 4); // 'seke.jpg' Upload::sanitizeFilename('../../etc/passwd'); // 'passwd' Upload::sanitizeFilename('avatar.php'); // 'avatar' Upload::sanitizeFilename('report.exe'); // 'report'
It looks at the name only. Whether the bytes match the extension is a question
only upload() answers, with finfo.
Development
composer install
composer check # phpstan (level max) + phpunit
License
MIT. See LICENSE.