Files
awo-hamburg-intranet/module/tasks/lib/TaskUploadStorage.php
Moritz Weinmann 743476f64a 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>
2026-06-22 09:20:02 +02:00

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();
}
}