242 lines
7.6 KiB
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();
|
|
}
|
|
}
|