Files
michaelschiemer/docs/claude/livecomponent-file-uploads.md
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

24 KiB

LiveComponent File Uploads

Komplette Dokumentation des File Upload Systems für LiveComponents mit Drag & Drop, Multi-File Support und Progress Tracking.

Übersicht

Das File Upload System ermöglicht es, Dateien direkt in LiveComponents hochzuladen mit:

  • Drag & Drop Support - Intuitive Dateiauswahl durch Ziehen & Ablegen
  • Multi-File Uploads - Mehrere Dateien gleichzeitig hochladen
  • Progress Tracking - Echtzeit-Fortschrittsanzeige pro Datei und gesamt
  • Preview Funktionalität - Bild-Vorschauen und Datei-Icons
  • Client-Side Validation - Validierung vor dem Upload (MIME-Type, Größe, Extension)
  • CSRF Protection - Automatische CSRF-Token-Integration
  • Component State Management - Nahtlose Integration mit LiveComponent State

Architektur

┌─────────────────────┐    ┌─────────────────────┐    ┌─────────────────────┐
│  FileUploadWidget   │───▶│ ComponentFileUploader│───▶│  Backend Route      │
│  (UI Component)     │    │  (Upload Manager)    │    │  /upload endpoint   │
└─────────────────────┘    └─────────────────────┘    └─────────────────────┘
         │                           │                           │
    Drop Zone UI            Multi-File Queue              State Update
    File List               Progress Tracking             HTML Refresh
    Progress Bars           CSRF Handling                 Event Dispatch

Komponenten

  1. Backend (src/Framework/LiveComponents/)

    • Controllers/LiveComponentController::handleUpload() - Upload Route Handler
    • LiveComponentHandler::handleUpload() - Business Logic
    • Contracts/SupportsFileUpload - Interface für uploadbare Components
    • ValueObjects/FileUploadProgress - Progress Tracking VO
    • ValueObjects/UploadedComponentFile - File Metadata VO
  2. Frontend (resources/js/modules/livecomponent/)

    • ComponentFileUploader.js - Core Upload Manager
    • FileUploadWidget.js - Pre-built UI Component
    • DragDropZone - Drag & Drop Handler
    • FileValidator - Client-Side Validation
    • UploadProgress - Progress Tracking
  3. Styling (resources/css/components/)

    • file-upload-widget.css - Complete UI Styling

Backend Implementation

Interface: SupportsFileUpload

LiveComponents, die Uploads unterstützen, müssen das SupportsFileUpload Interface implementieren:

<?php

use App\Framework\LiveComponents\Contracts\SupportsFileUpload;
use App\Framework\Http\UploadedFile;
use App\Framework\LiveComponents\ComponentEventDispatcher;
use App\Framework\LiveComponents\ValueObjects\ComponentData;

final class DocumentUploadComponent extends AbstractLiveComponent implements SupportsFileUpload
{
    private array $uploadedFiles = [];

    /**
     * Handle file upload
     */
    public function handleUpload(UploadedFile $file, ?ComponentEventDispatcher $events = null): ComponentData
    {
        // 1. Validate file (already done by framework, but you can add custom validation)
        if (!$this->isValidDocument($file)) {
            throw new \InvalidArgumentException('Invalid document type');
        }

        // 2. Process uploaded file
        $savedPath = $this->saveFile($file);

        // 3. Update component state
        $this->uploadedFiles[] = [
            'name' => $file->getClientFilename(),
            'path' => $savedPath,
            'size' => $file->getSize(),
            'uploaded_at' => time()
        ];

        // 4. Dispatch events if needed
        if ($events) {
            $events->dispatch('file-uploaded', [
                'filename' => $file->getClientFilename(),
                'path' => $savedPath
            ]);
        }

        // 5. Return updated component data
        return ComponentData::fromArray([
            'uploaded_files' => $this->uploadedFiles
        ]);
    }

