* * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ /** * \file agendwrapper/class/IcsProxy.class.php * \ingroup agendwrapper * \brief Kernlogik: Nextcloud CalDAV Fetch, Zeitzonen-Konvertierung, Caching */ require_once DOL_DOCUMENT_ROOT.'/core/lib/geturl.lib.php'; /** * ICS-Proxy-Klasse * * Holt ICS-Daten von Nextcloud, konvertiert alle VTIMEZONE-aware Zeiten * nach UTC und entfernt VTIMEZONE-Bloecke. */ class IcsProxy { /** @var DoliDB */ public $db; /** @var string Fehlermeldung */ public $error = ''; /** @var string[] Fehlermeldungen */ public $errors = array(); /** @var string Cron-Output */ public $output = ''; /** * Constructor * * @param DoliDB $db Database handler */ public function __construct($db) { $this->db = $db; } /** * Holt sauberes ICS fuer einen Kalender (mit Cache) * * @param int $calNumber Kalender-Nummer (1 oder 2) * @return string|false Bereinigter ICS-Inhalt oder false bei Fehler */ public function getCleanIcs($calNumber) { // Aus Cache lesen $cached = $this->getFromCache($calNumber); if ($cached !== false) { return $cached; } // Kalender-Name ermitteln $calName = getDolGlobalString('AGENDWRAPPER_CAL'.$calNumber.'_NAME'); if (empty($calName)) { $this->error = 'Kalender '.$calNumber.' nicht konfiguriert'; return false; } // Von Nextcloud holen $rawIcs = $this->fetchFromNextcloud($calName); if ($rawIcs === false) { return false; } // Zeitzonen konvertieren $cleanIcs = $this->convertToUtc($rawIcs); // In Cache schreiben $this->writeToCache($calNumber, $cleanIcs); return $cleanIcs; } /** * Holt die rohe ICS-Datei von Nextcloud via HTTP mit Basic Auth * * @param string $calName Kalender-Pfad-Segment (z.B. 'firmenkalender') * @return string|false Roher ICS-Inhalt oder false bei Fehler */ public function fetchFromNextcloud($calName) { $baseUrl = getDolGlobalString('AGENDWRAPPER_NEXTCLOUD_URL'); $user = getDolGlobalString('AGENDWRAPPER_NEXTCLOUD_USER'); $pass = dolDecrypt(getDolGlobalString('AGENDWRAPPER_NEXTCLOUD_PASS')); if (empty($baseUrl) || empty($user) || empty($pass)) { $this->error = 'Nextcloud-Verbindungsdaten nicht konfiguriert'; dol_syslog(__METHOD__.' '.$this->error, LOG_ERR); return false; } // URL zusammenbauen $url = rtrim($baseUrl, '/').'/'.urlencode($calName).'/?export'; // Basic Auth Header $headers = array( 'Authorization: Basic '.base64_encode($user.':'.$pass) ); dol_syslog(__METHOD__.' Rufe ab: '.$url, LOG_DEBUG); $result = getURLContent($url, 'GET', '', 1, $headers, array('https'), 0, -1, 10, 30); if (empty($result['http_code']) || $result['http_code'] != 200) { $httpCode = !empty($result['http_code']) ? $result['http_code'] : 'keine Antwort'; $curlError = !empty($result['curl_error_msg']) ? $result['curl_error_msg'] : ''; $this->error = 'HTTP '.$httpCode.' beim Abruf von '.$url; if ($curlError) { $this->error .= ' ('.$curlError.')'; } dol_syslog(__METHOD__.' '.$this->error, LOG_ERR); return false; } $content = $result['content']; // Pruefen ob es tatsaechlich ICS-Inhalt ist if (strpos($content, 'BEGIN:VCALENDAR') === false) { $this->error = 'Antwort von Nextcloud ist kein gueltiges ICS-Format'; dol_syslog(__METHOD__.' '.$this->error, LOG_ERR); return false; } return $content; } /** * Konvertiert alle DTSTART/DTEND mit TZID zu UTC und entfernt VTIMEZONE-Bloecke * * @param string $icsContent Roher ICS-Inhalt * @return string Bereinigter ICS-Inhalt (alle Zeiten in UTC) */ public function convertToUtc($icsContent) { // Zeilenumbrueche normalisieren $icsContent = str_replace("\r\n", "\n", $icsContent); $icsContent = str_replace("\r", "\n", $icsContent); // Zeilen-Entfaltung (RFC 5545: Continuation Lines beginnen mit Leerzeichen/Tab) $icsContent = preg_replace('/\n[ \t]/', '', $icsContent); $lines = explode("\n", $icsContent); $output = array(); $inVtimezone = false; $vtimezoneDepth = 0; foreach ($lines as $line) { $trimmed = trim($line); // VTIMEZONE-Bloecke ueberspringen if ($trimmed === 'BEGIN:VTIMEZONE') { $inVtimezone = true; $vtimezoneDepth++; continue; } if ($inVtimezone) { if ($trimmed === 'END:VTIMEZONE') { $vtimezoneDepth--; if ($vtimezoneDepth <= 0) { $inVtimezone = false; $vtimezoneDepth = 0; } } continue; } // DTSTART/DTEND mit TZID konvertieren // Muster: DTSTART;TZID=Europe/Berlin:20260215T100000 if (preg_match('/^(DTSTART|DTEND)(;[^:]*TZID=([^;:]+)[^:]*):(\d{8}T\d{6})$/', $trimmed, $matches)) { $property = $matches[1]; $tzid = $matches[3]; $dateStr = $matches[4]; $utcDate = $this->convertDateToUtc($dateStr, $tzid); if ($utcDate !== false) { $output[] = $property.':'.$utcDate; continue; } // Bei Fehler: Zeile unveraendert uebernehmen } // RRULE mit UNTIL und TZID - UNTIL ist normalerweise schon UTC // Keine Aenderung noetig fuer RRULE $output[] = $trimmed; } // Zusammenbauen mit CRLF (RFC 5545) $result = implode("\r\n", $output); // Zeilen laenger als 75 Zeichen falten (RFC 5545) $result = $this->foldLines($result); return $result; } /** * Konvertiert ein Datum mit Zeitzone nach UTC * * @param string $dateStr Datum im Format YYYYMMDDTHHMMSS * @param string $tzid Zeitzonen-ID (z.B. 'Europe/Berlin') * @return string|false UTC-Datum als YYYYMMDDTHHMMSSZ oder false bei Fehler */ private function convertDateToUtc($dateStr, $tzid) { try { $tz = new DateTimeZone($tzid); } catch (Exception $e) { dol_syslog(__METHOD__.' Unbekannte Zeitzone: '.$tzid, LOG_WARNING); return false; } // Datum parsen: 20260215T100000 $year = substr($dateStr, 0, 4); $month = substr($dateStr, 4, 2); $day = substr($dateStr, 6, 2); $hour = substr($dateStr, 9, 2); $min = substr($dateStr, 11, 2); $sec = substr($dateStr, 13, 2); $isoDate = $year.'-'.$month.'-'.$day.'T'.$hour.':'.$min.':'.$sec; try { $dt = new DateTime($isoDate, $tz); $dt->setTimezone(new DateTimeZone('UTC')); return $dt->format('Ymd\THis\Z'); } catch (Exception $e) { dol_syslog(__METHOD__.' Fehler beim Konvertieren: '.$dateStr.' ('.$tzid.'): '.$e->getMessage(), LOG_WARNING); return false; } } /** * Faltet lange Zeilen nach RFC 5545 (max 75 Oktette pro Zeile) * * @param string $icsContent ICS-Inhalt * @return string Gefalteter Inhalt */ private function foldLines($icsContent) { $lines = explode("\r\n", $icsContent); $folded = array(); foreach ($lines as $line) { if (strlen($line) <= 75) { $folded[] = $line; } else { // Erste Zeile: max 75 Zeichen $folded[] = substr($line, 0, 75); $rest = substr($line, 75); // Folgezeilen: Leerzeichen + max 74 Zeichen while (strlen($rest) > 0) { $folded[] = ' '.substr($rest, 0, 74); $rest = substr($rest, 74); } } } return implode("\r\n", $folded); } /** * Gibt den Pfad zur Cache-Datei zurueck * * @param int $calNumber Kalender-Nummer (1 oder 2) * @return string Absoluter Pfad */ private function getCachePath($calNumber) { global $conf; return DOL_DATA_ROOT.'/'.($conf->entity > 1 ? $conf->entity.'/' : '').'agendwrapper/temp/cal'.$calNumber.'.ics'; } /** * Prueft ob ein gueltiger Cache existiert und gibt den Inhalt zurueck * * @param int $calNumber Kalender-Nummer * @return string|false Cache-Inhalt oder false wenn ungueltig/abgelaufen */ private function getFromCache($calNumber) { $cachePath = $this->getCachePath($calNumber); if (!file_exists($cachePath)) { return false; } $cacheAge = time() - filemtime($cachePath); $maxAge = (int) getDolGlobalString('AGENDWRAPPER_CACHE_DURATION', '300'); if ($cacheAge > $maxAge) { return false; } $content = file_get_contents($cachePath); if ($content === false || empty($content)) { return false; } return $content; } /** * Schreibt Inhalt in die Cache-Datei * * @param int $calNumber Kalender-Nummer * @param string $content ICS-Inhalt * @return bool true bei Erfolg */ private function writeToCache($calNumber, $content) { $cachePath = $this->getCachePath($calNumber); $dir = dirname($cachePath); if (!is_dir($dir)) { dol_mkdir($dir); } $result = file_put_contents($cachePath, $content); if ($result === false) { dol_syslog(__METHOD__.' Kann Cache nicht schreiben: '.$cachePath, LOG_ERR); return false; } return true; } /** * Gibt Cache-Info fuer einen Kalender zurueck * * @param int $calNumber Kalender-Nummer * @return array Array mit 'exists', 'age', 'size', 'event_count' */ public function getCacheInfo($calNumber) { $cachePath = $this->getCachePath($calNumber); $info = array( 'exists' => false, 'age' => 0, 'size' => 0, 'event_count' => 0, 'path' => $cachePath, ); if (file_exists($cachePath)) { $info['exists'] = true; $info['age'] = time() - filemtime($cachePath); $info['size'] = filesize($cachePath); $content = file_get_contents($cachePath); if ($content !== false) { $info['event_count'] = substr_count($content, 'BEGIN:VEVENT'); } } return $info; } /** * Loescht die Cache-Dateien * * @param int|null $calNumber Kalender-Nummer oder null fuer alle * @return bool */ public function clearCache($calNumber = null) { if ($calNumber !== null) { $cachePath = $this->getCachePath($calNumber); if (file_exists($cachePath)) { return @unlink($cachePath); } return true; } // Alle Caches leeren $success = true; for ($i = 1; $i <= 2; $i++) { $cachePath = $this->getCachePath($i); if (file_exists($cachePath)) { if (!@unlink($cachePath)) { $success = false; } } } return $success; } /** * Cron-Job: Cache fuer alle konfigurierten Kalender aufwaermen * * @return int 0 bei Erfolg, -1 bei Fehler */ public function warmCache() { global $langs; $langs->load('agendwrapper@agendwrapper'); $hasError = false; $this->output = ''; for ($i = 1; $i <= 2; $i++) { $calName = getDolGlobalString('AGENDWRAPPER_CAL'.$i.'_NAME'); if (empty($calName)) { continue; } // Cache vorher leeren um frische Daten zu holen $this->clearCache($i); $result = $this->getCleanIcs($i); if ($result === false) { $this->output .= 'Kalender '.$i.' ('.$calName.'): Fehler - '.$this->error."\n"; $hasError = true; } else { $eventCount = substr_count($result, 'BEGIN:VEVENT'); $this->output .= 'Kalender '.$i.' ('.$calName.'): OK - '.$eventCount." Events\n"; } } if ($hasError) { return -1; } return 0; } /** * Zaehlt Events in einem ICS-String * * @param string $icsContent ICS-Inhalt * @return int Anzahl VEVENT-Bloecke */ public function countEvents($icsContent) { return substr_count($icsContent, 'BEGIN:VEVENT'); } }