[ ['name'=>,'type'=>,'tmp_name'=>,'error'=>,'size'=>], … ]] * * PHP liefert für name="files[field_key][]" die Form * $_FILES['files']['name'][field_key][i] usw. * * @param array $rawFiles Der $_FILES['files']-Eintrag * @return array>> */ public static function normalizeFilesInput(array $rawFiles): array { $names = $rawFiles['name'] ?? null; if (!is_array($names)) { return []; } $result = []; foreach ($names as $fieldKey => $fieldNames) { $fieldKey = (string) $fieldKey; if (!is_array($fieldNames)) { continue; } $entries = []; foreach (array_keys($fieldNames) as $i) { $entries[] = [ 'name' => (string) ($rawFiles['name'][$fieldKey][$i] ?? ''), 'type' => (string) ($rawFiles['type'][$fieldKey][$i] ?? ''), 'tmp_name' => (string) ($rawFiles['tmp_name'][$fieldKey][$i] ?? ''), 'error' => (int) ($rawFiles['error'][$fieldKey][$i] ?? UPLOAD_ERR_NO_FILE), 'size' => (int) ($rawFiles['size'][$fieldKey][$i] ?? 0), ]; } $result[$fieldKey] = $entries; } return $result; } /** * Validiert und speichert die Dateien eines einzelnen file-Feldes. * * @param array> $fieldFiles Normalisierte Datei-Einträge * @return array> Metadaten je gespeicherter Datei * @throws RuntimeException bei Validierungs- oder Speicherfehlern */ public static function storeFiles(int $assignmentId, int $methodId, string $fieldKey, array $fieldFiles): array { // Leere Slots (kein Upload) herausfiltern. $uploads = []; foreach ($fieldFiles as $file) { $error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE); if ($error === UPLOAD_ERR_NO_FILE) { continue; } if ($error !== UPLOAD_ERR_OK) { throw new RuntimeException('Upload fehlgeschlagen (Fehlercode ' . $error . ').'); } $uploads[] = $file; } if ($uploads === []) { return []; } if (count($uploads) > TaskUploadPolicy::MAX_FILE_COUNT) { throw new RuntimeException('Maximal ' . TaskUploadPolicy::MAX_FILE_COUNT . ' Dateien pro Feld erlaubt.'); } $totalBytes = 0; foreach ($uploads as $file) { $totalBytes += (int) ($file['size'] ?? 0); } if ($totalBytes > TaskUploadPolicy::MAX_TOTAL_BYTES) { throw new RuntimeException( 'Die Dateien überschreiten das Gesamtlimit von ' . TaskUploadPolicy::formatBytes(TaskUploadPolicy::MAX_TOTAL_BYTES) . '.' ); } $targetDir = self::targetDir(); if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) { throw new RuntimeException('Zielverzeichnis für Uploads konnte nicht angelegt werden.'); } $finfo = function_exists('finfo_open') ? finfo_open(FILEINFO_MIME_TYPE) : false; $stored = []; try { foreach ($uploads as $file) { $originalName = (string) ($file['name'] ?? ''); $tmpName = (string) ($file['tmp_name'] ?? ''); if ($tmpName === '' || !is_uploaded_file($tmpName)) { throw new RuntimeException('Ungültiger Upload für "' . $originalName . '".'); } if (!TaskUploadPolicy::isExtensionAllowed($originalName)) { throw new RuntimeException( 'Dateityp nicht erlaubt: "' . $originalName . '". Erlaubt sind ' . TaskUploadPolicy::HUMAN_READABLE_FORMATS_SHORT . '.' ); } $detectedMime = $finfo !== false ? (finfo_file($finfo, $tmpName) ?: null) : null; if (!TaskUploadPolicy::isMimeAllowedIfDetected($detectedMime)) { throw new RuntimeException('Dateiinhalt nicht erlaubt für "' . $originalName . '".'); } $safeName = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $originalName) ?? ''; $safeName = trim($safeName, '_'); if ($safeName === '') { $safeName = 'datei'; } $storedName = uniqid('tsk_', true) . '__' . $safeName; $absolutePath = $targetDir . '/' . $storedName; if (!move_uploaded_file($tmpName, $absolutePath)) { throw new RuntimeException('Datei "' . $originalName . '" konnte nicht gespeichert werden.'); } @chmod($absolutePath, 0664); $stored[] = [ 'file_name' => $originalName !== '' ? $originalName : $safeName, 'file_path' => self::relativeSubdir() . '/' . $storedName, // MIME deterministisch aus der (erlaubten) Endung — NIE der // Client-MIME. $detectedMime dient nur der Validierung oben. 'mime_type' => TaskUploadPolicy::mimeForFilename($originalName), 'file_size' => (int) ($file['size'] ?? 0), ]; } } catch (Throwable $throwable) { // Bereits verschobene Dateien dieses Feldes wieder entfernen, damit // bei einem Teil-Fehler keine verwaisten Dateien zurückbleiben. foreach ($stored as $meta) { $path = self::absolutePath((string) $meta['file_path']); if ($path !== null && is_file($path)) { @unlink($path); } } if ($finfo !== false) { finfo_close($finfo); } throw $throwable; } if ($finfo !== false) { finfo_close($finfo); } return $stored; } /** * Löscht die zu den relativen Pfaden gehörenden Dateien von der Platte. * Ungültige Pfade (Traversal, außerhalb des Submissions-Ordners) und bereits * fehlende Dateien werden still übersprungen. Gibt die Anzahl tatsächlich * gelöschter Dateien zurück. * * @param string[] $relativePaths */ public static function deleteFiles(array $relativePaths): int { $deleted = 0; foreach (array_unique($relativePaths) as $relativePath) { $absolutePath = self::absolutePath((string) $relativePath); if ($absolutePath !== null && is_file($absolutePath) && @unlink($absolutePath)) { $deleted++; } } return $deleted; } /** * Löst einen gespeicherten relativen Pfad sicher in einen Absolutpfad auf. * Gibt null zurück, wenn der Pfad außerhalb des Submissions-Ordners liegt * oder Traversal enthält. */ public static function absolutePath(string $relativePath): ?string { $relativePath = ltrim(str_replace('\\', '/', trim($relativePath)), '/'); if ($relativePath === '' || strpos($relativePath, '..') !== false) { return null; } if (strpos($relativePath, 'tasks/submissions/') !== 0) { return null; } return self::userdataDir() . '/' . $relativePath; } private static function userdataDir(): string { if (!defined('TASK_CERT_USERDATA_DIR')) { throw new RuntimeException('TASK_CERT_USERDATA_DIR ist nicht definiert.'); } return rtrim((string) TASK_CERT_USERDATA_DIR, '/'); } private static function relativeSubdir(): string { $now = new DateTimeImmutable(); return 'tasks/submissions/' . $now->format('Y') . '/' . $now->format('m'); } private static function targetDir(): string { return self::userdataDir() . '/' . self::relativeSubdir(); } }