    /**
     * Validate uploaded file
     */
    public function validateUpload(UploadedFile $file): array
    {
        $errors = [];

        // File type validation
        $allowedTypes = $this->getAllowedMimeTypes();
        if (!empty($allowedTypes) && !in_array($file->getClientMediaType(), $allowedTypes)) {
            $errors[] = "File type {$file->getClientMediaType()} is not allowed";
        }

        // File size validation
        $maxSize = $this->getMaxFileSize();
        if ($file->getSize() > $maxSize) {
            $errors[] = "File size exceeds maximum allowed size";
        }

        // Custom validation
        if (!$this->isValidDocument($file)) {
            $errors[] = "Invalid document format";
        }

        return $errors;
    }

    /**
     * Get allowed MIME types
     */
    public function getAllowedMimeTypes(): array
    {
        return [
            'application/pdf',
            'application/msword',
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
            'image/jpeg',
            'image/png'
        ];
    }

    /**
     * Get maximum file size in bytes
     */
    public function getMaxFileSize(): int
    {
        return 10 * 1024 * 1024; // 10MB
    }

    private function isValidDocument(UploadedFile $file): bool
    {
        // Custom validation logic
        return true;
    }

    private function saveFile(UploadedFile $file): string
    {
        // Save file to storage
        $uploadDir = '/var/www/storage/uploads';
        $filename = uniqid() . '_' . $file->getClientFilename();
        $file->moveTo($uploadDir . '/' . $filename);
        return $filename;
    }
}

Upload Endpoint

Route: POST /live-component/{id}/upload

Request Format:

Content-Type: multipart/form-data

file: <binary file data>
state: <JSON string of current component state>
params: <JSON string of additional parameters>
_csrf_token: <CSRF token>

Response Format:

{
  "success": true,
  "html": "<updated component HTML>",
  "state": {
    "id": "document-upload:abc123",
    "component": "DocumentUploadComponent",
    "data": {
      "uploaded_files": [...]
    }
  },
  "events": [...],
  "file": {
    "name": "document.pdf",
    "size": 1048576,
    "type": "application/pdf"
  }
}

Error Response:

{
  "success": false,
  "error": "File validation failed",
  "errors": [
    "File type application/octet-stream is not allowed"
  ]
}

Frontend Implementation

Quick Start: FileUploadWidget

Der einfachste Weg, File Uploads zu implementieren, ist die Verwendung des FileUploadWidget:

<!-- In your LiveComponent template -->
<div data-live-component="document-upload:abc123">
    <!-- Upload Widget Container -->
    <div id="upload-widget"></div>
</div>

<script type="module">
import { FileUploadWidget } from '/resources/js/modules/livecomponent/FileUploadWidget.js';

// Initialize widget
const widget = new FileUploadWidget(
    document.getElementById('upload-widget'),
    {
        maxFileSize: 10 * 1024 * 1024, // 10MB
        allowedMimeTypes: ['application/pdf', 'image/jpeg', 'image/png'],
        allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],
        maxFiles: 5,
        multiple: true,
        autoUpload: true,
        showPreviews: true,
        showProgress: true
    }
);
</script>

Advanced: ComponentFileUploader

Für vollständige Kontrolle verwenden Sie direkt ComponentFileUploader:

import { ComponentFileUploader } from '/resources/js/modules/livecomponent/ComponentFileUploader.js';

const componentElement = document.querySelector('[data-live-id="document-upload:abc123"]');
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');

