Steuerkram/src/Services/NextcloudImportService.php
fds2610-bot 69fcc84301 Fix #208: Totmannschalter für den Nextcloud-Import
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 17:25:26 +02:00

448 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Config;
use Steuerkram\Database;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\EventLog;
use Steuerkram\Models\Setting;
/**
* Importiert Belege aus einem Nextcloud-Ordner via WebDAV.
*
* Konfiguration über Einstellungen → Nextcloud-Belegimport (gespeichert in company_settings):
* nextcloud_url – Basis-URL der Nextcloud, z.B. https://cloud.example.com
* nextcloud_user – Benutzername
* nextcloud_pass – Passwort oder App-Passwort
* nextcloud_folder – Pfad zum Import-Ordner, z.B. /Belege/Import
* nextcloud_done – Pfad für verarbeitete Dateien (optional), z.B. /Belege/Importiert
*
* Ablauf pro Datei:
* 1. PROPFIND → Dateiliste
* 2. GET → Datei herunterladen in temporäre Datei
* 3. Duplikat-Check via SHA-256 gegen belege.file_hash
* 4. In upload-Verzeichnis ablegen
* 5. JPEG/PNG: weißen Rand wegschneiden
* 6. Hash nach Crop neu berechnen
* 7. Beleg in Datenbank anlegen
* 8. MOVE → Datei in erledigte-Ordner verschieben (oder löschen falls kein DONE-Ordner)
*/
class NextcloudImportService
{
private const ALLOWED_MIME = [
'application/pdf' => '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 = '<?xml version="1.0" encoding="UTF-8"?>'
. '<d:propfind xmlns:d="DAV:">'
. '<d:prop><d:displayname/><d:getcontenttype/><d:getcontentlength/><d:resourcetype/></d:prop>'
. '</d:propfind>';
$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;
}
}