From d660d2cc7a038356da4bd2e44ab1f1b36d35db48 Mon Sep 17 00:00:00 2001 From: Cedric Date: Wed, 13 May 2026 15:54:10 +0200 Subject: [PATCH] calDAV v2 --- includes/icloud_caldav.php | 218 +++++++++++++++++++++++++++ modules/calendar-ios/api.php | 14 +- modules/calendar-ios/caldav_sync.php | 29 +++- settings.php | 49 +++--- 4 files changed, 282 insertions(+), 28 deletions(-) create mode 100644 includes/icloud_caldav.php diff --git a/includes/icloud_caldav.php b/includes/icloud_caldav.php new file mode 100644 index 0000000..47aa51f --- /dev/null +++ b/includes/icloud_caldav.php @@ -0,0 +1,218 @@ + true, + CURLOPT_TIMEOUT => 25, + CURLOPT_CUSTOMREQUEST => 'PROPFIND', + CURLOPT_HTTPAUTH => CURLAUTH_BASIC, + CURLOPT_USERPWD => $username . ':' . $password, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/xml; charset=utf-8', + 'Depth: ' . $depth, + ], + CURLOPT_POSTFIELDS => $xml, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 10, + ]); + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($body === false) { + return ['code' => 0, 'body' => '']; + } + return ['code' => $code, 'body' => $body]; +} + +function hh_caldav_first_href_by_element(string $xml, string $parentLocalName): ?string +{ + $dom = new DOMDocument(); + if (!@$dom->loadXML($xml)) { + return null; + } + $xpath = new DOMXPath($dom); + $nodes = $xpath->query("//*[local-name()='{$parentLocalName}']//*[local-name()='href']"); + if ($nodes->length === 0) { + return null; + } + return trim($nodes->item(0)->textContent); +} + +/** + * @return list + */ +function hh_caldav_parse_calendar_collections(string $xml): array +{ + $dom = new DOMDocument(); + if (!@$dom->loadXML($xml)) { + return []; + } + $xpath = new DOMXPath($dom); + $responses = $xpath->query("//*[local-name()='response']"); + $out = []; + foreach ($responses as $resp) { + $hrefNodes = $xpath->query(".//*[local-name()='href']", $resp); + if ($hrefNodes->length === 0) { + continue; + } + $href = trim($hrefNodes->item(0)->textContent); + $cal = $xpath->query(".//*[local-name()='resourcetype']/*[local-name()='calendar']", $resp); + if ($cal->length === 0 || $href === '') { + continue; + } + $display = ''; + $dn = $xpath->query(".//*[local-name()='displayname']", $resp); + if ($dn->length > 0) { + $display = trim($dn->item(0)->textContent); + } + $out[] = ['href' => $href, 'display' => $display]; + } + return $out; +} + +/** + * Depuis la racine iCloud (https://caldav.icloud.com), découvre l’URL d’un calendrier (par défaut « Calendar » / « Calendrier », sinon le premier). + * + * @throws RuntimeException + */ +function hh_icloud_discover_default_calendar_url(string $username, string $password): string +{ + $password = hh_normalize_apple_app_password($password); + $root = 'https://caldav.icloud.com/'; + + $xml1 = '' + . '' + . '' + . ''; + $r1 = hh_caldav_propfind($root, $username, $password, $xml1, '0'); + if ($r1['code'] === 401 || $r1['code'] === 403) { + throw new RuntimeException('Identifiant Apple ou mot de passe d’app refusé (HTTP ' . $r1['code'] . ').'); + } + if (!in_array($r1['code'], [200, 207], true)) { + throw new RuntimeException('CalDAV racine inaccessible (HTTP ' . $r1['code'] . ').'); + } + $principalHref = hh_caldav_first_href_by_element($r1['body'], 'current-user-principal'); + if (!$principalHref) { + throw new RuntimeException('Réponse CalDAV inattendue : principal introuvable.'); + } + $principalUrl = hh_caldav_resolve_href($root, $principalHref); + + $xml2 = '' + . '' + . '' + . ''; + $r2 = hh_caldav_propfind($principalUrl, $username, $password, $xml2, '0'); + if (!in_array($r2['code'], [200, 207], true)) { + throw new RuntimeException('Lecture du principal CalDAV impossible (HTTP ' . $r2['code'] . ').'); + } + $homeHref = hh_caldav_first_href_by_element($r2['body'], 'calendar-home-set'); + if (!$homeHref) { + throw new RuntimeException('Aucun dossier de calendriers (calendar-home-set) trouvé.'); + } + $homeUrl = hh_caldav_resolve_href($principalUrl, $homeHref); + + $xml3 = '' + . '' + . '' + . ''; + $r3 = hh_caldav_propfind($homeUrl, $username, $password, $xml3, '1'); + if (!in_array($r3['code'], [200, 207], true)) { + throw new RuntimeException('Liste des calendriers impossible (HTTP ' . $r3['code'] . ').'); + } + $entries = hh_caldav_parse_calendar_collections($r3['body']); + if ($entries === []) { + throw new RuntimeException('Aucun calendrier trouvé sur ce compte iCloud.'); + } + + $preferred = ['calendar', 'calendrier', 'home', 'par défaut', 'default']; + $chosenHref = null; + foreach ($entries as $e) { + $lower = mb_strtolower($e['display']); + foreach ($preferred as $p) { + if ($lower !== '' && str_contains($lower, $p)) { + $chosenHref = $e['href']; + break 2; + } + } + } + if ($chosenHref === null) { + $chosenHref = $entries[0]['href']; + } + + return rtrim(hh_caldav_resolve_href($homeUrl, $chosenHref), '/') . '/'; +} + +/** + * Vérifie qu’une URL de collection calendrier répond en CalDAV (PROPFIND). + */ +function hh_caldav_test_calendar_collection(string $username, string $password, string $calendarUrl): int +{ + $password = hh_normalize_apple_app_password($password); + $xml = '' + . '' + . '' + . ''; + $r = hh_caldav_propfind(rtrim($calendarUrl, '/') . '/', $username, $password, $xml, '0'); + return $r['code']; +} + +/** + * Si URL = racine iCloud bien connue, découvre et retourne l’URL d’un calendrier ; sinon retourne l’URL telle quelle (trim). + * + * @throws RuntimeException + */ +function hh_icloud_resolve_calendar_url_if_needed(string $username, string $password, string $calendarUrl): string +{ + $calendarUrl = trim($calendarUrl); + if (hh_caldav_is_icloud_well_known_root($calendarUrl)) { + return hh_icloud_discover_default_calendar_url($username, $password); + } + return $calendarUrl; +} diff --git a/modules/calendar-ios/api.php b/modules/calendar-ios/api.php index e9cb0f7..5223adb 100644 --- a/modules/calendar-ios/api.php +++ b/modules/calendar-ios/api.php @@ -133,7 +133,15 @@ if ($action === 'sync' && $method === 'POST') { ios_require_csrf(); if (!$integration) ios_err('Connexion iCloud non configurée.', 400); - $remoteEvents = ios_fetch_remote_events($integration); + try { + $ctx = ios_caldav_prepare($integration, $meta_pdo); + } catch (Throwable $e) { + ios_err('CalDAV: ' . $e->getMessage(), 400); + } + $integration = $ctx['integration']; + $davPassword = $ctx['password']; + + $remoteEvents = ios_fetch_remote_events($integration, $davPassword); $remoteByUid = []; foreach ($remoteEvents as $re) $remoteByUid[$re['external_uid']] = $re; @@ -142,7 +150,7 @@ if ($action === 'sync' && $method === 'POST') { foreach ($localEvents as $evt) { if ($evt['sync_state'] === 'pending_push' || empty($evt['external_uid'])) { - $push = ios_push_event_to_remote($integration, $evt); + $push = ios_push_event_to_remote($integration, $evt, $davPassword); if ($push['code'] >= 200 && $push['code'] < 300) { $uid = $evt['external_uid'] ?: ('hh-' . $evt['id'] . '@househub'); $pdo->prepare("UPDATE pf_calendar_events SET external_uid=?, sync_state='synced', updated_at=NOW() WHERE id=?")->execute([$uid, $evt['id']]); @@ -155,7 +163,7 @@ if ($action === 'sync' && $method === 'POST') { $pendingDelete = $pdo->query("SELECT id, external_uid FROM pf_calendar_events WHERE deleted_at IS NOT NULL AND sync_state='pending_delete'")->fetchAll(); foreach ($pendingDelete as $evt) { if (!empty($evt['external_uid'])) { - ios_delete_remote_event($integration, $evt['external_uid']); + ios_delete_remote_event($integration, $evt['external_uid'], $davPassword); } $pdo->prepare("DELETE FROM pf_calendar_event_links WHERE calendar_event_id=?")->execute([$evt['id']]); $pdo->prepare("DELETE FROM pf_calendar_events WHERE id=?")->execute([$evt['id']]); diff --git a/modules/calendar-ios/caldav_sync.php b/modules/calendar-ios/caldav_sync.php index 437165d..ab3d0dc 100644 --- a/modules/calendar-ios/caldav_sync.php +++ b/modules/calendar-ios/caldav_sync.php @@ -1,5 +1,25 @@ prepare('UPDATE user_calendar_integrations SET calendar_url = ? WHERE id = ?') + ->execute([$resolvedUrl, $integration['id']]); + } + return ['integration' => $out, 'password' => $password]; +} function ios_make_ics(array $event): string { @@ -46,9 +66,8 @@ function ios_caldav_request(string $url, string $username, string $password, str ]; } -function ios_fetch_remote_events(array $integration): array +function ios_fetch_remote_events(array $integration, string $password): array { - $password = hh_decrypt_secret($integration['secret_encrypted']); $res = ios_caldav_request($integration['calendar_url'], $integration['username'], $password, 'GET', null, ['Accept: text/calendar']); if ($res['code'] < 200 || $res['code'] >= 400) { throw new RuntimeException('Lecture CalDAV impossible (HTTP ' . $res['code'] . ')'); @@ -82,18 +101,16 @@ function ios_fetch_remote_events(array $integration): array return $events; } -function ios_push_event_to_remote(array $integration, array $event): array +function ios_push_event_to_remote(array $integration, array $event, string $password): array { - $password = hh_decrypt_secret($integration['secret_encrypted']); $uid = $event['external_uid'] ?: ('hh-' . $event['id'] . '@househub'); $url = rtrim($integration['calendar_url'], '/') . '/' . rawurlencode($uid) . '.ics'; $ics = ios_make_ics($event); return ios_caldav_request($url, $integration['username'], $password, 'PUT', $ics); } -function ios_delete_remote_event(array $integration, string $externalUid): array +function ios_delete_remote_event(array $integration, string $externalUid, string $password): array { - $password = hh_decrypt_secret($integration['secret_encrypted']); $url = rtrim($integration['calendar_url'], '/') . '/' . rawurlencode($externalUid) . '.ics'; return ios_caldav_request($url, $integration['username'], $password, 'DELETE', null, ['Accept: */*']); } diff --git a/settings.php b/settings.php index 867c40b..09f2c1c 100644 --- a/settings.php +++ b/settings.php @@ -3,6 +3,7 @@ require __DIR__ . '/includes/auth.php'; require_login(); require_once __DIR__ . '/includes/meta_db.php'; require_once __DIR__ . '/includes/crypto.php'; +require_once __DIR__ . '/includes/icloud_caldav.php'; require_once __DIR__ . '/includes/i18n.php'; $user_id = $_SESSION['user']['id']; @@ -135,20 +136,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($action === 'calendar_ios_save') { $username = trim($_POST['icloud_username'] ?? ''); - $appPassword = trim($_POST['icloud_app_password'] ?? ''); + $appPassword = hh_normalize_apple_app_password((string) ($_POST['icloud_app_password'] ?? '')); $calendarUrl = trim($_POST['icloud_calendar_url'] ?? ''); if (!$username || !$appPassword || !$calendarUrl) { $error = "Merci de renseigner identifiant iCloud, mot de passe d'app et URL CalDAV."; } else { try { + $resolvedUrl = hh_icloud_resolve_calendar_url_if_needed($username, $appPassword, $calendarUrl); $encrypted = hh_encrypt_secret($appPassword); $meta_pdo->prepare(" INSERT INTO user_calendar_integrations (user_id, provider, username, secret_encrypted, calendar_url, status, updated_at) VALUES (?, 'icloud_caldav', ?, ?, ?, 'connected', NOW()) ON DUPLICATE KEY UPDATE username=VALUES(username), secret_encrypted=VALUES(secret_encrypted), calendar_url=VALUES(calendar_url), status='connected', updated_at=NOW() - ")->execute([$user_id, $username, $encrypted, $calendarUrl]); - $success = "Connexion calendrier iOS enregistrée."; + ")->execute([$user_id, $username, $encrypted, $resolvedUrl]); + $msg = "Connexion calendrier iOS enregistrée."; + if (rtrim($resolvedUrl, '/') !== rtrim($calendarUrl, '/')) { + $msg .= " URL du calendrier détectée automatiquement (tu avais mis la racine iCloud)."; + } + $success = $msg; } catch (\Throwable $e) { $error = "Impossible d'enregistrer la connexion iOS: " . $e->getMessage(); } @@ -156,29 +162,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } if ($action === 'calendar_ios_test') { - $row = $meta_pdo->prepare("SELECT username, secret_encrypted, calendar_url FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'"); + $row = $meta_pdo->prepare("SELECT id, username, secret_encrypted, calendar_url FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'"); $row->execute([$user_id]); $integration = $row->fetch(); if (!$integration) { $error = "Aucune connexion iOS configurée."; } else { try { - $pwd = hh_decrypt_secret($integration['secret_encrypted']); - $ch = curl_init($integration['calendar_url']); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 10, - CURLOPT_NOBODY => true, - CURLOPT_HTTPAUTH => CURLAUTH_BASIC, - CURLOPT_USERPWD => $integration['username'] . ':' . $pwd, - ]); - curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - if ($code >= 200 && $code < 400) { - $success = "Connexion iCloud CalDAV valide."; + $pwd = hh_normalize_apple_app_password(hh_decrypt_secret($integration['secret_encrypted'])); + $calendarUrl = trim($integration['calendar_url']); + $resolvedUrl = hh_icloud_resolve_calendar_url_if_needed($integration['username'], $pwd, $calendarUrl); + $code = hh_caldav_test_calendar_collection($integration['username'], $pwd, $resolvedUrl); + if (in_array($code, [200, 207], true)) { + if (rtrim($resolvedUrl, '/') !== rtrim($calendarUrl, '/')) { + $meta_pdo->prepare("UPDATE user_calendar_integrations SET calendar_url = ? WHERE id = ?") + ->execute([$resolvedUrl, $integration['id']]); + } + $success = "Connexion iCloud CalDAV valide (HTTP $code)."; } else { - $error = "Test connexion échoué (HTTP $code)."; + $error = "Test connexion échoué (HTTP $code). Vérifie identifiant Apple, mot de passe d’app (16 caractères) et que le compte iCloud a le Calendrier activé."; } } catch (\Throwable $e) { $error = "Test connexion impossible: " . $e->getMessage(); @@ -293,6 +295,11 @@ require __DIR__ . '/header.php';

📱 Intégration Calendrier iOS (CalDAV)

Configurez ici votre calendrier iCloud pour synchroniser les événements créés dans HouseHub.

+

+ Pour voir et gérer les événements : ouvrez le module + Calendrier iOS + (menu du haut ou burger « Calendrier iOS », ou carte sur l’accueil). Le module doit être coché dans « Modules actifs » ci-dessus. +

@@ -322,6 +329,10 @@ require __DIR__ . '/header.php';
+

+ Après enregistrement, utilisez « Tester la connexion » : un message vert confirme que l’URL CalDAV répond avec tes identifiants. + Sur la page Calendrier iOS, le bouton « Synchroniser » envoie les événements vers iCloud et récupère ceux créés sur l’iPhone. +

Dernière synchro: