Extbase Fileupload (again)

Es gibt wahrscheinlich schon genügend Fragen und Antworten zu dem Thema Filuploads in TYPO3 Extbase. Dennoch möchte ich eine Methode beschreiben, die ich kürzlich verwendet habe und die mir weniger Kopfzerbrechen bereitet als z.B. Helmuts Upload Example. Ich persönlich fand die Vorgehenswese, die hochgeladene Datei direkt als Filereferenz zu behandeln immer etwas unpassend und umständlich. Angenehmer wäre es, das `$_FILES` array einfach als Objekt abzubilden, damit man darauf Extbase Validatoren anwenden kann. Dies ist eigentlich auch kein großer Akt, da Extbase dieses Array automatisch in die Controller Action Parameter mappen kann. Fangen wir zunächst mal an ein wenig Klarheit beim Fileupload zu schaffen.

Generelles Mapping des $_FILES Arrays

Grundsätzlich ist das Mapping von Fileuploads ganz einfach. Im Fluid Template kann man sich den bereits vorhandenen Upload ViewHelper zu hilfe nehmen. Ein Formular mit Bildupload würde also so aussehen:

 

<f:form action="upload" enctype="multipart/form-data">
    Dateiupload:<br />
    <f:form.upload name="image" />
</f:form>

 

Im Controller könnten wir das Array jetzt einfach entgegen nehmen:

 

<?php

namespace TheCodingOwl\ExampleExtension\Controller;

use TYPO3\CMS\Extbase\Mvc\ActionController;

class UploadExampleController extends ActionController {
    /**
     * Upload an image
     *
     * @param array $image
     **/
    public function uploadAction(array $image) {
        // do something with the file
        // for example move it to somewhere else
        upload_copy_move($image['tmp_name'], 'some/path/' . $image['name']);
    }
}

 

Extbase übernimmmt das Mapping für uns.

Mapping als Model

Es geht allerdings noch besser. Wir können anstatt eines Arrays auch ein Model aus dem $_FILES Array machen. Damit hätten wir die Möglichkeit, Extbase Validatoren über Annotationen zu nutzen. Wir könnnten gezielt Mime-Type oder Dateigröße validieren, ohne das gesamte Array dabei zu validieren.

Zunächst benötigen wir eine Model Klasse für unseren Dateiupload.

 

<?php

class FileUpload {
    /**
     * $_FILES['tmp_name']
     *
     * @var string
     * @TYPO3\CMS\Extbase\Annotation\Validate("NotEmpty")
     */
    protected $tmpName = '';

    /**
     * $_FILES['name']
     *
     * @var string
     */
    protected $name = '';

    /**
     * $_FILES['size']
     *
     * @var int
     */
    protected $size = 0;

    /**
     * $_FILES['error']
     *
     * @var int
     */
    protected $error = \UPLOAD_ERR_OK;

    /**
     * $_FILES['type']
     *
     * @var string
     */
    protected $type = '';

    /**
    * Get the tmp_name of the uploads
    *
    * @var string
    */
    public function getTmpName(): string
    {
        return $this->tmpName;
    }

    /**
     * Get the name of the upload (this is not trustworthy!)
     *
     * @var string
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * Get the size of the upload in bytes (this is not trustworthy!)
     *
     * @var int
     */
    public function getSize(): int
    {
        return $this->size;
    }

    /**
     * Get the error code of the upload
     *
     * @var int
     */
    public function getError(): int
    {
        return $this->error;
    }

    /**
     * Get the mime type of the upload (this is not trustworthy!)
     *
     * @var string
     */
    public function getType(): string
    {
        return $this->type;
    }
}

 

In diesem Model haben wir jeweils eine Property für die verschiedenen Indizes des $_FILES Array. In diesem Beispiel habe ich bereits einen NotEmpty Validator für den Dateinamen als Annotation an die entsprechende Property gehangen. Sollte der Dateiname nun leer sein, wird der normale Extbase Validationsprozess uns auf das Formular zurück schicken und die Fehlermeldung kann mittlels `<f:form.validationResults />` ausgegeben werden.

