diff --git a/calendar-ios.php b/calendar-ios.php new file mode 100644 index 0000000..f6d4984 --- /dev/null +++ b/calendar-ios.php @@ -0,0 +1,64 @@ + + + + +
+
+
+

πŸ“±

+
+ +
+
+

CrΓ©ez vos Γ©vΓ©nements ici, puis synchronisez-les avec iCloud CalDAV.

+
+ +
+

Ajouter / Modifier un Γ©vΓ©nement

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Γ‰vΓ©nements

+
+
+
+
+ + + diff --git a/cron/calendar_ios_sync.php b/cron/calendar_ios_sync.php new file mode 100644 index 0000000..473f567 --- /dev/null +++ b/cron/calendar_ios_sync.php @@ -0,0 +1,20 @@ +query("SELECT user_id FROM user_calendar_integrations WHERE provider='icloud_caldav' AND status='connected'")->fetchAll(); +foreach ($rows as $row) { + $userId = (int)$row['user_id']; + $url = 'http://localhost/modules/calendar-ios/api.php?action=sync'; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 20, + CURLOPT_HTTPHEADER => [ + 'X-Requested-With: XMLHttpRequest', + // Le cron rΓ©el devra utiliser un mΓ©canisme d'auth/session technique. + ], + ]); + curl_exec($ch); + curl_close($ch); +} diff --git a/docker/init/00-setup.sql b/docker/init/00-setup.sql index df2c380..206a992 100644 --- a/docker/init/00-setup.sql +++ b/docker/init/00-setup.sql @@ -26,6 +26,22 @@ CREATE TABLE IF NOT EXISTS users ( FOREIGN KEY (family_id) REFERENCES families(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +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), + CONSTRAINT fk_calendar_integration_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Donner au user applicatif le droit de crΓ©er des DBs famille GRANT ALL PRIVILEGES ON `househub_f%`.* TO 'househub'@'%'; FLUSH PRIVILEGES; diff --git a/docker/schema_family.sql b/docker/schema_family.sql index 028b4b1..321070f 100644 --- a/docker/schema_family.sql +++ b/docker/schema_family.sql @@ -344,3 +344,37 @@ CREATE TABLE IF NOT EXISTS pf_todos ( updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ─── Calendar iOS ───────────────────────────────────────────────────────────── +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) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +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) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/header.php b/header.php index c9b81b3..4bcc86b 100644 --- a/header.php +++ b/header.php @@ -45,7 +45,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr'; - + @@ -102,6 +103,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr'; πŸš— πŸ“ βœ… + πŸ“± βš™οΈ ParamΓ¨tres πŸ›‘οΈ Admin @@ -124,7 +126,8 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr'; ID_ALEX: , ID_LAIA: , CURRENCY: '' - }; + }; + window.CSRF_TOKEN = ""; function tr(key) { return window.I18N[key] || key; diff --git a/includes/auth.php b/includes/auth.php index 0a68858..337638e 100644 --- a/includes/auth.php +++ b/includes/auth.php @@ -32,3 +32,25 @@ function require_login(?string $loginPage = '/login.php'): void exit; } } + +function csrf_token(): string +{ + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; +} + +function verify_csrf(?string $token): bool +{ + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + if (empty($_SESSION['csrf_token']) || !$token) { + return false; + } + return hash_equals($_SESSION['csrf_token'], $token); +} diff --git a/includes/crypto.php b/includes/crypto.php new file mode 100644 index 0000000..9f57f80 --- /dev/null +++ b/includes/crypto.php @@ -0,0 +1,35 @@ + 'Pressupost', 'menu_holidays' => 'Viatges', 'menu_gifts' => 'Regals', + 'menu_calendar_ios' => 'Calendari iOS', 'nav_home' => 'Inici', 'nav_calendar' => 'Calendari', 'nav_budget' => 'Pressupost', @@ -95,6 +96,8 @@ return [ 'menu_memo' => 'Notes', 'mod_todo_name' => 'Todo', 'mod_todo_desc' => 'Gestioneu les vostres tasques, llistes de la compra i recordatoris familiars.', + 'mod_calendar_ios_name' => 'Calendari iOS', + 'mod_calendar_ios_desc' => 'Sincronitzeu els esdeveniments de HouseHub amb el calendari de l\'iPhone (iCloud CalDAV).', 'menu_todo' => 'Todo', 'cta_open' => 'Obrir', 'cta_explore' => 'Explorar', @@ -103,6 +106,7 @@ return [ 'cta_service' => 'Revisar', 'cta_jot' => 'Apuntar', 'cta_check' => 'Marcar', + 'cta_sync' => 'Sincronitzar', // ========================================== // FAMILY CALENDAR diff --git a/includes/lang/en.php b/includes/lang/en.php index b972bd9..d9fedc8 100644 --- a/includes/lang/en.php +++ b/includes/lang/en.php @@ -23,6 +23,7 @@ return [ 'menu_budget' => 'Budget', 'menu_holidays' => 'Trips', 'menu_gifts' => 'Gifts', + 'menu_calendar_ios' => 'iOS Calendar', 'nav_home' => 'Home', 'nav_calendar' => 'Calendar', 'nav_budget' => 'Budget', @@ -96,6 +97,8 @@ return [ 'menu_memo' => 'Notes', 'mod_todo_name' => 'Todo', 'mod_todo_desc' => 'Manage tasks, shopping lists and family reminders.', + 'mod_calendar_ios_name' => 'iOS Calendar', + 'mod_calendar_ios_desc' => 'Sync HouseHub events with your iPhone calendar (iCloud CalDAV).', 'menu_todo' => 'Todo', 'cta_open' => 'Open', 'cta_explore' => 'Explore', @@ -104,6 +107,7 @@ return [ 'cta_service' => 'Service', 'cta_jot' => 'Jot down', 'cta_check' => 'Check off', + 'cta_sync' => 'Sync', // ========================================== // FAMILY CALENDAR diff --git a/includes/lang/fr.php b/includes/lang/fr.php index e65bfeb..6966dc1 100644 --- a/includes/lang/fr.php +++ b/includes/lang/fr.php @@ -24,6 +24,7 @@ return [ 'menu_budget' => 'Budget', 'menu_holidays' => 'Voyages', 'menu_gifts' => 'Cadeaux', + 'menu_calendar_ios' => 'Calendrier iOS', 'nav_home' => 'Accueil', 'nav_calendar' => 'Calendrier', 'nav_budget' => 'Budget', @@ -98,6 +99,8 @@ return [ 'menu_memo' => 'Notes', 'mod_todo_name' => 'Todo', 'mod_todo_desc' => 'GΓ©rez vos tΓ’ches, listes de courses et rappels en famille.', + 'mod_calendar_ios_name' => 'Calendrier iOS', + 'mod_calendar_ios_desc' => 'Synchronisez vos Γ©vΓ©nements HouseHub avec votre calendrier iPhone (iCloud CalDAV).', 'menu_todo' => 'Todo', 'cta_open' => 'Ouvrir', 'cta_explore' => 'Explorer', @@ -106,6 +109,7 @@ return [ 'cta_service' => 'RΓ©viser', 'cta_jot' => 'Griffonner', 'cta_check' => 'Cocher', + 'cta_sync' => 'Synchroniser', // ========================================== // FAMILY CALENDAR diff --git a/index.php b/index.php index cd02256..f6acd15 100644 --- a/index.php +++ b/index.php @@ -43,7 +43,7 @@ if ($_has_custom_bg): ?>

- +
@@ -109,6 +109,15 @@ if ($_has_custom_bg): ?> + + +
πŸ“±
+

+
+ +
+ +
diff --git a/login.php b/login.php index e6125a9..a22c90c 100644 --- a/login.php +++ b/login.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'; $pageTitle = tr('login_title'); $activePage = "login"; @@ -14,6 +15,9 @@ if (isset($_SESSION['user'])) { $error = null; if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!verify_csrf($_POST['csrf_token'] ?? null)) { + $error = "Session invalide (CSRF). Rechargez la page."; + } else { $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; @@ -36,6 +40,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } elseif ($user['family_id'] && !$user['family_active']) { $error = "Espace familial dΓ©sactivΓ©. Contactez l'administrateur."; } else { + session_regenerate_id(true); $_SESSION['user'] = [ 'id' => (int)$user['id'], 'username' => $user['username'], @@ -45,7 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ]; $_SESSION['family_db'] = $user['db_name']; $_SESSION['app_lang'] = $user['lang'] ?? 'fr'; - $_SESSION['enabled_modules'] = json_decode($user['enabled_modules'] ?? '["calendar","budget","holidays","gifts"]', true); + $_SESSION['enabled_modules'] = json_decode($user['enabled_modules'] ?? '["calendar","budget","holidays","gifts","calendar_ios"]', true); $redirectTo = $_GET['redirect'] ?? '/index.php'; header('Location: ' . $redirectTo); exit; @@ -54,6 +59,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $error = tr('error_invalid_credentials'); } } + } } require __DIR__ . '/header.php'; @@ -75,6 +81,7 @@ require __DIR__ . '/header.php';
+
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'; +
diff --git a/schema.sql b/schema.sql index 0bb5b8a..a6f503c 100644 --- a/schema.sql +++ b/schema.sql @@ -235,3 +235,37 @@ VALUES (1, 'admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/ig -- Personnes (IDs fixes correspondant aux constantes dans config.php) INSERT IGNORE INTO pf_people (id, name) VALUES (2, 'Alex'); INSERT IGNORE INTO pf_people (id, name) VALUES (3, 'Laia'); + +-- ─── Calendar iOS ───────────────────────────────────────────────────────────── +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) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +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) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/settings.php b/settings.php index 9bf23b9..867c40b 100644 --- a/settings.php +++ b/settings.php @@ -2,6 +2,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/i18n.php'; $user_id = $_SESSION['user']['id']; @@ -10,12 +11,31 @@ $family_id = $_SESSION['user']['family_id']; $error = null; $success = null; +$meta_pdo->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) +)"); + // ─── Actions ────────────────────────────────────────────────────────────────── if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!verify_csrf($_POST['csrf_token'] ?? null)) { + $error = "Session invalide (CSRF). Rechargez la page."; + } else { $action = $_POST['action'] ?? ''; if ($action === 'set_modules' && $family_id) { - $all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo', 'todo']; + $all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo', 'todo', 'calendar_ios']; $enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m]))); if (empty($enabled)) { $error = "Vous devez garder au moins un module actif."; @@ -112,6 +132,65 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $old) @unlink($old); $success = "Image d'accueil rΓ©initialisΓ©e."; } + + if ($action === 'calendar_ios_save') { + $username = trim($_POST['icloud_username'] ?? ''); + $appPassword = trim($_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 { + $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."; + } catch (\Throwable $e) { + $error = "Impossible d'enregistrer la connexion iOS: " . $e->getMessage(); + } + } + } + + 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->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."; + } else { + $error = "Test connexion Γ©chouΓ© (HTTP $code)."; + } + } catch (\Throwable $e) { + $error = "Test connexion impossible: " . $e->getMessage(); + } + } + } + + if ($action === 'calendar_ios_disconnect') { + $meta_pdo->prepare("DELETE FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'")->execute([$user_id]); + $success = "Connexion iOS supprimΓ©e."; + } + } } // ─── Chargement donnΓ©es ─────────────────────────────────────────────────────── @@ -131,6 +210,10 @@ if ($family_id) { $members = $mem->fetchAll(); } +$calendarIntegration = $meta_pdo->prepare("SELECT username, calendar_url, status, last_sync_at FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'"); +$calendarIntegration->execute([$user_id]); +$calendarIntegration = $calendarIntegration->fetch(); + $pageTitle = "ParamΓ¨tres β€” HouseHub"; $activePage = "settings"; require __DIR__ . '/header.php'; @@ -179,7 +262,7 @@ require __DIR__ . '/header.php';

🧩 Modules actifs

Choisissez les modules visibles dans la navigation (partagΓ© avec tous les membres de l'espace).

['icon' => 'πŸ“…', 'label' => tr('menu_calendar')], 'budget' => ['icon' => 'πŸ’°', 'label' => tr('menu_budget')], @@ -188,6 +271,7 @@ require __DIR__ . '/header.php'; 'garage' => ['icon' => 'πŸš—', 'label' => tr('menu_garage')], 'memo' => ['icon' => 'πŸ“', 'label' => tr('menu_memo')], 'todo' => ['icon' => 'βœ…', 'label' => tr('menu_todo')], + 'calendar_ios' => ['icon' => 'πŸ“±', 'label' => tr('menu_calendar_ios')], ]; ?> @@ -206,6 +290,43 @@ require __DIR__ . '/header.php'; +
+

πŸ“± IntΓ©gration Calendrier iOS (CalDAV)

+

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

+ + +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +

Dernière synchro:

+ +
+