const uploader = new ComponentFileUploader(componentElement, {
    // Configuration
    maxFileSize: 10 * 1024 * 1024,
    allowedMimeTypes: ['application/pdf', 'image/jpeg'],
    maxFiles: 10,
    autoUpload: true,
    multiple: true,
    maxConcurrentUploads: 2,

    // UI Elements
    dropZone: dropZone,
    fileInput: fileInput,

    // Callbacks
    onFileAdded: ({ fileId, file, progress }) => {
        console.log('File added:', file.name);
        // Update UI
    },

    onUploadStart: ({ fileId, file }) => {
        console.log('Upload started:', file.name);
    },

    onUploadProgress: ({ fileId, percentage, uploadSpeed, remainingTime }) => {
        console.log(`Upload progress: ${percentage}%`);
        // Update progress bar
    },

    onUploadComplete: ({ fileId, file, response }) => {
        console.log('Upload complete:', file.name);
        // Handle success
    },

    onUploadError: ({ fileId, file, error }) => {
        console.error('Upload failed:', error);
        // Handle error
    },

    onAllUploadsComplete: ({ totalFiles, successCount, errorCount }) => {
        console.log(`All uploads complete: ${successCount}/${totalFiles} succeeded`);
    }
});

// Programmatically add files
uploader.addFiles([file1, file2, file3]);

// Start uploads (if autoUpload is false)
uploader.uploadAll();

// Cancel all uploads
uploader.cancelAll();

// Get statistics
const stats = uploader.getStats();
console.log(`Progress: ${stats.overallProgress}%`);

Custom Drag & Drop

import { DragDropZone } from '/resources/js/modules/livecomponent/ComponentFileUploader.js';

const dropZone = new DragDropZone(document.getElementById('drop-area'), {
    onFilesDropped: (files) => {
        console.log('Files dropped:', files);
        uploader.addFiles(files);
    },
    onDragEnter: () => {
        console.log('Drag enter');
    },
    onDragLeave: () => {
        console.log('Drag leave');
    }
});

Client-Side Validation

import { FileValidator } from '/resources/js/modules/livecomponent/ComponentFileUploader.js';

const validator = new FileValidator({
    maxFileSize: 5 * 1024 * 1024, // 5MB
    allowedMimeTypes: ['image/jpeg', 'image/png'],
    allowedExtensions: ['jpg', 'jpeg', 'png'],
    minFileSize: 1024 // 1KB minimum
});

// Validate single file
const errors = validator.validate(file);
if (errors.length > 0) {
    console.error('Validation errors:', errors);
}

// Quick validation
if (!validator.isValid(file)) {
    console.error('File is not valid');
}

Configuration Options

ComponentFileUploader Options

{
    // File Constraints
    maxFileSize: 10 * 1024 * 1024,      // Maximum file size in bytes (default: 10MB)
    allowedMimeTypes: [],                // Array of allowed MIME types (empty = allow all)
    allowedExtensions: [],               // Array of allowed file extensions (empty = allow all)
    maxFiles: 10,                        // Maximum number of files (default: 10)

    // Upload Behavior
    autoUpload: true,                    // Auto-upload on file add (default: true)
    multiple: true,                      // Allow multiple files (default: true)
    maxConcurrentUploads: 2,             // Max concurrent uploads (default: 2)

    // Endpoints
    endpoint: '/live-component/{id}/upload', // Upload endpoint (default: auto-detected)

    // UI Elements (optional)
    dropZone: HTMLElement,               // Drop zone element
    fileInput: HTMLElement,              // File input element

    // Callbacks
    onFileAdded: (data) => {},          // Called when file is added
    onFileRemoved: (data) => {},        // Called when file is removed
    onUploadStart: (data) => {},        // Called when upload starts
    onUploadProgress: (data) => {},     // Called during upload
    onUploadComplete: (data) => {},     // Called on upload success
    onUploadError: (data) => {},        // Called on upload error
    onAllUploadsComplete: (data) => {}  // Called when all uploads are done
}

FileUploadWidget Options

{
    // Inherits all ComponentFileUploader options, plus:

    // UI Configuration
    showPreviews: true,                  // Show image previews (default: true)
    showProgress: true,                  // Show progress bars (default: true)
    showFileList: true,                  // Show file list (default: true)

    // Text Configuration
    dropZoneText: 'Drag & drop files here or click to browse',
    browseButtonText: 'Browse Files',
    uploadButtonText: 'Upload All'
}

