exec("
+CREATE TABLE IF NOT EXISTS user_calendar_integrations (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ user_id INT NOT NULL,
+ provider VARCHAR(50) NOT NULL DEFAULT 'icloud_caldav',
+ username VARCHAR(255) NOT NULL,
+ secret_encrypted TEXT NOT NULL,
+ dav_principal_url VARCHAR(1024) DEFAULT NULL,
+ calendar_url VARCHAR(1024) DEFAULT NULL,
+ status VARCHAR(30) DEFAULT 'connected',
+ last_sync_at DATETIME DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_user_provider (user_id, provider)
+)");
+$pdo->exec("
+CREATE TABLE IF NOT EXISTS pf_calendar_events (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ family_id INT NOT NULL,
+ created_by_user_id INT NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ description TEXT DEFAULT NULL,
+ location VARCHAR(255) DEFAULT NULL,
+ start_at DATETIME NOT NULL,
+ end_at DATETIME NOT NULL,
+ is_all_day TINYINT(1) DEFAULT 0,
+ timezone VARCHAR(64) DEFAULT 'Europe/Paris',
+ rrule VARCHAR(500) DEFAULT NULL,
+ status VARCHAR(50) DEFAULT 'confirmed',
+ external_uid VARCHAR(255) DEFAULT NULL,
+ sync_state VARCHAR(30) DEFAULT 'pending_push',
+ deleted_at DATETIME DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_external_uid (external_uid)
+)");
+$pdo->exec("
+CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ calendar_event_id INT NOT NULL,
+ external_uid VARCHAR(255) NOT NULL,
+ external_etag VARCHAR(255) DEFAULT NULL,
+ calendar_url VARCHAR(1024) DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_calendar_event (calendar_event_id),
+ UNIQUE KEY uq_external_link (external_uid)
+)");
+
+function ios_ok($data): void { echo json_encode(['ok' => true, 'data' => $data], JSON_UNESCAPED_UNICODE); exit; }
+function ios_err(string $message, int $status = 400): void { http_response_code($status); echo json_encode(['ok' => false, 'error' => $message]); exit; }
+function ios_body(): array { return json_decode(file_get_contents('php://input'), true) ?? []; }
+function ios_require_csrf(): void {
+ $token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
+ if (!verify_csrf($token)) {
+ ios_err('Token CSRF invalide', 403);
+ }
+}
+
+$integrationStmt = $meta_pdo->prepare("SELECT * FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'");
+$integrationStmt->execute([$userId]);
+$integration = $integrationStmt->fetch();
+
+if ($action === 'events' && $method === 'GET') {
+ $rows = $pdo->query("SELECT * FROM pf_calendar_events ORDER BY start_at ASC")->fetchAll();
+ ios_ok($rows);
+}
+
+if ($action === 'events' && $method === 'POST') {
+ ios_require_csrf();
+ $d = ios_body();
+ if (empty($d['title']) || empty($d['start_at']) || empty($d['end_at'])) {
+ ios_err('Champs requis manquants');
+ }
+ $stmt = $pdo->prepare("
+ INSERT INTO pf_calendar_events (family_id, created_by_user_id, title, description, location, start_at, end_at, timezone, sync_state)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', 'pending_push')
+ ");
+ $stmt->execute([$familyId, $userId, trim($d['title']), $d['description'] ?? null, $d['location'] ?? null, $d['start_at'], $d['end_at']]);
+ ios_ok(['id' => (int)$pdo->lastInsertId()]);
+}
+
+if ($action === 'events' && $method === 'PUT') {
+ ios_require_csrf();
+ $d = ios_body();
+ $id = (int)($d['id'] ?? 0);
+ if ($id <= 0) ios_err('ID invalide');
+ $stmt = $pdo->prepare("
+ UPDATE pf_calendar_events
+ SET title=?, description=?, location=?, start_at=?, end_at=?, updated_at=NOW(), sync_state='pending_push'
+ WHERE id=?
+ ");
+ $stmt->execute([trim($d['title'] ?? ''), $d['description'] ?? null, $d['location'] ?? null, $d['start_at'] ?? null, $d['end_at'] ?? null, $id]);
+ ios_ok(['updated' => true]);
+}
+
+if ($action === 'events' && $method === 'DELETE') {
+ ios_require_csrf();
+ $d = ios_body();
+ $id = (int)($d['id'] ?? 0);
+ if ($id <= 0) ios_err('ID invalide');
+ $row = $pdo->prepare("SELECT id FROM pf_calendar_events WHERE id=?");
+ $row->execute([$id]);
+ if (!$row->fetch()) ios_err('ΓvΓ©nement introuvable', 404);
+ $pdo->prepare("UPDATE pf_calendar_events SET deleted_at=NOW(), sync_state='pending_delete' WHERE id=?")->execute([$id]);
+ ios_ok(['deleted' => true]);
+}
+
+if ($action === 'sync_status' && $method === 'GET') {
+ if (!$integration) {
+ ios_ok(['message' => 'Aucune intΓ©gration iCloud configurΓ©e.']);
+ }
+ $msg = 'Connecté iCloud. Dernière synchro: ' . ($integration['last_sync_at'] ?? 'jamais');
+ ios_ok(['message' => $msg]);
+}
+
+if ($action === 'sync' && $method === 'POST') {
+ ios_require_csrf();
+ if (!$integration) ios_err('Connexion iCloud non configurΓ©e.', 400);
+
+ $remoteEvents = ios_fetch_remote_events($integration);
+ $remoteByUid = [];
+ foreach ($remoteEvents as $re) $remoteByUid[$re['external_uid']] = $re;
+
+ $localStmt = $pdo->query("SELECT * FROM pf_calendar_events WHERE deleted_at IS NULL");
+ $localEvents = $localStmt->fetchAll();
+
+ foreach ($localEvents as $evt) {
+ if ($evt['sync_state'] === 'pending_push' || empty($evt['external_uid'])) {
+ $push = ios_push_event_to_remote($integration, $evt);
+ 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']]);
+ $pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE external_etag=VALUES(external_etag), calendar_url=VALUES(calendar_url), updated_at=NOW()")
+ ->execute([$evt['id'], $uid, $integration['calendar_url'], null]);
+ }
+ }
+ }
+
+ $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']);
+ }
+ $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']]);
+ }
+
+ foreach ($remoteEvents as $remote) {
+ $existing = $pdo->prepare("SELECT id, updated_at FROM pf_calendar_events WHERE external_uid=? LIMIT 1");
+ $existing->execute([$remote['external_uid']]);
+ $row = $existing->fetch();
+ if (!$row) {
+ $pdo->prepare("
+ INSERT INTO pf_calendar_events (family_id, created_by_user_id, title, description, location, start_at, end_at, timezone, external_uid, sync_state)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', ?, 'synced')
+ ")->execute([$familyId, $userId, $remote['title'], $remote['description'], $remote['location'], $remote['start_at'], $remote['end_at'], $remote['external_uid']]);
+ $newId = (int)$pdo->lastInsertId();
+ $pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE updated_at=NOW()")
+ ->execute([$newId, $remote['external_uid'], $integration['calendar_url'], null]);
+ }
+ }
+
+ $meta_pdo->prepare("UPDATE user_calendar_integrations SET last_sync_at=NOW(), status='connected' WHERE id=?")->execute([$integration['id']]);
+ ios_ok(['message' => 'Synchronisation terminΓ©e.']);
+}
+
+ios_err('Action inconnue', 404);
diff --git a/modules/calendar-ios/assets/calendar-ios.css b/modules/calendar-ios/assets/calendar-ios.css
new file mode 100644
index 0000000..ed4e946
--- /dev/null
+++ b/modules/calendar-ios/assets/calendar-ios.css
@@ -0,0 +1,79 @@
+.ios-calendar-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.ios-calendar-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.ios-calendar-head h1 {
+ margin: 0;
+ font-size: 1.4rem;
+}
+
+.ios-form-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.ios-form-full {
+ grid-column: 1 / -1;
+}
+
+.ios-form-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.ios-events-list {
+ display: grid;
+ gap: 10px;
+}
+
+.ios-event-card {
+ border: 1px solid var(--pf-border);
+ border-radius: 10px;
+ padding: 12px;
+ background: var(--pf-bg-page);
+}
+
+.ios-event-card-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ align-items: center;
+}
+
+.ios-event-card-title {
+ font-weight: 700;
+}
+
+.ios-event-card-meta {
+ color: var(--pf-text-muted);
+ font-size: 0.9rem;
+}
+
+.ios-event-card-actions {
+ display: flex;
+ gap: 8px;
+}
+
+@media (max-width: 768px) {
+ .ios-form-grid {
+ grid-template-columns: 1fr;
+ }
+ .ios-calendar-head {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+ .ios-event-card-head {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
diff --git a/modules/calendar-ios/assets/calendar-ios.js b/modules/calendar-ios/assets/calendar-ios.js
new file mode 100644
index 0000000..60dd522
--- /dev/null
+++ b/modules/calendar-ios/assets/calendar-ios.js
@@ -0,0 +1,119 @@
+const iosEventsList = document.getElementById("ios-events-list");
+const iosSyncStatus = document.getElementById("ios-sync-status");
+const iosForm = document.getElementById("ios-event-form");
+
+async function iosApi(action, options = {}) {
+ const method = options.method || "GET";
+ const headers = {
+ "Accept": "application/json",
+ "X-Requested-With": "XMLHttpRequest",
+ ...(options.headers || {}),
+ };
+ if (method !== "GET") {
+ headers["X-CSRF-Token"] = window.CSRF_TOKEN || "";
+ headers["Content-Type"] = "application/json";
+ }
+ const response = await fetch(`/modules/calendar-ios/api.php?action=${encodeURIComponent(action)}`, {
+ method,
+ credentials: "same-origin",
+ headers,
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ });
+ const json = await response.json();
+ if (!json.ok) throw new Error(json.error || "Erreur inconnue");
+ return json.data;
+}
+
+function formatDate(iso) {
+ const d = new Date(iso);
+ return d.toLocaleString();
+}
+
+function fillForm(evt) {
+ document.getElementById("ios-event-id").value = evt.id;
+ document.getElementById("ios-title").value = evt.title || "";
+ document.getElementById("ios-location").value = evt.location || "";
+ document.getElementById("ios-description").value = evt.description || "";
+ document.getElementById("ios-start").value = (evt.start_at || "").slice(0, 16);
+ document.getElementById("ios-end").value = (evt.end_at || "").slice(0, 16);
+}
+
+function resetForm() {
+ iosForm.reset();
+ document.getElementById("ios-event-id").value = "";
+}
+
+async function loadEvents() {
+ const events = await iosApi("events");
+ iosEventsList.innerHTML = "";
+ if (!events.length) {
+ iosEventsList.innerHTML = "
Aucun Γ©vΓ©nement.
";
+ return;
+ }
+ events.forEach((evt) => {
+ const card = document.createElement("div");
+ card.className = "ios-event-card";
+ card.innerHTML = `
+
+
+
${evt.title || "(Sans titre)"}
+
${formatDate(evt.start_at)} β ${formatDate(evt.end_at)}
+
${evt.location || ""}
+
+
+
+
+
+
+ `;
+ card.querySelector("[data-edit]").addEventListener("click", () => fillForm(evt));
+ card.querySelector("[data-delete]").addEventListener("click", async () => {
+ if (!confirm("Supprimer cet Γ©vΓ©nement ?")) return;
+ await iosApi("events", { method: "DELETE", body: { id: evt.id } });
+ await loadEvents();
+ });
+ iosEventsList.appendChild(card);
+ });
+}
+
+async function loadSyncStatus() {
+ const status = await iosApi("sync_status");
+ iosSyncStatus.textContent = status.message;
+}
+
+iosForm.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const id = document.getElementById("ios-event-id").value;
+ const payload = {
+ id: id ? parseInt(id, 10) : null,
+ title: document.getElementById("ios-title").value.trim(),
+ location: document.getElementById("ios-location").value.trim(),
+ description: document.getElementById("ios-description").value.trim(),
+ start_at: document.getElementById("ios-start").value,
+ end_at: document.getElementById("ios-end").value,
+ };
+ await iosApi("events", { method: id ? "PUT" : "POST", body: payload });
+ resetForm();
+ await loadEvents();
+});
+
+document.getElementById("ios-form-reset").addEventListener("click", resetForm);
+document.getElementById("ios-sync-btn").addEventListener("click", async () => {
+ document.getElementById("ios-sync-btn").disabled = true;
+ try {
+ const data = await iosApi("sync", { method: "POST", body: {} });
+ iosSyncStatus.textContent = data.message;
+ await loadEvents();
+ } finally {
+ document.getElementById("ios-sync-btn").disabled = false;
+ }
+});
+
+window.addEventListener("DOMContentLoaded", async () => {
+ try {
+ await loadEvents();
+ await loadSyncStatus();
+ } catch (e) {
+ iosSyncStatus.textContent = e.message;
+ }
+});
diff --git a/modules/calendar-ios/caldav_sync.php b/modules/calendar-ios/caldav_sync.php
new file mode 100644
index 0000000..437165d
--- /dev/null
+++ b/modules/calendar-ios/caldav_sync.php
@@ -0,0 +1,99 @@
+ true,
+ CURLOPT_TIMEOUT => 20,
+ CURLOPT_CUSTOMREQUEST => $method,
+ CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
+ CURLOPT_USERPWD => $username . ':' . $password,
+ CURLOPT_HTTPHEADER => $requestHeaders,
+ CURLOPT_HEADER => true,
+ ]);
+ if ($body !== null) {
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+ }
+ $raw = curl_exec($ch);
+ $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
+ $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+ if ($raw === false) {
+ return ['code' => 0, 'headers' => '', 'body' => ''];
+ }
+ return [
+ 'code' => $code,
+ 'headers' => substr($raw, 0, $headerSize),
+ 'body' => substr($raw, $headerSize),
+ ];
+}
+
+function ios_fetch_remote_events(array $integration): 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'] . ')');
+ }
+ $blocks = preg_split('/BEGIN:VEVENT|END:VEVENT/', $res['body']);
+ $events = [];
+ foreach ($blocks as $chunk) {
+ if (strpos($chunk, 'UID:') === false) {
+ continue;
+ }
+ preg_match('/UID:(.+)\R/', $chunk, $uidM);
+ preg_match('/SUMMARY:(.+)\R/', $chunk, $sumM);
+ preg_match('/DESCRIPTION:(.+)\R/', $chunk, $descM);
+ preg_match('/LOCATION:(.+)\R/', $chunk, $locM);
+ preg_match('/DTSTART(?:;VALUE=DATE)?:(.+)\R/', $chunk, $startM);
+ preg_match('/DTEND(?:;VALUE=DATE)?:(.+)\R/', $chunk, $endM);
+ if (empty($uidM[1]) || empty($startM[1])) {
+ continue;
+ }
+ $start = date('Y-m-d H:i:s', strtotime(trim($startM[1])));
+ $end = !empty($endM[1]) ? date('Y-m-d H:i:s', strtotime(trim($endM[1]))) : $start;
+ $events[] = [
+ 'external_uid' => trim($uidM[1]),
+ 'title' => trim($sumM[1] ?? ''),
+ 'description' => trim($descM[1] ?? ''),
+ 'location' => trim($locM[1] ?? ''),
+ 'start_at' => $start,
+ 'end_at' => $end,
+ ];
+ }
+ return $events;
+}
+
+function ios_push_event_to_remote(array $integration, array $event): 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
+{
+ $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/register.php b/register.php
index 95121cc..b4da479 100644
--- a/register.php
+++ b/register.php
@@ -2,6 +2,7 @@
session_start();
require_once __DIR__ . '/includes/i18n.php';
require_once __DIR__ . '/includes/meta_db.php';
+require_once __DIR__ . '/includes/auth.php';
if (isset($_SESSION['user'])) {
header('Location: /index.php');
@@ -40,6 +41,9 @@ function createFamilyDb(PDO $meta, string $db_host, string $db_user, string $db_
// βββ Traitement du formulaire βββββββββββββββββββββββββββββββββββββββββββββββββ
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!verify_csrf($_POST['csrf_token'] ?? null)) {
+ $error = "Session invalide (CSRF). Rechargez la page.";
+ } else {
$action = $_POST['action'] ?? 'create';
$username = trim($_POST['username'] ?? '');
$display_name = trim($_POST['display_name'] ?? '');
@@ -93,6 +97,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'family_id' => (int)$family['id'],
];
$_SESSION['family_db'] = $family['db_name'];
+ session_regenerate_id(true);
header('Location: /index.php');
exit;
}
@@ -133,6 +138,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'family_id' => $family_id,
];
$_SESSION['family_db'] = $db_name;
+ session_regenerate_id(true);
header('Location: /index.php');
exit;
}
@@ -142,6 +148,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$error = "Erreur lors de la crΓ©ation : " . $e->getMessage();
}
}
+ }
}
$pageTitle = "Inscription β HouseHub";
@@ -172,6 +179,7 @@ require __DIR__ . '/header.php';