Improve private-event handling and CalDAV sync stability
This commit is contained in:
parent
79a207a6f2
commit
6354c8b4c5
|
|
@ -140,6 +140,14 @@ final class CalDavService
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listDeletedResources(int $limit = 500): array
|
||||||
|
{
|
||||||
|
$rows = $this->events->listCalDavTombstones($limit);
|
||||||
|
return array_values(array_filter(array_map(static function (array $row): string {
|
||||||
|
return trim((string) ($row['resource'] ?? ''));
|
||||||
|
}, $rows)));
|
||||||
|
}
|
||||||
|
|
||||||
public function resourceForEvent(array $event): string
|
public function resourceForEvent(array $event): string
|
||||||
{
|
{
|
||||||
$resource = trim((string) ($event['caldav_resource'] ?? ''));
|
$resource = trim((string) ($event['caldav_resource'] ?? ''));
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ final class EventService
|
||||||
{
|
{
|
||||||
private readonly string $eventsTable;
|
private readonly string $eventsTable;
|
||||||
private readonly string $exceptionsTable;
|
private readonly string $exceptionsTable;
|
||||||
|
private readonly string $tombstonesTable;
|
||||||
|
|
||||||
public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
|
public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
|
||||||
{
|
{
|
||||||
|
|
@ -19,6 +20,7 @@ final class EventService
|
||||||
$stem = trim($tableStem, '_');
|
$stem = trim($tableStem, '_');
|
||||||
$this->eventsTable = $prefix . $stem . '_events';
|
$this->eventsTable = $prefix . $stem . '_events';
|
||||||
$this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions';
|
$this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions';
|
||||||
|
$this->tombstonesTable = $prefix . $stem . '_caldav_tombstones';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listEvents(): array
|
public function listEvents(): array
|
||||||
|
|
@ -75,6 +77,7 @@ final class EventService
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
|
'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')),
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'description' => (string) ($payload['description'] ?? ''),
|
'description' => (string) ($payload['description'] ?? ''),
|
||||||
'location' => (string) ($payload['location'] ?? ''),
|
'location' => (string) ($payload['location'] ?? ''),
|
||||||
|
|
@ -104,7 +107,12 @@ final class EventService
|
||||||
if ($inserted === false) {
|
if ($inserted === false) {
|
||||||
throw new \RuntimeException('failed to create event');
|
throw new \RuntimeException('failed to create event');
|
||||||
}
|
}
|
||||||
return (array) $this->getEvent($this->db->insertId());
|
$created = (array) $this->getEvent($this->db->insertId());
|
||||||
|
$resource = trim((string) ($created['caldav_resource'] ?? ''));
|
||||||
|
if ($resource !== '') {
|
||||||
|
$this->clearCalDavTombstone($resource);
|
||||||
|
}
|
||||||
|
return $created;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateEvent(int $id, array $payload): ?array
|
public function updateEvent(int $id, array $payload): ?array
|
||||||
|
|
@ -148,6 +156,9 @@ final class EventService
|
||||||
$repeatNthWeekday
|
$repeatNthWeekday
|
||||||
);
|
);
|
||||||
$data = [
|
$data = [
|
||||||
|
'visibility' => array_key_exists('visibility', $payload)
|
||||||
|
? $this->canonicalVisibility((string) $payload['visibility'])
|
||||||
|
: $this->canonicalVisibility((string) ($existing['visibility'] ?? 'public')),
|
||||||
'title' => trim((string) ($payload['title'] ?? $existing['title'])),
|
'title' => trim((string) ($payload['title'] ?? $existing['title'])),
|
||||||
'description' => (string) ($payload['description'] ?? $existing['description']),
|
'description' => (string) ($payload['description'] ?? $existing['description']),
|
||||||
'location' => (string) ($payload['location'] ?? $existing['location']),
|
'location' => (string) ($payload['location'] ?? $existing['location']),
|
||||||
|
|
@ -179,13 +190,27 @@ final class EventService
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->db->update($this->eventsTable, $data, ['id' => $id]);
|
$this->db->update($this->eventsTable, $data, ['id' => $id]);
|
||||||
return $this->getEvent($id);
|
$updated = $this->getEvent($id);
|
||||||
|
if ($updated) {
|
||||||
|
$resource = trim((string) ($updated['caldav_resource'] ?? ''));
|
||||||
|
if ($resource !== '') {
|
||||||
|
$this->clearCalDavTombstone($resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteEvent(int $id): bool
|
public function deleteEvent(int $id): bool
|
||||||
{
|
{
|
||||||
|
$event = $this->getEvent($id);
|
||||||
$this->db->delete($this->exceptionsTable, ['event_id' => $id]);
|
$this->db->delete($this->exceptionsTable, ['event_id' => $id]);
|
||||||
$deleted = $this->db->delete($this->eventsTable, ['id' => $id]);
|
$deleted = $this->db->delete($this->eventsTable, ['id' => $id]);
|
||||||
|
if ($deleted !== false && $event) {
|
||||||
|
$resource = trim((string) ($event['caldav_resource'] ?? ''));
|
||||||
|
if ($resource !== '') {
|
||||||
|
$this->recordCalDavTombstone($resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
return $deleted !== false;
|
return $deleted !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,6 +303,7 @@ final class EventService
|
||||||
$event = [
|
$event = [
|
||||||
'id' => 0,
|
'id' => 0,
|
||||||
'uid' => 'preview@calendar-plugin',
|
'uid' => 'preview@calendar-plugin',
|
||||||
|
'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')),
|
||||||
'title' => (string) ($payload['title'] ?? ''),
|
'title' => (string) ($payload['title'] ?? ''),
|
||||||
'description' => (string) ($payload['description'] ?? ''),
|
'description' => (string) ($payload['description'] ?? ''),
|
||||||
'location' => (string) ($payload['location'] ?? ''),
|
'location' => (string) ($payload['location'] ?? ''),
|
||||||
|
|
@ -352,7 +378,7 @@ final class EventService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false): array
|
public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false, bool $redactPrivate = true): array
|
||||||
{
|
{
|
||||||
$tz = new DateTimeZone('Europe/London');
|
$tz = new DateTimeZone('Europe/London');
|
||||||
$anchor = $this->safeDate($dateAnchor, $tz);
|
$anchor = $this->safeDate($dateAnchor, $tz);
|
||||||
|
|
@ -382,10 +408,14 @@ final class EventService
|
||||||
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
|
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($redactPrivate) {
|
||||||
|
return array_map([$this, 'redactOccurrenceForPublic'], $out);
|
||||||
|
}
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listSidebarUpcoming(int $days = 14): array
|
public function listSidebarUpcoming(int $days = 14, bool $redactPrivate = true): array
|
||||||
{
|
{
|
||||||
$tz = new DateTimeZone('Europe/London');
|
$tz = new DateTimeZone('Europe/London');
|
||||||
$start = new DateTimeImmutable('today', $tz);
|
$start = new DateTimeImmutable('today', $tz);
|
||||||
|
|
@ -404,6 +434,10 @@ final class EventService
|
||||||
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
|
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($redactPrivate) {
|
||||||
|
return array_map([$this, 'redactOccurrenceForPublic'], $out);
|
||||||
|
}
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,11 +445,31 @@ final class EventService
|
||||||
{
|
{
|
||||||
$events = $this->listEvents();
|
$events = $this->listEvents();
|
||||||
$count = count($events);
|
$count = count($events);
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$resource = trim((string) ($event['caldav_resource'] ?? ''));
|
||||||
|
if ($resource !== '') {
|
||||||
|
$this->recordCalDavTombstone($resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
$this->db->query("DELETE FROM {$this->exceptionsTable}");
|
$this->db->query("DELETE FROM {$this->exceptionsTable}");
|
||||||
$this->db->query("DELETE FROM {$this->eventsTable}");
|
$this->db->query("DELETE FROM {$this->eventsTable}");
|
||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listCalDavTombstones(int $limit = 500): array
|
||||||
|
{
|
||||||
|
$limit = max(1, min($limit, 5000));
|
||||||
|
$rows = $this->db->getResults(
|
||||||
|
"SELECT resource, deleted_at FROM {$this->tombstonesTable} ORDER BY deleted_at DESC LIMIT {$limit}"
|
||||||
|
);
|
||||||
|
return array_map(static function (object $row): array {
|
||||||
|
return [
|
||||||
|
'resource' => (string) ($row->resource ?? ''),
|
||||||
|
'deleted_at' => (string) ($row->deleted_at ?? ''),
|
||||||
|
];
|
||||||
|
}, $rows);
|
||||||
|
}
|
||||||
|
|
||||||
public function seedDefaultEvents(): int
|
public function seedDefaultEvents(): int
|
||||||
{
|
{
|
||||||
$seed = [
|
$seed = [
|
||||||
|
|
@ -494,6 +548,9 @@ final class EventService
|
||||||
return [
|
return [
|
||||||
'id' => (int) $row->id,
|
'id' => (int) $row->id,
|
||||||
'uid' => (string) $row->uid,
|
'uid' => (string) $row->uid,
|
||||||
|
'visibility' => property_exists($row, 'visibility')
|
||||||
|
? $this->canonicalVisibility((string) ($row->visibility ?? 'public'))
|
||||||
|
: 'public',
|
||||||
'title' => (string) $row->title,
|
'title' => (string) $row->title,
|
||||||
'description' => (string) $row->description,
|
'description' => (string) $row->description,
|
||||||
'location' => (string) $row->location,
|
'location' => (string) $row->location,
|
||||||
|
|
@ -614,6 +671,26 @@ final class EventService
|
||||||
return in_array($v, ['none', 'count', 'until'], true) ? $v : 'none';
|
return in_array($v, ['none', 'count', 'until'], true) ? $v : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canonicalVisibility(string $value): string
|
||||||
|
{
|
||||||
|
$visibility = strtolower(trim($value));
|
||||||
|
return $visibility === 'private' ? 'private' : 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redactOccurrenceForPublic(array $occurrence): array
|
||||||
|
{
|
||||||
|
if ($this->canonicalVisibility((string) ($occurrence['visibility'] ?? 'public')) !== 'private') {
|
||||||
|
return $occurrence;
|
||||||
|
}
|
||||||
|
|
||||||
|
$occurrence['title'] = 'Private Event';
|
||||||
|
$occurrence['description'] = '';
|
||||||
|
$occurrence['location'] = '';
|
||||||
|
$occurrence['category'] = '';
|
||||||
|
|
||||||
|
return $occurrence;
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeMonthlyAnchor(
|
private function normalizeMonthlyAnchor(
|
||||||
string $startIso,
|
string $startIso,
|
||||||
string $endIso,
|
string $endIso,
|
||||||
|
|
@ -706,4 +783,30 @@ final class EventService
|
||||||
}
|
}
|
||||||
return [$newYear, $newMonth];
|
return [$newYear, $newMonth];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function clearCalDavTombstone(string $resource): void
|
||||||
|
{
|
||||||
|
$resource = trim($resource);
|
||||||
|
if ($resource === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->db->delete($this->tombstonesTable, ['resource' => $resource]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordCalDavTombstone(string $resource): void
|
||||||
|
{
|
||||||
|
$resource = trim($resource);
|
||||||
|
if ($resource === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$now = gmdate('c');
|
||||||
|
$this->db->delete($this->tombstonesTable, ['resource' => $resource]);
|
||||||
|
$this->db->insert(
|
||||||
|
$this->tombstonesTable,
|
||||||
|
[
|
||||||
|
'resource' => $resource,
|
||||||
|
'deleted_at' => $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,12 @@ final class IcsService
|
||||||
{
|
{
|
||||||
private const PRODID = '-//Calendar Plugin//EN';
|
private const PRODID = '-//Calendar Plugin//EN';
|
||||||
|
|
||||||
public function buildCalendar(array $events, callable $deletedKeysProvider, string $calendarName = 'Calendar'): string
|
public function buildCalendar(
|
||||||
|
array $events,
|
||||||
|
callable $deletedKeysProvider,
|
||||||
|
string $calendarName = 'Calendar',
|
||||||
|
bool $redactPrivate = false
|
||||||
|
): string
|
||||||
{
|
{
|
||||||
$lines = [
|
$lines = [
|
||||||
'BEGIN:VCALENDAR',
|
'BEGIN:VCALENDAR',
|
||||||
|
|
@ -20,10 +25,35 @@ final class IcsService
|
||||||
'CALSCALE:GREGORIAN',
|
'CALSCALE:GREGORIAN',
|
||||||
'X-WR-CALNAME:' . $this->escapeText($calendarName),
|
'X-WR-CALNAME:' . $this->escapeText($calendarName),
|
||||||
'X-WR-TIMEZONE:Europe/London',
|
'X-WR-TIMEZONE:Europe/London',
|
||||||
|
'BEGIN:VTIMEZONE',
|
||||||
|
'TZID:Europe/London',
|
||||||
|
'X-LIC-LOCATION:Europe/London',
|
||||||
|
'BEGIN:DAYLIGHT',
|
||||||
|
'TZOFFSETFROM:+0000',
|
||||||
|
'TZOFFSETTO:+0100',
|
||||||
|
'TZNAME:BST',
|
||||||
|
'DTSTART:19700329T010000',
|
||||||
|
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
|
||||||
|
'END:DAYLIGHT',
|
||||||
|
'BEGIN:STANDARD',
|
||||||
|
'TZOFFSETFROM:+0100',
|
||||||
|
'TZOFFSETTO:+0000',
|
||||||
|
'TZNAME:GMT',
|
||||||
|
'DTSTART:19701025T020000',
|
||||||
|
'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
|
||||||
|
'END:STANDARD',
|
||||||
|
'END:VTIMEZONE',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($events as $event) {
|
foreach ($events as $event) {
|
||||||
$lines = array_merge($lines, $this->eventToLines($event, (array) $deletedKeysProvider((int) ($event['id'] ?? 0))));
|
$lines = array_merge(
|
||||||
|
$lines,
|
||||||
|
$this->eventToLines(
|
||||||
|
$event,
|
||||||
|
(array) $deletedKeysProvider((int) ($event['id'] ?? 0)),
|
||||||
|
$redactPrivate
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$lines[] = 'END:VCALENDAR';
|
$lines[] = 'END:VCALENDAR';
|
||||||
|
|
@ -76,6 +106,11 @@ final class IcsService
|
||||||
'repeat_until' => null,
|
'repeat_until' => null,
|
||||||
'timezone' => 'Europe/London',
|
'timezone' => 'Europe/London',
|
||||||
];
|
];
|
||||||
|
if (isset($props['CLASS'][0])) {
|
||||||
|
$payload['visibility'] = strtoupper((string) $props['CLASS'][0]) === 'PRIVATE' ? 'private' : 'public';
|
||||||
|
} elseif (isset($props['X-CALENDARSERVER-ACCESS'][0])) {
|
||||||
|
$payload['visibility'] = strtoupper((string) $props['X-CALENDARSERVER-ACCESS'][0]) === 'PRIVATE' ? 'private' : 'public';
|
||||||
|
}
|
||||||
|
|
||||||
$rrule = (string) ($props['RRULE'][0] ?? '');
|
$rrule = (string) ($props['RRULE'][0] ?? '');
|
||||||
if ($rrule !== '') {
|
if ($rrule !== '') {
|
||||||
|
|
@ -97,7 +132,7 @@ final class IcsService
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function eventToLines(array $event, array $deletedKeys): array
|
private function eventToLines(array $event, array $deletedKeys, bool $redactPrivate): array
|
||||||
{
|
{
|
||||||
$uid = (string) ($event['uid'] ?? '');
|
$uid = (string) ($event['uid'] ?? '');
|
||||||
$uid = $uid !== '' ? $uid : ('event-' . (string) ($event['id'] ?? 0) . '@calendar-plugin');
|
$uid = $uid !== '' ? $uid : ('event-' . (string) ($event['id'] ?? 0) . '@calendar-plugin');
|
||||||
|
|
@ -109,18 +144,26 @@ final class IcsService
|
||||||
}
|
}
|
||||||
|
|
||||||
$allDay = (bool) ($event['all_day_event'] ?? false);
|
$allDay = (bool) ($event['all_day_event'] ?? false);
|
||||||
|
$visibility = strtolower(trim((string) ($event['visibility'] ?? 'public'))) === 'private' ? 'private' : 'public';
|
||||||
|
$isRedactedPrivate = $redactPrivate && $visibility === 'private';
|
||||||
$updated = $this->toDateTime((string) ($event['updated_at'] ?? '')) ?? new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
$updated = $this->toDateTime((string) ($event['updated_at'] ?? '')) ?? new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||||
|
|
||||||
$lines = [
|
$lines = [
|
||||||
'BEGIN:VEVENT',
|
'BEGIN:VEVENT',
|
||||||
'UID:' . $this->escapeText($uid),
|
'UID:' . $this->escapeText($uid),
|
||||||
'SUMMARY:' . $this->escapeText((string) ($event['title'] ?? 'Untitled')),
|
'SUMMARY:' . $this->escapeText($isRedactedPrivate ? 'Private Event' : (string) ($event['title'] ?? 'Untitled')),
|
||||||
'DESCRIPTION:' . $this->escapeText((string) ($event['description'] ?? '')),
|
|
||||||
'LOCATION:' . $this->escapeText((string) ($event['location'] ?? '')),
|
|
||||||
'CATEGORIES:' . $this->escapeText((string) ($event['category'] ?? '')),
|
|
||||||
'DTSTAMP:' . $this->toUtcIcs($updated),
|
'DTSTAMP:' . $this->toUtcIcs($updated),
|
||||||
'LAST-MODIFIED:' . $this->toUtcIcs($updated),
|
'LAST-MODIFIED:' . $this->toUtcIcs($updated),
|
||||||
];
|
];
|
||||||
|
if (!$isRedactedPrivate) {
|
||||||
|
$icsVisibility = strtoupper($visibility === 'private' ? 'PRIVATE' : 'PUBLIC');
|
||||||
|
$lines[] = 'CLASS:' . $icsVisibility;
|
||||||
|
// Compatibility hint for clients that rely on CalendarServer-style access fields.
|
||||||
|
$lines[] = 'X-CALENDARSERVER-ACCESS:' . $icsVisibility;
|
||||||
|
$lines[] = 'DESCRIPTION:' . $this->escapeText((string) ($event['description'] ?? ''));
|
||||||
|
$lines[] = 'LOCATION:' . $this->escapeText((string) ($event['location'] ?? ''));
|
||||||
|
$lines[] = 'CATEGORIES:' . $this->escapeText((string) ($event['category'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
if ($allDay) {
|
if ($allDay) {
|
||||||
$lines[] = 'DTSTART;VALUE=DATE:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd');
|
$lines[] = 'DTSTART;VALUE=DATE:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd');
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,7 @@ final class RecurrenceExpander
|
||||||
return [
|
return [
|
||||||
'event_id' => (int) ($event['id'] ?? 0),
|
'event_id' => (int) ($event['id'] ?? 0),
|
||||||
'uid' => (string) ($event['uid'] ?? ''),
|
'uid' => (string) ($event['uid'] ?? ''),
|
||||||
|
'visibility' => (string) ($event['visibility'] ?? 'public'),
|
||||||
'title' => (string) ($event['title'] ?? ''),
|
'title' => (string) ($event['title'] ?? ''),
|
||||||
'description' => (string) ($event['description'] ?? ''),
|
'description' => (string) ($event['description'] ?? ''),
|
||||||
'location' => (string) ($event['location'] ?? ''),
|
'location' => (string) ($event['location'] ?? ''),
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use DateTimeZone;
|
||||||
|
|
||||||
final class MigrationManager
|
final class MigrationManager
|
||||||
{
|
{
|
||||||
private const SCHEMA_VERSION = '3';
|
private const SCHEMA_VERSION = '5';
|
||||||
private const STEM_OPTION = 'calendar_plugin_table_stem';
|
private const STEM_OPTION = 'calendar_plugin_table_stem';
|
||||||
|
|
||||||
public function __construct(private readonly DatabaseAdapterInterface $db, private readonly string $tableStem = 'cs_calendar')
|
public function __construct(private readonly DatabaseAdapterInterface $db, private readonly string $tableStem = 'cs_calendar')
|
||||||
|
|
@ -31,10 +31,12 @@ final class MigrationManager
|
||||||
$users = $prefix . $stem . '_users';
|
$users = $prefix . $stem . '_users';
|
||||||
$tokens = $prefix . $stem . '_user_tokens';
|
$tokens = $prefix . $stem . '_user_tokens';
|
||||||
$audit = $prefix . $stem . '_audit_log';
|
$audit = $prefix . $stem . '_audit_log';
|
||||||
|
$tombstones = $prefix . $stem . '_caldav_tombstones';
|
||||||
|
|
||||||
$sqlEvents = "CREATE TABLE {$events} (
|
$sqlEvents = "CREATE TABLE {$events} (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
uid VARCHAR(191) NOT NULL,
|
uid VARCHAR(191) NOT NULL,
|
||||||
|
visibility VARCHAR(16) NOT NULL DEFAULT 'public',
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description LONGTEXT NOT NULL,
|
description LONGTEXT NOT NULL,
|
||||||
location TEXT NOT NULL,
|
location TEXT NOT NULL,
|
||||||
|
|
@ -115,11 +117,18 @@ final class MigrationManager
|
||||||
KEY created_at (created_at)
|
KEY created_at (created_at)
|
||||||
) {$charsetCollate};";
|
) {$charsetCollate};";
|
||||||
|
|
||||||
|
$sqlTombstones = "CREATE TABLE {$tombstones} (
|
||||||
|
resource VARCHAR(191) NOT NULL,
|
||||||
|
deleted_at VARCHAR(32) NOT NULL,
|
||||||
|
PRIMARY KEY (resource)
|
||||||
|
) {$charsetCollate};";
|
||||||
|
|
||||||
dbDelta($sqlEvents);
|
dbDelta($sqlEvents);
|
||||||
dbDelta($sqlExceptions);
|
dbDelta($sqlExceptions);
|
||||||
dbDelta($sqlUsers);
|
dbDelta($sqlUsers);
|
||||||
dbDelta($sqlTokens);
|
dbDelta($sqlTokens);
|
||||||
dbDelta($sqlAudit);
|
dbDelta($sqlAudit);
|
||||||
|
dbDelta($sqlTombstones);
|
||||||
|
|
||||||
// Ensure every event has a stable CalDAV object resource name.
|
// Ensure every event has a stable CalDAV object resource name.
|
||||||
$this->db->query(
|
$this->db->query(
|
||||||
|
|
@ -129,6 +138,13 @@ final class MigrationManager
|
||||||
AND uid IS NOT NULL
|
AND uid IS NOT NULL
|
||||||
AND uid <> ''"
|
AND uid <> ''"
|
||||||
);
|
);
|
||||||
|
$this->db->query(
|
||||||
|
"UPDATE {$events}
|
||||||
|
SET visibility = 'public'
|
||||||
|
WHERE visibility IS NULL
|
||||||
|
OR visibility = ''
|
||||||
|
OR visibility NOT IN ('public', 'private')"
|
||||||
|
);
|
||||||
$this->normalizeEventDateTimesToLondon($events);
|
$this->normalizeEventDateTimesToLondon($events);
|
||||||
|
|
||||||
update_option(self::STEM_OPTION, $stem);
|
update_option(self::STEM_OPTION, $stem);
|
||||||
|
|
@ -170,6 +186,7 @@ final class MigrationManager
|
||||||
$prefix . $stem . '_users',
|
$prefix . $stem . '_users',
|
||||||
$prefix . $stem . '_user_tokens',
|
$prefix . $stem . '_user_tokens',
|
||||||
$prefix . $stem . '_audit_log',
|
$prefix . $stem . '_audit_log',
|
||||||
|
$prefix . $stem . '_caldav_tombstones',
|
||||||
];
|
];
|
||||||
foreach ($tables as $table) {
|
foreach ($tables as $table) {
|
||||||
$sql = $this->db->prepare('SHOW TABLES LIKE %s', $table);
|
$sql = $this->db->prepare('SHOW TABLES LIKE %s', $table);
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,7 @@ final class Plugin
|
||||||
<input id="cp-event-id" type="hidden" />
|
<input id="cp-event-id" type="hidden" />
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
|
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
|
||||||
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-title" type="text" style="width:100%;border:0;outline:none;" /></div>
|
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-title" type="text" style="width:100%;border:0;outline:none;" /></div>
|
||||||
|
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Visibility</label><select id="cp-visibility" style="width:100%;border:0;outline:none;background:#fff;"><option value="public">Public</option><option value="private">Private</option></select></div>
|
||||||
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-category" type="text" style="width:100%;border:0;outline:none;" /></div>
|
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-category" type="text" style="width:100%;border:0;outline:none;" /></div>
|
||||||
<div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-location" type="text" style="width:100%;border:0;outline:none;" /></div>
|
<div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-location" type="text" style="width:100%;border:0;outline:none;" /></div>
|
||||||
<div style="grid-column:1 / span 4;"><label style="display:inline-flex;align-items:center;gap:6px;"><input id="cp-all-day" type="checkbox" /> All Day Event</label></div>
|
<div style="grid-column:1 / span 4;"><label style="display:inline-flex;align-items:center;gap:6px;"><input id="cp-all-day" type="checkbox" /> All Day Event</label></div>
|
||||||
|
|
@ -256,6 +257,7 @@ final class Plugin
|
||||||
<h3 style="margin:0 0 8px 0;">Event Details</h3>
|
<h3 style="margin:0 0 8px 0;">Event Details</h3>
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
|
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
|
||||||
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-details-title" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-details-title" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
||||||
|
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Visibility</label><input id="cp-details-visibility" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
||||||
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-details-category" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-details-category" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
||||||
<div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-details-location" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
<div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-details-location" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
||||||
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Start</label><input id="cp-details-start" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Start</label><input id="cp-details-start" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
||||||
|
|
@ -409,6 +411,7 @@ final class Plugin
|
||||||
const clearEditor=()=>{
|
const clearEditor=()=>{
|
||||||
s("cp-event-id").value="";
|
s("cp-event-id").value="";
|
||||||
["cp-title","cp-description","cp-location","cp-category","cp-occurrence-key"].forEach(k=>s(k).value="");
|
["cp-title","cp-description","cp-location","cp-category","cp-occurrence-key"].forEach(k=>s(k).value="");
|
||||||
|
s("cp-visibility").value="public";
|
||||||
s("cp-occurrence-key-iso").value="";
|
s("cp-occurrence-key-iso").value="";
|
||||||
s("cp-all-day").checked=false;
|
s("cp-all-day").checked=false;
|
||||||
s("cp-repeat-type").value="none";
|
s("cp-repeat-type").value="none";
|
||||||
|
|
@ -452,6 +455,7 @@ final class Plugin
|
||||||
|
|
||||||
const openDetailsForItem=(it)=>{
|
const openDetailsForItem=(it)=>{
|
||||||
s("cp-details-title").value=it.title||"";
|
s("cp-details-title").value=it.title||"";
|
||||||
|
s("cp-details-visibility").value=(it.visibility||"public")==="private"?"Private":"Public";
|
||||||
s("cp-details-category").value=it.category||"";
|
s("cp-details-category").value=it.category||"";
|
||||||
s("cp-details-location").value=it.location||"";
|
s("cp-details-location").value=it.location||"";
|
||||||
s("cp-details-start").value=detailsDateTime(it.occurrence_start||it.start_datetime,!!it.all_day_event);
|
s("cp-details-start").value=detailsDateTime(it.occurrence_start||it.start_datetime,!!it.all_day_event);
|
||||||
|
|
@ -478,6 +482,7 @@ final class Plugin
|
||||||
s("cp-editor-title").textContent="Edit Event";
|
s("cp-editor-title").textContent="Edit Event";
|
||||||
s("cp-event-id").value=itemId(source);
|
s("cp-event-id").value=itemId(source);
|
||||||
s("cp-title").value=source.title||"";
|
s("cp-title").value=source.title||"";
|
||||||
|
s("cp-visibility").value=source.visibility==="private"?"private":"public";
|
||||||
s("cp-description").value=source.description||"";
|
s("cp-description").value=source.description||"";
|
||||||
s("cp-location").value=source.location||"";
|
s("cp-location").value=source.location||"";
|
||||||
s("cp-category").value=source.category||"";
|
s("cp-category").value=source.category||"";
|
||||||
|
|
@ -532,6 +537,7 @@ final class Plugin
|
||||||
ok:true,
|
ok:true,
|
||||||
payload:{
|
payload:{
|
||||||
title:title,
|
title:title,
|
||||||
|
visibility:s("cp-visibility").value==="private"?"private":"public",
|
||||||
description:s("cp-description").value,
|
description:s("cp-description").value,
|
||||||
location:s("cp-location").value,
|
location:s("cp-location").value,
|
||||||
category:s("cp-category").value,
|
category:s("cp-category").value,
|
||||||
|
|
@ -1592,10 +1598,16 @@ HTML
|
||||||
$date = (string) ($request->get_param('date') ?: gmdate('Y-m-d'));
|
$date = (string) ($request->get_param('date') ?: gmdate('Y-m-d'));
|
||||||
$futureOnlyRaw = (string) ($request->get_param('future_only') ?? '');
|
$futureOnlyRaw = (string) ($request->get_param('future_only') ?? '');
|
||||||
$futureOnly = in_array(strtolower($futureOnlyRaw), ['1', 'true', 'yes', 'on'], true);
|
$futureOnly = in_array(strtolower($futureOnlyRaw), ['1', 'true', 'yes', 'on'], true);
|
||||||
$items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly);
|
$includePrivateDetails = $this->canWriteCalendar($request);
|
||||||
|
$items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly, !$includePrivateDetails);
|
||||||
return [
|
return [
|
||||||
'data' => $items,
|
'data' => $items,
|
||||||
'meta' => ['count' => count($items), 'view' => $view, 'future_only' => $futureOnly],
|
'meta' => [
|
||||||
|
'count' => count($items),
|
||||||
|
'view' => $view,
|
||||||
|
'future_only' => $futureOnly,
|
||||||
|
'redacted_private' => !$includePrivateDetails,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -1608,7 +1620,7 @@ HTML
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'callback' => function (): array {
|
'callback' => function (): array {
|
||||||
$items = $this->eventService->listSidebarUpcoming(14);
|
$items = $this->eventService->listSidebarUpcoming(14, true);
|
||||||
return [
|
return [
|
||||||
'data' => $items,
|
'data' => $items,
|
||||||
'meta' => ['count' => count($items), 'window_days' => 14],
|
'meta' => ['count' => count($items), 'window_days' => 14],
|
||||||
|
|
@ -1840,13 +1852,19 @@ HTML
|
||||||
[
|
[
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'callback' => function (): array {
|
'callback' => function ($request): array|\WP_Error {
|
||||||
$settings = $this->settingsService->getAll();
|
$settings = $this->settingsService->getAll();
|
||||||
|
$icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read');
|
||||||
|
$includePrivateDetails = $this->canWriteCalendar($request);
|
||||||
|
if ($icsMode === 'authenticated_read' && !$includePrivateDetails) {
|
||||||
|
return $this->error('auth_required', 'authentication required', 401);
|
||||||
|
}
|
||||||
$calendarName = (string) ($settings['caldav_calendar_name'] ?? 'Public Calendar');
|
$calendarName = (string) ($settings['caldav_calendar_name'] ?? 'Public Calendar');
|
||||||
$ics = $this->icsService->buildCalendar(
|
$ics = $this->icsService->buildCalendar(
|
||||||
$this->eventService->listEvents(),
|
$this->eventService->listEvents(),
|
||||||
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
||||||
$calendarName
|
$calendarName,
|
||||||
|
!$includePrivateDetails
|
||||||
);
|
);
|
||||||
return ['data' => $ics];
|
return ['data' => $ics];
|
||||||
},
|
},
|
||||||
|
|
@ -1964,9 +1982,11 @@ HTML
|
||||||
private function serveIcsResponse(): void
|
private function serveIcsResponse(): void
|
||||||
{
|
{
|
||||||
$settings = $this->settingsService->getAll();
|
$settings = $this->settingsService->getAll();
|
||||||
|
$icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read');
|
||||||
|
$includePrivateDetails = $this->canWriteCalendar(null);
|
||||||
if (
|
if (
|
||||||
(string) ($settings['ics_access_mode'] ?? 'public_read') === 'authenticated_read'
|
$icsMode === 'authenticated_read'
|
||||||
&& $this->resolveCalDavUserForRequest(null) === null
|
&& !$includePrivateDetails
|
||||||
) {
|
) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
@ -1978,7 +1998,8 @@ HTML
|
||||||
$ics = $this->icsService->buildCalendar(
|
$ics = $this->icsService->buildCalendar(
|
||||||
$this->eventService->listEvents(),
|
$this->eventService->listEvents(),
|
||||||
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
||||||
$calendarName
|
$calendarName,
|
||||||
|
!$includePrivateDetails
|
||||||
);
|
);
|
||||||
$etag = '"' . substr(sha1($ics), 0, 16) . '"';
|
$etag = '"' . substr(sha1($ics), 0, 16) . '"';
|
||||||
$lastModified = gmdate('D, d M Y H:i:s') . ' GMT';
|
$lastModified = gmdate('D, d M Y H:i:s') . ' GMT';
|
||||||
|
|
@ -2010,7 +2031,18 @@ HTML
|
||||||
$resourcePrefix = $collection;
|
$resourcePrefix = $collection;
|
||||||
|
|
||||||
if ($method === 'HEAD') {
|
if ($method === 'HEAD') {
|
||||||
if ($path === $root || $path === $root . '/' || $path === $calendarsRoot || $path === rtrim($calendarsRoot, '/') || $path === $collection || $path === rtrim($collection, '/')) {
|
if (
|
||||||
|
$path === $root
|
||||||
|
|| $path === $root . '/'
|
||||||
|
|| $path === $principalCollection
|
||||||
|
|| $path === rtrim($principalCollection, '/')
|
||||||
|
|| $path === $principal
|
||||||
|
|| $path === rtrim($principal, '/')
|
||||||
|
|| $path === $calendarsRoot
|
||||||
|
|| $path === rtrim($calendarsRoot, '/')
|
||||||
|
|| $path === $collection
|
||||||
|
|| $path === rtrim($collection, '/')
|
||||||
|
) {
|
||||||
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
||||||
header('DAV: 1, calendar-access');
|
header('DAV: 1, calendar-access');
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
|
|
@ -2030,6 +2062,26 @@ HTML
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
if (
|
||||||
|
$path === $root
|
||||||
|
|| $path === $root . '/'
|
||||||
|
|| $path === $principalCollection
|
||||||
|
|| $path === rtrim($principalCollection, '/')
|
||||||
|
|| $path === $principal
|
||||||
|
|| $path === rtrim($principal, '/')
|
||||||
|
|| $path === $calendarsRoot
|
||||||
|
|| $path === rtrim($calendarsRoot, '/')
|
||||||
|
|| $path === $collection
|
||||||
|
|| $path === rtrim($collection, '/')
|
||||||
|
) {
|
||||||
|
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
||||||
|
header('DAV: 1, calendar-access');
|
||||||
|
http_response_code(200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($method === 'OPTIONS') {
|
if ($method === 'OPTIONS') {
|
||||||
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
||||||
header('DAV: 1, calendar-access');
|
header('DAV: 1, calendar-access');
|
||||||
|
|
@ -2079,7 +2131,7 @@ HTML
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'REPORT' && $path === $collection) {
|
if ($method === 'REPORT' && ($path === $collection || $path === rtrim($collection, '/'))) {
|
||||||
$body = (string) file_get_contents('php://input');
|
$body = (string) file_get_contents('php://input');
|
||||||
header('Content-Type: application/xml; charset=utf-8');
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
http_response_code(207);
|
http_response_code(207);
|
||||||
|
|
@ -2269,7 +2321,15 @@ HTML
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|
||||||
if (str_contains($bodyLower, 'sync-collection')) {
|
if (str_contains($bodyLower, 'sync-collection')) {
|
||||||
|
$clientSyncToken = $this->extractSyncCollectionToken($xmlBody);
|
||||||
|
// If client token is already current, no changes should be emitted.
|
||||||
|
if ($clientSyncToken !== '' && $clientSyncToken === $syncToken) {
|
||||||
|
$items = [];
|
||||||
|
} else {
|
||||||
|
// Fallback implementation: emit current objects for initial/out-of-date tokens.
|
||||||
|
// Avoid emitting historical tombstones because many clients treat large 404 sets as transient failures.
|
||||||
$items = $this->calDavService->multiget($resources);
|
$items = $this->calDavService->multiget($resources);
|
||||||
|
}
|
||||||
} elseif (str_contains($bodyLower, 'calendar-query')) {
|
} elseif (str_contains($bodyLower, 'calendar-query')) {
|
||||||
$items = $this->calDavService->multiget($resources);
|
$items = $this->calDavService->multiget($resources);
|
||||||
if (preg_match('/start\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $s) && preg_match('/end\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $e)) {
|
if (preg_match('/start\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $s) && preg_match('/end\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $e)) {
|
||||||
|
|
@ -2324,6 +2384,9 @@ HTML
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$seed .= (string) ($row['resource'] ?? '') . '|' . (string) ($row['etag'] ?? '') . ';';
|
$seed .= (string) ($row['resource'] ?? '') . '|' . (string) ($row['etag'] ?? '') . ';';
|
||||||
}
|
}
|
||||||
|
foreach ($this->calDavService->listDeletedResources(1000) as $deletedResource) {
|
||||||
|
$seed .= 'deleted:' . $deletedResource . ';';
|
||||||
|
}
|
||||||
return 'urn:calendar-plugin:sync:' . sha1($seed);
|
return 'urn:calendar-plugin:sync:' . sha1($seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2353,6 +2416,15 @@ HTML
|
||||||
return $this->icalToIso((string) $m[1]);
|
return $this->icalToIso((string) $m[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function extractSyncCollectionToken(string $xmlBody): string
|
||||||
|
{
|
||||||
|
if (!preg_match('/<[^>]*sync-token[^>]*>(.*?)<\\/[^>]*sync-token>/is', $xmlBody, $m)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$token = trim(html_entity_decode((string) $m[1], ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
private function unwrapServiceResult(array $result): array|\WP_Error
|
private function unwrapServiceResult(array $result): array|\WP_Error
|
||||||
{
|
{
|
||||||
if (isset($result['error']) && is_array($result['error'])) {
|
if (isset($result['error']) && is_array($result['error'])) {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
ca4806cd3e7827b9666e6929cbcae8f3e2eba87e499f7dd7008d8edf2fcc774e staging/calendar-plugin/calendar-plugin.php
|
ca4806cd3e7827b9666e6929cbcae8f3e2eba87e499f7dd7008d8edf2fcc774e ./calendar-plugin.php
|
||||||
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb staging/calendar-plugin/src/Contracts/AuthAdapterInterface.php
|
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb ./src/Contracts/AuthAdapterInterface.php
|
||||||
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d staging/calendar-plugin/src/Contracts/DatabaseAdapterInterface.php
|
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d ./src/Contracts/DatabaseAdapterInterface.php
|
||||||
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba staging/calendar-plugin/src/Contracts/HttpAdapterInterface.php
|
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba ./src/Contracts/HttpAdapterInterface.php
|
||||||
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 staging/calendar-plugin/src/Contracts/OptionsAdapterInterface.php
|
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 ./src/Contracts/OptionsAdapterInterface.php
|
||||||
47d55e845b8a696c55fa86f597bb2760b6e35d974b70f365593d38697ef398c8 staging/calendar-plugin/src/Domain/CalDavService.php
|
da068811cd923bd6cf04a209b9b4eec55fa57aeedfccaa3250ebd37b906c6d04 ./src/Domain/CalDavService.php
|
||||||
4dc3337761c97aa550896fcc377aaab8338c598f39e489742dacbfd20e1a71b1 staging/calendar-plugin/src/Domain/EventService.php
|
c8ab00f23e7cd198228734515c3ce9a2589b1ab6cb815307f3b8f7a3051e04d2 ./src/Domain/EventService.php
|
||||||
412a22ecd910535c7ace2549a86eacf08cc9cd824f1767e60116d8593355f57f staging/calendar-plugin/src/Domain/IcsService.php
|
f48006beb0c5c8d7a98e9f33f80a6a08fb92a999c28c937f9afd814e98de0a05 ./src/Domain/IcsService.php
|
||||||
5f7fb1f8c00136ad2c4a2dc8b909dabf6494a194b09aa73557a4bf68d73f4ed2 staging/calendar-plugin/src/Domain/RecurrenceExpander.php
|
fd5377f7852b0f35550a453451d98882b8488b332d9a051dac603851c7b586d1 ./src/Domain/RecurrenceExpander.php
|
||||||
20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 staging/calendar-plugin/src/Domain/SettingsService.php
|
20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 ./src/Domain/SettingsService.php
|
||||||
9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f staging/calendar-plugin/src/Domain/UserService.php
|
9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f ./src/Domain/UserService.php
|
||||||
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b staging/calendar-plugin/src/Infrastructure/ServiceContainer.php
|
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b ./src/Infrastructure/ServiceContainer.php
|
||||||
70334df8ca06c8fe61d81f24cb0d8f19285180f9950054bd86af0083adf8b4c8 staging/calendar-plugin/src/Infrastructure/WordPress/MigrationManager.php
|
e65577c707c5a66e2097faa7720170180c79a9d9df219f0e1061fdcec55be744 ./src/Infrastructure/WordPress/MigrationManager.php
|
||||||
8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 staging/calendar-plugin/src/Infrastructure/WordPress/WordPressAuthAdapter.php
|
8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 ./src/Infrastructure/WordPress/WordPressAuthAdapter.php
|
||||||
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c staging/calendar-plugin/src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
|
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c ./src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
|
||||||
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b staging/calendar-plugin/src/Infrastructure/WordPress/WordPressHttpAdapter.php
|
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b ./src/Infrastructure/WordPress/WordPressHttpAdapter.php
|
||||||
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea staging/calendar-plugin/src/Infrastructure/WordPress/WordPressOptionsAdapter.php
|
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea ./src/Infrastructure/WordPress/WordPressOptionsAdapter.php
|
||||||
ffc7c3eef8f3ed0873b5f614765219925d07c24ffd6ffa7422a3d862a7342450 staging/calendar-plugin/src/Plugin.php
|
0cc96b252ac86e91a724e73beecfe6f57a1afdb6f5d9a26e44aed00a3f0e20f0 ./src/Plugin.php
|
||||||
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 staging/calendar-plugin/src/bootstrap.php
|
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
|
||||||
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c staging/calendar-plugin/uninstall.php
|
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -140,6 +140,14 @@ final class CalDavService
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listDeletedResources(int $limit = 500): array
|
||||||
|
{
|
||||||
|
$rows = $this->events->listCalDavTombstones($limit);
|
||||||
|
return array_values(array_filter(array_map(static function (array $row): string {
|
||||||
|
return trim((string) ($row['resource'] ?? ''));
|
||||||
|
}, $rows)));
|
||||||
|
}
|
||||||
|
|
||||||
public function resourceForEvent(array $event): string
|
public function resourceForEvent(array $event): string
|
||||||
{
|
{
|
||||||
$resource = trim((string) ($event['caldav_resource'] ?? ''));
|
$resource = trim((string) ($event['caldav_resource'] ?? ''));
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ final class EventService
|
||||||
{
|
{
|
||||||
private readonly string $eventsTable;
|
private readonly string $eventsTable;
|
||||||
private readonly string $exceptionsTable;
|
private readonly string $exceptionsTable;
|
||||||
|
private readonly string $tombstonesTable;
|
||||||
|
|
||||||
public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
|
public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
|
||||||
{
|
{
|
||||||
|
|
@ -19,6 +20,7 @@ final class EventService
|
||||||
$stem = trim($tableStem, '_');
|
$stem = trim($tableStem, '_');
|
||||||
$this->eventsTable = $prefix . $stem . '_events';
|
$this->eventsTable = $prefix . $stem . '_events';
|
||||||
$this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions';
|
$this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions';
|
||||||
|
$this->tombstonesTable = $prefix . $stem . '_caldav_tombstones';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listEvents(): array
|
public function listEvents(): array
|
||||||
|
|
@ -75,6 +77,7 @@ final class EventService
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
|
'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')),
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'description' => (string) ($payload['description'] ?? ''),
|
'description' => (string) ($payload['description'] ?? ''),
|
||||||
'location' => (string) ($payload['location'] ?? ''),
|
'location' => (string) ($payload['location'] ?? ''),
|
||||||
|
|
@ -104,7 +107,12 @@ final class EventService
|
||||||
if ($inserted === false) {
|
if ($inserted === false) {
|
||||||
throw new \RuntimeException('failed to create event');
|
throw new \RuntimeException('failed to create event');
|
||||||
}
|
}
|
||||||
return (array) $this->getEvent($this->db->insertId());
|
$created = (array) $this->getEvent($this->db->insertId());
|
||||||
|
$resource = trim((string) ($created['caldav_resource'] ?? ''));
|
||||||
|
if ($resource !== '') {
|
||||||
|
$this->clearCalDavTombstone($resource);
|
||||||
|
}
|
||||||
|
return $created;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateEvent(int $id, array $payload): ?array
|
public function updateEvent(int $id, array $payload): ?array
|
||||||
|
|
@ -148,6 +156,9 @@ final class EventService
|
||||||
$repeatNthWeekday
|
$repeatNthWeekday
|
||||||
);
|
);
|
||||||
$data = [
|
$data = [
|
||||||
|
'visibility' => array_key_exists('visibility', $payload)
|
||||||
|
? $this->canonicalVisibility((string) $payload['visibility'])
|
||||||
|
: $this->canonicalVisibility((string) ($existing['visibility'] ?? 'public')),
|
||||||
'title' => trim((string) ($payload['title'] ?? $existing['title'])),
|
'title' => trim((string) ($payload['title'] ?? $existing['title'])),
|
||||||
'description' => (string) ($payload['description'] ?? $existing['description']),
|
'description' => (string) ($payload['description'] ?? $existing['description']),
|
||||||
'location' => (string) ($payload['location'] ?? $existing['location']),
|
'location' => (string) ($payload['location'] ?? $existing['location']),
|
||||||
|
|
@ -179,13 +190,27 @@ final class EventService
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->db->update($this->eventsTable, $data, ['id' => $id]);
|
$this->db->update($this->eventsTable, $data, ['id' => $id]);
|
||||||
return $this->getEvent($id);
|
$updated = $this->getEvent($id);
|
||||||
|
if ($updated) {
|
||||||
|
$resource = trim((string) ($updated['caldav_resource'] ?? ''));
|
||||||
|
if ($resource !== '') {
|
||||||
|
$this->clearCalDavTombstone($resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteEvent(int $id): bool
|
public function deleteEvent(int $id): bool
|
||||||
{
|
{
|
||||||
|
$event = $this->getEvent($id);
|
||||||
$this->db->delete($this->exceptionsTable, ['event_id' => $id]);
|
$this->db->delete($this->exceptionsTable, ['event_id' => $id]);
|
||||||
$deleted = $this->db->delete($this->eventsTable, ['id' => $id]);
|
$deleted = $this->db->delete($this->eventsTable, ['id' => $id]);
|
||||||
|
if ($deleted !== false && $event) {
|
||||||
|
$resource = trim((string) ($event['caldav_resource'] ?? ''));
|
||||||
|
if ($resource !== '') {
|
||||||
|
$this->recordCalDavTombstone($resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
return $deleted !== false;
|
return $deleted !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,6 +303,7 @@ final class EventService
|
||||||
$event = [
|
$event = [
|
||||||
'id' => 0,
|
'id' => 0,
|
||||||
'uid' => 'preview@calendar-plugin',
|
'uid' => 'preview@calendar-plugin',
|
||||||
|
'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')),
|
||||||
'title' => (string) ($payload['title'] ?? ''),
|
'title' => (string) ($payload['title'] ?? ''),
|
||||||
'description' => (string) ($payload['description'] ?? ''),
|
'description' => (string) ($payload['description'] ?? ''),
|
||||||
'location' => (string) ($payload['location'] ?? ''),
|
'location' => (string) ($payload['location'] ?? ''),
|
||||||
|
|
@ -352,7 +378,7 @@ final class EventService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false): array
|
public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false, bool $redactPrivate = true): array
|
||||||
{
|
{
|
||||||
$tz = new DateTimeZone('Europe/London');
|
$tz = new DateTimeZone('Europe/London');
|
||||||
$anchor = $this->safeDate($dateAnchor, $tz);
|
$anchor = $this->safeDate($dateAnchor, $tz);
|
||||||
|
|
@ -382,10 +408,14 @@ final class EventService
|
||||||
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
|
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($redactPrivate) {
|
||||||
|
return array_map([$this, 'redactOccurrenceForPublic'], $out);
|
||||||
|
}
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listSidebarUpcoming(int $days = 14): array
|
public function listSidebarUpcoming(int $days = 14, bool $redactPrivate = true): array
|
||||||
{
|
{
|
||||||
$tz = new DateTimeZone('Europe/London');
|
$tz = new DateTimeZone('Europe/London');
|
||||||
$start = new DateTimeImmutable('today', $tz);
|
$start = new DateTimeImmutable('today', $tz);
|
||||||
|
|
@ -404,6 +434,10 @@ final class EventService
|
||||||
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
|
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($redactPrivate) {
|
||||||
|
return array_map([$this, 'redactOccurrenceForPublic'], $out);
|
||||||
|
}
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,11 +445,31 @@ final class EventService
|
||||||
{
|
{
|
||||||
$events = $this->listEvents();
|
$events = $this->listEvents();
|
||||||
$count = count($events);
|
$count = count($events);
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$resource = trim((string) ($event['caldav_resource'] ?? ''));
|
||||||
|
if ($resource !== '') {
|
||||||
|
$this->recordCalDavTombstone($resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
$this->db->query("DELETE FROM {$this->exceptionsTable}");
|
$this->db->query("DELETE FROM {$this->exceptionsTable}");
|
||||||
$this->db->query("DELETE FROM {$this->eventsTable}");
|
$this->db->query("DELETE FROM {$this->eventsTable}");
|
||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listCalDavTombstones(int $limit = 500): array
|
||||||
|
{
|
||||||
|
$limit = max(1, min($limit, 5000));
|
||||||
|
$rows = $this->db->getResults(
|
||||||
|
"SELECT resource, deleted_at FROM {$this->tombstonesTable} ORDER BY deleted_at DESC LIMIT {$limit}"
|
||||||
|
);
|
||||||
|
return array_map(static function (object $row): array {
|
||||||
|
return [
|
||||||
|
'resource' => (string) ($row->resource ?? ''),
|
||||||
|
'deleted_at' => (string) ($row->deleted_at ?? ''),
|
||||||
|
];
|
||||||
|
}, $rows);
|
||||||
|
}
|
||||||
|
|
||||||
public function seedDefaultEvents(): int
|
public function seedDefaultEvents(): int
|
||||||
{
|
{
|
||||||
$seed = [
|
$seed = [
|
||||||
|
|
@ -494,6 +548,9 @@ final class EventService
|
||||||
return [
|
return [
|
||||||
'id' => (int) $row->id,
|
'id' => (int) $row->id,
|
||||||
'uid' => (string) $row->uid,
|
'uid' => (string) $row->uid,
|
||||||
|
'visibility' => property_exists($row, 'visibility')
|
||||||
|
? $this->canonicalVisibility((string) ($row->visibility ?? 'public'))
|
||||||
|
: 'public',
|
||||||
'title' => (string) $row->title,
|
'title' => (string) $row->title,
|
||||||
'description' => (string) $row->description,
|
'description' => (string) $row->description,
|
||||||
'location' => (string) $row->location,
|
'location' => (string) $row->location,
|
||||||
|
|
@ -614,6 +671,26 @@ final class EventService
|
||||||
return in_array($v, ['none', 'count', 'until'], true) ? $v : 'none';
|
return in_array($v, ['none', 'count', 'until'], true) ? $v : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canonicalVisibility(string $value): string
|
||||||
|
{
|
||||||
|
$visibility = strtolower(trim($value));
|
||||||
|
return $visibility === 'private' ? 'private' : 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redactOccurrenceForPublic(array $occurrence): array
|
||||||
|
{
|
||||||
|
if ($this->canonicalVisibility((string) ($occurrence['visibility'] ?? 'public')) !== 'private') {
|
||||||
|
return $occurrence;
|
||||||
|
}
|
||||||
|
|
||||||
|
$occurrence['title'] = 'Private Event';
|
||||||
|
$occurrence['description'] = '';
|
||||||
|
$occurrence['location'] = '';
|
||||||
|
$occurrence['category'] = '';
|
||||||
|
|
||||||
|
return $occurrence;
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeMonthlyAnchor(
|
private function normalizeMonthlyAnchor(
|
||||||
string $startIso,
|
string $startIso,
|
||||||
string $endIso,
|
string $endIso,
|
||||||
|
|
@ -706,4 +783,30 @@ final class EventService
|
||||||
}
|
}
|
||||||
return [$newYear, $newMonth];
|
return [$newYear, $newMonth];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function clearCalDavTombstone(string $resource): void
|
||||||
|
{
|
||||||
|
$resource = trim($resource);
|
||||||
|
if ($resource === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->db->delete($this->tombstonesTable, ['resource' => $resource]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordCalDavTombstone(string $resource): void
|
||||||
|
{
|
||||||
|
$resource = trim($resource);
|
||||||
|
if ($resource === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$now = gmdate('c');
|
||||||
|
$this->db->delete($this->tombstonesTable, ['resource' => $resource]);
|
||||||
|
$this->db->insert(
|
||||||
|
$this->tombstonesTable,
|
||||||
|
[
|
||||||
|
'resource' => $resource,
|
||||||
|
'deleted_at' => $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,12 @@ final class IcsService
|
||||||
{
|
{
|
||||||
private const PRODID = '-//Calendar Plugin//EN';
|
private const PRODID = '-//Calendar Plugin//EN';
|
||||||
|
|
||||||
public function buildCalendar(array $events, callable $deletedKeysProvider, string $calendarName = 'Calendar'): string
|
public function buildCalendar(
|
||||||
|
array $events,
|
||||||
|
callable $deletedKeysProvider,
|
||||||
|
string $calendarName = 'Calendar',
|
||||||
|
bool $redactPrivate = false
|
||||||
|
): string
|
||||||
{
|
{
|
||||||
$lines = [
|
$lines = [
|
||||||
'BEGIN:VCALENDAR',
|
'BEGIN:VCALENDAR',
|
||||||
|
|
@ -20,10 +25,35 @@ final class IcsService
|
||||||
'CALSCALE:GREGORIAN',
|
'CALSCALE:GREGORIAN',
|
||||||
'X-WR-CALNAME:' . $this->escapeText($calendarName),
|
'X-WR-CALNAME:' . $this->escapeText($calendarName),
|
||||||
'X-WR-TIMEZONE:Europe/London',
|
'X-WR-TIMEZONE:Europe/London',
|
||||||
|
'BEGIN:VTIMEZONE',
|
||||||
|
'TZID:Europe/London',
|
||||||
|
'X-LIC-LOCATION:Europe/London',
|
||||||
|
'BEGIN:DAYLIGHT',
|
||||||
|
'TZOFFSETFROM:+0000',
|
||||||
|
'TZOFFSETTO:+0100',
|
||||||
|
'TZNAME:BST',
|
||||||
|
'DTSTART:19700329T010000',
|
||||||
|
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
|
||||||
|
'END:DAYLIGHT',
|
||||||
|
'BEGIN:STANDARD',
|
||||||
|
'TZOFFSETFROM:+0100',
|
||||||
|
'TZOFFSETTO:+0000',
|
||||||
|
'TZNAME:GMT',
|
||||||
|
'DTSTART:19701025T020000',
|
||||||
|
'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
|
||||||
|
'END:STANDARD',
|
||||||
|
'END:VTIMEZONE',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($events as $event) {
|
foreach ($events as $event) {
|
||||||
$lines = array_merge($lines, $this->eventToLines($event, (array) $deletedKeysProvider((int) ($event['id'] ?? 0))));
|
$lines = array_merge(
|
||||||
|
$lines,
|
||||||
|
$this->eventToLines(
|
||||||
|
$event,
|
||||||
|
(array) $deletedKeysProvider((int) ($event['id'] ?? 0)),
|
||||||
|
$redactPrivate
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$lines[] = 'END:VCALENDAR';
|
$lines[] = 'END:VCALENDAR';
|
||||||
|
|
@ -76,6 +106,11 @@ final class IcsService
|
||||||
'repeat_until' => null,
|
'repeat_until' => null,
|
||||||
'timezone' => 'Europe/London',
|
'timezone' => 'Europe/London',
|
||||||
];
|
];
|
||||||
|
if (isset($props['CLASS'][0])) {
|
||||||
|
$payload['visibility'] = strtoupper((string) $props['CLASS'][0]) === 'PRIVATE' ? 'private' : 'public';
|
||||||
|
} elseif (isset($props['X-CALENDARSERVER-ACCESS'][0])) {
|
||||||
|
$payload['visibility'] = strtoupper((string) $props['X-CALENDARSERVER-ACCESS'][0]) === 'PRIVATE' ? 'private' : 'public';
|
||||||
|
}
|
||||||
|
|
||||||
$rrule = (string) ($props['RRULE'][0] ?? '');
|
$rrule = (string) ($props['RRULE'][0] ?? '');
|
||||||
if ($rrule !== '') {
|
if ($rrule !== '') {
|
||||||
|
|
@ -97,7 +132,7 @@ final class IcsService
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function eventToLines(array $event, array $deletedKeys): array
|
private function eventToLines(array $event, array $deletedKeys, bool $redactPrivate): array
|
||||||
{
|
{
|
||||||
$uid = (string) ($event['uid'] ?? '');
|
$uid = (string) ($event['uid'] ?? '');
|
||||||
$uid = $uid !== '' ? $uid : ('event-' . (string) ($event['id'] ?? 0) . '@calendar-plugin');
|
$uid = $uid !== '' ? $uid : ('event-' . (string) ($event['id'] ?? 0) . '@calendar-plugin');
|
||||||
|
|
@ -109,18 +144,26 @@ final class IcsService
|
||||||
}
|
}
|
||||||
|
|
||||||
$allDay = (bool) ($event['all_day_event'] ?? false);
|
$allDay = (bool) ($event['all_day_event'] ?? false);
|
||||||
|
$visibility = strtolower(trim((string) ($event['visibility'] ?? 'public'))) === 'private' ? 'private' : 'public';
|
||||||
|
$isRedactedPrivate = $redactPrivate && $visibility === 'private';
|
||||||
$updated = $this->toDateTime((string) ($event['updated_at'] ?? '')) ?? new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
$updated = $this->toDateTime((string) ($event['updated_at'] ?? '')) ?? new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||||
|
|
||||||
$lines = [
|
$lines = [
|
||||||
'BEGIN:VEVENT',
|
'BEGIN:VEVENT',
|
||||||
'UID:' . $this->escapeText($uid),
|
'UID:' . $this->escapeText($uid),
|
||||||
'SUMMARY:' . $this->escapeText((string) ($event['title'] ?? 'Untitled')),
|
'SUMMARY:' . $this->escapeText($isRedactedPrivate ? 'Private Event' : (string) ($event['title'] ?? 'Untitled')),
|
||||||
'DESCRIPTION:' . $this->escapeText((string) ($event['description'] ?? '')),
|
|
||||||
'LOCATION:' . $this->escapeText((string) ($event['location'] ?? '')),
|
|
||||||
'CATEGORIES:' . $this->escapeText((string) ($event['category'] ?? '')),
|
|
||||||
'DTSTAMP:' . $this->toUtcIcs($updated),
|
'DTSTAMP:' . $this->toUtcIcs($updated),
|
||||||
'LAST-MODIFIED:' . $this->toUtcIcs($updated),
|
'LAST-MODIFIED:' . $this->toUtcIcs($updated),
|
||||||
];
|
];
|
||||||
|
if (!$isRedactedPrivate) {
|
||||||
|
$icsVisibility = strtoupper($visibility === 'private' ? 'PRIVATE' : 'PUBLIC');
|
||||||
|
$lines[] = 'CLASS:' . $icsVisibility;
|
||||||
|
// Compatibility hint for clients that rely on CalendarServer-style access fields.
|
||||||
|
$lines[] = 'X-CALENDARSERVER-ACCESS:' . $icsVisibility;
|
||||||
|
$lines[] = 'DESCRIPTION:' . $this->escapeText((string) ($event['description'] ?? ''));
|
||||||
|
$lines[] = 'LOCATION:' . $this->escapeText((string) ($event['location'] ?? ''));
|
||||||
|
$lines[] = 'CATEGORIES:' . $this->escapeText((string) ($event['category'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
if ($allDay) {
|
if ($allDay) {
|
||||||
$lines[] = 'DTSTART;VALUE=DATE:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd');
|
$lines[] = 'DTSTART;VALUE=DATE:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd');
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,7 @@ final class RecurrenceExpander
|
||||||
return [
|
return [
|
||||||
'event_id' => (int) ($event['id'] ?? 0),
|
'event_id' => (int) ($event['id'] ?? 0),
|
||||||
'uid' => (string) ($event['uid'] ?? ''),
|
'uid' => (string) ($event['uid'] ?? ''),
|
||||||
|
'visibility' => (string) ($event['visibility'] ?? 'public'),
|
||||||
'title' => (string) ($event['title'] ?? ''),
|
'title' => (string) ($event['title'] ?? ''),
|
||||||
'description' => (string) ($event['description'] ?? ''),
|
'description' => (string) ($event['description'] ?? ''),
|
||||||
'location' => (string) ($event['location'] ?? ''),
|
'location' => (string) ($event['location'] ?? ''),
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use DateTimeZone;
|
||||||
|
|
||||||
final class MigrationManager
|
final class MigrationManager
|
||||||
{
|
{
|
||||||
private const SCHEMA_VERSION = '3';
|
private const SCHEMA_VERSION = '5';
|
||||||
private const STEM_OPTION = 'calendar_plugin_table_stem';
|
private const STEM_OPTION = 'calendar_plugin_table_stem';
|
||||||
|
|
||||||
public function __construct(private readonly DatabaseAdapterInterface $db, private readonly string $tableStem = 'cs_calendar')
|
public function __construct(private readonly DatabaseAdapterInterface $db, private readonly string $tableStem = 'cs_calendar')
|
||||||
|
|
@ -31,10 +31,12 @@ final class MigrationManager
|
||||||
$users = $prefix . $stem . '_users';
|
$users = $prefix . $stem . '_users';
|
||||||
$tokens = $prefix . $stem . '_user_tokens';
|
$tokens = $prefix . $stem . '_user_tokens';
|
||||||
$audit = $prefix . $stem . '_audit_log';
|
$audit = $prefix . $stem . '_audit_log';
|
||||||
|
$tombstones = $prefix . $stem . '_caldav_tombstones';
|
||||||
|
|
||||||
$sqlEvents = "CREATE TABLE {$events} (
|
$sqlEvents = "CREATE TABLE {$events} (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
uid VARCHAR(191) NOT NULL,
|
uid VARCHAR(191) NOT NULL,
|
||||||
|
visibility VARCHAR(16) NOT NULL DEFAULT 'public',
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description LONGTEXT NOT NULL,
|
description LONGTEXT NOT NULL,
|
||||||
location TEXT NOT NULL,
|
location TEXT NOT NULL,
|
||||||
|
|
@ -115,11 +117,18 @@ final class MigrationManager
|
||||||
KEY created_at (created_at)
|
KEY created_at (created_at)
|
||||||
) {$charsetCollate};";
|
) {$charsetCollate};";
|
||||||
|
|
||||||
|
$sqlTombstones = "CREATE TABLE {$tombstones} (
|
||||||
|
resource VARCHAR(191) NOT NULL,
|
||||||
|
deleted_at VARCHAR(32) NOT NULL,
|
||||||
|
PRIMARY KEY (resource)
|
||||||
|
) {$charsetCollate};";
|
||||||
|
|
||||||
dbDelta($sqlEvents);
|
dbDelta($sqlEvents);
|
||||||
dbDelta($sqlExceptions);
|
dbDelta($sqlExceptions);
|
||||||
dbDelta($sqlUsers);
|
dbDelta($sqlUsers);
|
||||||
dbDelta($sqlTokens);
|
dbDelta($sqlTokens);
|
||||||
dbDelta($sqlAudit);
|
dbDelta($sqlAudit);
|
||||||
|
dbDelta($sqlTombstones);
|
||||||
|
|
||||||
// Ensure every event has a stable CalDAV object resource name.
|
// Ensure every event has a stable CalDAV object resource name.
|
||||||
$this->db->query(
|
$this->db->query(
|
||||||
|
|
@ -129,6 +138,13 @@ final class MigrationManager
|
||||||
AND uid IS NOT NULL
|
AND uid IS NOT NULL
|
||||||
AND uid <> ''"
|
AND uid <> ''"
|
||||||
);
|
);
|
||||||
|
$this->db->query(
|
||||||
|
"UPDATE {$events}
|
||||||
|
SET visibility = 'public'
|
||||||
|
WHERE visibility IS NULL
|
||||||
|
OR visibility = ''
|
||||||
|
OR visibility NOT IN ('public', 'private')"
|
||||||
|
);
|
||||||
$this->normalizeEventDateTimesToLondon($events);
|
$this->normalizeEventDateTimesToLondon($events);
|
||||||
|
|
||||||
update_option(self::STEM_OPTION, $stem);
|
update_option(self::STEM_OPTION, $stem);
|
||||||
|
|
@ -170,6 +186,7 @@ final class MigrationManager
|
||||||
$prefix . $stem . '_users',
|
$prefix . $stem . '_users',
|
||||||
$prefix . $stem . '_user_tokens',
|
$prefix . $stem . '_user_tokens',
|
||||||
$prefix . $stem . '_audit_log',
|
$prefix . $stem . '_audit_log',
|
||||||
|
$prefix . $stem . '_caldav_tombstones',
|
||||||
];
|
];
|
||||||
foreach ($tables as $table) {
|
foreach ($tables as $table) {
|
||||||
$sql = $this->db->prepare('SHOW TABLES LIKE %s', $table);
|
$sql = $this->db->prepare('SHOW TABLES LIKE %s', $table);
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,7 @@ final class Plugin
|
||||||
<input id="cp-event-id" type="hidden" />
|
<input id="cp-event-id" type="hidden" />
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
|
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
|
||||||
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-title" type="text" style="width:100%;border:0;outline:none;" /></div>
|
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-title" type="text" style="width:100%;border:0;outline:none;" /></div>
|
||||||
|
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Visibility</label><select id="cp-visibility" style="width:100%;border:0;outline:none;background:#fff;"><option value="public">Public</option><option value="private">Private</option></select></div>
|
||||||
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-category" type="text" style="width:100%;border:0;outline:none;" /></div>
|
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-category" type="text" style="width:100%;border:0;outline:none;" /></div>
|
||||||
<div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-location" type="text" style="width:100%;border:0;outline:none;" /></div>
|
<div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-location" type="text" style="width:100%;border:0;outline:none;" /></div>
|
||||||
<div style="grid-column:1 / span 4;"><label style="display:inline-flex;align-items:center;gap:6px;"><input id="cp-all-day" type="checkbox" /> All Day Event</label></div>
|
<div style="grid-column:1 / span 4;"><label style="display:inline-flex;align-items:center;gap:6px;"><input id="cp-all-day" type="checkbox" /> All Day Event</label></div>
|
||||||
|
|
@ -256,6 +257,7 @@ final class Plugin
|
||||||
<h3 style="margin:0 0 8px 0;">Event Details</h3>
|
<h3 style="margin:0 0 8px 0;">Event Details</h3>
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
|
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
|
||||||
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-details-title" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-details-title" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
||||||
|
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Visibility</label><input id="cp-details-visibility" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
||||||
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-details-category" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-details-category" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
||||||
<div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-details-location" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
<div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-details-location" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
||||||
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Start</label><input id="cp-details-start" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Start</label><input id="cp-details-start" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
|
||||||
|
|
@ -409,6 +411,7 @@ final class Plugin
|
||||||
const clearEditor=()=>{
|
const clearEditor=()=>{
|
||||||
s("cp-event-id").value="";
|
s("cp-event-id").value="";
|
||||||
["cp-title","cp-description","cp-location","cp-category","cp-occurrence-key"].forEach(k=>s(k).value="");
|
["cp-title","cp-description","cp-location","cp-category","cp-occurrence-key"].forEach(k=>s(k).value="");
|
||||||
|
s("cp-visibility").value="public";
|
||||||
s("cp-occurrence-key-iso").value="";
|
s("cp-occurrence-key-iso").value="";
|
||||||
s("cp-all-day").checked=false;
|
s("cp-all-day").checked=false;
|
||||||
s("cp-repeat-type").value="none";
|
s("cp-repeat-type").value="none";
|
||||||
|
|
@ -452,6 +455,7 @@ final class Plugin
|
||||||
|
|
||||||
const openDetailsForItem=(it)=>{
|
const openDetailsForItem=(it)=>{
|
||||||
s("cp-details-title").value=it.title||"";
|
s("cp-details-title").value=it.title||"";
|
||||||
|
s("cp-details-visibility").value=(it.visibility||"public")==="private"?"Private":"Public";
|
||||||
s("cp-details-category").value=it.category||"";
|
s("cp-details-category").value=it.category||"";
|
||||||
s("cp-details-location").value=it.location||"";
|
s("cp-details-location").value=it.location||"";
|
||||||
s("cp-details-start").value=detailsDateTime(it.occurrence_start||it.start_datetime,!!it.all_day_event);
|
s("cp-details-start").value=detailsDateTime(it.occurrence_start||it.start_datetime,!!it.all_day_event);
|
||||||
|
|
@ -478,6 +482,7 @@ final class Plugin
|
||||||
s("cp-editor-title").textContent="Edit Event";
|
s("cp-editor-title").textContent="Edit Event";
|
||||||
s("cp-event-id").value=itemId(source);
|
s("cp-event-id").value=itemId(source);
|
||||||
s("cp-title").value=source.title||"";
|
s("cp-title").value=source.title||"";
|
||||||
|
s("cp-visibility").value=source.visibility==="private"?"private":"public";
|
||||||
s("cp-description").value=source.description||"";
|
s("cp-description").value=source.description||"";
|
||||||
s("cp-location").value=source.location||"";
|
s("cp-location").value=source.location||"";
|
||||||
s("cp-category").value=source.category||"";
|
s("cp-category").value=source.category||"";
|
||||||
|
|
@ -532,6 +537,7 @@ final class Plugin
|
||||||
ok:true,
|
ok:true,
|
||||||
payload:{
|
payload:{
|
||||||
title:title,
|
title:title,
|
||||||
|
visibility:s("cp-visibility").value==="private"?"private":"public",
|
||||||
description:s("cp-description").value,
|
description:s("cp-description").value,
|
||||||
location:s("cp-location").value,
|
location:s("cp-location").value,
|
||||||
category:s("cp-category").value,
|
category:s("cp-category").value,
|
||||||
|
|
@ -1592,10 +1598,16 @@ HTML
|
||||||
$date = (string) ($request->get_param('date') ?: gmdate('Y-m-d'));
|
$date = (string) ($request->get_param('date') ?: gmdate('Y-m-d'));
|
||||||
$futureOnlyRaw = (string) ($request->get_param('future_only') ?? '');
|
$futureOnlyRaw = (string) ($request->get_param('future_only') ?? '');
|
||||||
$futureOnly = in_array(strtolower($futureOnlyRaw), ['1', 'true', 'yes', 'on'], true);
|
$futureOnly = in_array(strtolower($futureOnlyRaw), ['1', 'true', 'yes', 'on'], true);
|
||||||
$items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly);
|
$includePrivateDetails = $this->canWriteCalendar($request);
|
||||||
|
$items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly, !$includePrivateDetails);
|
||||||
return [
|
return [
|
||||||
'data' => $items,
|
'data' => $items,
|
||||||
'meta' => ['count' => count($items), 'view' => $view, 'future_only' => $futureOnly],
|
'meta' => [
|
||||||
|
'count' => count($items),
|
||||||
|
'view' => $view,
|
||||||
|
'future_only' => $futureOnly,
|
||||||
|
'redacted_private' => !$includePrivateDetails,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -1608,7 +1620,7 @@ HTML
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'callback' => function (): array {
|
'callback' => function (): array {
|
||||||
$items = $this->eventService->listSidebarUpcoming(14);
|
$items = $this->eventService->listSidebarUpcoming(14, true);
|
||||||
return [
|
return [
|
||||||
'data' => $items,
|
'data' => $items,
|
||||||
'meta' => ['count' => count($items), 'window_days' => 14],
|
'meta' => ['count' => count($items), 'window_days' => 14],
|
||||||
|
|
@ -1840,13 +1852,19 @@ HTML
|
||||||
[
|
[
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'callback' => function (): array {
|
'callback' => function ($request): array|\WP_Error {
|
||||||
$settings = $this->settingsService->getAll();
|
$settings = $this->settingsService->getAll();
|
||||||
|
$icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read');
|
||||||
|
$includePrivateDetails = $this->canWriteCalendar($request);
|
||||||
|
if ($icsMode === 'authenticated_read' && !$includePrivateDetails) {
|
||||||
|
return $this->error('auth_required', 'authentication required', 401);
|
||||||
|
}
|
||||||
$calendarName = (string) ($settings['caldav_calendar_name'] ?? 'Public Calendar');
|
$calendarName = (string) ($settings['caldav_calendar_name'] ?? 'Public Calendar');
|
||||||
$ics = $this->icsService->buildCalendar(
|
$ics = $this->icsService->buildCalendar(
|
||||||
$this->eventService->listEvents(),
|
$this->eventService->listEvents(),
|
||||||
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
||||||
$calendarName
|
$calendarName,
|
||||||
|
!$includePrivateDetails
|
||||||
);
|
);
|
||||||
return ['data' => $ics];
|
return ['data' => $ics];
|
||||||
},
|
},
|
||||||
|
|
@ -1964,9 +1982,11 @@ HTML
|
||||||
private function serveIcsResponse(): void
|
private function serveIcsResponse(): void
|
||||||
{
|
{
|
||||||
$settings = $this->settingsService->getAll();
|
$settings = $this->settingsService->getAll();
|
||||||
|
$icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read');
|
||||||
|
$includePrivateDetails = $this->canWriteCalendar(null);
|
||||||
if (
|
if (
|
||||||
(string) ($settings['ics_access_mode'] ?? 'public_read') === 'authenticated_read'
|
$icsMode === 'authenticated_read'
|
||||||
&& $this->resolveCalDavUserForRequest(null) === null
|
&& !$includePrivateDetails
|
||||||
) {
|
) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
@ -1978,7 +1998,8 @@ HTML
|
||||||
$ics = $this->icsService->buildCalendar(
|
$ics = $this->icsService->buildCalendar(
|
||||||
$this->eventService->listEvents(),
|
$this->eventService->listEvents(),
|
||||||
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
||||||
$calendarName
|
$calendarName,
|
||||||
|
!$includePrivateDetails
|
||||||
);
|
);
|
||||||
$etag = '"' . substr(sha1($ics), 0, 16) . '"';
|
$etag = '"' . substr(sha1($ics), 0, 16) . '"';
|
||||||
$lastModified = gmdate('D, d M Y H:i:s') . ' GMT';
|
$lastModified = gmdate('D, d M Y H:i:s') . ' GMT';
|
||||||
|
|
@ -2010,7 +2031,18 @@ HTML
|
||||||
$resourcePrefix = $collection;
|
$resourcePrefix = $collection;
|
||||||
|
|
||||||
if ($method === 'HEAD') {
|
if ($method === 'HEAD') {
|
||||||
if ($path === $root || $path === $root . '/' || $path === $calendarsRoot || $path === rtrim($calendarsRoot, '/') || $path === $collection || $path === rtrim($collection, '/')) {
|
if (
|
||||||
|
$path === $root
|
||||||
|
|| $path === $root . '/'
|
||||||
|
|| $path === $principalCollection
|
||||||
|
|| $path === rtrim($principalCollection, '/')
|
||||||
|
|| $path === $principal
|
||||||
|
|| $path === rtrim($principal, '/')
|
||||||
|
|| $path === $calendarsRoot
|
||||||
|
|| $path === rtrim($calendarsRoot, '/')
|
||||||
|
|| $path === $collection
|
||||||
|
|| $path === rtrim($collection, '/')
|
||||||
|
) {
|
||||||
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
||||||
header('DAV: 1, calendar-access');
|
header('DAV: 1, calendar-access');
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
|
|
@ -2030,6 +2062,26 @@ HTML
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
if (
|
||||||
|
$path === $root
|
||||||
|
|| $path === $root . '/'
|
||||||
|
|| $path === $principalCollection
|
||||||
|
|| $path === rtrim($principalCollection, '/')
|
||||||
|
|| $path === $principal
|
||||||
|
|| $path === rtrim($principal, '/')
|
||||||
|
|| $path === $calendarsRoot
|
||||||
|
|| $path === rtrim($calendarsRoot, '/')
|
||||||
|
|| $path === $collection
|
||||||
|
|| $path === rtrim($collection, '/')
|
||||||
|
) {
|
||||||
|
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
||||||
|
header('DAV: 1, calendar-access');
|
||||||
|
http_response_code(200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($method === 'OPTIONS') {
|
if ($method === 'OPTIONS') {
|
||||||
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
||||||
header('DAV: 1, calendar-access');
|
header('DAV: 1, calendar-access');
|
||||||
|
|
@ -2079,7 +2131,7 @@ HTML
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'REPORT' && $path === $collection) {
|
if ($method === 'REPORT' && ($path === $collection || $path === rtrim($collection, '/'))) {
|
||||||
$body = (string) file_get_contents('php://input');
|
$body = (string) file_get_contents('php://input');
|
||||||
header('Content-Type: application/xml; charset=utf-8');
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
http_response_code(207);
|
http_response_code(207);
|
||||||
|
|
@ -2269,7 +2321,15 @@ HTML
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|
||||||
if (str_contains($bodyLower, 'sync-collection')) {
|
if (str_contains($bodyLower, 'sync-collection')) {
|
||||||
|
$clientSyncToken = $this->extractSyncCollectionToken($xmlBody);
|
||||||
|
// If client token is already current, no changes should be emitted.
|
||||||
|
if ($clientSyncToken !== '' && $clientSyncToken === $syncToken) {
|
||||||
|
$items = [];
|
||||||
|
} else {
|
||||||
|
// Fallback implementation: emit current objects for initial/out-of-date tokens.
|
||||||
|
// Avoid emitting historical tombstones because many clients treat large 404 sets as transient failures.
|
||||||
$items = $this->calDavService->multiget($resources);
|
$items = $this->calDavService->multiget($resources);
|
||||||
|
}
|
||||||
} elseif (str_contains($bodyLower, 'calendar-query')) {
|
} elseif (str_contains($bodyLower, 'calendar-query')) {
|
||||||
$items = $this->calDavService->multiget($resources);
|
$items = $this->calDavService->multiget($resources);
|
||||||
if (preg_match('/start\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $s) && preg_match('/end\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $e)) {
|
if (preg_match('/start\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $s) && preg_match('/end\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $e)) {
|
||||||
|
|
@ -2324,6 +2384,9 @@ HTML
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$seed .= (string) ($row['resource'] ?? '') . '|' . (string) ($row['etag'] ?? '') . ';';
|
$seed .= (string) ($row['resource'] ?? '') . '|' . (string) ($row['etag'] ?? '') . ';';
|
||||||
}
|
}
|
||||||
|
foreach ($this->calDavService->listDeletedResources(1000) as $deletedResource) {
|
||||||
|
$seed .= 'deleted:' . $deletedResource . ';';
|
||||||
|
}
|
||||||
return 'urn:calendar-plugin:sync:' . sha1($seed);
|
return 'urn:calendar-plugin:sync:' . sha1($seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2353,6 +2416,15 @@ HTML
|
||||||
return $this->icalToIso((string) $m[1]);
|
return $this->icalToIso((string) $m[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function extractSyncCollectionToken(string $xmlBody): string
|
||||||
|
{
|
||||||
|
if (!preg_match('/<[^>]*sync-token[^>]*>(.*?)<\\/[^>]*sync-token>/is', $xmlBody, $m)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$token = trim(html_entity_decode((string) $m[1], ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
private function unwrapServiceResult(array $result): array|\WP_Error
|
private function unwrapServiceResult(array $result): array|\WP_Error
|
||||||
{
|
{
|
||||||
if (isset($result['error']) && is_array($result['error'])) {
|
if (isset($result['error']) && is_array($result['error'])) {
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ The endpoint must support these operations at minimum.
|
||||||
- `REPORT` (`calendar-query`): return events in collection, including time-range filtering.
|
- `REPORT` (`calendar-query`): return events in collection, including time-range filtering.
|
||||||
- `REPORT` (`calendar-multiget`): fetch specific event resources by href.
|
- `REPORT` (`calendar-multiget`): fetch specific event resources by href.
|
||||||
- `GET`: retrieve individual event resource (`text/calendar`).
|
- `GET`: retrieve individual event resource (`text/calendar`).
|
||||||
|
- `GET` on CalDAV collection resources should return `200` (empty body acceptable) for client availability probes.
|
||||||
|
|
||||||
### Create and Update
|
### Create and Update
|
||||||
- `PUT`: create new event resource or replace an existing event resource.
|
- `PUT`: create new event resource or replace an existing event resource.
|
||||||
|
|
@ -86,6 +87,8 @@ The endpoint must support these operations at minimum.
|
||||||
- `If-Match`/`If-None-Match` preconditions must be honored for safe updates/creates.
|
- `If-Match`/`If-None-Match` preconditions must be honored for safe updates/creates.
|
||||||
- `REPORT` (`sync-collection`) should be supported for incremental sync tokens.
|
- `REPORT` (`sync-collection`) should be supported for incremental sync tokens.
|
||||||
- Sync token invalidation/rotation behavior must be deterministic and documented.
|
- Sync token invalidation/rotation behavior must be deterministic and documented.
|
||||||
|
- `sync-collection` no-change requests (client token equals current token) should return an empty change set (`207` with no changed/deleted `response` entries).
|
||||||
|
- `sync-collection` should prefer stable incremental behavior over historical replay; unchanged resyncs must not emit large historical tombstone sets.
|
||||||
|
|
||||||
## iCalendar Representation Requirements
|
## iCalendar Representation Requirements
|
||||||
CalDAV event payloads must be standards-compatible `VCALENDAR` with `VEVENT` components.
|
CalDAV event payloads must be standards-compatible `VCALENDAR` with `VEVENT` components.
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ URI rules:
|
||||||
### Calendar collection resources
|
### Calendar collection resources
|
||||||
- `OPTIONS`
|
- `OPTIONS`
|
||||||
- `PROPFIND`
|
- `PROPFIND`
|
||||||
|
- `GET` (availability probe support; returns `200` with empty body on collection URL)
|
||||||
- `REPORT` (`calendar-query`, `calendar-multiget`, `sync-collection`)
|
- `REPORT` (`calendar-query`, `calendar-multiget`, `sync-collection`)
|
||||||
|
|
||||||
### Event object resources
|
### Event object resources
|
||||||
|
|
@ -78,6 +79,9 @@ Calendar collection and principal responses must support, at minimum, these prop
|
||||||
- `calendar-query` with time-range filtering
|
- `calendar-query` with time-range filtering
|
||||||
- `calendar-multiget` by href set
|
- `calendar-multiget` by href set
|
||||||
- `sync-collection` for incremental changes since sync token
|
- `sync-collection` for incremental changes since sync token
|
||||||
|
- Collection `REPORT` handling must accept both canonical and non-canonical trailing-slash variants (for example `/caldav/calendars/public/` and `/caldav/calendars/public`).
|
||||||
|
- For `sync-collection`, if client sync-token equals server sync-token, server should return `207` with no `D:response` change entries.
|
||||||
|
- `sync-collection` responses must not emit large sets of historical `404` tombstones for unchanged state.
|
||||||
|
|
||||||
If a report is unsupported for a resource, server returns standards-appropriate error status with DAV error body.
|
If a report is unsupported for a resource, server returns standards-appropriate error status with DAV error body.
|
||||||
|
|
||||||
|
|
@ -115,4 +119,7 @@ Acceptance should verify:
|
||||||
- URI layout and discovery flows are stable.
|
- URI layout and discovery flows are stable.
|
||||||
- Required methods return expected statuses.
|
- Required methods return expected statuses.
|
||||||
- REPORT responses include correct event sets.
|
- REPORT responses include correct event sets.
|
||||||
|
- Collection `GET` returns `200` for authenticated probe requests.
|
||||||
|
- `sync-collection` no-change request (current token) returns `207` with zero change responses.
|
||||||
|
- `sync-collection` works with and without trailing slash on collection URI.
|
||||||
- Conditional write and etag behavior prevents stale overwrite.
|
- Conditional write and etag behavior prevents stale overwrite.
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,10 @@ Must run:
|
||||||
- Recurrence exception behavior (single occurrence delete without split) is mandatory coverage.
|
- Recurrence exception behavior (single occurrence delete without split) is mandatory coverage.
|
||||||
- Authn/Authz paths for admin, API, and CalDAV roles are mandatory coverage.
|
- Authn/Authz paths for admin, API, and CalDAV roles are mandatory coverage.
|
||||||
- Error-model contract coverage is mandatory for API endpoints.
|
- Error-model contract coverage is mandatory for API endpoints.
|
||||||
|
- CalDAV client-compatibility regressions are mandatory coverage, including:
|
||||||
|
- collection `GET` availability probe compatibility
|
||||||
|
- collection `REPORT` handling with and without trailing slash
|
||||||
|
- `sync-collection` no-change stability (current token -> zero change entries)
|
||||||
|
|
||||||
## Pass/Fail Gates
|
## Pass/Fail Gates
|
||||||
- Any required suite failure blocks merge/release as applicable.
|
- Any required suite failure blocks merge/release as applicable.
|
||||||
|
|
|
||||||
|
|
@ -105,10 +105,12 @@ fi
|
||||||
"${SSH[@]}" "set -euo pipefail; rm -rf '${STAGE_DIR}/extracted'; mkdir -p '${STAGE_DIR}/extracted'; unzip -q '${STAGE_DIR}/${artifact_base}' -d '${STAGE_DIR}/extracted'; test -f '${STAGE_DIR}/extracted/calendar-plugin/calendar-plugin.php'"
|
"${SSH[@]}" "set -euo pipefail; rm -rf '${STAGE_DIR}/extracted'; mkdir -p '${STAGE_DIR}/extracted'; unzip -q '${STAGE_DIR}/${artifact_base}' -d '${STAGE_DIR}/extracted'; test -f '${STAGE_DIR}/extracted/calendar-plugin/calendar-plugin.php'"
|
||||||
"${SSH[@]}" "set -euo pipefail; rsync -a --delete '${STAGE_DIR}/extracted/calendar-plugin/' '${REMOTE_APP_DIR}/'"
|
"${SSH[@]}" "set -euo pipefail; rsync -a --delete '${STAGE_DIR}/extracted/calendar-plugin/' '${REMOTE_APP_DIR}/'"
|
||||||
"${SSH[@]}" "set -euo pipefail; chown -R www-data:www-data '${REMOTE_APP_DIR}'"
|
"${SSH[@]}" "set -euo pipefail; chown -R www-data:www-data '${REMOTE_APP_DIR}'"
|
||||||
"${SSH[@]}" "set -euo pipefail; '${REMOTE_WP_CLI}' --path='${WP_ROOT}' plugin activate calendar-plugin --allow-root >/dev/null 2>&1 || true"
|
# Always cycle plugin activation so activation-hook migrations run on every deploy.
|
||||||
|
"${SSH[@]}" "set -euo pipefail; '${REMOTE_WP_CLI}' --path='${WP_ROOT}' plugin deactivate calendar-plugin --allow-root >/dev/null 2>&1 || true; '${REMOTE_WP_CLI}' --path='${WP_ROOT}' plugin activate calendar-plugin --allow-root >/dev/null"
|
||||||
|
|
||||||
# Exact-match style checksum dry-run check
|
# Exact-match style checksum dry-run check for content drift.
|
||||||
"${SSH[@]}" "set -euo pipefail; rsync -avznc --delete '${STAGE_DIR}/extracted/calendar-plugin/' '${REMOTE_APP_DIR}/' >/tmp/codex_rsync_check.out; if grep -Eq '^[^./]|^\./' /tmp/codex_rsync_check.out; then cat /tmp/codex_rsync_check.out; exit 1; fi"
|
# Ignore directory metadata-only differences, which are expected after chown/remote extraction.
|
||||||
|
"${SSH[@]}" "set -euo pipefail; rsync -rcn --delete --omit-dir-times --no-perms --no-owner --no-group --itemize-changes '${STAGE_DIR}/extracted/calendar-plugin/' '${REMOTE_APP_DIR}/' >/tmp/codex_rsync_check.out; if grep -Eq '^(>f|\\*deleting|cd|cL|cD|cS)' /tmp/codex_rsync_check.out; then cat /tmp/codex_rsync_check.out; exit 1; fi"
|
||||||
|
|
||||||
# Ownership sanity check: must be zero mismatches
|
# Ownership sanity check: must be zero mismatches
|
||||||
non_owned="$("${SSH[@]}" "set -euo pipefail; find '${REMOTE_APP_DIR}' \( ! -user www-data -o ! -group www-data \) | wc -l")"
|
non_owned="$("${SSH[@]}" "set -euo pipefail; find '${REMOTE_APP_DIR}' \( ! -user www-data -o ! -group www-data \) | wc -l")"
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,31 @@ Adjust paths to actual implementation while preserving case coverage.
|
||||||
- CalDAV link uses `/<url_slug>/caldav/`.
|
- CalDAV link uses `/<url_slug>/caldav/`.
|
||||||
- Link path changes match configured slug value.
|
- Link path changes match configured slug value.
|
||||||
|
|
||||||
|
## CalDAV Sync Compatibility Tests
|
||||||
|
|
||||||
|
### API-CALDAV-001 Collection URL Variant Compatibility
|
||||||
|
- Method: `REPORT sync-collection` against both:
|
||||||
|
- `.../caldav/calendars/public/`
|
||||||
|
- `.../caldav/calendars/public`
|
||||||
|
- Assertions:
|
||||||
|
- Both return `207`.
|
||||||
|
- Neither returns method errors due to trailing slash variant.
|
||||||
|
|
||||||
|
### API-CALDAV-002 Collection Availability Probe
|
||||||
|
- Method: `GET .../caldav/calendars/public/` (authenticated)
|
||||||
|
- Assertions:
|
||||||
|
- Returns `200` (body may be empty).
|
||||||
|
- Does not force client into temporary unavailable state on probe.
|
||||||
|
|
||||||
|
### API-CALDAV-003 No-Change Incremental Sync Stability
|
||||||
|
- Method:
|
||||||
|
1. run `REPORT sync-collection` to obtain sync token
|
||||||
|
2. rerun `REPORT sync-collection` with returned token and no intervening changes
|
||||||
|
- Assertions:
|
||||||
|
- Returns `207`
|
||||||
|
- Contains zero `<D:response>` change entries
|
||||||
|
- Does not emit historical `404` tombstone floods for unchanged state
|
||||||
|
|
||||||
## Negative and Security Tests
|
## Negative and Security Tests
|
||||||
|
|
||||||
### API-SEC-001 Unauthorized Access
|
### API-SEC-001 Unauthorized Access
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@ fi
|
||||||
BASE_URL="${BASE_URL:-${WP_URL:-}}"
|
BASE_URL="${BASE_URL:-${WP_URL:-}}"
|
||||||
AUTH_USER="${CAL_TEST_USER:-adrians@chezstephens.org.uk}"
|
AUTH_USER="${CAL_TEST_USER:-adrians@chezstephens.org.uk}"
|
||||||
AUTH_PASS="${CAL_TEST_PASSWORD:-brillig1}"
|
AUTH_PASS="${CAL_TEST_PASSWORD:-brillig1}"
|
||||||
EXPECTED_TABLE_PREFIX="${CAL_TABLE_PREFIX_EXPECTED:-wp_cs_calendar}"
|
|
||||||
ENABLE_PREFIX_CHECK="${CAL_ENABLE_PREFIX_CHECK:-1}"
|
ENABLE_PREFIX_CHECK="${CAL_ENABLE_PREFIX_CHECK:-1}"
|
||||||
|
CAL_URL_SLUG="${CAL_URL_SLUG:-}"
|
||||||
|
CAL_CALDAV_PATH="${CAL_CALDAV_PATH:-}"
|
||||||
REMOTE_WP_PATH="${REMOTE_WP_PATH:-}"
|
REMOTE_WP_PATH="${REMOTE_WP_PATH:-}"
|
||||||
if [[ -z "${REMOTE_WP_PATH}" ]] && [[ -n "${REMOTE_APP_DIR:-}" ]]; then
|
if [[ -z "${REMOTE_WP_PATH}" ]] && [[ -n "${REMOTE_APP_DIR:-}" ]]; then
|
||||||
REMOTE_WP_PATH="$(dirname "$(dirname "${REMOTE_APP_DIR}")")"
|
REMOTE_WP_PATH="$(dirname "$(dirname "${REMOTE_APP_DIR}")")"
|
||||||
|
|
@ -24,6 +25,10 @@ fi
|
||||||
FAILURES=0
|
FAILURES=0
|
||||||
CREATED_EVENT_ID=""
|
CREATED_EVENT_ID=""
|
||||||
CREATED_REC_EVENT_ID=""
|
CREATED_REC_EVENT_ID=""
|
||||||
|
CREATED_PRIVATE_EVENT_ID=""
|
||||||
|
CREATED_SYNC_DELETE_EVENT_ID=""
|
||||||
|
DETECTED_URL_SLUG=""
|
||||||
|
SSH_OK=0
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'TXT'
|
cat <<'TXT'
|
||||||
|
|
@ -36,8 +41,9 @@ Env overrides:
|
||||||
BASE_URL
|
BASE_URL
|
||||||
CAL_TEST_USER
|
CAL_TEST_USER
|
||||||
CAL_TEST_PASSWORD
|
CAL_TEST_PASSWORD
|
||||||
CAL_TABLE_PREFIX_EXPECTED
|
|
||||||
CAL_ENABLE_PREFIX_CHECK
|
CAL_ENABLE_PREFIX_CHECK
|
||||||
|
CAL_URL_SLUG
|
||||||
|
CAL_CALDAV_PATH
|
||||||
|
|
||||||
Defaults:
|
Defaults:
|
||||||
BASE_URL -> credentials/.env:WP_URL
|
BASE_URL -> credentials/.env:WP_URL
|
||||||
|
|
@ -110,6 +116,14 @@ cleanup() {
|
||||||
curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
|
curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
|
||||||
"${BASE_URL}/wp-json/calendar/v1/events/${CREATED_REC_EVENT_ID}" >/dev/null || true
|
"${BASE_URL}/wp-json/calendar/v1/events/${CREATED_REC_EVENT_ID}" >/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
if [[ -n "${CREATED_PRIVATE_EVENT_ID}" ]]; then
|
||||||
|
curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
|
||||||
|
"${BASE_URL}/wp-json/calendar/v1/events/${CREATED_PRIVATE_EVENT_ID}" >/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ -n "${CREATED_SYNC_DELETE_EVENT_ID}" ]]; then
|
||||||
|
curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
|
||||||
|
"${BASE_URL}/wp-json/calendar/v1/events/${CREATED_SYNC_DELETE_EVENT_ID}" >/dev/null || true
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
|
@ -122,7 +136,11 @@ else
|
||||||
record_fail "health endpoint unreachable"
|
record_fail "health endpoint unreachable"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${ENABLE_PREFIX_CHECK}" == "1" ]] && [[ -n "${REMOTE_HOST:-}" ]] && [[ -n "${REMOTE_USER:-}" ]] && [[ -n "${REMOTE_SSH_KEY_PATH:-}" ]] && [[ -n "${REMOTE_PORT:-}" ]] && [[ -n "${REMOTE_WP_CLI:-}" ]]; then
|
if [[ -n "${REMOTE_HOST:-}" ]] && [[ -n "${REMOTE_USER:-}" ]] && [[ -n "${REMOTE_SSH_KEY_PATH:-}" ]] && [[ -n "${REMOTE_PORT:-}" ]] && [[ -n "${REMOTE_WP_CLI:-}" ]]; then
|
||||||
|
SSH_OK=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${ENABLE_PREFIX_CHECK}" == "1" ]] && [[ "${SSH_OK}" == "1" ]]; then
|
||||||
step "table prefix configuration"
|
step "table prefix configuration"
|
||||||
SSH_KEY_PATH="${REMOTE_SSH_KEY_PATH}"
|
SSH_KEY_PATH="${REMOTE_SSH_KEY_PATH}"
|
||||||
if [[ "${SSH_KEY_PATH}" != /* ]]; then
|
if [[ "${SSH_KEY_PATH}" != /* ]]; then
|
||||||
|
|
@ -131,16 +149,21 @@ if [[ "${ENABLE_PREFIX_CHECK}" == "1" ]] && [[ -n "${REMOTE_HOST:-}" ]] && [[ -n
|
||||||
SSH_PREFIX=(ssh -F /dev/null -i "${SSH_KEY_PATH}" -p "${REMOTE_PORT}" -o StrictHostKeyChecking=accept-new "${REMOTE_USER}@${REMOTE_HOST}")
|
SSH_PREFIX=(ssh -F /dev/null -i "${SSH_KEY_PATH}" -p "${REMOTE_PORT}" -o StrictHostKeyChecking=accept-new "${REMOTE_USER}@${REMOTE_HOST}")
|
||||||
if ! "${SSH_PREFIX[@]}" "echo ok" >/dev/null 2>&1; then
|
if ! "${SSH_PREFIX[@]}" "echo ok" >/dev/null 2>&1; then
|
||||||
echo "[remote-tests] WARN: SSH unavailable, skipping table prefix check"
|
echo "[remote-tests] WARN: SSH unavailable, skipping table prefix check"
|
||||||
|
SSH_OK=0
|
||||||
else
|
else
|
||||||
|
DETECTED_URL_SLUG="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} option get calendar_plugin_url_slug --allow-root 2>/dev/null || true" | tr -d '\r' | tail -n1)"
|
||||||
STEM="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} option get calendar_plugin_table_stem --allow-root 2>/dev/null || true" | tr -d '\r' | tail -n1)"
|
STEM="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} option get calendar_plugin_table_stem --allow-root 2>/dev/null || true" | tr -d '\r' | tail -n1)"
|
||||||
if [[ -n "${STEM}" ]] && [[ "${STEM}" != "cs_calendar" ]] && [[ "${STEM}" != "calendar" ]]; then
|
WP_DB_PREFIX="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} eval 'global \$wpdb; echo \$wpdb->prefix;' --allow-root 2>/dev/null || true" | tr -d '\r' | tail -n1)"
|
||||||
record_fail "table stem option expected cs_calendar/calendar got '${STEM}'"
|
|
||||||
fi
|
|
||||||
TABLE_LIST="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} db query \"SHOW TABLES;\" --allow-root --silent --skip-column-names 2>/dev/null || true" | tr -d '\r')"
|
TABLE_LIST="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} db query \"SHOW TABLES;\" --allow-root --silent --skip-column-names 2>/dev/null || true" | tr -d '\r')"
|
||||||
TABLE_EXISTS="$(printf '%s\n' "${TABLE_LIST}" | grep -Fx "${EXPECTED_TABLE_PREFIX}_events" | head -n1 || true)"
|
if [[ -n "${STEM}" ]] && [[ -n "${WP_DB_PREFIX}" ]]; then
|
||||||
LEGACY_EXISTS="$(printf '%s\n' "${TABLE_LIST}" | grep -Fx "wp_calendar_events" | head -n1 || true)"
|
EXPECTED_EVENTS_TABLE="${WP_DB_PREFIX}${STEM}_events"
|
||||||
if [[ "${TABLE_EXISTS}" != "${EXPECTED_TABLE_PREFIX}_events" ]] && [[ "${LEGACY_EXISTS}" != "wp_calendar_events" ]]; then
|
if ! printf '%s\n' "${TABLE_LIST}" | grep -Fxq "${EXPECTED_EVENTS_TABLE}"; then
|
||||||
record_fail "expected table prefix '${EXPECTED_TABLE_PREFIX}' (or legacy wp_calendar) not found"
|
record_fail "table stem '${STEM}' does not match an existing events table (${EXPECTED_EVENTS_TABLE})"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if ! printf '%s\n' "${TABLE_LIST}" | grep -Eq '_calendar_.*_events$|_calendar_events$'; then
|
||||||
|
record_fail "no calendar events table detected from SHOW TABLES output"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
@ -267,19 +290,217 @@ PY
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
step "public privacy redaction"
|
||||||
|
PRIV_UID="remote-private-redact-$(date +%s)@calendar-plugin"
|
||||||
|
PRIV_JSON=$(cat <<JSON
|
||||||
|
{"uid":"${PRIV_UID}","title":"Remote Private Leak Check","visibility":"private","description":"SHOULD_NOT_LEAK","location":"SECRET_ROOM","category":"SECRET_CAT","start_datetime":"2026-04-22T10:00:00+01:00","end_datetime":"2026-04-22T11:00:00+01:00","repeat_type":"none","repeat_interval":1,"repeat_range_mode":"none"}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
PRIV_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -H 'Content-Type: application/json' \
|
||||||
|
-d "${PRIV_JSON}" -o /tmp/remote_test_private_create.json -w '%{http_code}' \
|
||||||
|
"${BASE_URL}/wp-json/calendar/v1/events" || true)"
|
||||||
|
if [[ "${PRIV_HTTP}" != "200" ]]; then
|
||||||
|
record_fail "private event create failed (http ${PRIV_HTTP})"
|
||||||
|
else
|
||||||
|
CREATED_PRIVATE_UID="$(python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
p=json.load(open('/tmp/remote_test_private_create.json'))
|
||||||
|
print(p.get('data', {}).get('uid', ''))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
CREATED_PRIVATE_EVENT_ID="$(python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
p=json.load(open('/tmp/remote_test_private_create.json'))
|
||||||
|
print(p.get('data', {}).get('id', ''))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
PUB_HTTP="$(curl -sS \
|
||||||
|
-o /tmp/remote_test_private_public.json -w '%{http_code}' \
|
||||||
|
"${BASE_URL}/wp-json/calendar/v1/public/events?view=month&date=2026-04-01" || true)"
|
||||||
|
if [[ "${PUB_HTTP}" != "200" ]]; then
|
||||||
|
record_fail "public events read for privacy check failed (http ${PUB_HTTP})"
|
||||||
|
else
|
||||||
|
if ! python3 - "${CREATED_PRIVATE_UID}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
uid=sys.argv[1]
|
||||||
|
payload=json.load(open('/tmp/remote_test_private_public.json'))
|
||||||
|
items=payload.get('data', [])
|
||||||
|
target=None
|
||||||
|
for item in items:
|
||||||
|
if item.get('uid') == uid:
|
||||||
|
target=item
|
||||||
|
break
|
||||||
|
assert target is not None
|
||||||
|
assert target.get('title') == 'Private Event'
|
||||||
|
assert (target.get('description') or '') == ''
|
||||||
|
assert (target.get('location') or '') == ''
|
||||||
|
assert (target.get('category') or '') == ''
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
record_fail "private event leaked in public payload"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
step "caldav discovery compatibility"
|
step "caldav discovery compatibility"
|
||||||
CALDAV_UNAUTH="$(curl -s -o /tmp/remote_test_caldav_unauth.txt -w '%{http_code}' "${BASE_URL}/caldav/" || true)"
|
CALDAV_ROOT_URL=""
|
||||||
|
if [[ -n "${CAL_CALDAV_PATH}" ]]; then
|
||||||
|
if [[ "${CAL_CALDAV_PATH}" == http://* || "${CAL_CALDAV_PATH}" == https://* ]]; then
|
||||||
|
CALDAV_ROOT_URL="${CAL_CALDAV_PATH%/}/"
|
||||||
|
else
|
||||||
|
CALDAV_ROOT_URL="${BASE_URL}/${CAL_CALDAV_PATH#/}"
|
||||||
|
CALDAV_ROOT_URL="${CALDAV_ROOT_URL%/}/"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
URL_SLUG="${CAL_URL_SLUG}"
|
||||||
|
if [[ -z "${URL_SLUG}" ]]; then
|
||||||
|
URL_SLUG="${DETECTED_URL_SLUG}"
|
||||||
|
fi
|
||||||
|
if [[ -n "${URL_SLUG}" ]]; then
|
||||||
|
CALDAV_ROOT_URL="${BASE_URL}/${URL_SLUG#/}/caldav/"
|
||||||
|
else
|
||||||
|
CALDAV_ROOT_URL="${BASE_URL}/caldav/"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
CALDAV_UNAUTH="$(curl -s -o /tmp/remote_test_caldav_unauth.txt -w '%{http_code}' "${CALDAV_ROOT_URL}" || true)"
|
||||||
|
if [[ "${CALDAV_UNAUTH}" == "404" ]]; then
|
||||||
|
ALT_CANDIDATES=("${BASE_URL}/caldav/")
|
||||||
|
if [[ -n "${DETECTED_URL_SLUG}" ]]; then
|
||||||
|
ALT_CANDIDATES+=("${BASE_URL}/${DETECTED_URL_SLUG#/}/caldav/")
|
||||||
|
fi
|
||||||
|
if [[ -n "${CAL_URL_SLUG}" ]]; then
|
||||||
|
ALT_CANDIDATES+=("${BASE_URL}/${CAL_URL_SLUG#/}/caldav/")
|
||||||
|
fi
|
||||||
|
for candidate in "${ALT_CANDIDATES[@]}"; do
|
||||||
|
code="$(curl -s -o /tmp/remote_test_caldav_unauth.txt -w '%{http_code}' "${candidate}" || true)"
|
||||||
|
if [[ "${code}" != "404" ]]; then
|
||||||
|
CALDAV_ROOT_URL="${candidate}"
|
||||||
|
CALDAV_UNAUTH="${code}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
if [[ "${CALDAV_UNAUTH}" != "401" ]]; then
|
if [[ "${CALDAV_UNAUTH}" != "401" ]]; then
|
||||||
record_fail "caldav unauth challenge expected 401 got ${CALDAV_UNAUTH}"
|
record_fail "caldav unauth challenge expected 401 got ${CALDAV_UNAUTH} (${CALDAV_ROOT_URL})"
|
||||||
fi
|
fi
|
||||||
CALDAV_PROP="$(curl -s -u "${AUTH_USER}:${AUTH_PASS}" -X PROPFIND -H 'Depth: 0' \
|
CALDAV_PROP="$(curl -s -u "${AUTH_USER}:${AUTH_PASS}" -X PROPFIND -H 'Depth: 0' \
|
||||||
-o /tmp/remote_test_caldav_propfind.xml -w '%{http_code}' \
|
-o /tmp/remote_test_caldav_propfind.xml -w '%{http_code}' \
|
||||||
"${BASE_URL}/caldav/" || true)"
|
"${CALDAV_ROOT_URL}" || true)"
|
||||||
if [[ "${CALDAV_PROP}" != "207" ]]; then
|
if [[ "${CALDAV_PROP}" != "207" ]]; then
|
||||||
record_fail "caldav root PROPFIND expected 207 got ${CALDAV_PROP}"
|
record_fail "caldav root PROPFIND expected 207 got ${CALDAV_PROP} (${CALDAV_ROOT_URL})"
|
||||||
elif ! grep -Eqi 'calendar-home-set|current-user-principal' /tmp/remote_test_caldav_propfind.xml; then
|
elif ! grep -Eqi 'calendar-home-set|current-user-principal' /tmp/remote_test_caldav_propfind.xml; then
|
||||||
record_fail "caldav PROPFIND missing expected discovery properties"
|
record_fail "caldav PROPFIND missing expected discovery properties"
|
||||||
fi
|
fi
|
||||||
|
CALDAV_COLLECTION_URL="${CALDAV_ROOT_URL%/}/calendars/public/"
|
||||||
|
CALDAV_COLLECTION_GET="$(curl -s -u "${AUTH_USER}:${AUTH_PASS}" -X GET \
|
||||||
|
-o /tmp/remote_test_caldav_collection_get.txt -w '%{http_code}' \
|
||||||
|
"${CALDAV_COLLECTION_URL}" || true)"
|
||||||
|
if [[ "${CALDAV_COLLECTION_GET}" != "200" ]]; then
|
||||||
|
record_fail "caldav collection GET expected 200 got ${CALDAV_COLLECTION_GET} (${CALDAV_COLLECTION_URL})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "caldav sync-collection stability"
|
||||||
|
SYNC_UID="remote-caldav-sync-del-$(date +%s)@calendar-plugin"
|
||||||
|
SYNC_CREATE_JSON=$(cat <<JSON
|
||||||
|
{"uid":"${SYNC_UID}","title":"Remote CalDAV Sync Delete","description":"sync tombstone regression","location":"Remote","category":"QA","start_datetime":"2026-04-25T10:00:00+01:00","end_datetime":"2026-04-25T11:00:00+01:00","repeat_type":"none","repeat_interval":1,"repeat_range_mode":"none"}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
SYNC_CREATE_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -H 'Content-Type: application/json' \
|
||||||
|
-d "${SYNC_CREATE_JSON}" -o /tmp/remote_test_sync_create.json -w '%{http_code}' \
|
||||||
|
"${BASE_URL}/wp-json/calendar/v1/events" || true)"
|
||||||
|
if [[ "${SYNC_CREATE_HTTP}" != "200" ]]; then
|
||||||
|
record_fail "sync tombstone setup create failed (http ${SYNC_CREATE_HTTP})"
|
||||||
|
else
|
||||||
|
CREATED_SYNC_DELETE_EVENT_ID="$(python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
p=json.load(open('/tmp/remote_test_sync_create.json'))
|
||||||
|
print(p.get('data', {}).get('id', ''))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
SYNC_RESOURCE="$(python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
p=json.load(open('/tmp/remote_test_sync_create.json'))
|
||||||
|
print(p.get('data', {}).get('caldav_resource', ''))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if [[ -z "${CREATED_SYNC_DELETE_EVENT_ID}" || -z "${SYNC_RESOURCE}" ]]; then
|
||||||
|
record_fail "sync tombstone setup missing id/resource"
|
||||||
|
else
|
||||||
|
SYNC_DELETE_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
|
||||||
|
-o /tmp/remote_test_sync_delete.json -w '%{http_code}' \
|
||||||
|
"${BASE_URL}/wp-json/calendar/v1/events/${CREATED_SYNC_DELETE_EVENT_ID}" || true)"
|
||||||
|
if [[ "${SYNC_DELETE_HTTP}" != "200" ]]; then
|
||||||
|
record_fail "sync stability delete setup failed (http ${SYNC_DELETE_HTTP})"
|
||||||
|
else
|
||||||
|
CREATED_SYNC_DELETE_EVENT_ID=""
|
||||||
|
SYNC_REPORT_BODY_INITIAL='<?xml version="1.0" encoding="utf-8"?><D:sync-collection xmlns:D="DAV:"><D:sync-token/><D:sync-level>1</D:sync-level><D:prop><D:getetag/></D:prop></D:sync-collection>'
|
||||||
|
SYNC_REPORT_INITIAL_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X REPORT -H 'Depth: 1' -H 'Content-Type: application/xml; charset=utf-8' \
|
||||||
|
--data "${SYNC_REPORT_BODY_INITIAL}" -o /tmp/remote_test_sync_report_initial.xml -w '%{http_code}' \
|
||||||
|
"${CALDAV_COLLECTION_URL}" || true)"
|
||||||
|
if [[ "${SYNC_REPORT_INITIAL_HTTP}" != "207" ]]; then
|
||||||
|
record_fail "initial sync-collection report failed (http ${SYNC_REPORT_INITIAL_HTTP})"
|
||||||
|
fi
|
||||||
|
PRE_DELETE_TOKEN="$(python3 - <<'PY'
|
||||||
|
import re
|
||||||
|
xml=open('/tmp/remote_test_sync_report_initial.xml', encoding='utf-8', errors='ignore').read()
|
||||||
|
m=re.search(r'<D:sync-token>(.*?)</D:sync-token>', xml, re.S)
|
||||||
|
print((m.group(1).strip() if m else ''))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if [[ -z "${PRE_DELETE_TOKEN}" ]]; then
|
||||||
|
record_fail "initial sync-collection missing sync-token"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SYNC_REPORT_BODY="<?xml version=\"1.0\" encoding=\"utf-8\"?><D:sync-collection xmlns:D=\"DAV:\"><D:sync-token>${PRE_DELETE_TOKEN}</D:sync-token><D:sync-level>1</D:sync-level><D:prop><D:getetag/></D:prop></D:sync-collection>"
|
||||||
|
SYNC_REPORT_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X REPORT -H 'Depth: 1' -H 'Content-Type: application/xml; charset=utf-8' \
|
||||||
|
--data "${SYNC_REPORT_BODY}" -o /tmp/remote_test_sync_report.xml -w '%{http_code}' \
|
||||||
|
"${CALDAV_COLLECTION_URL}" || true)"
|
||||||
|
if [[ "${SYNC_REPORT_HTTP}" != "207" ]]; then
|
||||||
|
record_fail "sync-collection report failed (http ${SYNC_REPORT_HTTP})"
|
||||||
|
else
|
||||||
|
SYNC_REPORT_HTTP_NOSLASH="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X REPORT -H 'Depth: 1' -H 'Content-Type: application/xml; charset=utf-8' \
|
||||||
|
--data "${SYNC_REPORT_BODY}" -o /tmp/remote_test_sync_report_noslash.xml -w '%{http_code}' \
|
||||||
|
"${CALDAV_COLLECTION_URL%/}" || true)"
|
||||||
|
if [[ "${SYNC_REPORT_HTTP_NOSLASH}" != "207" ]]; then
|
||||||
|
record_fail "sync-collection report without trailing slash failed (http ${SYNC_REPORT_HTTP_NOSLASH})"
|
||||||
|
fi
|
||||||
|
if grep -Fq 'HTTP/1.1 404 Not Found' /tmp/remote_test_sync_report.xml; then
|
||||||
|
record_fail "sync-collection returned 404 entries for incremental sync"
|
||||||
|
fi
|
||||||
|
SYNC_TOKEN_1="$(python3 - <<'PY'
|
||||||
|
import re
|
||||||
|
xml=open('/tmp/remote_test_sync_report.xml', encoding='utf-8', errors='ignore').read()
|
||||||
|
m=re.search(r'<D:sync-token>(.*?)</D:sync-token>', xml, re.S)
|
||||||
|
print((m.group(1).strip() if m else ''))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if [[ -z "${SYNC_TOKEN_1}" ]]; then
|
||||||
|
record_fail "incremental sync-collection missing sync-token"
|
||||||
|
else
|
||||||
|
SYNC_REPORT_BODY_NOCHANGE="<?xml version=\"1.0\" encoding=\"utf-8\"?><D:sync-collection xmlns:D=\"DAV:\"><D:sync-token>${SYNC_TOKEN_1}</D:sync-token><D:sync-level>1</D:sync-level><D:prop><D:getetag/></D:prop></D:sync-collection>"
|
||||||
|
SYNC_REPORT_NOCHANGE_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X REPORT -H 'Depth: 1' -H 'Content-Type: application/xml; charset=utf-8' \
|
||||||
|
--data "${SYNC_REPORT_BODY_NOCHANGE}" -o /tmp/remote_test_sync_report_nochange.xml -w '%{http_code}' \
|
||||||
|
"${CALDAV_COLLECTION_URL}" || true)"
|
||||||
|
if [[ "${SYNC_REPORT_NOCHANGE_HTTP}" != "207" ]]; then
|
||||||
|
record_fail "no-change sync-collection report failed (http ${SYNC_REPORT_NOCHANGE_HTTP})"
|
||||||
|
else
|
||||||
|
RESPONSE_COUNT_NOCHANGE="$(python3 - <<'PY'
|
||||||
|
import re
|
||||||
|
xml=open('/tmp/remote_test_sync_report_nochange.xml', encoding='utf-8', errors='ignore').read()
|
||||||
|
print(len(re.findall(r'<D:response>', xml)))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if [[ "${RESPONSE_COUNT_NOCHANGE}" != "0" ]]; then
|
||||||
|
record_fail "no-change sync-collection should return 0 responses, got ${RESPONSE_COUNT_NOCHANGE}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
printf '\n[remote-tests] completed with %d failure(s)\n' "${FAILURES}"
|
printf '\n[remote-tests] completed with %d failure(s)\n' "${FAILURES}"
|
||||||
if [[ "${FAILURES}" -gt 0 ]]; then
|
if [[ "${FAILURES}" -gt 0 ]]; then
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,9 @@ Use a minimal subset:
|
||||||
- discoverable calendar collection href
|
- discoverable calendar collection href
|
||||||
- Authenticated `PROPFIND` on principal must return `calendar-home-set`.
|
- Authenticated `PROPFIND` on principal must return `calendar-home-set`.
|
||||||
- Authenticated `PROPFIND /caldav/calendars/` must include calendar collection metadata (`<C:calendar/>`) and supported component set (`VEVENT`).
|
- Authenticated `PROPFIND /caldav/calendars/` must include calendar collection metadata (`<C:calendar/>`) and supported component set (`VEVENT`).
|
||||||
|
- Authenticated `GET` on calendar collection (`.../calendars/public/`) must return `200` (availability probe compatibility).
|
||||||
|
- Authenticated `REPORT sync-collection` must succeed for both trailing slash and no-trailing-slash collection URLs.
|
||||||
|
- No-change `sync-collection` (current token) must return no change entries.
|
||||||
- Legacy local harness check is archived at `fixture-tests/fixture_caldav_client_compat_smoke.sh`.
|
- Legacy local harness check is archived at `fixture-tests/fixture_caldav_client_compat_smoke.sh`.
|
||||||
|
|
||||||
### SMK-010 Lifecycle Controls (Staging Only)
|
### SMK-010 Lifecycle Controls (Staging Only)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue