From ef658d1c0454d508fc93602115f45f3e67f29235 Mon Sep 17 00:00:00 2001 From: Andrew Gioia Date: Mon, 9 Feb 2026 16:43:38 -0500 Subject: [PATCH] ICS controller cleanup and improvements to location metadata in VEVENT --- .../CalendarSettingsController.php | 15 +++- app/Http/Controllers/IcsController.php | 84 +++++++++++++++++-- lang/en/calendar.php | 4 + lang/it/calendar.php | 4 + .../calendar/settings/calendar.blade.php | 33 ++++++++ routes/web.php | 3 +- 6 files changed, 135 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/CalendarSettingsController.php b/app/Http/Controllers/CalendarSettingsController.php index 3ec2c87..70694f6 100644 --- a/app/Http/Controllers/CalendarSettingsController.php +++ b/app/Http/Controllers/CalendarSettingsController.php @@ -161,12 +161,20 @@ class CalendarSettingsController extends Controller // if it's remote $icsUrl = null; - if (($meta?->is_remote ?? false) && $meta?->subscription_id) { + $shareUrl = null; + $isRemote = (bool) ($meta?->is_remote ?? false); + $isShared = (bool) ($meta?->is_shared ?? false); + + if ($isRemote && $meta?->subscription_id) { $icsUrl = Subscription::query() ->whereKey($meta->subscription_id) ->value('source'); } + if (!$isRemote && $isShared) { + $shareUrl = route('calendar.ics', ['calendarUri' => $instance->uri]); + } + return $this->frame( 'calendar.settings.calendar', [ @@ -175,6 +183,7 @@ class CalendarSettingsController extends Controller 'instance' => $instance, 'meta' => $meta, 'icsUrl' => $icsUrl, + 'shareUrl' => $shareUrl, 'userTz' => $user->timezone, ]); } @@ -195,6 +204,7 @@ class CalendarSettingsController extends Controller 'description' => ['nullable', 'string', 'max:500'], 'timezone' => ['nullable', 'string', 'max:64'], 'color' => ['nullable', 'regex:/^#[0-9A-F]{6}$/i'], + 'is_shared' => ['nullable', 'boolean'], ]); $timezone = filled($data['timezone'] ?? null) @@ -204,6 +214,8 @@ class CalendarSettingsController extends Controller $color = $data['color'] ?? $instance->resolvedColor(); DB::transaction(function () use ($instance, $data, $timezone, $color) { + $isRemote = (bool) ($instance->meta?->is_remote ?? false); + $isShared = $isRemote ? false : (bool) ($data['is_shared'] ?? false); // update sabre calendar instance (dav-facing) $instance->update([ @@ -220,6 +232,7 @@ class CalendarSettingsController extends Controller 'title' => $data['displayname'], 'color' => $color, 'color_fg' => contrast_text_color($color), + 'is_shared' => $isShared, ] ); }); diff --git a/app/Http/Controllers/IcsController.php b/app/Http/Controllers/IcsController.php index da3b636..12613a5 100644 --- a/app/Http/Controllers/IcsController.php +++ b/app/Http/Controllers/IcsController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\CalendarInstance; +use App\Models\EventMeta; use Illuminate\Support\Facades\Response; use Carbon\Carbon; use Sabre\VObject\Component\VCalendar; @@ -12,9 +13,17 @@ class IcsController extends Controller { public function download(string $calendarUri) { - $instance = CalendarInstance::where('uri', $calendarUri)->firstOrFail(); + $instance = CalendarInstance::where('uri', $calendarUri) + ->with('meta') + ->firstOrFail(); - $calendar = $instance->calendar()->with(['events.meta'])->firstOrFail(); + $isRemote = (bool) ($instance->meta?->is_remote ?? false); + $isShared = (bool) ($instance->meta?->is_shared ?? false); + if ($isRemote || !$isShared) { + abort(404); + } + + $calendar = $instance->calendar()->with(['events.meta.venue'])->firstOrFail(); $timezone = $instance->timezone ?? 'UTC'; $ical = $this->generateICalendarFeed($calendar->events, $timezone); @@ -40,7 +49,9 @@ class IcsController extends Controller try { $parsed = Reader::read($ical); foreach ($parsed->select('VEVENT') as $vevent) { - $vcalendar->add(clone $vevent); + $cloned = clone $vevent; + $this->applyLocationProperties($cloned, $event->meta); + $vcalendar->add($cloned); } continue; } catch (\Throwable $e) { @@ -62,15 +73,76 @@ class IcsController extends Controller $vevent->add('DESCRIPTION', $meta->description ?? ''); $vevent->add('DTSTART', $start, ['TZID' => $tz]); $vevent->add('DTEND', $end, ['TZID' => $tz]); - $vevent->add('DTSTAMP', Carbon::parse($event->lastmodified)->utc()); - if ($meta->location) { - $vevent->add('LOCATION', $meta->location); + if ($event->lastmodified) { + $vevent->add('DTSTAMP', Carbon::createFromTimestamp($event->lastmodified)->utc()); } + $this->applyLocationProperties($vevent, $meta); } return $vcalendar->serialize(); } + protected function applyLocationProperties($vevent, ?EventMeta $meta): void + { + if (!$meta) { + return; + } + + $venue = $meta->venue; + $label = trim((string) ($meta->location_label ?? $meta->location ?? $venue?->display_name ?? '')); + + $address = trim((string) ($venue?->raw_address ?? '')); + if ($address === '') { + $address = trim(implode("\n", array_filter([ + $venue?->street, + trim(implode(' ', array_filter([$venue?->city, $venue?->state, $venue?->postal]))), + $venue?->country, + ]))); + } + + $locationText = trim(implode("\n", array_filter([ + $label !== '' ? $label : null, + $address !== '' ? $address : null, + ]))); + + if (!isset($vevent->LOCATION) && $locationText !== '') { + $vevent->add('LOCATION', $locationText); + } + + $lat = $venue?->lat; + $lon = $venue?->lon; + if (!is_numeric($lat) || !is_numeric($lon)) { + return; + } + + if (!isset($vevent->GEO)) { + $vevent->add('GEO', sprintf('%.6f;%.6f', $lat, $lon)); + } + + if (!isset($vevent->{'X-APPLE-STRUCTURED-LOCATION'})) { + $params = [ + 'VALUE' => 'URI', + ]; + + if ($address !== '') { + $params['X-ADDRESS'] = $address; + } + if ($label !== '') { + $params['X-TITLE'] = $label; + } + + if (!empty($params['X-ADDRESS']) || !empty($params['X-TITLE'])) { + $params['X-APPLE-REFERENCEFRAME'] = '1'; + $params['X-APPLE-RADIUS'] = '150'; + $vevent->add( + 'X-APPLE-STRUCTURED-LOCATION', + sprintf('geo:%.6f,%.6f', $lat, $lon), + $params + ); + } + } + } + protected function escape(?string $text): string { return str_replace(['\\', ';', ',', "\n"], ['\\\\', '\;', '\,', '\n'], $text ?? ''); diff --git a/lang/en/calendar.php b/lang/en/calendar.php index 4b22ff0..56b4ba6 100644 --- a/lang/en/calendar.php +++ b/lang/en/calendar.php @@ -16,6 +16,10 @@ return [ 'create' => 'Create calendar', 'description' => 'Description', 'ics' => [ + 'share' => 'Share this calendar publicly', + 'share_help' => 'Anyone with this link can subscribe to your calendar.', + 'public_url' => 'Public subscription URL', + 'public_url_help' => 'Copy this URL into a third-party calendar app to subscribe.', 'url' => 'ICS URL', 'url_help' => 'You can\'t edit a public calendar URL. If you need to make a change, unsubscribe and add it again.', ], diff --git a/lang/it/calendar.php b/lang/it/calendar.php index 617322e..7fbc4dc 100644 --- a/lang/it/calendar.php +++ b/lang/it/calendar.php @@ -16,6 +16,10 @@ return [ 'create' => 'Crea calendario', 'description' => 'Descrizione', 'ics' => [ + 'share' => 'Condividi questo calendario pubblicamente', + 'share_help' => 'Chiunque abbia questo link puo iscriversi al tuo calendario.', + 'public_url' => 'URL pubblico di iscrizione', + 'public_url_help' => 'Copia questo URL in un app calendario di terze parti per iscriverti.', 'url' => 'URL ICS', 'url_help' => 'Non puoi modificare un URL di calendario pubblico. Se devi fare una modifica, annulla l iscrizione e aggiungilo di nuovo.', ], diff --git a/resources/views/calendar/settings/calendar.blade.php b/resources/views/calendar/settings/calendar.blade.php index 4b79621..2657c61 100644 --- a/resources/views/calendar/settings/calendar.blade.php +++ b/resources/views/calendar/settings/calendar.blade.php @@ -10,6 +10,8 @@ $meta = $data['meta'] ?? null; $isRemote = (bool) ($meta?->is_remote ?? false); + $isShared = (bool) ($meta?->is_shared ?? false); + $shareUrl = $data['shareUrl'] ?? null; $color = old('color', $instance->resolvedColor()); @@ -75,6 +77,37 @@ + @if (!$isRemote) +
+
+ + +

{{ __('calendar.ics.share_help') }}

+
+
+ + @if ($shareUrl) +
+
+ +
+
+ @endif + @endif + @if ($isRemote)
diff --git a/routes/web.php b/routes/web.php index e95034c..78ab5e0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -161,7 +161,8 @@ Route::match( ->withoutMiddleware([VerifyCsrfToken::class]); // our subscriptions -Route::get('/ics/{calendarUri}.ics', [IcsController::class, 'download']); +Route::get('/ics/{calendarUri}.ics', [IcsController::class, 'download']) + ->name('calendar.ics'); // remote subscriptions Route::middleware(['auth'])->group(function () {