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>
452 lines
12 KiB
PHP
Executable file
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');
|
|
}
|
|
}
|