/public/ical.php URL (iPhone Subscribed Calendar): https://yourdomain/ical.php?user= iPhone will prompt for username/password (use test / 12345). */ use Espo\Core\Application; // ========= Security: Enforce HTTPS ========= $proto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ($_SERVER['REQUEST_SCHEME'] ?? ''); $httpsOk = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($proto === 'https'); if (!$httpsOk) { http_response_code(403); echo "HTTPS required"; exit; } // ========= Security: Basic Auth (App User) ========= // WARNING: hardcoded for testing. Move to env/config for production. $APP_USER = 'test'; $APP_PASS = 'password'; $authUser = $_SERVER['PHP_AUTH_USER'] ?? null; $authPass = $_SERVER['PHP_AUTH_PW'] ?? null; if ($authUser !== $APP_USER || $authPass !== $APP_PASS) { header('WWW-Authenticate: Basic realm="Espo ICS"'); http_response_code(401); echo "Unauthorized"; exit; } // ========= Bootstrap Espo exactly like your index.php ========= include "../bootstrap.php"; // Depending on your index.php, this is the common pattern: $app = new Application(); $container = $app->getContainer(); /** @var \Espo\ORM\EntityManager $entityManager */ $entityManager = $container->get('EntityManager'); // ========= Utilities ========= function esc_ics(string $s): string { // RFC 5545 escaping: backslash, comma, semicolon, newline $s = str_replace('\\', '\\\\', $s); $s = str_replace("\r\n", "\n", $s); $s = str_replace("\r", "\n", $s); $s = str_replace("\n", '\\n', $s); $s = str_replace(',', '\\,', $s); $s = str_replace(';', '\\;', $s); return $s; } function fold_line(string $line): string { // Fold at 75 octets; continuation lines start with a space $out = ''; $len = strlen($line); for ($i = 0; $i < $len; $i += 75) { $chunk = substr($line, $i, 75); $out .= ($i === 0 ? $chunk : "\r\n " . $chunk); } return $out; } function dt_utc(?string $isoOrDb): string { if (!$isoOrDb) return gmdate('Ymd\THis\Z'); $ts = strtotime($isoOrDb); return gmdate('Ymd\THis\Z', $ts ?: time()); } function date_only(string $isoOrDb): string { $ts = strtotime($isoOrDb); return gmdate('Ymd', $ts ?: time()); } function iso_to_db(string $iso): string { $ts = strtotime($iso); return gmdate('Y-m-d H:i:s', $ts ?: time()); } // ========= Inputs ========= $userId = $_GET['user'] ?? ''; $fromIso = $_GET['from'] ?? null; $toIso = $_GET['to'] ?? null; if ($userId === '') { http_response_code(400); echo "Missing user"; exit; } // Default time window: last 7 days to next 12 months if ($fromIso === null) { $fromIso = gmdate('Y-m-d\TH:i:s\Z', time() - 7 * 86400); } if ($toIso === null) { $toIso = gmdate('Y-m-d\TH:i:s\Z', time() + 365 * 86400); } $fromDb = iso_to_db($fromIso); $toDb = iso_to_db($toIso); // ========= Query Meetings ========= $repo = $entityManager->getRepository('Meeting'); /* Overlap logic catches any event that intersects the window: dateStart <= to AND dateEnd >= from Also filter by assignedUserId. If your users are attendees instead, adjust this filter accordingly. */ $params = [ 'where' => [ ['type' => 'equals', 'field' => 'assignedUserId', 'value' => $userId], ['type' => 'lte', 'field' => 'dateStart', 'value' => $toDb], ['type' => 'gte', 'field' => 'dateEnd', 'value' => $fromDb], ], 'orderBy' => 'dateStart', 'order' => 'asc', 'limit' => 2000, ]; $list = $repo->find($params); // Map to events $events = []; foreach ($list as $m) { $events[] = [ 'id' => (string)$m->get('id'), 'name' => (string)($m->get('name') ?? ''), 'description'=> (string)($m->get('description') ?? ''), 'location' => (string)($m->get('location') ?? ''), 'dateStart' => (string)$m->get('dateStart'), 'dateEnd' => (string)$m->get('dateEnd'), 'createdAt' => (string)($m->get('createdAt') ?? ''), 'modifiedAt' => (string)($m->get('modifiedAt') ?? ''), 'isAllDay' => (bool)($m->get('allDay') ?? false), ]; } // ========= Output ICS ========= header('Content-Type: text/calendar; charset=utf-8'); header('Content-Disposition: attachment; filename="espocrm_meetings.ics"'); $lines = []; $lines[] = 'BEGIN:VCALENDAR'; $lines[] = 'PRODID:-//EspoCRM//Meetings ICS//EN'; $lines[] = 'VERSION:2.0'; $lines[] = 'CALSCALE:GREGORIAN'; $lines[] = 'METHOD:PUBLISH'; // During testing you can keep this line; remove later if desired: $lines[] = 'X-ESPO-DEBUG-FOUND:' . count($events); $now = gmdate('Ymd\THis\Z'); foreach ($events as $e) { $uid = 'espo-' . ($e['id'] ?: uniqid('', true)) . '@yourdomain'; $summary = esc_ics($e['name']); $desc = esc_ics($e['description']); $location = esc_ics($e['location']); $dtstart = dt_utc($e['dateStart']); $dtend = dt_utc($e['dateEnd']); $created = dt_utc($e['createdAt']); $modified = dt_utc($e['modifiedAt']); $isAllDay = !empty($e['isAllDay']); $lines[] = 'BEGIN:VEVENT'; $lines[] = 'UID:' . $uid; $lines[] = 'DTSTAMP:' . $now; if ($isAllDay) { // All-day uses date-only; DTEND is exclusive next day $startDate = date_only($e['dateStart'] ?: gmdate('Y-m-d\TH:i:s\Z')); $endBase = $e['dateEnd'] ?: $e['dateStart']; $endTs = strtotime($endBase) + 86400; $endDateExcl = gmdate('Ymd', $endTs); $lines[] = 'DTSTART;VALUE=DATE:' . $startDate; $lines[] = 'DTEND;VALUE=DATE:' . $endDateExcl; } else { $lines[] = 'DTSTART:' . $dtstart; $lines[] = 'DTEND:' . $dtend; } if ($summary !== '') $lines[] = 'SUMMARY:' . $summary; if ($desc !== '') $lines[] = 'DESCRIPTION:' . $desc; if ($location !== '') $lines[] = 'LOCATION:' . $location; if (!empty($e['createdAt'])) $lines[] = 'CREATED:' . $created; if (!empty($e['modifiedAt'])) $lines[] = 'LAST-MODIFIED:' . $modified; $lines[] = 'END:VEVENT'; } $lines[] = 'END:VCALENDAR'; // Fold and output $out = ''; foreach ($lines as $line) { $out .= fold_line($line) . "\r\n"; } echo $out;