Für eine noch genauere Fehlerbehandlung, habe ich einen Validator geschrieben, welcher das gesamte Model als Ganzes validiert und entsprechende Meldungen generiert. Es gibt zwei alternativen, wie man diesen Validator benutzen kann. Einerseits kann man den Validator mittels `@TYPO3\CMS\Extbase\Annotation\Validate()` an den Parameter im Controller knüpfen. Die andere Möglichkeit nutzt das automatische Validieren von Models anhand des Namespaces. Hat man also ein Model `MyVendor\MyExt\Domain\Model\FileUpload` kann man den Validator als `MyVendor\MyExt\Domain\Validator\FileUploadValidator` nutzen und Extbase weis automatisch, dass es diesen Validator nutzen muss, wenn im Controller das Model `MyVendor\MyExt\Domain\Model\FileUpload` entgegen genomment wird. Mehr Informationen über das Validieren von Models gibt es auch in der offiziellen TYPO3 Dokumentation.

Der Validator könnte wie folgt aussehen:

 

<?php

use TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator;
use TYPO3\CMS\Core\Utility\StringUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TheCodingOwl\UploadHelper\Model\FileUpload;

class FileValidator extends AbstractValidator {
    const INVALID_TYPE = 1595090575;
    const ERROR_TYPE_NOT_ALLOWED = 1595090576;
    const ERROR_NO_IMAGE = 1595090577;
    const ERROR_SIZE = 1595090578;
    const OPTION_ALLOWED_TYPES = 'allowedTypes';
    const OPTION_MAX_SIZE = 'allowedSize';
    protected $imageFileTypes = [
        'image/bmp',
        'image/gif',
        'image/vnd.microsoft.icon',
        'image/jpeg',
        'image/png',
        'image/svg+xml',
        'image/tiff',
        'image/webp'
    ];
    protected $options = [];
    public function isValid($file) {
        if (!$file instanceof FileUpload) {
            $this->addError(
                'Value to validate not of FileUpload type but of %s!', 
                self::INVALID_TYPE, 
                ['type' => gettype($file)]
            );
            return;
        }
        if (!$this->hasAllowedType($file)) {
            $this->addError(
                'The filetype is not of the accepted file types %s!', 
                self::ERROR_TYPE_NOT_ALLOWED, 
                [self::OPTION_ALLOWED_TYPES => $this->options[self::OPTION_ALLOWED_TYPES]]
            );
            return;
        }
        if ($this->checkImage($file->getType()) && $this->isImage($file)) {
            $this->addError(
                'The uploaded file is not an image!',
                self::ERROR_NO_IMAGE,
                [self::OPTION_ALLOWED_TYPES => $this->options[self::OPTION_ALLOWED_TYPES]]
            );
            return;
        }
        if (!$this->isAllowedSize($file)) {
            $this->addError(
                'The uploaded file is to big, the maximum size is %s!',
                self::ERROR_SIZE,
                [self::OPTION_MAX_SIZE => $this->options[self::OPTION_MAX_SIZE]]
            );
            return;
        }
    }
    
    protected function hasAllowedType(FileUpload $file): bool {
        if (!GeneralUtility::inList($this->options[self::OPTION_ALLOWED_TYPES], $file->getType())) {
            return FALSE;
        }
        $fileInfo = new finfo(FILEINFO_MIME_TYPE);
        $mimeType = $fileInfo->file($file->getTmpName());
        if (!GeneralUtility::inList($this->options[self::OPTION_ALLOWED_TYPES], $mimeType)) {
            return FALSE;
        }
        return TRUE;
    }
    
