kithkin/app/Jobs/SyncSubscription.php

242 lines
7.6 KiB
PHP

<?php
namespace App\Jobs;
use App\Models\Calendar;
use App\Models\CalendarInstance;
use App\Models\Event;
use App\Models\EventMeta;
use App\Models\Subscription;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Sabre\VObject\ParseException;
use Sabre\VObject\Reader;
class SyncSubscription implements ShouldQueue
{
use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;
public Subscription $subscription;
public function __construct(Subscription $subscription)
{
$this->subscription = $subscription;
}
public function handle(): void
{
// normalize the source a bit so comparisons/logging are consistent
$source = rtrim(trim((string) $this->subscription->source), '/');
// 1) download the remote feed
try {
$resp = Http::retry(3, 5000)
->timeout(30)
->withHeaders([
'User-Agent' => 'Kithkin CalDAV Bot',
'Accept' => 'text/calendar, text/plain;q=0.9, */*;q=0.8',
])
->get($source);
$resp->throw();
$body = $resp->body();
$contentType = (string) $resp->header('Content-Type');
}
catch (ConnectionException | RequestException | \Throwable $e)
{
$this->markSyncFailed('fetch_failed', $e->getMessage());
Log::warning('Feed fetch failed', [
'sub' => $this->subscription->id,
'url' => $source,
'msg' => $e->getMessage(),
]);
$this->fail($e);
return;
}
// 2) ensure we actually got an ICS payload, not HTML or other content
if (! $this->looksLikeIcs($body))
{
$this->markSyncFailed('not_ical', 'remote feed did not return vcALENDAR data');
Log::warning('Feed did not return iCalendar data', [
'sub' => $this->subscription->id,
'url' => $source,
'status' => $resp->status(),
'content_type' => $contentType ?? null,
'starts_with' => substr(ltrim($body), 0, 200),
]);
$this->fail(new \RuntimeException('Remote feed did not return VCALENDAR data.'));
return;
}
// 3) get or create mirror calendar
$meta = $this->subscription->meta()->first() ?? $this->subscription->meta()->create();
if (! $meta->calendar_id) {
$meta->calendar_id = $this->createMirrorCalendar($meta, $source);
$meta->save();
}
$calendarId = (int) $meta->calendar_id;
// 4) parse the VCALENDAR
try {
$vcalendar = Reader::read($body);
}
catch (ParseException | \Throwable $e)
{
$this->markSyncFailed('parse_failed', $e->getMessage());
Log::warning('ICS parse failed', [
'sub' => $this->subscription->id,
'url' => $source,
'msg' => $e->getMessage(),
'starts_with' => substr(ltrim($body), 0, 200),
]);
$this->fail($e);
return;
}
Log::info('Syncing subscription', [
'sub' => $this->subscription->id,
'url' => $source,
]);
// 5) upsert events
foreach (($vcalendar->VEVENT ?? []) as $vevent) {
$uid = isset($vevent->UID) ? (string) $vevent->UID : null;
$dtStart = $vevent->DTSTART ?? null;
// skip malformed events (rare, but it happens)
if (! $uid || ! $dtStart) {
continue;
}
$now = now()->timestamp;
$blob = (string) $vevent->serialize();
$object = Event::updateOrCreate(
['uid' => $uid, 'calendarid' => $calendarId],
[
'uri' => Str::uuid().'.ics',
'lastmodified' => $now,
'etag' => md5($blob),
'size' => strlen($blob),
'componenttype' => 'VEVENT',
'calendardata' => $blob,
]
);
// sabre gives DateTime objects here; Carbon::instance is safest
$start = Carbon::instance($dtStart->getDateTime())->utc();
$end = isset($vevent->DTEND)
? Carbon::instance($vevent->DTEND->getDateTime())->utc()
: $start;
EventMeta::upsertForEvent($object->id, [
'title' => (string) ($vevent->SUMMARY ?? 'Untitled'),
'description' => (string) ($vevent->DESCRIPTION ?? ''),
'location' => (string) ($vevent->LOCATION ?? ''),
'all_day' => $dtStart->isFloating(),
'start_at' => $start,
'end_at' => $end,
]);
}
// sync is ok
$this->markSyncOk();
Log::info('Syncing subscription complete', [
'sub' => $this->subscription->id,
]);
}
private function looksLikeIcs(string $body): bool
{
// cheap + effective: an .ics must include BEGIN:VCALENDAR
// allow leading whitespace/bom
$haystack = ltrim($body);
return str_contains($haystack, 'BEGIN:VCALENDAR');
}
private function mirrorDescription(string $source): string
{
return 'Remote feed: '.$source;
}
/**
* Lazily builds the shadow calendar + instance when missing.
*/
private function createMirrorCalendar($meta, string $source): int
{
if ($meta->calendar_id) {
return (int) $meta->calendar_id;
}
$desc = $this->mirrorDescription($source);
$existing = CalendarInstance::where('principaluri', $this->subscription->principaluri)
->where('description', $desc)
->first();
if ($existing) {
return (int) $existing->calendarid;
}
$calendar = Calendar::create([
'synctoken' => 1,
'components' => 'VEVENT',
]);
CalendarInstance::create([
'calendarid' => $calendar->id,
'principaluri' => $this->subscription->principaluri,
'uri' => (string) Str::uuid(),
'displayname' => $this->subscription->displayname,
'description' => $desc,
'calendarcolor' => $meta->color ?? '#1a1a1a',
'timezone' => config('app.timezone', 'UTC'),
]);
return (int) $calendar->id;
}
/**
* sync helpers
*/
private function markSyncOk(): void
{
$this->subscription->forceFill([
'last_sync_status' => 'ok',
'last_sync_error' => null,
'last_sync_at' => now(),
'lastmodified' => now()->timestamp, // sabre uses int timestamps
])->save();
}
private function markSyncFailed(string $status, string $message): void
{
$this->subscription->forceFill([
'last_sync_status' => $status,
'last_sync_error' => mb_substr($message, 0, 5000),
'last_sync_at' => now(),
'lastmodified' => now()->timestamp,
])->save();
}
}