dolibarr.agendawrapper/class/IcsProxy.class.php
data e7fbffa12f AgendWrapper v1.0.0 - ICS-Proxy fuer Nextcloud-Kalender
Erstes Release des Moduls. Fungiert als ICS-Proxy zwischen
Nextcloud CalDAV und Dolibarr, um VTIMEZONE-Inkompatibilitaeten
zu umgehen.

- Proxy-Endpunkt mit API-Key-Absicherung
- Automatische VTIMEZONE->UTC Konvertierung
- Basic-Auth-Anbindung an Nextcloud
- Datei-basiertes Caching
- Admin-Seite mit Verbindungstest
- Statusseite mit Cache-Verwaltung
- Optionaler Cron-Job
- Sprachdateien de_DE + en_US

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:29:23 +01:00

452 lines
12 KiB
PHP
Executable file

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \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');
}
}