'pdf', 'image/png' => 'png', 'image/jpeg' => 'jpg', 'image/tiff' => 'tif', 'application/xml' => 'xml', 'text/xml' => 'xml', ]; private string $baseUrl; private string $user; private string $pass; private string $folder; private string|null $doneFolder; private string $davBase; private array $pendingEvents = []; private string|null $configError = null; /** Details des letzten fehlgeschlagenen Requests (HTTP-Code bzw. cURL-Fehler) für das Ereignis-Log */ private string $lastTransferError = ''; /** @var string[] CURLOPT_RESOLVE-Einträge für die beim Konstruieren geprüfte(n) IP(s) von baseUrl */ private array $pinnedResolve = []; public function __construct() { $cfg = Setting::get(); $this->baseUrl = rtrim($cfg['nextcloud_url'] ?? '', '/'); $this->user = $cfg['nextcloud_user'] ?? ''; $this->pass = $cfg['nextcloud_pass'] ?? ''; $this->folder = '/' . trim($cfg['nextcloud_folder'] ?: 'Belege/Import', '/'); $doneRaw = $cfg['nextcloud_done'] ?? ''; $this->doneFolder = $doneRaw !== '' ? ('/' . trim($doneRaw, '/')) : null; // Defensiv geprüft, auch wenn die URL beim Speichern in den Einstellungen // bereits validiert wurde (Schutz gegen SSRF, siehe SsrfGuard). Die dabei // geprüften IP-Adressen werden für alle cURL-Requests dieser Instanz fest // angebunden (CURLOPT_RESOLVE in newCurl()), damit cURL den Hostnamen nicht // zu einem späteren Zeitpunkt erneut selbst auflöst (DNS-Rebinding/TOCTOU, // siehe Issue #130). if ($this->baseUrl !== '') { try { $this->pinnedResolve = SsrfGuard::resolvePinned($this->baseUrl); } catch (\RuntimeException $e) { $this->configError = 'Nextcloud-URL ungültig: ' . $e->getMessage(); $this->baseUrl = ''; } } $this->davBase = $this->baseUrl . '/remote.php/dav/files/' . rawurlencode($this->user); } /** * Führt den Import durch. Gibt ein Array mit Ergebnissen zurück: * [ * 'imported' => int, // neu importierte Belege * 'skipped' => int, // übersprungene (Duplikat oder falscher Typ) * 'errors' => int, // Fehler beim Verarbeiten * 'details' => string[], // Log-Meldungen * ] */ public function import(): array { $start = microtime(true); $result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [], 'imported_ids' => []]; if (!$this->baseUrl || !$this->user || !$this->pass) { $msg = $this->configError ?? 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.'; $result['details'][] = $msg; $result['errors']++; $this->persistEvent($msg); return $result; } $files = $this->listFiles(); if ($files === null) { $msg = 'FEHLER: PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen.' . ($this->lastTransferError !== '' ? ' – ' . $this->lastTransferError : ''); $result['details'][] = $msg; $result['errors']++; $this->persistEvent($msg); return $result; } NextcloudWatchdog::markRun(); $result['details'][] = count($files) . ' Datei(en) im Import-Ordner gefunden.'; foreach ($files as $file) { $this->processFile($file, $result); } if ($result['imported'] + $result['skipped'] + $result['errors'] > 0) { $elapsed = round(microtime(true) - $start, 2); $summary = sprintf( 'Fertig in %ss – importiert: %d, übersprungen: %d, Fehler: %d', $elapsed, $result['imported'], $result['skipped'], $result['errors'] ); $this->persistEvent($summary); $result['details'][] = $summary; } return $result; } private function persistEvent(string $message): void { EventLog::add('nextcloud-import', $message); } /** * Listet alle Dateien im konfigurierten Ordner via PROPFIND. * Gibt null zurück bei Verbindungsfehler. * * @return array{href:string, filename:string, contenttype:string, size:int}[]|null */ private function listFiles(): ?array { $url = $this->davBase . $this->folder; $body = '' . '' . '' . ''; $ch = $this->newCurl($url); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PROPFIND'); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/xml; charset=utf-8', 'Depth: 1', ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $failed = $response === false || $httpCode < 200 || $httpCode >= 300; $this->lastTransferError = $failed ? $this->describeTransfer($ch) : ''; curl_close($ch); if ($failed) { return null; } return $this->parsePropfind((string)$response); } /** * Parst die PROPFIND-Antwort und gibt Datei-Einträge zurück * (ohne den Ordner selbst, nur Nicht-Collection-Einträge). * * @return array{href:string, filename:string, contenttype:string, size:int}[] */ private function parsePropfind(string $xml): array { $files = []; $dom = new \DOMDocument(); if (!@$dom->loadXML($xml)) { return $files; } $xpath = new \DOMXPath($dom); $xpath->registerNamespace('d', 'DAV:'); $responses = $xpath->query('//d:response'); if ($responses === false) { return $files; } foreach ($responses as $resp) { // Ordner überspringen $collection = $xpath->query('.//d:resourcetype/d:collection', $resp); if ($collection !== false && $collection->length > 0) { continue; } $hrefNode = $xpath->query('.//d:href', $resp); $contentTypeNode = $xpath->query('.//d:getcontenttype', $resp); $sizeNode = $xpath->query('.//d:getcontentlength', $resp); if ($hrefNode === false || $hrefNode->length === 0) { continue; } $href = $hrefNode->item(0)->textContent; $contentType = $contentTypeNode !== false && $contentTypeNode->length > 0 ? strtolower(trim($contentTypeNode->item(0)->textContent)) : ''; $size = $sizeNode !== false && $sizeNode->length > 0 ? (int)$sizeNode->item(0)->textContent : 0; // MIME: nur den Typ, ohne charset etc. if (str_contains($contentType, ';')) { $contentType = trim(explode(';', $contentType)[0]); } $files[] = [ 'href' => $href, 'filename' => rawurldecode(basename($href)), 'contenttype' => $contentType, 'size' => $size, ]; } return $files; } /** * Verarbeitet eine einzelne Datei: Download, Duplikat-Check, Import, Verschieben. */ private function processFile(array $file, array &$result): void { $filename = $file['filename']; $contentType = $file['contenttype']; // MIME prüfen if (!isset(self::ALLOWED_MIME[$contentType])) { $msg = "Übersprungen (unbekannter Typ «{$contentType}»): {$filename}"; $result['details'][] = $msg; $this->persistEvent($msg); $result['skipped']++; return; } $ext = self::ALLOWED_MIME[$contentType]; // Datei herunterladen $fileUrl = $this->baseUrl . $file['href']; $tmpPath = sys_get_temp_dir() . '/nextcloud_import_' . uniqid() . '.' . $ext; if (!$this->downloadFile($fileUrl, $tmpPath)) { $msg = "FEHLER beim Download: {$filename}" . ($this->lastTransferError !== '' ? ' – ' . $this->lastTransferError : ''); $result['details'][] = $msg; $this->persistEvent($msg); $result['errors']++; return; } // Duplikat-Check via Hash (vor Crop, da sonst Hash-Vergleich sinnlos) $rawHash = hash_file('sha256', $tmpPath); $existing = Database::fetchOne( 'SELECT id, beleg_nr FROM belege WHERE file_hash = ?', [$rawHash] ); if ($existing) { $msg = "Duplikat (Beleg {$existing['beleg_nr']}): {$filename}"; $result['details'][] = $msg; $this->persistEvent($msg); $result['skipped']++; unlink($tmpPath); $this->moveFile($file['href'], $filename); return; } // In Upload-Verzeichnis ablegen $belegNr = Beleg::generateBelegNr(); $year = date('Y'); $relDir = "{$year}/{$belegNr}"; $absDir = Config::get('upload_dir') . "/{$relDir}"; if (!is_dir($absDir) && !mkdir($absDir, 0755, true)) { $msg = "FEHLER: Verzeichnis konnte nicht erstellt werden: {$absDir}"; $result['details'][] = $msg; $this->persistEvent($msg); $result['errors']++; unlink($tmpPath); return; } $storagePath = "{$relDir}/original.{$ext}"; $absPath = Config::get('upload_dir') . "/{$storagePath}"; if (!rename($tmpPath, $absPath)) { $msg = "FEHLER: Datei konnte nicht verschoben werden: {$filename}"; $result['details'][] = $msg; $this->persistEvent($msg); $result['errors']++; @unlink($tmpPath); return; } // JPEG/PNG: weißen Rand wegschneiden if (in_array($contentType, ['image/jpeg', 'image/png'], true)) { (new ImageCropService())->cropWhitespace($absPath); } // Hash nach Crop neu berechnen $hash = hash_file('sha256', $absPath); // In Datenbank anlegen $today = date('Y-m-d'); $belegId = Beleg::create([ 'beleg_nr' => $belegNr, 'filename' => $filename, 'storage_path' => $storagePath, 'mime_type' => $contentType, 'file_hash' => $hash, 'beleg_datum' => null, 'rechnungs_nr' => null, 'betrag_brutto' => null, 'belegtyp' => 'Eingangsrechnung', 'notes' => 'Nextcloud-Import', ]); // Datei in Nextcloud verschieben/löschen $this->moveFile($file['href'], $filename); $msg = "Importiert: {$filename} → Beleg {$belegNr}"; $result['details'][] = $msg; $this->persistEvent($msg); $result['imported']++; $result['imported_ids'][] = $belegId; } /** * Lädt eine Datei per GET in eine lokale Datei herunter. */ private function downloadFile(string $url, string $targetPath): bool { $fh = fopen($targetPath, 'wb'); if ($fh === false) { return false; } $ch = $this->newCurl($url); curl_setopt($ch, CURLOPT_FILE, $fh); $ok = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $failed = $ok === false || $httpCode < 200 || $httpCode >= 300; $this->lastTransferError = $failed ? $this->describeTransfer($ch) : ''; curl_close($ch); fclose($fh); if ($failed) { @unlink($targetPath); return false; } return true; } /** * Verschiebt eine Nextcloud-Datei in den DONE-Ordner (MOVE). * Falls kein DONE-Ordner konfiguriert, wird die Datei gelöscht (DELETE). */ private function moveFile(string $href, string $filename): void { $srcUrl = $this->baseUrl . $href; if ($this->doneFolder !== null) { $destHref = $this->doneFolder . '/' . rawurlencode($filename); $destUrl = $this->davBase . $destHref; $ch = $this->newCurl($srcUrl); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'MOVE'); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Destination: ' . $destUrl, 'Overwrite: T', ]); curl_exec($ch); curl_close($ch); } else { $ch = $this->newCurl($srcUrl); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); curl_exec($ch); curl_close($ch); } } /** * Beschreibt einen fehlgeschlagenen Request so, dass er ohne Server-Zugriff * eingegrenzt werden kann: Transportfehler mit cURL-Code, sonst HTTP-Status * samt tatsächlich kontaktierter Gegenstelle. */ private function describeTransfer(\CurlHandle $ch): string { $errno = curl_errno($ch); if ($errno !== 0) { return sprintf('cURL-Fehler %d: %s', $errno, curl_error($ch)); } $ip = (string)curl_getinfo($ch, CURLINFO_PRIMARY_IP); return sprintf( 'HTTP %d von %s', (int)curl_getinfo($ch, CURLINFO_HTTP_CODE), $ip !== '' ? $ip : 'unbekannt' ); } /** * Erstellt einen vorkonfigurierten cURL-Handle mit Basis-Auth und TLS-Einstellungen. * * @return \CurlHandle */ private function newCurl(string $url): \CurlHandle { $ch = curl_init($url); curl_setopt($ch, CURLOPT_USERPWD, $this->user . ':' . $this->pass); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Redirects nicht folgen: eine Weiterleitung könnte sonst die SSRF-Prüfung // der Nextcloud-URL (SsrfGuard) umgehen. curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); curl_setopt($ch, CURLOPT_TIMEOUT, 60); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); if (!empty($this->pinnedResolve)) { // Host fest auf die beim Konstruieren geprüfte(n) IP(s) anbinden, statt // ihn erneut über den System-Resolver aufzulösen (DNS-Rebinding-Schutz, // siehe SsrfGuard::resolvePinned()). Host-Header/SNI bleiben unverändert. curl_setopt($ch, CURLOPT_RESOLVE, $this->pinnedResolve); } return $ch; } }