prepare( "UPDATE users SET agenda_feed_token_hash=:hash,agenda_feed_created_at=datetime('now'),updated_at=datetime('now') WHERE id=:id AND active=1", ) ->execute([':hash' => hash('sha256', $token), ':id' => $userId]); return $token; } public static function revoke(int $userId): void { DB::pdo() ->prepare( "UPDATE users SET agenda_feed_token_hash=NULL,agenda_feed_created_at=NULL,updated_at=datetime('now') WHERE id=?", ) ->execute([$userId]); } public static function userForToken(string $token): ?array { if (!preg_match('/^[a-f0-9]{64}$/', $token)) { return null; } $s = DB::pdo()->prepare('SELECT id,role FROM users WHERE agenda_feed_token_hash=? AND active=1 LIMIT 1'); $s->execute([hash('sha256', $token)]); $user = $s->fetch(PDO::FETCH_ASSOC) ?: null; if (!$user || !PermissionService::can('agenda', 'view', (string) $user['role'])) { return null; } return $user; } public static function absoluteUrl(string $path): string { $configured = rtrim((string) getenv('APP_URL'), '/'); if ($configured !== '' && preg_match('~^https?://~i', $configured)) { return $configured . $path; } $proto = (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''); if (!in_array($proto, ['http', 'https'], true)) { $proto = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http'; } $host = (string) ($_SERVER['HTTP_X_FORWARDED_HOST'] ?? ($_SERVER['HTTP_HOST'] ?? 'localhost')); $host = trim(explode(',', $host)[0]); if (!preg_match('/^[a-z0-9.:-]+$/i', $host)) { $host = 'localhost'; } return $proto . '://' . $host . $path; } public static function render(array $items, string $name): string { $lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Globinours//Agenda refuge//FR', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:' . self::escape($name), 'X-WR-TIMEZONE:Europe/Paris', ]; $stamp = gmdate('Ymd\THis\Z'); foreach ($items as $item) { $source = (string) ($item['source_type'] ?? 'agenda'); $sourceId = (int) ($item['source_id'] ?? $item['id']); $instance = (string) $item['instance_starts_at']; $uid = $source . '-' . $sourceId . '-' . substr(hash('sha256', $instance), 0, 12) . '@globinours'; $lines[] = 'BEGIN:VEVENT'; $lines[] = 'UID:' . $uid; $lines[] = 'DTSTAMP:' . $stamp; if ((int) $item['all_day'] === 1) { $start = new DateTimeImmutable(substr($instance, 0, 10)); $lines[] = 'DTSTART;VALUE=DATE:' . $start->format('Ymd'); $lines[] = 'DTEND;VALUE=DATE:' . $start->modify('+1 day')->format('Ymd'); } else { $start = new DateTimeImmutable($instance, new DateTimeZone('Europe/Paris')); $end = !empty($item['instance_ends_at']) ? new DateTimeImmutable($item['instance_ends_at'], new DateTimeZone('Europe/Paris')) : $start->modify('+1 hour'); $lines[] = 'DTSTART;TZID=Europe/Paris:' . $start->format('Ymd\THis'); $lines[] = 'DTEND;TZID=Europe/Paris:' . $end->format('Ymd\THis'); } $lines[] = 'SUMMARY:' . self::escape((string) $item['title']); $description = trim((string) ($item['description'] ?? '')); if (!empty($item['animal_name'])) { $description = trim($description . "\n" . t('animal.animal') . ' : ' . $item['animal_name']); } if ($description !== '') { $lines[] = 'DESCRIPTION:' . self::escape($description); } if (!empty($item['location'])) { $lines[] = 'LOCATION:' . self::escape((string) $item['location']); } if (!empty($item['animal_id'])) { $lines[] = 'URL:' . self::escape(self::absoluteUrl('/animal?id=' . (int) $item['animal_id'])); } $lines[] = 'STATUS:' . (($item['status'] ?? 'planned') === 'completed' ? 'COMPLETED' : 'CONFIRMED'); $lines[] = 'END:VEVENT'; } $lines[] = 'END:VCALENDAR'; return implode("\r\n", array_map([self::class, 'fold'], $lines)) . "\r\n"; } private static function escape(string $value): string { return str_replace(['\\', "\r\n", "\n", "\r", ';', ','], ['\\\\', '\\n', '\\n', '\\n', '\\;', '\\,'], $value); } private static function fold(string $line): string { $out = ''; while (strlen($line) > 73) { $cut = 73; while ($cut > 0 && (ord($line[$cut]) & 0xc0) === 0x80) { $cut--; } $out .= substr($line, 0, $cut) . "\r\n "; $line = substr($line, $cut); } return $out . $line; } }