Module tasks + knowledgecenter_update aus Kundenprojekt übernehmen (bereinigt)
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>
This commit is contained in:
240
module/tasks/lib/TaskUploadStorage.php
Normal file
240
module/tasks/lib/TaskUploadStorage.php
Normal file
@@ -0,0 +1,240 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user