Callback Data Structures

onFileAdded

{
    fileId: 'unique-file-id',
    file: File,                          // Native File object
    progress: {
        fileId: 'unique-file-id',
        fileName: 'document.pdf',
        fileSize: 1048576,
        uploadedBytes: 0,
        percentage: 0,
        status: 'pending',
        error: null,
        uploadSpeed: 0,
        remainingTime: 0
    }
}

onUploadProgress

{
    fileId: 'unique-file-id',
    fileName: 'document.pdf',
    fileSize: 1048576,
    uploadedBytes: 524288,               // Bytes uploaded so far
    percentage: 50,                      // Upload percentage (0-100)
    status: 'uploading',
    uploadSpeed: 1048576,                // Bytes per second
    remainingTime: 0.5                   // Seconds remaining
}

onUploadComplete

{
    fileId: 'unique-file-id',
    file: File,
    response: {
        success: true,
        html: '<updated component HTML>',
        state: {...},
        events: [...],
        file: {
            name: 'document.pdf',
            size: 1048576,
            type: 'application/pdf'
        }
    }
}

onUploadError

{
    fileId: 'unique-file-id',
    file: File,
    error: 'File validation failed'
}

Use Cases & Examples

Basic Image Upload

final class ProfileImageUpload extends AbstractLiveComponent implements SupportsFileUpload
{
    private ?string $profileImage = null;

    public function handleUpload(UploadedFile $file, ?ComponentEventDispatcher $events = null): ComponentData
    {
        // Save image
        $filename = $this->imageService->save($file, 'profiles');

        // Update state
        $this->profileImage = $filename;

        return ComponentData::fromArray([
            'profile_image' => $this->profileImage
        ]);
    }

    public function validateUpload(UploadedFile $file): array
    {
        $errors = [];

        // Only allow images
        if (!str_starts_with($file->getClientMediaType(), 'image/')) {
            $errors[] = 'Only images are allowed';
        }

        // Max 2MB
        if ($file->getSize() > 2 * 1024 * 1024) {
            $errors[] = 'Image must be smaller than 2MB';
        }

        return $errors;
    }

    public function getAllowedMimeTypes(): array
    {
        return ['image/jpeg', 'image/png', 'image/webp'];
    }

    public function getMaxFileSize(): int
    {
        return 2 * 1024 * 1024;
    }
}

Multi-Document Upload with Progress

import { ComponentFileUploader } from './ComponentFileUploader.js';

const uploader = new ComponentFileUploader(componentElement, {
    maxFiles: 20,
    maxFileSize: 50 * 1024 * 1024, // 50MB
    allowedMimeTypes: [
        'application/pdf',
        'application/msword',
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
    ],

    onUploadProgress: ({ fileId, percentage, uploadSpeed, remainingTime }) => {
        // Update progress UI
        const progressBar = document.querySelector(`[data-file-id="${fileId}"] .progress-bar`);
        progressBar.style.width = `${percentage}%`;

        const progressText = document.querySelector(`[data-file-id="${fileId}"] .progress-text`);
        progressText.textContent = `${percentage}% - ${formatSpeed(uploadSpeed)} - ${formatTime(remainingTime)} remaining`;
    },

    onAllUploadsComplete: ({ successCount, errorCount }) => {
        if (errorCount === 0) {
            alert(`All ${successCount} files uploaded successfully!`);
        } else {
            alert(`${successCount} files uploaded, ${errorCount} failed`);
        }
    }
});

Custom Validation Messages

const validator = new FileValidator({
    maxFileSize: 10 * 1024 * 1024,
    allowedMimeTypes: ['application/pdf']
});

const errors = validator.validate(file);

// Translate errors
const translatedErrors = errors.map(error => {
    if (error.includes('File size')) {
        return 'Dateigröße überschreitet das Maximum';
    } else if (error.includes('File type')) {
        return 'Nur PDF-Dateien sind erlaubt';
    }
    return error;
});

