@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Normalise le mot de passe d’application Apple (supprime les espaces, garde les tirets).
|
||||
*/
|
||||
function hh_normalize_apple_app_password(string $password): string
|
||||
{
|
||||
return preg_replace('/\s+/', '', trim($password));
|
||||
}
|
||||
|
||||
function hh_caldav_is_icloud_well_known_root(string $url): bool
|
||||
{
|
||||
$parts = parse_url(rtrim(trim($url), '/'));
|
||||
if (empty($parts['scheme']) || empty($parts['host'])) {
|
||||
return false;
|
||||
}
|
||||
$host = strtolower($parts['host']);
|
||||
if ($host !== 'caldav.icloud.com') {
|
||||
return false;
|
||||
}
|
||||
$path = $parts['path'] ?? '';
|
||||
return $path === '' || $path === '/';
|
||||
}
|
||||
|
||||
function hh_caldav_resolve_href(string $againstUrl, string $href): string
|
||||
{
|
||||
$href = trim($href);
|
||||
if ($href === '') {
|
||||
return $againstUrl;
|
||||
}
|
||||
if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
|
||||
return $href;
|
||||
}
|
||||
$parts = parse_url($againstUrl);
|
||||
$scheme = $parts['scheme'] ?? 'https';
|
||||
$host = $parts['host'] ?? '';
|
||||
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
|
||||
if ($href[0] === '/') {
|
||||
return $scheme . '://' . $host . $port . $href;
|
||||
}
|
||||
$base = preg_replace('#/[^/]*$#', '/', $againstUrl);
|
||||
return rtrim($base, '/') . '/' . ltrim($href, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{code:int, body:string}
|
||||
*/
|
||||
function hh_caldav_propfind(string $url, string $username, string $password, string $xml, string $depth = '0'): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => 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<array{href:string, display:string}>
|
||||
*/
|
||||
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 = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
. '<propfind xmlns="DAV:">'
|
||||
. '<prop><current-user-principal/></prop>'
|
||||
. '</propfind>';
|
||||
$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 = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
. '<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
|
||||
. '<prop><C:calendar-home-set/></prop>'
|
||||
. '</propfind>';
|
||||
$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 = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
. '<propfind xmlns="DAV:">'
|
||||
. '<prop><resourcetype/><displayname/></prop>'
|
||||
. '</propfind>';
|
||||
$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 = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
. '<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
|
||||
. '<prop><resourcetype/><displayname/><C:supported-calendar-component-set/></prop>'
|
||||
. '</propfind>';
|
||||
$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;
|
||||
}
|
||||
@@ -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']]);
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
<?php
|
||||
require_once dirname(__DIR__, 2) . '/includes/crypto.php';
|
||||
require_once dirname(__DIR__, 2) . '/includes/icloud_caldav.php';
|
||||
|
||||
/**
|
||||
* Mot de passe normalisé + URL calendrier (découverte si racine iCloud).
|
||||
*
|
||||
* @return array{integration: array, password: string}
|
||||
*/
|
||||
function ios_caldav_prepare(array $integration, ?PDO $metaPdo = null): array
|
||||
{
|
||||
$password = hh_normalize_apple_app_password(hh_decrypt_secret($integration['secret_encrypted']));
|
||||
$url = trim($integration['calendar_url'] ?? '');
|
||||
$resolvedUrl = hh_icloud_resolve_calendar_url_if_needed($integration['username'], $password, $url);
|
||||
$out = $integration;
|
||||
$out['calendar_url'] = $resolvedUrl;
|
||||
if ($metaPdo && !empty($integration['id']) && rtrim($url, '/') !== rtrim($resolvedUrl, '/')) {
|
||||
$metaPdo->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: */*']);
|
||||
}
|
||||
|
||||
+30
-19
@@ -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';
|
||||
<section class="pf-panel-card">
|
||||
<h2 class="pf-card-h2 pf-card-h2--tight">📱 Intégration Calendrier iOS (CalDAV)</h2>
|
||||
<p class="pf-muted-note">Configurez ici votre calendrier iCloud pour synchroniser les événements créés dans HouseHub.</p>
|
||||
<p class="pf-muted-note" style="margin-top:8px;">
|
||||
Pour voir et gérer les événements : ouvrez le module
|
||||
<a href="/calendar-ios.php" style="color:var(--primary);font-weight:600;">Calendrier iOS</a>
|
||||
(menu du haut ou burger « Calendrier iOS », ou carte sur l’accueil). Le module doit être coché dans « Modules actifs » ci-dessus.
|
||||
</p>
|
||||
<form method="post" class="pf-stack-md">
|
||||
<input type="hidden" name="action" value="calendar_ios_save">
|
||||
<div class="pf-form-group">
|
||||
@@ -322,6 +329,10 @@ require __DIR__ . '/header.php';
|
||||
<button type="submit" class="pf-btn btn-secondary">Déconnecter</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="pf-muted-note" style="margin-top:10px;">
|
||||
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.
|
||||
</p>
|
||||
<?php if (!empty($calendarIntegration['last_sync_at'])): ?>
|
||||
<p class="pf-muted-note">Dernière synchro: <?= htmlspecialchars($calendarIntegration['last_sync_at']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
Reference in New Issue
Block a user