diff --git a/README.md b/README.md index 7501b70..00c5bfa 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ Davis [![Build Status][ci_badge]][ci_link] [![Publish Docker image](https://github.com/tchapi/davis/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/tchapi/davis/actions/workflows/main.yml) [![Latest release][release_badge]][release_link] +[![License](https://img.shields.io/github/license/tchapi/davis)](https://github.com/tchapi/davis/blob/main/LICENSE) +![Platform](https://img.shields.io/badge/platform-amd64%20%7C%20arm64-blue?logo=docker) +![PHP Version](https://img.shields.io/badge/php-8.2%20%7C%208.3%20%7C%208.4-777BB4?logo=php&logoColor=white) [![Sponsor me][sponsor_badge]][sponsor_link] A modern, simple, feature-packed, fully translatable DAV server, admin interface and frontend based on `sabre/dav`, built with [Symfony 7](https://symfony.com/) and [Bootstrap 5](https://getbootstrap.com/), initially inspired by [Baïkal](https://github.com/sabre-io/Baikal) (_see dependencies table below for more detail_) diff --git a/migrations/Version20260131161930.php b/migrations/Version20260131161930.php new file mode 100644 index 0000000..90e0d36 --- /dev/null +++ b/migrations/Version20260131161930.php @@ -0,0 +1,60 @@ +connection->getDatabasePlatform()->getName(); + + if ('mysql' === $engine) { + $this->addSql('ALTER TABLE calendarinstances ADD public TINYINT(1) DEFAULT 0 NOT NULL'); + } elseif ('postgresql' === $engine) { + $this->addSql('ALTER TABLE calendarinstances ADD public BOOLEAN DEFAULT FALSE NOT NULL'); + } elseif ('sqlite' === $engine) { + $this->addSql('ALTER TABLE calendarinstances ADD public BOOLEAN DEFAULT 0 NOT NULL'); + } + + // Migrate ACCESS_PUBLIC (10) to ACCESS_SHAREDOWNER (1) + public = true + if ('postgresql' === $engine) { + $this->addSql('UPDATE calendarinstances SET public = TRUE, access = 1 WHERE access = 10'); + } else { + // MySQL and SQLite accept 1/0 for booleans + $this->addSql('UPDATE calendarinstances SET public = 1, access = 1 WHERE access = 10'); + } + } + + public function down(Schema $schema): void + { + $engine = $this->connection->getDatabasePlatform()->getName(); + + // Revert public = true back to ACCESS_PUBLIC (10) + if ('postgresql' === $engine) { + $this->addSql('UPDATE calendarinstances SET access = 10 WHERE is_public = TRUE'); + } else { + $this->addSql('UPDATE calendarinstances SET access = 10 WHERE is_public = 1'); + } + + if ('mysql' === $engine) { + $this->addSql('ALTER TABLE calendarinstances DROP public'); + } elseif ('postgresql' === $engine) { + $this->addSql('ALTER TABLE calendarinstances DROP COLUMN public'); + } elseif ('sqlite' === $engine) { + $this->addSql('ALTER TABLE calendarinstances DROP COLUMN public'); + } + } +} diff --git a/src/Controller/Admin/CalendarController.php b/src/Controller/Admin/CalendarController.php index 006b567..d76cec8 100644 --- a/src/Controller/Admin/CalendarController.php +++ b/src/Controller/Admin/CalendarController.php @@ -9,6 +9,7 @@ use App\Entity\SchedulingObject; use App\Form\CalendarInstanceType; use Doctrine\Persistence\ManagerRegistry; +use Sabre\DAV\Sharing\Plugin as SharingPlugin; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -100,9 +101,6 @@ public function calendarEdit(ManagerRegistry $doctrine, Request $request, string $form->get('events')->setData(in_array(Calendar::COMPONENT_EVENTS, $components)); $form->get('todos')->setData(in_array(Calendar::COMPONENT_TODOS, $components)); $form->get('notes')->setData(in_array(Calendar::COMPONENT_NOTES, $components)); - if ($arePublicCalendarsEnabled) { - $form->get('public')->setData($calendarInstance->isPublic()); - } $form->get('principalUri')->setData(Principal::PREFIX.$username); $form->handleRequest($request); @@ -123,9 +121,9 @@ public function calendarEdit(ManagerRegistry $doctrine, Request $request, string $components[] = Calendar::COMPONENT_NOTES; } if ($arePublicCalendarsEnabled && true === $form->get('public')->getData()) { - $calendarInstance->setAccess(CalendarInstance::ACCESS_PUBLIC); + $calendarInstance->setPublic(true); } else { - $calendarInstance->setAccess(CalendarInstance::ACCESS_SHAREDOWNER); + $calendarInstance->setPublic(false); } $calendarInstance->getCalendar()->setComponents(implode(',', $components)); @@ -168,7 +166,7 @@ public function calendarShares(ManagerRegistry $doctrine, string $username, stri 'displayName' => $instance['displayName'], 'email' => $instance['email'], 'accessText' => $trans->trans('calendar.share_access.'.$instance[0]['access']), - 'isWriteAccess' => CalendarInstance::ACCESS_READWRITE === $instance[0]['access'], + 'isWriteAccess' => SharingPlugin::ACCESS_READWRITE === $instance[0]['access'], 'revokeUrl' => $this->generateUrl('calendar_revoke', ['username' => $username, 'id' => $instance[0]['id']]), ]; } @@ -197,7 +195,7 @@ public function calendarShareAdd(ManagerRegistry $doctrine, Request $request, st // already existing first, so we can update it: $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $newShareeToAdd->getUri()); - $writeAccess = ('true' === $request->get('write') ? CalendarInstance::ACCESS_READWRITE : CalendarInstance::ACCESS_READ); + $writeAccess = ('true' === $request->get('write') ? SharingPlugin::ACCESS_READWRITE : SharingPlugin::ACCESS_READ); $entityManager = $doctrine->getManager(); diff --git a/src/Controller/DAVController.php b/src/Controller/DAVController.php index 566b1ec..1b07825 100644 --- a/src/Controller/DAVController.php +++ b/src/Controller/DAVController.php @@ -248,6 +248,7 @@ private function initServer(string $authMethod, string $authRealm = User::DEFAUL $aclPlugin = new PublicAwareDAVACLPlugin($this->em, $this->publicCalendarsEnabled); $aclPlugin->hideNodesFromListings = true; + $aclPlugin->allowUnauthenticatedAccess = true; // Already the default, but setting it is future-proof // Fetch admins, if any $admins = $this->em->getRepository(Principal::class)->findBy(['isAdmin' => true]); diff --git a/src/Entity/CalendarInstance.php b/src/Entity/CalendarInstance.php index f993cad..1929272 100644 --- a/src/Entity/CalendarInstance.php +++ b/src/Entity/CalendarInstance.php @@ -4,6 +4,7 @@ use App\Constants; use Doctrine\ORM\Mapping as ORM; +use Sabre\DAV\Sharing\Plugin as SharingPlugin; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Validator\Constraints as Assert; @@ -12,27 +13,11 @@ #[UniqueEntity(fields: ['principalUri', 'uri'], errorPath: 'uri', message: 'form.uri.unique')] class CalendarInstance { - public const INVITE_NORESPONSE = 1; - public const INVITE_ACCEPTED = 2; - public const INVITE_DECLINED = 3; - public const INVITE_INVALID = 4; - - public const ACCESS_NOTSHARED = 0; - public const ACCESS_SHAREDOWNER = 1; - public const ACCESS_READ = 2; - public const ACCESS_READWRITE = 3; - public const ACCESS_NOACCESS = 4; - - // Used to identify a public calendar, available to anyone without logging in. - // It can't be shared, and it's owned by the principal. - public const ACCESS_PUBLIC = 10; - public static function getOwnerAccesses(): array { return [ - self::ACCESS_NOTSHARED, - self::ACCESS_SHAREDOWNER, - self::ACCESS_PUBLIC, + SharingPlugin::ACCESS_NOTSHARED, + SharingPlugin::ACCESS_SHAREDOWNER, ]; } @@ -83,12 +68,16 @@ public static function getOwnerAccesses(): array #[ORM\Column(name: 'share_invitestatus', type: 'integer', options: ['default' => 2])] private $shareInviteStatus; + #[ORM\Column(name: 'public', type: 'boolean', options: ['default' => false])] + private $public; + public function __construct() { - $this->shareInviteStatus = self::INVITE_ACCEPTED; + $this->shareInviteStatus = SharingPlugin::INVITE_ACCEPTED; $this->transparent = 0; $this->calendarOrder = 0; - $this->access = self::ACCESS_SHAREDOWNER; + $this->access = SharingPlugin::ACCESS_SHAREDOWNER; + $this->public = false; } public function getId(): ?int @@ -137,9 +126,16 @@ public function isShared(): bool return !in_array($this->access, self::getOwnerAccesses()); } + public function setPublic(bool $public): self + { + $this->public = $public; + + return $this; + } + public function isPublic(): bool { - return self::ACCESS_PUBLIC === $this->access; + return $this->public; } public function isAutomaticallyGenerated(): bool diff --git a/src/Form/CalendarInstanceType.php b/src/Form/CalendarInstanceType.php index 1749bd8..0eeaed2 100644 --- a/src/Form/CalendarInstanceType.php +++ b/src/Form/CalendarInstanceType.php @@ -29,7 +29,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]) ->add('public', ChoiceType::class, [ 'label' => 'form.public', - 'mapped' => false, 'disabled' => $options['shared'], 'help' => 'form.public.help.caldav', 'required' => true, diff --git a/src/Plugins/PublicAwareDAVACLPlugin.php b/src/Plugins/PublicAwareDAVACLPlugin.php index 248f842..6d71c3c 100644 --- a/src/Plugins/PublicAwareDAVACLPlugin.php +++ b/src/Plugins/PublicAwareDAVACLPlugin.php @@ -41,33 +41,31 @@ public function beforeMethod(RequestInterface $request, ResponseInterface $respo public function getAcl($node): array { + // Note: + // '{DAV:}unauthenticated' - only unauthenticated users + // '{DAV:}all' - all users (both authenticated and unauthenticated) + // '{DAV:}authenticated' - only authenticated users $acl = parent::getAcl($node); - if ($node instanceof \Sabre\CalDAV\Calendar) { - if (CalendarInstance::ACCESS_PUBLIC === $node->getShareAccess() && $this->public_calendars_enabled) { - // We must add the ACL on the calendar itself - $acl[] = [ - 'principal' => '{DAV:}unauthenticated', - 'privilege' => '{DAV:}read', - 'protected' => false, - ]; - } - } elseif ($node instanceof \Sabre\CalDAV\CalendarObject) { - // The property is private in \Sabre\CalDAV\CalendarObject and we don't want to create - // a new class just to access it, so we use a closure. - $calendarInfo = (fn () => $this->calendarInfo)->call($node); - // [0] is the calendarId, [1] is the calendarInstanceId - $calendarInstanceId = $calendarInfo['id'][1]; + if ($this->public_calendars_enabled) { + // Handle both Calendar AND SharedCalendar (which extends Calendar) + if ($node instanceof \Sabre\CalDAV\Calendar || $node instanceof \Sabre\CalDAV\CalendarObject) { + // The property is private in \Sabre\CalDAV\CalendarObject and we don't want to create + // a new class just to access it, so we use a closure. + $calendarInfo = (fn () => $this->calendarInfo)->call($node); + // [0] is the calendarId, [1] is the calendarInstanceId + $calendarInstanceId = $calendarInfo['id'][1]; - $calendar = $this->em->getRepository(CalendarInstance::class)->findOneById($calendarInstanceId); + $calendar = $this->em->getRepository(CalendarInstance::class)->findOneById($calendarInstanceId); - if ($calendar && $calendar->isPublic() && $this->public_calendars_enabled) { - // We must add the ACL on the object itself - $acl[] = [ - 'principal' => '{DAV:}unauthenticated', - 'privilege' => '{DAV:}read', - 'protected' => false, - ]; + if ($calendar && $calendar->isPublic()) { + // Add unauthenticated read access on the object itself + $acl[] = [ + 'principal' => '{DAV:}unauthenticated', + 'privilege' => '{DAV:}read', + 'protected' => false, + ]; + } } } diff --git a/src/Services/BirthdayService.php b/src/Services/BirthdayService.php index 020f38f..f33b6ce 100644 --- a/src/Services/BirthdayService.php +++ b/src/Services/BirthdayService.php @@ -19,6 +19,7 @@ use App\Entity\Card; use App\Entity\Principal; use Doctrine\Persistence\ManagerRegistry; +use Sabre\DAV\Sharing\Plugin as SharingPlugin; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VCard; use Sabre\VObject\DateTimeParser; @@ -94,7 +95,7 @@ public function ensureBirthdayCalendarExists(string $principalUri): CalendarInst ->setPrincipalUri($principalUri) ->setDisplayName('🎁 Birthdays') ->setDescription('Birthdays') - ->setAccess(CalendarInstance::ACCESS_READ) + ->setAccess(SharingPlugin::ACCESS_READ) ->setCalendarOrder(0) ->setCalendar($calendar) ->setTransparent(1) diff --git a/templates/calendars/index.html.twig b/templates/calendars/index.html.twig index 84ad84b..0a106ec 100644 --- a/templates/calendars/index.html.twig +++ b/templates/calendars/index.html.twig @@ -15,8 +15,8 @@
{{ calendar.displayName }} - {% if calendar.access == constant('\\App\\Entity\\CalendarInstance::ACCESS_PUBLIC') %} - {{ ('calendar.share_access.' ~ calendar.access)|trans }} + {% if calendar.isPublic() %} + {{ ('calendar.public')|trans }} {% endif %}   @@ -79,7 +79,7 @@
{{ calendar.displayName }} - {% if calendar.access == constant('\\App\\Entity\\CalendarInstance::ACCESS_READWRITE') %} + {% if calendar.access == constant('Sabre\\DAV\\Sharing\\Plugin::ACCESS_READWRITE') %} {{ ('calendar.share_access.' ~ calendar.access)|trans }} {% else %} {{ ('calendar.share_access.' ~ calendar.access)|trans }} diff --git a/translations/messages+intl-icu.de.xlf b/translations/messages+intl-icu.de.xlf index d0036c9..3f9a28c 100644 --- a/translations/messages+intl-icu.de.xlf +++ b/translations/messages+intl-icu.de.xlf @@ -585,8 +585,8 @@ calendar.share_access.3 lesen / schreiben - - calendar.share_access.10 + + calendar.public öffentlich diff --git a/translations/messages+intl-icu.en.xlf b/translations/messages+intl-icu.en.xlf index be02b53..4c6e1cd 100644 --- a/translations/messages+intl-icu.en.xlf +++ b/translations/messages+intl-icu.en.xlf @@ -585,8 +585,8 @@ calendar.share_access.3 read / write - - calendar.share_access.10 + + calendar.public public