if (translatedErrors.length > 0) {
    showErrorMessages(translatedErrors);
}

Security Considerations

CSRF Protection

Das System verwendet automatisch CSRF-Tokens:

// CSRF token wird automatisch aus Component-Element gelesen
const csrfToken = componentElement.dataset.csrfToken;

// Und in jedem Upload-Request gesendet
xhr.setRequestHeader('X-CSRF-Form-ID', csrfTokens.form_id);
xhr.setRequestHeader('X-CSRF-Token', csrfTokens.token);

File Validation

Backend Validation ist PFLICHT:

public function validateUpload(UploadedFile $file): array
{
    $errors = [];

    // 1. MIME type validation
    $allowedTypes = $this->getAllowedMimeTypes();
    if (!in_array($file->getClientMediaType(), $allowedTypes)) {
        $errors[] = 'Invalid file type';
    }

    // 2. File extension validation
    $extension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
    if (!in_array(strtolower($extension), ['pdf', 'jpg', 'png'])) {
        $errors[] = 'Invalid file extension';
    }

    // 3. File size validation
    if ($file->getSize() > $this->getMaxFileSize()) {
        $errors[] = 'File too large';
    }

    // 4. File content validation (check magic bytes)
    $finfo = new \finfo(FILEINFO_MIME_TYPE);
    $actualMimeType = $finfo->file($file->getStream()->getMetadata('uri'));
    if ($actualMimeType !== $file->getClientMediaType()) {
        $errors[] = 'File content does not match declared type';
    }

    return $errors;
}

Secure File Storage

private function saveFile(UploadedFile $file): string
{
    // 1. Generate secure filename (no user input)
    $filename = bin2hex(random_bytes(16)) . '.' . $this->getSecureExtension($file);

    // 2. Store outside web root
    $uploadDir = '/var/www/storage/uploads';

    // 3. Set restrictive permissions
    $file->moveTo($uploadDir . '/' . $filename);
    chmod($uploadDir . '/' . $filename, 0600);

    return $filename;
}

private function getSecureExtension(UploadedFile $file): string
{
    // Use MIME type to determine extension, not user-provided extension
    return match ($file->getClientMediaType()) {
        'application/pdf' => 'pdf',
        'image/jpeg' => 'jpg',
        'image/png' => 'png',
        default => 'bin'
    };
}

Performance Optimization

Concurrent Uploads

const uploader = new ComponentFileUploader(componentElement, {
    maxConcurrentUploads: 3, // Upload 3 files simultaneously
    autoUpload: true
});

Chunked Uploads (Large Files)

Für große Dateien sollten Chunked Uploads implementiert werden:

// TODO: Chunked upload support (geplant für v2.0)
// Aktuell empfohlen: maxFileSize Limit für große Dateien

Progress Throttling

let lastProgressUpdate = 0;

onUploadProgress: ({ fileId, percentage }) => {
    const now = Date.now();

    // Only update UI every 100ms
    if (now - lastProgressUpdate > 100) {
        updateProgressBar(fileId, percentage);
        lastProgressUpdate = now;
    }
}

Troubleshooting

Problem: Upload schlägt fehl mit 413 (Request Entity Too Large)

Lösung: Erhöhe PHP Upload Limits:

; php.ini
upload_max_filesize = 50M
post_max_size = 50M

Problem: CSRF Token fehlt

Lösung: Stelle sicher, dass Component CSRF Token hat:

<div data-live-component="upload:123" data-csrf-token="<?= $csrfToken ?>">
    ...
</div>

Problem: Uploads sind langsam

Lösungen:

  1. Erhöhe maxConcurrentUploads
  2. Implementiere Chunked Uploads
  3. Komprimiere Dateien vor Upload (z.B. Bilder)
  4. Verwende CDN für statische Assets

Problem: Browser hängt bei vielen Dateien

Lösung: Limitiere maxFiles und zeige Queue-Status:

