448 lines
16 KiB
PHP
448 lines
16 KiB
PHP
<?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;
|
||
}
|
||
}
|