    protected function checkImage(string $fileType): bool {
        if (!GeneralUtility::inList($this->imageFileTypes, $fileType)) {
            return FALSE;
        }
        
        return TRUE;
    }
    
    protected function isImage(FileUpload $file): bool {
        $image = FALSE;
        $type = $file->getType();
        if ($type === 'image/bmp') {
            $image = \imagecreatefrombmp($file->getTmpName());
        } elseif($type === 'image/jpeg') {
            $image = \imagecreatefromjpeg($file->getTmpName());
        } elseif($type === 'image/png') {
            $image = \imagecreatefrompng($file->getTmpName());
        } elseif($type === 'image/gif') {
            $image = \imagecreatefromgif($file->getTmpName());
        }
        if ($image === FALSE) {
            return FALSE;
        }
        
        \imagedestroy($image);
        return TRUE;
    }
    
    protected function isAllowedSize(FileUpload $file): bool {
        if ($file->getSize() > $this->options[self::OPTION_MAX_SIZE]) {
            return FALSE;
        }
        if (\filesize($file) > $this->options[self::OPTION_MAX_SIZE]) {
            
            return FALSE;
        }
        
        return TRUE;
    }
    
    public function getOptions(): array {
        return $this->options;
    }
    
    public function setOption($name, $value) {
        $this->option[$name] = $value;
    }
}

 

Im Controller ändern wir die Action entsprechend.

 

<?php

namespace TheCodingOwl\ExampleExtension\Controller;

use TYPO3\CMS\Extbase\Mvc\ActionController;

class UploadExampleController extends ActionController {
    /**
     * Upload an image
     *
     * @param FileUpload $image
     **/
    public function uploadAction(FileUpload $image) {
        // do something with the file
        // for example move it to somewhere else
        upload_copy_move($image->getTmpName(), 'some/path/' . $image->getName());
    }
}

 

Dieser Ansatz sorgt nicht dafür, dass die Datei bereits beim Mapping im File Abstraction Layer von TYPO3 hinterlegt wird, sondern im temporären Ordner des Betriebssystems verbleibt. Sollte es einen Validierungsfehler geben, muss man die Datei also nicht manuell entfernen, da im Idealfall das Betriebssystem diesen temporären Ordner verwaltet. Sollte man die Upload Datei doch von Hand löschen wollen (zum Beispiel im Validator), reicht es ein `unlink($image->getTmpName())` zu machen. Sollte das Validieren erfolgreich sein, muss man sich nun allerdings im Controller (beziehungsweise in einem darin aufgerufenen Service) um das Hinzufügen der Upload Datei zum File Abstraction Layer kümmern. Das Ganze ist jedoch ganz einfach. Die ResourceFactory besitzt Methoden, welche einem dabei helfen dies zu tun.

 

$resourceFactory = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Resource\ResourceFactory::class);
$storage = $resourceFactory->getStorageObject($storageId);
$file = $storage->addFile($image->getTmpName(), $storage->getFolder('/user_upload/'), $image->getName());

 

Eine FileReference kann nun genau so erstellt werden, wie es auch im Upload Example gemacht wird. 

 

$fileReference = $resourceFactory->createFileReferenceObject(
    [
        'uid_local' => $file->getUid(),
        'uid_foreign' => uniqid('NEW_'),
        'uid' => uniqid('NEW_'),
        'crop' => null,
    ]
);

 

Entsprechend kann diese FileReference nun an ein anderes Model angeknüpft und weiterverwendet werden.

Fazit:

Ein Fileupload muss nicht kompliziert sein. Sollte man eine Datei zum Beispiel nur dazu brauchen, weil man einen XML Import bauen möchte und diese Datei temporär genutzt wird, braucht man diese nicht über die FAL mappen, sondern kann dieses einfache Model benutzen und damit die Vorzüge des Models und der Validatoren in Extbase nutzen, ohne die Datei wieder aus der FAL löschen zu müssen, nachdem man sie nicht mehr benötigt.