const uploader = new ComponentFileUploader(componentElement, {
    maxFiles: 20, // Limit gleichzeitig ausgewählte Dateien
    maxConcurrentUploads: 2 // Aber nur 2 gleichzeitig hochladen
});

Problem: Drag & Drop funktioniert nicht

Lösung: Prüfe Event Listener Setup:

// Stelle sicher, dass Drop Zone Element existiert
const dropZone = document.getElementById('drop-zone');
if (!dropZone) {
    console.error('Drop zone element not found');
}

// Prüfe CSS cursor
dropZone.style.cursor = 'pointer';

Best Practices

1. Immer Backend-Validierung

// ❌ Niemals nur Frontend-Validierung verlassen
// ✅ Immer Backend validateUpload() implementieren

2. Sichere Dateinamen

// ❌ User-provided filenames verwenden
$file->moveTo('/uploads/' . $file->getClientFilename());

// ✅ Sichere, generierte Dateinamen
$file->moveTo('/uploads/' . bin2hex(random_bytes(16)) . '.pdf');

3. Progress Feedback

// ✅ Immer Progress anzeigen für bessere UX
onUploadProgress: ({ percentage }) => {
    updateProgressBar(percentage);
    updateStatusText(`Uploading: ${percentage}%`);
}

4. Error Handling

// ✅ User-freundliche Fehlermeldungen
onUploadError: ({ error }) => {
    const userMessage = translateError(error);
    showNotification(userMessage, 'error');
    logError(error); // Log für Debugging
}

5. File Size Limits

// ✅ Realistische Limits setzen
public function getMaxFileSize(): int
{
    // 10MB für Dokumente
    return 10 * 1024 * 1024;

    // 5MB für Bilder
    // return 5 * 1024 * 1024;
}

Testing

Unit Tests (Frontend)

import { FileValidator } from './ComponentFileUploader.js';

describe('FileValidator', () => {
    it('validates file size', () => {
        const validator = new FileValidator({
            maxFileSize: 1024 * 1024 // 1MB
        });

        const file = new File(['x'.repeat(2 * 1024 * 1024)], 'large.pdf');
        const errors = validator.validate(file);

        expect(errors.length).toBeGreaterThan(0);
        expect(errors[0]).toContain('File size');
    });

    it('validates MIME type', () => {
        const validator = new FileValidator({
            allowedMimeTypes: ['application/pdf']
        });

        const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
        const errors = validator.validate(file);

        expect(errors.length).toBeGreaterThan(0);
        expect(errors[0]).toContain('not allowed');
    });
});

Integration Tests (Backend)

it('handles file upload successfully', function () {
    $component = new DocumentUploadComponent();

    $file = createUploadedFile('test.pdf', 'application/pdf', 1024);

    $result = $component->handleUpload($file);

    expect($result->toArray())->toHaveKey('uploaded_files');
});

it('validates file type', function () {
    $component = new DocumentUploadComponent();

    $file = createUploadedFile('test.exe', 'application/octet-stream', 1024);

    $errors = $component->validateUpload($file);

    expect($errors)->not->toBeEmpty();
});

Zusammenfassung

Das File Upload System bietet:

  • Einfache Integration - FileUploadWidget für schnellen Start
  • Flexible API - ComponentFileUploader für vollständige Kontrolle
  • Drag & Drop - Intuitive Dateiauswahl
  • Multi-File Support - Mehrere Dateien gleichzeitig
  • Progress Tracking - Echtzeit-Fortschrittsanzeige
  • Validation - Client & Server-Side
  • Security - CSRF Protection, sichere Dateinamen
  • Performance - Concurrent Uploads, Queue Management
  • Responsive Design - Mobile-optimiert
  • Dark Mode - Automatische Theme-Unterstützung

Framework Integration:

  • Value Objects für Type Safety
  • Event System Integration
  • Component State Management
  • CSRF Token Handling
  • Automatic HTML Refresh