Files
Mitgliederverwaltung/lib/Service/BundleImportService.php
T
shahondin1624 d48e2b7d9d feat: v0.2.0 — backup system, self-update with Ed25519 signing, column pickers, import fixes
Major features:
- Full backup & restore system (JSON snapshots of all 20 tables + settings)
  - Web UI, REST API, OCC CLI commands, scheduled background job
- Self-update from Gitea releases with Ed25519 signature verification
- Configurable column visibility on all data tables (persisted via localStorage)

Fixes:
- NC admin group fallback for PermissionService (IGroupManager)
- Bundle import inline error correction (editable error rows)

New files: BackupService, BackupSettingsService, BackupController, BackupJob,
SelfUpdateService, 4 OCC commands, ColumnPicker component, Backup.vue,
Ed25519 signing scripts, signature verification tests (18 tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:11:51 +02:00

583 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service;
use Psr\Log\LoggerInterface;
/**
* Service for importing ZIP bundles containing multiple entity CSVs.
*
* Handles:
* - ZIP extraction and entity type detection by filename or header analysis
* - Topological sort of entity dependencies for correct import order
* - ID remapping across entities (old ID -> new ID)
* - Per-entity import via EntityImportService
*
* Part of Issue #151.
*/
class BundleImportService {
private EntityImportService $entityImportService;
private EntityExportService $exportService;
private LoggerInterface $logger;
/**
* Entity dependency graph for topological sort.
* Key = entity type, Value = array of entity types it depends on.
*/
private const DEPENDENCY_GRAPH = [
'stufen' => [],
'familien' => [],
'mitglieder' => ['stufen', 'familien'],
'adressen' => ['mitglieder'],
'telefonnummern' => ['mitglieder'],
'emails' => ['mitglieder'],
'stufenverlauf' => ['mitglieder', 'stufen'],
'beitragsregeln' => [],
'beitraege' => ['mitglieder'],
'lager' => [],
'lagerteilnehmer' => ['lager', 'mitglieder'],
'verletzungen' => ['mitglieder', 'lager'],
'gespeicherte_abfragen' => [],
];
/**
* Filename -> entity type mapping (from EntityExportService::ENTITY_TYPES).
*/
private array $filenameMap = [];
public function __construct(
EntityImportService $entityImportService,
EntityExportService $exportService,
LoggerInterface $logger
) {
$this->entityImportService = $entityImportService;
$this->exportService = $exportService;
$this->logger = $logger;
// Build filename lookup
foreach (EntityExportService::ENTITY_TYPES as $type => $meta) {
$this->filenameMap[strtolower($meta['filename'])] = $type;
}
}
/**
* Analyze a ZIP file: extract CSVs, detect entity types, determine import order.
*
* @param string $zipContent Raw ZIP binary content
* @return array{entities: array[], importOrder: string[], unknown: string[]}
*/
public function analyzeBundle(string $zipContent): array {
$csvFiles = $this->extractCsvFiles($zipContent);
$entities = [];
$unknown = [];
foreach ($csvFiles as $filename => $content) {
$type = $this->detectTypeByFilename($filename);
if ($type === null) {
// Fall back to header-based detection
try {
$parsed = $this->entityImportService->parseFile($content, ';', 'UTF-8');
$detection = $this->entityImportService->detectEntityType($parsed['columns']);
$type = $detection['type'];
} catch (\Exception $e) {
// Cannot parse this file
$type = null;
}
}
if ($type !== null) {
// Count rows
try {
$parsed = $this->entityImportService->parseFile($content, ';', 'UTF-8');
$rowCount = $parsed['totalRows'];
} catch (\Exception $e) {
$rowCount = 0;
}
$entities[] = [
'filename' => $filename,
'type' => $type,
'label' => EntityExportService::ENTITY_TYPES[$type]['label'] ?? $type,
'rowCount' => $rowCount,
];
} else {
$unknown[] = $filename;
}
}
// Determine import order based on dependency graph
$detectedTypes = array_map(fn($e) => $e['type'], $entities);
$importOrder = $this->topologicalSort($detectedTypes);
// Sort entities array by import order
$orderIndex = array_flip($importOrder);
usort($entities, function ($a, $b) use ($orderIndex) {
$ia = $orderIndex[$a['type']] ?? PHP_INT_MAX;
$ib = $orderIndex[$b['type']] ?? PHP_INT_MAX;
return $ia <=> $ib;
});
return [
'entities' => $entities,
'importOrder' => $importOrder,
'unknown' => $unknown,
];
}
/**
* Execute bundle import: import all entities in dependency order with ID remapping.
*
* @param string $zipContent Raw ZIP binary content
* @param array $entityOverrides Optional type overrides: {filename: type}
* @return array{results: array[], idMap: array}
*/
public function executeBundle(string $zipContent, array $entityOverrides = [], array $corrections = []): array {
$csvFiles = $this->extractCsvFiles($zipContent);
$analysis = $this->analyzeBundle($zipContent);
// Apply overrides
foreach ($analysis['entities'] as &$entity) {
if (isset($entityOverrides[$entity['filename']])) {
$entity['type'] = $entityOverrides[$entity['filename']];
}
}
// ID remapping table: type => {oldId => newId}
$idMap = [];
$results = [];
foreach ($analysis['importOrder'] as $type) {
// Find the entity entry for this type
$entityEntry = null;
foreach ($analysis['entities'] as $e) {
if ($e['type'] === $type) {
$entityEntry = $e;
break;
}
}
if ($entityEntry === null) {
continue; // This type is not in the ZIP
}
$filename = $entityEntry['filename'];
$content = $csvFiles[$filename] ?? null;
if ($content === null) {
$results[] = [
'type' => $type,
'label' => $entityEntry['label'],
'status' => 'error',
'error' => 'CSV-Datei nicht gefunden',
'created' => 0,
'skipped' => 0,
'errors' => 1,
];
continue;
}
try {
// Apply ID remapping to the CSV content before import
$remappedContent = $this->applyIdRemapping($type, $content, $idMap);
// Parse, auto-map, and execute
$parsed = $this->entityImportService->parseFile($remappedContent, ';', 'UTF-8');
$mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']);
$entityCorrections = $corrections[$type] ?? [];
$result = $this->entityImportService->execute(
$type,
$remappedContent,
$mapping,
';',
'UTF-8',
false,
$entityCorrections
);
// Collect ID mappings from created entities
$this->collectIdMappings($type, $content, $result, $idMap);
$results[] = [
'type' => $type,
'label' => $entityEntry['label'],
'status' => 'success',
'created' => $result['created'],
'skipped' => $result['skipped'],
'errors' => $result['errors'],
'details' => $result['details'],
];
} catch (\Exception $e) {
$this->logger->error('Bundle import failed for entity type', [
'type' => $type,
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
$results[] = [
'type' => $type,
'label' => $entityEntry['label'],
'status' => 'error',
'error' => $e->getMessage(),
'created' => 0,
'skipped' => 0,
'errors' => 1,
];
}
}
return [
'results' => $results,
'idMap' => $idMap,
];
}
/**
* Preview bundle import (dry-run for all entities).
*
* @param string $zipContent Raw ZIP binary content
* @param array $corrections Per-entity corrections: { entityType: { rowNum: { fieldKey: value } } }
* @return array{results: array[]}
*/
public function previewBundle(string $zipContent, array $corrections = []): array {
$csvFiles = $this->extractCsvFiles($zipContent);
$analysis = $this->analyzeBundle($zipContent);
$results = [];
foreach ($analysis['importOrder'] as $type) {
$entityEntry = null;
foreach ($analysis['entities'] as $e) {
if ($e['type'] === $type) {
$entityEntry = $e;
break;
}
}
if ($entityEntry === null) continue;
$filename = $entityEntry['filename'];
$content = $csvFiles[$filename] ?? null;
if ($content === null) continue;
try {
$parsed = $this->entityImportService->parseFile($content, ';', 'UTF-8');
$mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']);
$entityCorrections = $corrections[$type] ?? [];
$preview = $this->entityImportService->preview(
$type,
$content,
$mapping,
';',
'UTF-8',
false,
$entityCorrections
);
// Include target fields so the frontend can render editable inputs
$schema = $this->entityImportService->getImportSchema($type);
$results[] = [
'type' => $type,
'label' => $entityEntry['label'],
'summary' => $preview['summary'],
'fkWarnings' => $preview['fkWarnings'],
'errorCount' => count($preview['errors']),
'errors' => array_slice($preview['errors'], 0, 10),
'targetFields' => $schema['targetFields'],
];
} catch (\Exception $e) {
$results[] = [
'type' => $type,
'label' => $entityEntry['label'],
'summary' => ['total' => 0, 'toCreate' => 0, 'duplicates' => 0, 'errors' => 1],
'fkWarnings' => [],
'errorCount' => 1,
'errors' => [['_rowIndex' => 0, '_errors' => [$e->getMessage()]]],
'targetFields' => [],
];
}
}
return ['results' => $results];
}
// ── ZIP extraction ─────────────────────────────────────────────
/**
* Extract all CSV files from a ZIP archive.
*
* @param string $zipContent Raw ZIP binary
* @return array<string, string> Filename => CSV content
*/
private function extractCsvFiles(string $zipContent): array {
if (!class_exists(\ZipArchive::class)) {
throw new \RuntimeException('ZipArchive ist nicht verfuegbar.');
}
$tmpFile = tempnam(sys_get_temp_dir(), 'mv_import_');
if ($tmpFile === false) {
throw new \RuntimeException('Temporaere Datei konnte nicht erstellt werden.');
}
// Restrict permissions to owner-only before writing content
chmod($tmpFile, 0600);
try {
file_put_contents($tmpFile, $zipContent);
$zip = new \ZipArchive();
$result = $zip->open($tmpFile);
if ($result !== true) {
throw new ValidationException('ZIP-Datei konnte nicht geoeffnet werden. Fehlercode: ' . $result);
}
$csvFiles = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false) {
continue;
}
// Reject path traversal: entries containing '..' or absolute paths
if (str_contains($name, '..') || str_starts_with($name, '/') || str_starts_with($name, '\\')) {
$this->logger->warning('Skipping ZIP entry with suspicious path', [
'entry' => $name,
'app' => 'mitgliederverwaltung',
]);
continue;
}
$baseName = basename($name);
// Only include CSV files (skip directories, hidden files)
if (
!str_starts_with($baseName, '.') &&
!str_starts_with($baseName, '__') &&
(str_ends_with(strtolower($baseName), '.csv') || str_ends_with(strtolower($baseName), '.txt'))
) {
$content = $zip->getFromIndex($i);
if ($content !== false) {
$csvFiles[$baseName] = $content;
}
}
}
$zip->close();
if (empty($csvFiles)) {
throw new ValidationException('ZIP-Archiv enthaelt keine CSV-Dateien.');
}
return $csvFiles;
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
// ── Entity type detection ──────────────────────────────────────
/**
* Detect entity type by filename matching against known export filenames.
*/
private function detectTypeByFilename(string $filename): ?string {
return $this->filenameMap[strtolower($filename)] ?? null;
}
// ── Topological sort ───────────────────────────────────────────
/**
* Topological sort of entity types based on dependency graph.
* Only includes types present in the $types array.
*
* @param string[] $types Entity types to sort
* @return string[] Sorted import order
*/
private function topologicalSort(array $types): array {
$typeSet = array_flip($types);
$sorted = [];
$visited = [];
foreach ($types as $type) {
$this->visit($type, $typeSet, $visited, $sorted);
}
return $sorted;
}
private function visit(string $type, array $typeSet, array &$visited, array &$sorted): void {
if (isset($visited[$type])) {
return;
}
$visited[$type] = true;
$deps = self::DEPENDENCY_GRAPH[$type] ?? [];
foreach ($deps as $dep) {
if (isset($typeSet[$dep])) {
$this->visit($dep, $typeSet, $visited, $sorted);
}
}
$sorted[] = $type;
}
// ── ID remapping ───────────────────────────────────────────────
/**
* Apply ID remapping to CSV content based on previously imported entities.
*
* Replaces old IDs in FK columns with new IDs from the idMap.
*/
private function applyIdRemapping(string $type, string $content, array $idMap): string {
// Determine which columns need remapping for this type
$fkColumns = $this->getFkColumnsForRemapping($type);
if (empty($fkColumns)) {
return $content;
}
// Parse the CSV
$lines = explode("\n", str_replace("\r\n", "\n", $content));
if (count($lines) < 2) {
return $content;
}
// Check for BOM
$bom = '';
if (substr($lines[0], 0, 3) === "\xEF\xBB\xBF") {
$bom = "\xEF\xBB\xBF";
$lines[0] = substr($lines[0], 3);
}
// Parse header to find column indices
$headers = str_getcsv($lines[0], ';');
$headers = array_map('strtolower', array_map('trim', $headers));
$remapIndices = [];
foreach ($fkColumns as $colInfo) {
$headerIdx = array_search(strtolower($colInfo['header']), $headers);
if ($headerIdx !== false) {
$remapIndices[$headerIdx] = $colInfo['source_type'];
}
}
if (empty($remapIndices)) {
return $content;
}
// Rebuild the CSV with remapped IDs
$output = $bom . $lines[0] . "\r\n";
for ($i = 1; $i < count($lines); $i++) {
$line = trim($lines[$i]);
if ($line === '') continue;
$row = str_getcsv($line, ';');
foreach ($remapIndices as $colIdx => $sourceType) {
if (isset($row[$colIdx]) && $row[$colIdx] !== '' && isset($idMap[$sourceType])) {
$oldId = $row[$colIdx];
if (isset($idMap[$sourceType][$oldId])) {
$row[$colIdx] = (string)$idMap[$sourceType][$oldId];
}
}
}
// Re-encode as CSV line
$escaped = array_map(function ($field) {
if (str_contains($field, ';') || str_contains($field, '"') || str_contains($field, "\n")) {
return '"' . str_replace('"', '""', $field) . '"';
}
return $field;
}, $row);
$output .= implode(';', $escaped) . "\r\n";
}
return $output;
}
/**
* Get FK columns that need ID remapping for a given entity type.
*
* @return array[] Each: {header, source_type}
*/
private function getFkColumnsForRemapping(string $type): array {
return match ($type) {
'mitglieder' => [
['header' => 'stufe-id', 'source_type' => 'stufen'],
['header' => 'familien-id', 'source_type' => 'familien'],
],
'adressen', 'telefonnummern', 'emails' => [
['header' => 'mitglied-id', 'source_type' => 'mitglieder'],
],
'stufenverlauf' => [
['header' => 'mitglied-id', 'source_type' => 'mitglieder'],
['header' => 'stufe-id', 'source_type' => 'stufen'],
],
'beitraege' => [
['header' => 'mitglied-id', 'source_type' => 'mitglieder'],
],
'lagerteilnehmer' => [
['header' => 'lager-id', 'source_type' => 'lager'],
['header' => 'mitglied-id', 'source_type' => 'mitglieder'],
],
'verletzungen' => [
['header' => 'mitglied-id', 'source_type' => 'mitglieder'],
['header' => 'lager-id', 'source_type' => 'lager'],
],
default => [],
};
}
/**
* Collect ID mappings from import results.
* Maps old CSV IDs to new database IDs.
*/
private function collectIdMappings(string $type, string $originalContent, array $result, array &$idMap): void {
if (!isset($idMap[$type])) {
$idMap[$type] = [];
}
// Parse original content to get old IDs
$lines = explode("\n", str_replace("\r\n", "\n", $originalContent));
if (count($lines) < 2) return;
// Remove BOM
if (substr($lines[0], 0, 3) === "\xEF\xBB\xBF") {
$lines[0] = substr($lines[0], 3);
}
$headers = str_getcsv($lines[0], ';');
$headers = array_map('strtolower', array_map('trim', $headers));
$idColIdx = array_search('id', $headers);
if ($idColIdx === false) return;
// Build row-to-oldId mapping
$oldIds = [];
for ($i = 1; $i < count($lines); $i++) {
$line = trim($lines[$i]);
if ($line === '') continue;
$row = str_getcsv($line, ';');
if (isset($row[$idColIdx]) && $row[$idColIdx] !== '') {
$oldIds[$i - 1] = $row[$idColIdx]; // 0-indexed row
}
}
// Match with created results
$createdIdx = 0;
foreach ($result['details'] as $detail) {
if ($detail['status'] === 'created' && isset($detail['id'])) {
$rowNum = $detail['row'] - 2; // Convert to 0-indexed
if (isset($oldIds[$rowNum])) {
$idMap[$type][$oldIds[$rowNum]] = $detail['id'];
}
}
}
}
}