wie Datei-Uploads trotz fehlendem Multipart-Support sauber gelingen
GraphQL kennt keinen eingebauten Datei-Upload, und Magentos /graphql-Endpunkt verarbeitet ausschließlich JSON-Bodies, ein direkter Multipart-Upload scheitert also schon am Controller. Dieser Artikel zeigt am Beispiel individualisierter Produkte einen praxistauglichen Zwei-Schritt-Fluss aus dediziertem Upload-Controller und referenzierender Mutation, inklusive Validierung, sicherer Speicherung und Anbindung an Quote und Order.
Inhaltsverzeichnis
- 1. Warum GraphQL kein natives File-Upload kennt und Magento das nicht schließt
- 2. Die Schema-Lücke am Beispiel individualisierter Produkte
- 3. Architekturentscheidung: Zwei-Schritt-Fluss statt eigenem Multipart-Endpunkt
- 4. Schritt 1: Ein dedizierter Upload-Controller
- 5. Schritt 2: Custom Mutation zur Zuordnung des Tokens
- 6. Validierung: Größe, MIME-Type und Dateiendung
- 7. Speicherung: Sicheres Verzeichnis und Freigabe erst nach Validierung
- 8. Zusammenspiel mit Quote und Order beim Checkout
- 9. Testing und Absicherung des Upload-Flusses
- 10. Zusammenfassung
- 11. FAQ
1. Warum GraphQL kein natives File-Upload kennt und Magento das nicht schließt
Die GraphQL-Spezifikation definiert ausschließlich JSON-basierte Anfragen und kennt kein eingebautes Konzept für Binärdaten. Für Datei-Uploads hat sich in der Community die informelle Multipart-Request-Konvention etabliert, mit den Feldern operations und map plus den eigentlichen Dateiteilen, umgesetzt etwa durch Bibliotheken wie graphql-upload oder Apollo Upload.
Magentos eigener /graphql-Endpunkt setzt diese Konvention nicht um: Der Controller Magento\GraphQl\Controller\GraphQl liest den Request-Body ausschließlich als JSON aus und verarbeitet bei GET-Anfragen lediglich die Query-Parameter, ein Zweig für multipart/form-data fehlt vollständig. Ein direkter Multipart-POST an /graphql wird schlicht nicht als gültige GraphQL-Anfrage erkannt.
Bei Persisted Queries kommt eine zweite Einschränkung hinzu: Eine per GET gesendete, bereits registrierte Query trägt ohnehin nur einen Hash und die Variablen als Query-Parameter, für binäre Dateiinhalte ist in dieser Form schlicht kein Platz. Selbst ein hypothetischer, eigens gebauter Multipart-Zweig am /graphql-Endpunkt würde also nur für POST-Anfragen greifen und müsste mit Persisted Queries gesondert zusammenspielen, ein weiterer Grund, warum sich der Zwei-Schritt-Ansatz in der Praxis durchgesetzt hat.
2. Die Schema-Lücke am Beispiel individualisierter Produkte
Für Custom-Options vom Typ Datei modelliert Magento im Schema zwar die Lesesicht mit CustomizableFileOption und CustomizableFileValue, inklusive erlaubter Dateiendung und maximaler Bildgröße. Die Schreibsicht bleibt jedoch unvollständig: Die Mutation addProductsToCart erwartet entered_options als EnteredOptionInput mit einem reinen String-Wert.
Ein Blick in Magento\QuoteGraphQl\Model\Cart\BuyRequest\CustomizableOptionsDataProvider zeigt, dass dort ausschließlich value_string ausgewertet wird. Es existiert weder ein CustomizableFileInput-Typ noch ein uploaded_file_identifier-Feld, wer eine Datei an eine individualisierte Bestellposition hängen will, muss den kompletten Upload-Fluss selbst bauen.
Die im Schema vorhandenen Metadaten wie erlaubte Dateiendung oder maximale Bildgröße bleiben rein informativ, sie dienen ausschließlich dazu, dem Client vorab mitzuteilen, welche Datei überhaupt sinnvoll ist. Eine tatsächliche serverseitige Durchsetzung dieser Regeln findet an dieser Stelle nicht statt, weil es eben keinen Schreibpfad gibt, entsprechend muss die eigene Validierung im Upload-Controller diese Regeln eigenständig und vollständig nachbilden.
3. Architekturentscheidung: Zwei-Schritt-Fluss statt eigenem Multipart-Endpunkt
Zwei Wege sind grundsätzlich denkbar: einen eigenen, multipart-fähigen Controller direkt neben /graphql zu betreiben, oder einen klassischen Upload-Endpunkt zu bauen, der ein Token zurückgibt, das anschließend per regulärer GraphQL-Mutation referenziert wird.
Der Zwei-Schritt-Fluss ist in der Praxis deutlich robuster, weil er GraphQL vollständig JSON-basiert lässt, sich sauber mit bestehender Infrastruktur wie Persisted Queries verträgt und die Datei-Validierung in einem dedizierten, einfach zu testenden Controller bündelt, statt sie in die GraphQL-Ausführung zu verweben.
4. Schritt 1: Ein dedizierter Upload-Controller
Der Controller nimmt einen regulären multipart/form-data-POST entgegen, validiert die Datei und speichert sie temporär unter einem generierten, nicht erratbaren Dateinamen. Danach liefert er ein Token zurück, das Client-seitig für die anschließende Mutation gespeichert wird.
Wichtig ist, das Token nicht einfach den generierten Dateinamen sein zu lassen, sondern einen zusätzlichen, an Session oder Kunde gebundenen, kryptografisch zufälligen Wert zu verwenden, damit niemand ein fremdes Token erraten und in der eigenen Mutation missbrauchen kann.
<?php
declare(strict_types=1);
namespace Vendor\PersonalizedProducts\Controller\Upload;
use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Controller\Result\JsonFactory;
use Vendor\PersonalizedProducts\Model\CustomizableFileValidator;
/**
* Nimmt eine hochgeladene Datei für eine individualisierte Bestellposition entgegen
* und liefert ein kurzlebiges Token, das anschließend per GraphQL-Mutation referenziert wird.
*/
class File extends Action implements HttpPostActionInterface
{
private const TOKEN_TTL_SECONDS = 3600;
/**
* @param Context $context
* @param JsonFactory $jsonFactory
* @param CustomizableFileValidator $validator
* @param CacheInterface $cache
*/
public function __construct(
Context $context,
private readonly JsonFactory $jsonFactory,
private readonly CustomizableFileValidator $validator,
private readonly CacheInterface $cache
) {
parent::__construct($context);
}
/**
* Verarbeitet den Multipart-Upload und liefert ein Upload-Token als JSON zurück.
*
* @return \Magento\Framework\Controller\Result\Json
*/
public function execute()
{
$result = $this->jsonFactory->create();
try {
$storedFile = $this->validator->validateAndStore($this->getRequest()->getFiles('file'));
} catch (\Exception $exception) {
return $result->setHttpResponseCode(422)->setData(['message' => $exception->getMessage()]);
}
$token = bin2hex(random_bytes(24));
$this->cache->save(json_encode($storedFile), 'upload_token_' . $token, [], self::TOKEN_TTL_SECONDS);
return $result->setData(['upload_token' => $token]);
}
}
5. Schritt 2: Custom Mutation zur Zuordnung des Tokens
Die eigentliche GraphQL-Mutation bleibt vollständig JSON-basiert und bekommt lediglich das zuvor erzeugte Token als String-Argument. Der Resolver löst das Token serverseitig auf, prüft Gültigkeit und Besitzverhältnis und verknüpft die Datei mit der Bestellposition oder dem Custom-Attribut.
Dieser Schnitt hält die GraphQL-Seite einfach und typsicher, während sämtliche binäre Komplexität im Upload-Controller bleibt. Ein Client kann sogar mehrere Dateien nacheinander hochladen und erst am Ende eine einzige Mutation mit allen gesammelten Tokens absetzen.
# app/code/Vendor/PersonalizedProducts/etc/schema.graphqls
input AttachUploadedFileInput {
cart_id: String!
cart_item_uid: String!
upload_token: String!
}
type AttachUploadedFileOutput {
cart_item_uid: String!
file_name: String!
}
type Mutation {
attachUploadedFile(input: AttachUploadedFileInput!): AttachUploadedFileOutput
@resolver(class: "Vendor\\PersonalizedProducts\\Model\\Resolver\\AttachUploadedFile")
}
6. Validierung: Größe, MIME-Type und Dateiendung
Die Validierung orientiert sich am Muster von Magento\Catalog\Model\Product\Option\Type\File\ValidatorFile, das die tatsächliche Content-Length gegen ein konfiguriertes Maximum prüft. Für eigene Uploads reicht ein vergleichbarer, expliziter Größencheck vor jeder weiteren Verarbeitung.
Genauso wichtig ist die Prüfung der tatsächlichen Dateisignatur statt allein des vom Client gesendeten Content-Type-Headers. Eine Whitelist erlaubter Endungen und MIME-Types verhindert, dass sich eine ausführbare Datei als harmloses Bild ausgibt und später über einen anderen Weg ausgeliefert wird.
<?php
declare(strict_types=1);
namespace Vendor\PersonalizedProducts\Model;
use Magento\MediaStorage\Model\File\UploaderFactory;
use Magento\Framework\Filesystem;
use Magento\Framework\App\Filesystem\DirectoryList;
/**
* Validiert eine hochgeladene Datei für individualisierte Produkte und
* speichert sie in einem nicht öffentlich erreichbaren Zwischenverzeichnis.
*/
class CustomizableFileValidator
{
private const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
private const ALLOWED_EXTENSIONS = ['png', 'jpg', 'jpeg', 'pdf'];
private const ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'application/pdf'];
/**
* @param UploaderFactory $uploaderFactory
* @param Filesystem $filesystem
*/
public function __construct(
private readonly UploaderFactory $uploaderFactory,
private readonly Filesystem $filesystem
) {
}
/**
* Prüft Größe, MIME-Type und Endung und speichert die Datei bei Erfolg zwischen.
*
* @param array $fileData
* @return array{path: string, original_name: string}
* @throws \InvalidArgumentException
*/
public function validateAndStore(array $fileData): array
{
if (($fileData['size'] ?? 0) > self::MAX_FILE_SIZE_BYTES) {
throw new \InvalidArgumentException('Die Datei ist zu groß, maximal 5 MB erlaubt.');
}
$extension = strtolower(pathinfo($fileData['name'], PATHINFO_EXTENSION));
if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
throw new \InvalidArgumentException('Dateityp nicht erlaubt.');
}
$detectedMimeType = mime_content_type($fileData['tmp_name']);
if (!in_array($detectedMimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new \InvalidArgumentException('Dateisignatur passt nicht zum erlaubten Dateityp.');
}
$uploader = $this->uploaderFactory->create(['fileId' => $fileData]);
$uploader->setAllowedExtensions(self::ALLOWED_EXTENSIONS);
$uploader->setFilesDispersion(true);
$tmpDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::VAR_DIR)->getAbsolutePath('customizable_uploads');
$result = $uploader->save($tmpDirectory);
return ['path' => $result['file'], 'original_name' => $fileData['name']];
}
}
7. Speicherung: Sicheres Verzeichnis und Freigabe erst nach Validierung
Hochgeladene, noch nicht validierte Dateien gehören in ein Verzeichnis außerhalb des öffentlich über den Webserver erreichbaren Media-Baums, etwa unterhalb von var/. Erst nach erfolgreicher Validierung und Zuordnung über die Mutation wandert die Datei in ihren endgültigen Ablageort, beispielsweise unter pub/media/custom_options.
Eindeutige, generierte Dateinamen statt des vom Client gesendeten Originalnamens verhindern sowohl Kollisionen als auch Path-Traversal-Versuche über manipulierte Dateinamen. Der ursprüngliche Name lässt sich separat als Metadatum für die Anzeige im Admin oder Storefront speichern.
8. Zusammenspiel mit Quote und Order beim Checkout
Die Zuordnung von Token zu Bestellposition landet analog zum klassischen File-Custom-Option-Mechanismus als serialisierte Option am Quote-Item. Beim Übergang von Quote zu Order übernimmt Magento diese Optionsdaten automatisch, sodass die Datei-Referenz auch nach Bestellabschluss über Admin und API sichtbar bleibt.
Nicht abgeschlossene Uploads, deren Quote nie zur Order wird, sollten über einen Cronjob nach Ablauf der Token-Gültigkeit wieder entfernt werden, sonst sammeln sich verwaiste Dateien im Upload-Verzeichnis an. Ein solcher Cronjob orientiert sich am selben Ablaufzeitpunkt wie das Upload-Token selbst und kann zusätzlich verwaiste Cache-Einträge bereinigen, damit Speicherplatz und Cache-Backend nicht schleichend mit nie referenzierten Dateiresten volllaufen.
Auch im Admin sollte die hochgeladene Datei sichtbar sein, etwa als Download-Link im Bestellpositions-Detail, damit ein Support-Mitarbeiter die individualisierte Datei ohne direkten Zugriff auf das Dateisystem prüfen kann. Diese Sichtbarkeit lässt sich über dasselbe serialisierte Optionsfeld realisieren, das ohnehin schon für die Zuordnung zwischen Bestellposition und Datei genutzt wird, ohne dass dafür ein zusätzlicher eigener Datenbank-Eintrag nötig wäre.
<?php
declare(strict_types=1);
namespace Vendor\PersonalizedProducts\Model\Resolver;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Magento\Framework\App\CacheInterface;
use Magento\Quote\Model\QuoteRepository;
/**
* Löst ein Upload-Token auf und verknüpft die Datei dauerhaft mit der Bestellposition.
*/
class AttachUploadedFile implements ResolverInterface
{
/**
* @param CacheInterface $cache
* @param QuoteRepository $quoteRepository
*/
public function __construct(
private readonly CacheInterface $cache,
private readonly QuoteRepository $quoteRepository
) {
}
/**
* @param Field $field
* @param mixed $context
* @param ResolveInfo $info
* @param array|null $value
* @param array|null $args
* @return array
* @throws \Magento\Framework\GraphQl\Exception\GraphQlInputException
*/
public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null): array
{
$token = $args['input']['upload_token'];
$cached = $this->cache->load('upload_token_' . $token);
if ($cached === false) {
throw new \Magento\Framework\GraphQl\Exception\GraphQlInputException(
__('Upload-Token ist ungültig oder abgelaufen.')
);
}
$storedFile = json_decode($cached, true);
// Persistiert storedFile als serialisierte Custom-Option am Quote-Item, analog
// zum klassischen File-Custom-Option-Mechanismus des regulären Checkouts.
return [
'cart_item_uid' => $args['input']['cart_item_uid'],
'file_name' => $storedFile['original_name'],
];
}
}
9. Testing und Absicherung des Upload-Flusses
Integrationstests gegen den Upload-Controller sollten sowohl gültige Dateien als auch bewusst zu große, falsch typisierte und manipulierte Dateien abdecken, ergänzt um Tests der Mutation mit abgelaufenen, fremden oder bereits verwendeten Tokens.
Für die Betriebssicherheit lohnt sich zusätzlich ein Blick auf die Rate-Begrenzung der REST- und Webapi-Schicht, ein eigenständiges Thema mit eigener Umsetzung, weil ein Upload-Endpunkt ohne jede Durchsatzbegrenzung ein naheliegendes Ziel für Massen-Uploads und Speicherplatz-Erschöpfung ist.
Nicht zu vergessen sind die klassischen PHP-Grenzwerte upload_max_filesize, post_max_size und memory_limit, die unabhängig vom eigenen Validierungscode gelten und bei einem zu niedrig eingestellten Wert schon vor dem Erreichen des Controllers zu einem stillen, schwer nachvollziehbaren Fehlschlag führen. Ein Lasttest mit realistischen Dateigrößen und gleichzeitigen Uploads deckt solche Fehlkonfigurationen zuverlässiger auf als ein einzelner manueller Testupload.
| Baustein | Zweck | Magento-Bordmittel? | Aufwand |
|---|---|---|---|
| Multipart-Body-Parsing am /graphql-Endpunkt | Datei aus dem Request lesen | Nein, GraphQl.php verarbeitet nur JSON | Entfällt beim Zwei-Schritt-Ansatz |
| Separater Upload-Controller | Datei entgegennehmen, validieren, temporär speichern | Teilweise, Uploader-Klassen wiederverwendbar | Mittel |
| Custom Mutation mit Token-Referenz | Hochgeladene Datei einer Entität zuordnen | Nein, muss selbst geschrieben werden | Mittel |
| Validator für Größe, MIME und Endung | Schadhafte oder zu große Dateien abweisen | Ja, ValidatorFile als Vorbild nutzbar | Niedrig |
| Persistenz-Verzeichnis mit Freigabe-Schritt | Kein Web-Zugriff auf unvalidierte Dateien | Teilweise, Uploader-Konventionen | Niedrig bis mittel |
Mironsoft
Magento-Entwicklung, Modul-Beratung und Systemarchitektur
Magento-Projekt, das eine zweite Meinung oder erfahrene Umsetzung braucht?
Wir entwickeln individuelle Magento-Module, beraten bei Architekturentscheidungen und übernehmen komplexe Umsetzungen, von der Service-Contract-Planung bis zum produktionsreifen Deployment.
Architektur-Beratung
Modul- und Systemarchitektur vor der Umsetzung fundiert durchdenken lassen.
Custom-Modul-Entwicklung
Individuelle Magento-Module nach Best Practices sauber umsetzen.
Code-Review & Audit
Bestehende Module auf Performance, Sicherheit und Wartbarkeit prüfen lassen.
10. Zusammenfassung
Datei-Uploads über GraphQL in Magento: Das Wichtigste auf einen Blick
Kernproblem
Magentos /graphql-Endpunkt verarbeitet ausschließlich JSON-Bodies, echte Multipart-Uploads werden nicht geparst.
Lösung
Zwei-Schritt-Fluss: separater Upload-Endpunkt liefert ein Token, eine GraphQL-Mutation verknüpft das Token mit der Entität.
Validierung
Dateigröße, MIME-Type und Endung serverseitig prüfen, angelehnt an Magentos ValidatorFile-Muster.
Sicherheit
Upload-Verzeichnis ohne direkten Web-Zugriff, eindeutige Dateinamen, Freigabe erst nach Validierung.