Adds basic subscription handling, fixes timezone issues app wide, improves event saving and timezone issues there, updates user table and seeding, moves to email address as principal uri component and not ulid

This commit is contained in:
Andrew Gioia 2025-07-21 13:51:39 -04:00
parent 8d0738019f
commit a82d9fe01f
Signed by: andrew
GPG Key ID: FC09694A000800C8
18 changed files with 4096 additions and 64 deletions

View File

@ -1,16 +1,20 @@
APP_NAME=Laravel
APP_NAME=Kithkin
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_TIMEZONE=America/New_York # custom
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
ADMIN_EMAIL=admin@kithkin.dev
ADMIN_PASSWORD=
ADMIN_NAME="Kithkin Admin"
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
@ -20,12 +24,12 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=kithkin
DB_USERNAME=root
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120

View File

@ -7,6 +7,7 @@ use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use App\Models\Calendar;
use App\Models\CalendarMeta;
use App\Models\CalendarInstance;
class CalendarController extends Controller
{
@ -15,7 +16,7 @@ class CalendarController extends Controller
*/
public function index()
{
$principal = 'principals/' . auth()->id();
$principal = auth()->user()->principal_uri;
$calendars = Calendar::query()
->select('calendars.*', 'ci.displayname as instance_displayname') // ← add
@ -54,7 +55,7 @@ class CalendarController extends Controller
// update the calendar instance row
$instance = CalendarInstance::create([
'calendarid' => $calId,
'principaluri' => 'principals/'.auth()->id(),
'principaluri' => auth()->user()->principal_uri,
'uri' => Str::uuid(),
'displayname' => $data['name'],
'description' => $data['description'] ?? null,
@ -81,16 +82,14 @@ class CalendarController extends Controller
$this->authorize('view', $calendar);
$calendar->load([
'meta',
'instances' => fn ($q) =>
$q->where('principaluri', 'principals/'.auth()->id()),
'meta',
'instances' => fn ($q) => $q->where('principaluri', auth()->user()->principal_uri),
]);
/* grab the single instance for convenience in the view */
$instance = $calendar->instances->first();
$caldavUrl = $instance?->caldavUrl(); // null-safe
/* events + meta, newest first */
$events = $calendar->events()
->with('meta')
@ -113,7 +112,7 @@ class CalendarController extends Controller
$calendar->load([
'meta',
'instances' => fn ($q) =>
$q->where('principaluri', 'principals/'.auth()->id()),
$q->where('principaluri', auth()->user()->principal_uri),
]);
$instance = $calendar->instances->first(); // may be null but shouldnt
@ -137,7 +136,7 @@ class CalendarController extends Controller
// update the instance row
$calendar->instances()
->where('principaluri', 'principals/'.auth()->id())
->where('principaluri', auth()->user()->principal_uri)
->update([
'displayname' => $data['name'],
'description' => $data['description'] ?? '',

View File

@ -4,42 +4,50 @@ namespace App\Http\Controllers;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use App\Services\Dav\LaravelSabreAuthBackend;
use App\Services\Dav\LaravelSabrePrincipalBackend;
use Sabre\DAV;
use Sabre\DAV\Auth\Plugin as AuthPlugin;
use Sabre\DAVACL\Plugin as ACLPlugin;
use Sabre\DAVACL\PrincipalCollection;
use Sabre\CalDAV\CalendarRoot;
use Sabre\CalDAV\Plugin as CalDavPlugin;
use Sabre\CalDAV\Backend\PDO as CalDAVPDO;
use Sabre\CardDAV\AddressBookRoot;
class DavController extends Controller
{
public function handle()
{
// debug
\Log::info('SabreDAV DavController');
// get raw pdo from laravel
$pdo = DB::connection()->getPdo(); // get raw PDO from Laravel
// setup the backends
$authBackend = new LaravelSabreAuthBackend();
$principalBackend = new LaravelSabrePrincipalBackend();
\Log::info('SabreDAV DavController');
$calendarBackend = new CalDAVPDO($pdo);
$nodes = [
new PrincipalCollection($principalBackend),
new CalendarRoot($principalBackend, $calendarBackend)
// Add your Calendars or Addressbooks here
];
$server = new DAV\Server($nodes);
$server->setBaseUri('/dav/');
$server->addPlugin(new AuthPlugin($authBackend, 'WebDAV'));
$server->addPlugin(new AuthPlugin($authBackend, 'Kithkin DAV'));
$server->addPlugin(new ACLPlugin());
$server->addPlugin(new CalDavPlugin());
$server->on('beforeMethod', function () {
\Log::info('SabreDAV beforeMethod triggered');
});
ob_start();
$server->exec();
$status = $server->httpResponse->getStatus();
$content = ob_get_contents();
ob_end_clean();
$server->exec();
exit;
}

View File

@ -12,14 +12,32 @@ use App\Models\EventMeta;
class EventController extends Controller
{
/**
* create a new event page
*/
public function create(Calendar $calendar)
{
$this->authorize('update', $calendar);
return view('events.form', ['calendar' => $calendar, 'event' => new Event]);
$instance = $calendar->instanceForUser();
$event = new Event;
$event->meta = (object) [
'title' => '',
'description' => '',
'location' => '',
'start_at' => null,
'end_at' => null,
'all_day' => false,
'category' => '',
];
$start = $event->start_at;
$end = $event->end_at;
return view('events.form', compact('calendar', 'instance', 'event', 'start', 'end'));
}
/**
* insert VEVENT into Sabres calendarobjects + meta row
* insert vevent into sabres calendarobjects + meta row
*/
public function store(Request $req, Calendar $calendar)
{
@ -37,8 +55,13 @@ class EventController extends Controller
// prepare payload
$uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST);
$start = new Carbon($data['start_at'], $calendar->timezone);
$end = new Carbon($data['end_at'], $calendar->timezone);
// store events as UTC in the database; convert to calendar time in the view
$client_timezone = $calendar->timezone ?? 'UTC';
$start = Carbon::createFromFormat('Y-m-d\TH:i', $data['start_at'], $client_timezone)
->setTimezone('UTC');
$end = Carbon::createFromFormat('Y-m-d\TH:i', $data['end_at'], $client_timezone)
->setTimezone('UTC');
// prepare strings
$description = $data['description'] ?? '';
@ -46,7 +69,6 @@ class EventController extends Controller
$description = str_replace("\n", '\\n', $description);
$location = str_replace("\n", '\\n', $location);
/* build minimal iCalendar payload */
$ical = <<<ICS
BEGIN:VCALENDAR
@ -89,10 +111,25 @@ ICS;
return redirect()->route('calendars.show', $calendar);
}
/**
* show the event edit form
*/
public function edit(Calendar $calendar, Event $event)
{
$this->authorize('update', $calendar);
return view('events.form', compact('calendar', 'event'));
$instance = $calendar->instanceForUser();
$timezone = $instance?->timezone ?? 'UTC';
$start = optional($event->meta?->start_at)
?->timezone($timezone)
?->format('Y-m-d\TH:i');
$end = optional($event->meta?->end_at)
?->timezone($timezone)
?->format('Y-m-d\TH:i');
return view('events.form', compact('calendar', 'instance', 'event', 'start', 'end'));
}
/**
@ -112,9 +149,9 @@ ICS;
]);
// rebuild the icalendar payload
$tz = $calendar->timezone;
$start = new \Carbon\Carbon($data['start_at'], $tz);
$end = new \Carbon\Carbon($data['end_at'], $tz);
$calendar_timezone = $calendar->timezone ?? 'UTC';
$start = Carbon::createFromFormat('Y-m-d\TH:i', $data['start_at'], $calendar_timezone)->setTimezone($calendar_timezone);
$end = Carbon::createFromFormat('Y-m-d\TH:i', $data['end_at'], $calendar_timezone)->setTimezone($calendar_timezone);
// prepare strings
$description = $data['description'] ?? '';
@ -132,8 +169,8 @@ PRODID:-//Kithkin//Laravel CalDAV//EN
BEGIN:VEVENT
UID:$uid
DTSTAMP:{$start->utc()->format('Ymd\\THis\\Z')}
DTSTART;TZID={$tz}:{$start->format('Ymd\\THis')}
DTEND;TZID={$tz}:{$end->format('Ymd\\THis')}
DTSTART;TZID={$calendar_timezone}:{$start->format('Ymd\\THis')}
DTEND;TZID={$calendar_timezone}:{$end->format('Ymd\\THis')}
SUMMARY:{$data['title']}
DESCRIPTION:$description
LOCATION:$location

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Models\CalendarInstance;
use Illuminate\Support\Facades\Response;
use Carbon\Carbon;
class SubscriptionController extends Controller
{
public function download(string $calendarUri)
{
$instance = CalendarInstance::where('uri', $calendarUri)->firstOrFail();
$calendar = $instance->calendar()->with(['events.meta'])->firstOrFail();
$timezone = $instance->timezone ?? 'UTC';
$ical = $this->generateICalendarFeed($calendar->events, $timezone);
return Response::make($ical, 200, [
'Content-Type' => 'text/calendar; charset=utf-8',
'Content-Disposition' => 'inline; filename="' . $calendarUri . '.ics"',
]);
}
protected function generateICalendarFeed($events, string $tz): string
{
$output = [];
$output[] = 'BEGIN:VCALENDAR';
$output[] = 'VERSION:2.0';
$output[] = 'PRODID:-//Kithkin Calendar//EN';
$output[] = 'CALSCALE:GREGORIAN';
$output[] = 'METHOD:PUBLISH';
foreach ($events as $event) {
$meta = $event->meta;
if (!$meta || !$meta->start_at || !$meta->end_at) {
continue;
}
$start = Carbon::parse($meta->start_at)->timezone($tz)->format('Ymd\THis');
$end = Carbon::parse($meta->end_at)->timezone($tz)->format('Ymd\THis');
$output[] = 'BEGIN:VEVENT';
$output[] = 'UID:' . $event->uid;
$output[] = 'SUMMARY:' . $this->escape($meta->title ?? '(Untitled)');
$output[] = 'DESCRIPTION:' . $this->escape($meta->description ?? '');
$output[] = 'DTSTART;TZID=' . $tz . ':' . $start;
$output[] = 'DTEND;TZID=' . $tz . ':' . $end;
$output[] = 'DTSTAMP:' . Carbon::parse($event->lastmodified)->format('Ymd\THis\Z');
if ($meta->location) {
$output[] = 'LOCATION:' . $this->escape($meta->location);
}
$output[] = 'END:VEVENT';
}
$output[] = 'END:VCALENDAR';
return implode("\r\n", $output);
}
protected function escape(?string $text): string
{
return str_replace(['\\', ';', ',', "\n"], ['\\\\', '\;', '\,', '\n'], $text ?? '');
}
}

View File

@ -15,7 +15,6 @@ class Calendar extends Model
protected $fillable = [
'displayname',
'description',
'timezone',
];
/* all event components (VEVENT, VTODO, …) */
@ -30,9 +29,19 @@ class Calendar extends Model
return $this->hasOne(CalendarMeta::class, 'calendar_id');
}
/* get instance */
/* get instances */
public function instances()
{
return $this->hasMany(CalendarInstance::class, 'calendarid');
}
/* get the primary? instance for a user */
public function instanceForUser(?User $user = null)
{
$user = $user ?? auth()->user();
return $this->instances()
->where('principaluri', 'principals/' . $user->email)
->first();
}
}

View File

@ -9,6 +9,15 @@ class CalendarInstance extends Model
{
protected $table = 'calendarinstances';
public $timestamps = false;
protected $fillable = [
'calendarid',
'principaluri',
'uri',
'displayname',
'description',
'calendarcolor',
'timezone'
];
public function calendar(): BelongsTo
{
@ -20,7 +29,7 @@ class CalendarInstance extends Model
// e.g. https://kithkin.lan/dav/calendars/1/48f888f3-c5c5-…/
return url(
'/dav/calendars/' .
auth()->id() . '/' .
auth()->user()->email . '/' .
$this->uri . '/'
);
}

View File

@ -24,6 +24,12 @@ class Event extends Model
'calendardata',
];
/* casts */
protected $casts = [
'start_at' => 'datetime',
'end_at' => 'datetime',
];
/* owning calendar */
public function calendar(): BelongsTo
{

View File

@ -61,4 +61,12 @@ class User extends Authenticatable
{
return $this->hasMany(Calendar::class);
}
/**
* get the current user's principal uri
*/
public function getPrincipalUriAttribute(): string
{
return 'principals/' . $this->email;
}
}

View File

@ -22,7 +22,7 @@ class CalendarPolicy
public function view(User $user, Calendar $calendar): bool
{
return $calendar->instances()
->where('principaluri', 'principals/'.$user->id)
->where('principaluri', 'principals/'.$user->email)
->exists();
}

View File

@ -7,6 +7,8 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Sabre\DAV\Auth\Backend\AbstractBasic;
use Sabre\DAV\Auth\Backend\BasicCallBack;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
class LaravelSabreAuthBackend extends AbstractBasic
{
@ -15,6 +17,11 @@ class LaravelSabreAuthBackend extends AbstractBasic
\Log::info('LaravelSabreAuthBackend instantiated');
}
public function challenge(RequestInterface $request, ResponseInterface $response)
{
$response->addHeader('WWW-Authenticate', 'Basic realm="WebDAV", charset="UTF-8"');
}
protected function validateUserPass($username, $password)
{
$user = User::where('email', $username)->first();
@ -23,18 +30,18 @@ class LaravelSabreAuthBackend extends AbstractBasic
// THIS is the new required step
$this->currentPrincipal = 'principals/' . $user->email;
\Log::info('SabreDAV auth success', ['principal' => $this->currentPrincipal]);
\Log::info('validateUserPass auth success', ['principal' => $this->currentPrincipal, 'username' => $username]);
return true;
}
\Log::warning('SabreDAV auth failed', ['username' => $username]);
\Log::warning('validateUserPass auth failed', ['username' => $username]);
return false;
}
public function getCurrentPrincipal()
{
\Log::debug('SabreDAV getCurrentPrincipal', ['current' => $$this->currentPrincipal]);
\Log::debug('getCurrentPrincipal', ['current' => $$this->currentPrincipal]);
return $this->currentPrincipal;
}
}

View File

@ -13,7 +13,7 @@ class LaravelSabrePrincipalBackend extends AbstractBackend
{
return User::all()->map(function ($user) {
return [
'uri' => 'principals/' . $user->id,
'uri' => 'principals/' . $user->email,
'{DAV:}displayname' => $user->name,
];
})->toArray();

3891
curl.html

File diff suppressed because one or more lines are too long

View File

@ -10,7 +10,7 @@ return new class extends Migration
{
Schema::create('users', function (Blueprint $table) {
$table->ulid('id')->primary(); // ulid primary key
$table->string('uri')->unique()->nullable()->after('email'); // formerly from sabre principals table
$table->string('uri')->nullable(); // formerly from sabre principals table
$table->string('displayname')->nullable(); // formerly from sabre principals table
$table->string('name')->nullable(); // custom name if necessary
$table->string('email')->unique();

View File

@ -27,7 +27,7 @@ class DatabaseSeeder extends Seeder
]
);
/** fill the Sabre-friendly columns */
/** fill the sabre-friendly columns */
$user->update([
'uri' => 'principals/'.$user->email,
'displayname' => $user->name,

View File

@ -32,7 +32,7 @@
<div>
<dt class="text-sm font-medium text-gray-500">{{ __('Timezone') }}</dt>
<dd class="mt-1 text-gray-900">{{ $instance->timezone }}</dd>
<dd class="mt-1 text-gray-900"> {{ $instance?->timezone ?? __('Not set') }}</dd>
</div>
@php

View File

@ -30,7 +30,7 @@
<div class="mb-6">
<x-input-label for="title" :value="__('Title')" />
<x-text-input id="title" name="title" type="text" class="mt-1 block w-full"
:value="old('title', $event->meta->title)" required autofocus />
:value="old('title', $event->meta?->title ?? '')" required autofocus />
<x-input-error class="mt-2" :messages="$errors->get('title')" />
</div>
@ -38,7 +38,7 @@
<div class="mb-6">
<x-input-label for="description" :value="__('Description')" />
<textarea id="description" name="description" rows="3"
class="mt-1 block w-full rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring">{{ old('description', $event->meta->description) }}</textarea>
class="mt-1 block w-full rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring">{{ old('description', $event->meta?->description ?? '') }}</textarea>
<x-input-error class="mt-2" :messages="$errors->get('description')" />
</div>
@ -46,7 +46,7 @@
<div class="mb-6">
<x-input-label for="location" :value="__('Location')" />
<x-text-input id="location" name="location" type="text" class="mt-1 block w-full"
:value="old('location', $event->meta->location)" />
:value="old('location', $event->meta?->location ?? '')" />
<x-input-error class="mt-2" :messages="$errors->get('location')" />
</div>
@ -57,9 +57,8 @@
<x-input-label for="start_at" :value="__('Starts')" />
<x-text-input id="start_at" name="start_at" type="datetime-local"
class="mt-1 block w-full"
:value="old('start_at', $event->meta->start_at
? $event->meta->start_at->format('Y-m-d\TH:i')
: '')" required />
:value="old('start_at', $start)"
required />
<x-input-error class="mt-2" :messages="$errors->get('start_at')" />
</div>
@ -67,9 +66,8 @@
<x-input-label for="end_at" :value="__('Ends')" />
<x-text-input id="end_at" name="end_at" type="datetime-local"
class="mt-1 block w-full"
:value="old('end_at', $event->meta->end_at
? $event->meta->end_at->format('Y-m-d\TH:i')
: '')" required />
:value="old('end_at', $end)"
required />
<x-input-error class="mt-2" :messages="$errors->get('end_at')" />
</div>
@ -78,7 +76,7 @@
{{-- All-day --}}
<div class="flex items-center mb-6">
<input id="all_day" name="all_day" type="checkbox" value="1"
@checked(old('all_day', $event->meta->all_day)) />
@checked(old('all_day', $event->meta?->all_day)) />
<label for="all_day" class="ms-2 text-sm text-gray-700">
{{ __('All day event') }}
</label>

View File

@ -5,6 +5,7 @@ use App\Http\Controllers\ProfileController;
use App\Http\Controllers\CalendarController;
use App\Http\Controllers\EventController;
use App\Http\Controllers\DavController;
use App\Http\Controllers\SubscriptionController;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
/*
@ -56,9 +57,13 @@ require __DIR__.'/auth.php';
| authentication for DAV clients.
*/
// default dav handling
Route::match(
['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD', 'PROPFIND', 'PROPPATCH', 'MKCOL', 'COPY', 'MOVE', 'REPORT', 'LOCK', 'UNLOCK'],
'/dav/{any?}',
[DavController::class, 'handle']
)->where('any', '.*')
->withoutMiddleware([VerifyCsrfToken::class]);
// subscriptions
Route::get('/subscriptions/{calendarUri}.ics', [SubscriptionController::class, 'download']);