Beide Module wurden aus einem anderen Kundenprojekt kopiert und bereinigt: Knowledgecenter: - Bild-Cache (1.985 Dateien) geleert, Ordnerstruktur mit .gitkeep behalten - Files-Gallery-Lizenzschlüssel entfernt (config.php) - Kundenspezifische Kategorien gelöscht: Teil I/II/III QM-Struktur, nummerierte QM-Kapitel, Gremien (Präsidium/Finanzausschuss/Landesausschuss), Sitzungsservice-Serie, Protokoll-Abteilungen, Testeinträge - 56 generische Kategorien bleiben (Downloads, Onboarding, Personalthemen …) Tasks: - Alle Betriebsdaten gelöscht (114 Tasks, 914 Submissions, 579 Assignments) - Task-Definitionen und Verlinkungen geleert - Kategoriestruktur bleibt (Allgemein, IT, Personal, Buchhaltung …) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
241 lines
8.6 KiB
PHP
241 lines
8.6 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Nimmt hochgeladene Dateien für Formular-Felder vom Typ "file" entgegen,
|
|
* validiert sie gegen TaskUploadPolicy und legt sie unter
|
|
* userdata/tasks/submissions/{Y}/{m}/ ab.
|
|
*
|
|
* Gibt pro Datei Metadaten zurück (Originalname, relativer Pfad, MIME, Größe),
|
|
* die im task_submission.payload_json gespeichert werden. Der relative Pfad
|
|
* (z. B. "tasks/submissions/2026/06/tsk_….pdf") ist die einzige Referenz, die
|
|
* der Download-Endpoint später auflöst.
|
|
*/
|
|
class TaskUploadStorage
|
|
{
|
|
/**
|
|
* Wandelt $_FILES['files'] (verschachtelt nach field_key) in eine
|
|
* pro-Feld-Struktur um:
|
|
* ['field_key' => [ ['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<string,mixed> $rawFiles Der $_FILES['files']-Eintrag
|
|
* @return array<string,array<int,array<string,mixed>>>
|
|
*/
|
|
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<int,array<string,mixed>> $fieldFiles Normalisierte Datei-Einträge
|
|
* @return array<int,array<string,mixed>> 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();
|
|
}
|
|
}
|