diff --git a/.env b/.env index b4cb2ae..e4ec418 100644 --- a/.env +++ b/.env @@ -72,6 +72,10 @@ WEBDAV_ENABLED=false # Do we allow calendars to be public ? PUBLIC_CALENDARS_ENABLED=true +# For Birthday calendars, what should be the reminder offset ? +# (The default is PT9H, 9am on the day of the event) +BIRTHDAY_REMINDER_OFFSET=PT9H + # What mail is used as the sender for invites ? INVITE_FROM_ADDRESS=no-reply@example.org diff --git a/README.md b/README.md index e930f4f..747b142 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Davis [![Latest release][release_badge]][release_link] [![Sponsor me][sponsor_badge]][sponsor_link] -A simple, fully translatable and full-featured 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_) +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_) ### Web admin dashboard @@ -18,6 +18,12 @@ Supports **Basic authentication**, as well as **IMAP** and **LDAP** (_via extern The underlying server implementation supports (*non-exhaustive list*) CalDAV, CardDAV, WebDAV, calendar sharing, scheduling, mail notifications, and server-side subscriptions (*depending on the capabilities of the client*). +### Additional features ✨ + +- Subscriptions (to be added via the client, such as the macOS calendar, for instance) +- Public calendars, available to anyone with the link +- Automatic birthday calendar, updated on the fly when birthdates change in your contacts + ### Deployment Easily containerisable (_`Dockerfile` and sample `docker-compose` configuration file provided_). @@ -61,17 +67,17 @@ Dependencies 1. Retrieve the dependencies: -``` -composer install -``` + ``` + composer install + ``` 2. At least put the correct credentials to your database (driver and url) in your `.env.local` file so you can easily create the necessary tables. 3. Run the migrations to create all the necessary tables: -``` -bin/console doctrine:migrations:migrate -``` + ``` + bin/console doctrine:migrations:migrate + ``` **Davis** can also be used with a pre-existing MySQL database (_for instance, one previously managed by Baïkal_). See the paragraph "Migrating from Baikal" for more info. @@ -87,14 +93,14 @@ Create your own `.env.local` file to change the necessary variables, if you plan > > If your installation is behind a web server like Apache or Nginx, you can setup the env vars directly in your Apache or Nginx configuration (see below). Skip this part in this case. -a. The database driver and url (_you should already have it configured since you created the database previously_) +**a. The database driver and url** (_you should already have it configured since you created the database previously_) ```shell DATABASE_DRIVER=mysql # or postgresql, or sqlite -DATABASE_URL=mysql://db_user:db_pass@host:3306/db_name?serverVersion=mariadb-10.6.10&charset=utf8mb4 +DATABASE_URL=mysql://db_user:db_pass@host:3306/db_name?serverVersion=10.9.3-MariaDB&charset=utf8mb4 ``` -b. The admin password for the backend +**b. The admin password for the backend** ```shell ADMIN_LOGIN=admin @@ -105,7 +111,7 @@ ADMIN_PASSWORD=test > > You can bypass auth entirely if you use a third party authorization provider such as Authelia. In that case, set the `ADMIN_AUTH_BYPASS` env var to `true` (case-sensitive, this is actually the string `true`, not a boolean) to allow full access to the dashboard. This does not change the behaviour of the DAV server. -c. The auth Realm and method for HTTP auth +**c. The auth Realm and method for HTTP auth** ```shell AUTH_REALM=SabreDAV @@ -113,7 +119,7 @@ AUTH_METHOD=Basic # can be "Basic", "IMAP" or "LDAP" ``` > See [the following paragraph](#specific-environment-variables-for-imap-and-ldap-authentication-methods) for more information if you choose either IMAP or LDAP. -d. The global flags to enable CalDAV, CardDAV and WebDAV. You can also disable the option to have calendars public +**d. The global flags to enable CalDAV, CardDAV and WebDAV**. You can also disable the option to have calendars public ```shell CALDAV_ENABLED=true @@ -128,14 +134,34 @@ PUBLIC_CALENDARS_ENABLED=true > By default, `PUBLIC_CALENDARS_ENABLED` is true. That doesn't mean that all calendars are public by default — it just means that you have an option, upon calendar creation, to set the calendar public (but it's not public by default). -e. The email address that your invites are going to be sent from +**e. The email address that your invites are going to be sent from** ```shell INVITE_FROM_ADDRESS=no-reply@example.org ``` -f. The paths for the WebDAV installation +**f. The reminder offset for all birthdays** +You must specify a relative duration, as specified in [the RFC 5545 spec](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.3.6) + +```shell +BIRTHDAY_REMINDER_OFFSET=PT9H +``` + +If you don't want a reminder for birthday events, set it to the `false` value (lowercase): + +```shell +BIRTHDAY_REMINDER_OFFSET=false +``` + +> [!NOTE] +> +> By default, if the env var is not set or empty, we use `PT9H` (9am on the date of the birthday). + +**g. The paths for the WebDAV installation** + +> [!TIP] +> > I recommend that you use absolute directories so you know exactly where your files reside. ```shell @@ -152,7 +178,7 @@ WEBDAV_HOMES_DIR= > > By default, home directories are disabled totally (the env var is set to an empty string). If needed, it is recommended to use a folder that is **NOT** a child of the public dir, such as `/webdav/homes` for instance, so that users cannot access other users' homes. -g. The log file path +**h. The log file path** You can use an absolute file path here, and you can use Symfony's `%kernel.logs_dir%` and `%kernel.environment%` placeholders if needed (as in the default value). Setting it to `/dev/null` will disable logging altogether. @@ -160,7 +186,7 @@ You can use an absolute file path here, and you can use Symfony's `%kernel.logs_ LOG_FILE_PATH="%kernel.logs_dir%/%kernel.environment%.log" ``` -h. The timezone you want for the app +**i. The timezone you want for the app** This must comply with the [official list](https://www.php.net/manual/en/timezones.php) @@ -230,29 +256,27 @@ If you're migrating from Baïkal, then you will likely want to do the following 1. Get a backup of your data (without the `CREATE` statements, but with complete `INSERT` statements): -```shell -mysqldump -u root -p --no-create-info --complete-insert baikal > baikal_to_davis.sql # baikal is the actual name of your database -``` - + ```shell + mysqldump -u root -p --no-create-info --complete-insert baikal > baikal_to_davis.sql # baikal is the actual name of your database + ``` 2. Create a new database for Davis (let's name it `davis`) and create the base schema: -```shell -bin/console doctrine:migrations:migrate 'DoctrineMigrations\Version20191030113307' --no-interaction -``` - + ```shell + bin/console doctrine:migrations:migrate 'DoctrineMigrations\Version20191030113307' --no-interaction + ``` 3. Reimport the data back: -``` -mysql -uroot -p davis < baikal_to_davis.sql -``` + ``` + mysql -uroot -p davis < baikal_to_davis.sql + ``` 4. Run the necessary remaining migrations: -``` -bin/console doctrine:migrations:migrate -``` + ``` + bin/console doctrine:migrations:migrate + ``` # 🌐 Access / Webserver @@ -262,7 +286,7 @@ The administration interface is available at `/dashboard`. You need to login to The main endpoint for CalDAV, WebDAV or CardDAV is at `/dav`. -> [!NOTE] +> [!TIP] > > For shared hosting, the `symfony/apache-pack` is included and provides a standard `.htaccess` file in the public directory so redirections should work out of the box. @@ -316,7 +340,7 @@ dav.domain.tld { SetEnv APP_ENV prod SetEnv APP_SECRET SetEnv DATABASE_DRIVER "mysql" - SetEnv DATABASE_URL "mysql://db_user:db_pass@host:3306/db_name?serverVersion=mariadb-10.6.10&charset=utf8mb4" + SetEnv DATABASE_URL "mysql://db_user:db_pass@host:3306/db_name?serverVersion=10.9.3-MariaDB&charset=utf8mb4" # ... etc ``` @@ -345,7 +369,7 @@ server { fastcgi_param APP_ENV prod; fastcgi_param APP_SECRET ; fastcgi_param DATABASE_DRIVER "mysql"; - fastcgi_param DATABASE_URL "mysql://db_user:db_pass@host:3306/db_name?serverVersion=mariadb-10.6.10&charset=utf8mb4"; + fastcgi_param DATABASE_URL "mysql://db_user:db_pass@host:3306/db_name?serverVersion=10.9.3-MariaDB&charset=utf8mb4"; # ... etc ... fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; @@ -429,7 +453,7 @@ docker pull ghcr.io/tchapi/davis-standalone:v4.4.0 ### Edge image -The edge image is built from the tip of the main branch: +The edge image is generally built from the tip of the main branch, but might sometimes be used for specific branch testing: ``` docker pull ghcr.io/tchapi/davis:edge @@ -437,7 +461,7 @@ docker pull ghcr.io/tchapi/davis:edge > [!WARNING] > -> The `edge` image must not be considered stable. Use only release images for production. +> The `edge` image must not be considered stable. **Use only release images for production setups**. ## Full stack @@ -531,7 +555,12 @@ Depending on how you run Davis, logs are either: > [!NOTE] > -> It's `./var/log` (relative to the Davis installation), not `/var/log` +> It's `./var/log` (relative to the Davis installation), not `/var/log`. +> +> To tail the aplication log on Docker, do: +> ``` +> docker exec -it davis tail /var/www/davis/var/log/prod.log +> ``` ### I have a "Bad timezone configuration env var" error on the dashboard @@ -580,6 +609,14 @@ Check if your instance can reach your LDAP server: - Check that the `LDAP_DN_PATTERN` filter is compliant with your LDAP service - Example: `uid=%u,ou=people,dc=domain,dc=com`: [LLDAP](https://github.com/lldap/lldap) uses `people` instead of `users`. +### The birthday calendar is not synced / not up to date + +An update event might have been missed. In this case, it's easy to resync all contacts by issuing the command: + +``` +bin/console dav:sync-birthday-calendar +``` + # 📚 Libraries used - Symfony 7 (Licence : MIT) diff --git a/config/services.yaml b/config/services.yaml index 9efac1d..c20afdf 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -9,6 +9,10 @@ parameters: timezone: '%env(APP_TIMEZONE)%' public_calendars_enabled: '%env(default:default_public_calendars_enabled:bool:PUBLIC_CALENDARS_ENABLED)%' default_public_calendars_enabled: "true" + birthday_reminder_offset: '%env(default:default_birthday_reminder_offset:BIRTHDAY_REMINDER_OFFSET)%' + default_birthday_reminder_offset: "PT9H" + caldav_enabled: "%env(bool:CALDAV_ENABLED)%" + carddav_enabled: "%env(bool:CARDDAV_ENABLED)%" services: # default configuration for services in *this* file @@ -68,6 +72,10 @@ services: tags: - { name: monolog.processor } + App\Services\BirthdayService: + arguments: + $birthdayReminderOffset: "%birthday_reminder_offset%" + when@dev: services: Symfony\Component\HttpKernel\Profiler\Profiler: '@profiler' diff --git a/docker/.env b/docker/.env index aaaf860..a3b62d6 100644 --- a/docker/.env +++ b/docker/.env @@ -6,6 +6,8 @@ CARDDAV_ENABLED=true WEBDAV_ENABLED=false PUBLIC_CALENDARS_ENABLED=true +BIRTHDAY_REMINDER_OFFSET=PT9H + APP_TIMEZONE=Europe/Paris LOG_FILE_PATH="%kernel.logs_dir%/%kernel.environment%.log" diff --git a/migrations/Version20250421163214.php b/migrations/Version20250421163214.php new file mode 100644 index 0000000..59eae2e --- /dev/null +++ b/migrations/Version20250421163214.php @@ -0,0 +1,41 @@ +connection->getDatabasePlatform()->getName(); + + if ('mysql' === $engine) { + $this->addSql('ALTER TABLE addressbooks ADD included_in_birthday_calendar TINYINT(1) DEFAULT 0'); + } elseif ('postgresql' === $engine) { + $this->addSql('ALTER TABLE addressbooks ADD COLUMN included_in_birthday_calendar BOOLEAN DEFAULT FALSE;'); + } elseif ('sqlite' === $engine) { + $this->addSql('ALTER TABLE addressbooks ADD COLUMN included_in_birthday_calendar INTEGER DEFAULT 0;'); + } + } + + public function down(Schema $schema): void + { + if ('mysql' === $this->connection->getDatabasePlatform()->getName()) { + $this->addSql('ALTER TABLE addressbooks DROP included_in_birthday_calendar'); + } else { + $this->addSql('ALTER TABLE addressbooks DROP COLUMN included_in_birthday_calendar'); + } + } +} diff --git a/src/Command/SyncBirthdayCalendars.php b/src/Command/SyncBirthdayCalendars.php new file mode 100644 index 0000000..ed07490 --- /dev/null +++ b/src/Command/SyncBirthdayCalendars.php @@ -0,0 +1,64 @@ +setName('dav:sync-birthday-calendar') + ->setDescription('Synchronizes the birthday calendar') + ->addArgument('username', + InputArgument::OPTIONAL, + 'Username for whom the birthday calendar will be synchronized'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $username = $input->getArgument('username'); + + if (!is_null($username)) { + if (!$this->doctrine->getRepository(User::class)->findOneByUsername($username)) { + throw new \InvalidArgumentException("User <$username> is unknown."); + } + + $output->writeln("Start birthday calendar sync for $username"); + $this->birthdayService->syncUser($username); + + return self::SUCCESS; + } + + $output->writeln('Start birthday calendar sync for all users ...'); + $p = new ProgressBar($output); + $p->start(); + + $users = $this->doctrine->getRepository(User::class)->findAll(); + + foreach ($users as $user) { + $p->advance(); + $this->birthdayService->syncUser($user->getUsername()); + } + + $p->finish(); + $output->writeln(''); + + return self::SUCCESS; + } +} diff --git a/src/Constants.php b/src/Constants.php new file mode 100644 index 0000000..4b0b6ff --- /dev/null +++ b/src/Constants.php @@ -0,0 +1,12 @@ + "\d+"])] - public function addressbookCreate(ManagerRegistry $doctrine, Request $request, string $username, ?int $id, TranslatorInterface $trans): Response + public function addressbookCreate(ManagerRegistry $doctrine, Request $request, string $username, ?int $id, TranslatorInterface $trans, BirthdayService $birthdayService): Response { $principal = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$username); @@ -47,8 +48,13 @@ public function addressbookCreate(ManagerRegistry $doctrine, Request $request, s $addressbook = new AddressBook(); } - $form = $this->createForm(AddressBookType::class, $addressbook, ['new' => !$id]); + $isBirthdayCalendarEnabled = $this->getParameter('caldav_enabled') && $this->getParameter('carddav_enabled'); + $form = $this->createForm(AddressBookType::class, $addressbook, ['new' => !$id, 'birthday_calendar_enabled' => $isBirthdayCalendarEnabled]); + + if ($isBirthdayCalendarEnabled) { + $form->get('includedInBirthdayCalendar')->setData($addressbook->isIncludedInBirthdayCalendar()); + } $form->get('principalUri')->setData(Principal::PREFIX.$username); $form->handleRequest($request); @@ -61,6 +67,17 @@ public function addressbookCreate(ManagerRegistry $doctrine, Request $request, s $this->addFlash('success', $trans->trans('addressbooks.saved')); + if ($isBirthdayCalendarEnabled && true === $form->get('includedInBirthdayCalendar')->getData()) { + $addressbook->setIncludedInBirthdayCalendar(true); + } else { + $addressbook->setIncludedInBirthdayCalendar(false); + } + + if ($isBirthdayCalendarEnabled) { + // Let's sync the user birthday calendar if needed + $birthdayService->syncUser($username); + } + return $this->redirectToRoute('addressbook_index', ['username' => $username]); } @@ -73,7 +90,7 @@ public function addressbookCreate(ManagerRegistry $doctrine, Request $request, s } #[Route('/{username}/delete/{id}', name: 'delete', requirements: ['id' => "\d+"])] - public function addressbookDelete(ManagerRegistry $doctrine, string $username, string $id, TranslatorInterface $trans): Response + public function addressbookDelete(ManagerRegistry $doctrine, string $username, string $id, TranslatorInterface $trans, BirthdayService $birthdayService): Response { $addressbook = $doctrine->getRepository(AddressBook::class)->findOneById($id); if (!$addressbook) { @@ -93,6 +110,12 @@ public function addressbookDelete(ManagerRegistry $doctrine, string $username, s $entityManager->flush(); $this->addFlash('success', $trans->trans('addressbooks.deleted')); + $isBirthdayCalendarEnabled = $this->getParameter('caldav_enabled') && $this->getParameter('carddav_enabled'); + if ($isBirthdayCalendarEnabled) { + // Let's sync the user birthday calendar if needed + $birthdayService->syncUser($username); + } + return $this->redirectToRoute('addressbook_index', ['username' => $username]); } } diff --git a/src/Controller/Admin/CalendarController.php b/src/Controller/Admin/CalendarController.php index 76a98b1..006b567 100644 --- a/src/Controller/Admin/CalendarController.php +++ b/src/Controller/Admin/CalendarController.php @@ -32,8 +32,14 @@ public function calendars(ManagerRegistry $doctrine, UrlGeneratorInterface $rout // Separate shared calendars $calendars = []; $shared = []; + $auto = []; foreach ($allCalendars as $calendar) { - if (!$calendar->isShared()) { + if ($calendar->isAutomaticallyGenerated()) { + $auto[] = [ + 'entity' => $calendar, + 'uri' => $router->generate('dav', ['path' => 'calendars/'.$username.'/'.$calendar->getUri()], UrlGeneratorInterface::ABSOLUTE_URL), + ]; + } elseif (!$calendar->isShared()) { $calendars[] = [ 'entity' => $calendar, 'uri' => $router->generate('dav', ['path' => 'calendars/'.$username.'/'.$calendar->getUri()], UrlGeneratorInterface::ABSOLUTE_URL), @@ -53,6 +59,7 @@ public function calendars(ManagerRegistry $doctrine, UrlGeneratorInterface $rout 'calendars' => $calendars, 'subscriptions' => $subscriptions, 'shared' => $shared, + 'auto' => $auto, 'principal' => $principal, 'username' => $username, 'allPrincipals' => $allPrincipalsExcept, diff --git a/src/Controller/DAVController.php b/src/Controller/DAVController.php index 32e5377..566b1ec 100644 --- a/src/Controller/DAVController.php +++ b/src/Controller/DAVController.php @@ -4,9 +4,11 @@ use App\Entity\Principal; use App\Entity\User; +use App\Plugins\BirthdayCalendarPlugin; use App\Plugins\DavisIMipPlugin; use App\Plugins\PublicAwareDAVACLPlugin; use App\Services\BasicAuth; +use App\Services\BirthdayService; use App\Services\IMAPAuth; use App\Services\LDAPAuth; use Doctrine\ORM\EntityManagerInterface; @@ -100,6 +102,11 @@ class DAVController extends AbstractController */ protected $mailer; + /** + * @var BirthdayService + */ + protected $birthdayService; + /** * Base URI of the server. * @@ -142,7 +149,7 @@ class DAVController extends AbstractController */ protected $server; - public function __construct(MailerInterface $mailer, BasicAuth $basicAuthBackend, IMAPAuth $IMAPAuthBackend, LDAPAuth $LDAPAuthBackend, UrlGeneratorInterface $router, EntityManagerInterface $entityManager, LoggerInterface $logger, string $publicDir, bool $calDAVEnabled = true, bool $cardDAVEnabled = true, bool $webDAVEnabled = false, bool $publicCalendarsEnabled = true, ?string $inviteAddress = null, ?string $authMethod = null, ?string $authRealm = null, ?string $webdavPublicDir = null, ?string $webdavHomesDir = null, ?string $webdavTmpDir = null) + public function __construct(MailerInterface $mailer, BasicAuth $basicAuthBackend, IMAPAuth $IMAPAuthBackend, LDAPAuth $LDAPAuthBackend, UrlGeneratorInterface $router, EntityManagerInterface $entityManager, LoggerInterface $logger, BirthdayService $birthdayService, string $publicDir, bool $calDAVEnabled = true, bool $cardDAVEnabled = true, bool $webDAVEnabled = false, bool $publicCalendarsEnabled = true, ?string $inviteAddress = null, ?string $authMethod = null, ?string $authRealm = null, ?string $webdavPublicDir = null, ?string $webdavHomesDir = null, ?string $webdavTmpDir = null) { $this->publicDir = $publicDir; @@ -159,6 +166,7 @@ public function __construct(MailerInterface $mailer, BasicAuth $basicAuthBackend $this->em = $entityManager; $this->logger = $logger; $this->mailer = $mailer; + $this->birthdayService = $birthdayService; $this->baseUri = $router->generate('dav', ['path' => '']); $this->basicAuthBackend = $basicAuthBackend; @@ -272,6 +280,10 @@ private function initServer(string $authMethod, string $authRealm = User::DEFAUL $this->server->addPlugin(new \Sabre\CardDAV\VCFExportPlugin()); } + if ($this->cardDAVEnabled && $this->calDAVEnabled) { + $this->server->addPlugin(new BirthdayCalendarPlugin($this->birthdayService)); + } + // WebDAV plugins if ($this->webDAVEnabled && $this->webdavTmpDir && $this->webdavPublicDir) { if (!is_dir($this->webdavTmpDir) || !is_dir($this->webdavPublicDir)) { diff --git a/src/Entity/AddressBook.php b/src/Entity/AddressBook.php index 6c024ad..5b1ebaa 100644 --- a/src/Entity/AddressBook.php +++ b/src/Entity/AddressBook.php @@ -34,6 +34,9 @@ class AddressBook #[ORM\Column(type: 'string', length: 255)] private $synctoken; + #[ORM\Column(type: 'boolean', nullable: true, options: ['default' => false])] + private $includedInBirthdayCalendar; + #[ORM\OneToMany(targetEntity: "App\Entity\Card", mappedBy: 'addressBook')] private $cards; @@ -43,6 +46,7 @@ class AddressBook public function __construct() { $this->synctoken = 1; + $this->includedInBirthdayCalendar = false; $this->cards = new ArrayCollection(); $this->changes = new ArrayCollection(); } @@ -76,6 +80,18 @@ public function setDisplayName(string $displayName): self return $this; } + public function isIncludedInBirthdayCalendar(): ?bool + { + return $this->includedInBirthdayCalendar; + } + + public function setIncludedInBirthdayCalendar(bool $includedInBirthdayCalendar): self + { + $this->includedInBirthdayCalendar = $includedInBirthdayCalendar; + + return $this; + } + public function getUri(): ?string { return $this->uri; diff --git a/src/Entity/CalendarInstance.php b/src/Entity/CalendarInstance.php index 2986831..f993cad 100644 --- a/src/Entity/CalendarInstance.php +++ b/src/Entity/CalendarInstance.php @@ -2,6 +2,7 @@ namespace App\Entity; +use App\Constants; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Validator\Constraints as Assert; @@ -141,6 +142,11 @@ public function isPublic(): bool return self::ACCESS_PUBLIC === $this->access; } + public function isAutomaticallyGenerated(): bool + { + return in_array($this->uri, [Constants::BIRTHDAY_CALENDAR_URI]); + } + public function getDisplayName(): ?string { return $this->displayName; diff --git a/src/Form/AddressBookType.php b/src/Form/AddressBookType.php index 577e16a..829db98 100644 --- a/src/Form/AddressBookType.php +++ b/src/Form/AddressBookType.php @@ -4,6 +4,7 @@ use App\Entity\AddressBook; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; @@ -28,6 +29,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'form.displayName', 'help' => 'form.name.help.carddav', ]) + ->add('includedInBirthdayCalendar', ChoiceType::class, [ + 'label' => 'form.includedInBirthdayCalendar', + 'help' => 'form.includedInBirthdayCalendar.help', + 'required' => true, + 'choices' => ['yes' => true, 'no' => false], + ]) ->add('description', TextareaType::class, [ 'label' => 'form.description', 'required' => false, @@ -35,6 +42,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('save', SubmitType::class, [ 'label' => 'save', ]); + + if (!$options['birthday_calendar_enabled']) { + $builder->remove('includedInBirthdayCalendar'); + } } public function configureOptions(OptionsResolver $resolver): void @@ -42,6 +53,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->setDefaults([ 'new' => false, 'data_class' => AddressBook::class, + 'birthday_calendar_enabled' => true, ]); } } diff --git a/src/Plugins/BirthdayCalendarPlugin.php b/src/Plugins/BirthdayCalendarPlugin.php new file mode 100644 index 0000000..11b292c --- /dev/null +++ b/src/Plugins/BirthdayCalendarPlugin.php @@ -0,0 +1,90 @@ +birthdayService = $birthdayService; + } + + public function initialize(DAV\Server $server) + { + $this->server = $server; + + // Hook into card creation + $server->on('afterCreateFile', [$this, 'afterCardCreate']); + + // Hook into card updates + $server->on('afterWriteContent', [$this, 'afterCardUpdate']); + + // Hook into card deletion + // Note: The node no longer exists at afterCardDelete so we + // use beforeCardDelete for simplicity + $server->on('beforeUnbind', [$this, 'beforeCardDelete']); + } + + private function resyncCurrentPrincipal() + { + $authPlugin = $this->server->getPlugin('auth'); + + if (!$authPlugin) { + return null; + } + + $principal = $authPlugin->getCurrentPrincipal(); + + if ($principal) { + $this->birthdayService->syncPrincipal($principal); + } + } + + public function afterCardCreate($path, DAV\ICollection $parentNode) + { + if (!$parentNode instanceof CardDAV\IAddressBook) { + return; + } + + $principal = $this->resyncCurrentPrincipal(); + } + + public function afterCardUpdate($path, DAV\IFile $node) + { + if (!$node instanceof CardDAV\ICard) { + return; + } + + $principal = $this->resyncCurrentPrincipal(); + } + + public function beforeCardDelete($path) + { + $node = $this->server->tree->getNodeForPath($path); + + if (!$node instanceof CardDAV\ICard) { + return; + } + + $principal = $this->resyncCurrentPrincipal(); + } + + public function getPluginName(): string + { + return 'birthday-calendar'; + } +} diff --git a/src/Services/BirthdayService.php b/src/Services/BirthdayService.php new file mode 100644 index 0000000..be14e53 --- /dev/null +++ b/src/Services/BirthdayService.php @@ -0,0 +1,357 @@ +doctrine->getRepository(AddressBook::class)->findOneById($addressBookId); + + if (!$book->isIncludedInBirthdayCalendar()) { + return; + } + + $principalUri = $book->getPrincipalUri(); + $calendar = $this->ensureBirthdayCalendarExists($principalUri); + + $this->updateCalendar($cardUri, $cardData, $book, $calendar->getCalendar()); + } + + public function onCardDeleted(int $addressBookId, string $cardUri): void + { + $book = $this->doctrine->getRepository(AddressBook::class)->findOneById($addressBookId); + + if (!$book->isIncludedInBirthdayCalendar()) { + return; + } + + $principalUri = $book->getPrincipalUri(); + $calendar = $this->ensureBirthdayCalendarExists($principalUri); + + $objectUri = $book->getUri().'-'.$cardUri.'.ics'; + $calendarObject = $this->doctrine->getRepository(CalendarObject::class)->findOneBy(['calendar' => $calendar, 'uri' => $objectUri]); + + $em = $this->doctrine->getManager(); + $em->remove($calendarObject); + $em->flush(); + } + + public function shouldBirthdayCalendarExist(string $principalUri): bool + { + $addressbooks = $this->doctrine->getRepository(AddressBook::class)->findByPrincipalUri($principalUri); + + return array_reduce($addressbooks, function ($carry, $addressbook) { + return $carry || $addressbook->isIncludedInBirthdayCalendar(); + }, false); + } + + public function ensureBirthdayCalendarExists(string $principalUri): CalendarInstance + { + $instance = $this->doctrine->getRepository(CalendarInstance::class)->findOneBy(['principalUri' => $principalUri, 'uri' => Constants::BIRTHDAY_CALENDAR_URI]); + + if ($instance) { + return $instance; + } + + $em = $this->doctrine->getManager(); + + $calendar = new Calendar(); + $em->persist($calendar); + + $instance = (new CalendarInstance()) + ->setPrincipalUri($principalUri) + ->setDisplayName('🎁 Birthdays') + ->setDescription('Birthdays') + ->setAccess(CalendarInstance::ACCESS_READ) + ->setCalendarOrder(0) + ->setCalendar($calendar) + ->setTransparent(1) + ->setShareInviteStatus(CalendarInstance::INVITE_ACCEPTED) + ->setUri(Constants::BIRTHDAY_CALENDAR_URI); + + $em->persist($instance); + $em->flush(); + + return $instance; + } + + public function deleteBirthdayCalendar(string $principalUri): void + { + $instance = $this->doctrine->getRepository(CalendarInstance::class)->findOneBy(['principalUri' => $principalUri, 'uri' => Constants::BIRTHDAY_CALENDAR_URI]); + + if (!$instance) { + return; + } + + $em = $this->doctrine->getManager(); + + $em->remove($instance); + $em->remove($instance->getCalendar()); + $em->flush(); + } + + /** + * @throws InvalidDataException + */ + public function buildDataFromContact(string $cardData): ?VCalendar + { + if (empty($cardData)) { + return null; + } + + try { + $doc = Reader::read($cardData); + // We're always converting to vCard 4.0 so we can rely on the + // VCardConverter handling the X-APPLE-OMIT-YEAR property for us. + if (!$doc instanceof VCard) { + return null; + } + $doc = $doc->convert(Document::VCARD40); + } catch (\Exception $e) { + return null; + } + + if (!isset($doc->BDAY) || !isset($doc->FN)) { + return null; + } + + $birthday = $doc->BDAY; + if (!(string) $birthday) { + return null; + } + + // Skip if the BDAY property is not of the right type. + if (!$birthday instanceof DateAndOrTime) { + return null; + } + + // Skip if we can't parse the BDAY value. + try { + $dateParts = DateTimeParser::parseVCardDateTime($birthday->getValue()); + } catch (InvalidDataException $e) { + return null; + } + + if (null !== $dateParts['year']) { + $parameters = $birthday->parameters(); + $omitYear = (isset($parameters['X-APPLE-OMIT-YEAR']) && $parameters['X-APPLE-OMIT-YEAR'] === $dateParts['year']); + // 'X-APPLE-OMIT-YEAR' is not always present, at least iOS 12.4 uses the hard coded date of 1604 (the start of the gregorian calendar) when the year is unknown + if ($omitYear || 1604 === (int) $dateParts['year']) { + $dateParts['year'] = null; + } + } + + $originalYear = null; + if (null !== $dateParts['year']) { + $originalYear = (int) $dateParts['year']; + } + + try { + if ($birthday instanceof DateAndOrTime) { + $date = $birthday->getDateTime(); + } else { + $date = new \DateTimeImmutable($birthday); + } + } catch (\Exception $e) { + return null; + } + + $summary = '🎂 '.$doc->FN->getValue().($originalYear ? (' ('.$originalYear.')') : ''); + + $vCal = new VCalendar(); + $vCal->VERSION = '2.0'; + $vCal->PRODID = '-//IDN davis//Birthday calendar//EN'; + $vEvent = $vCal->createComponent('VEVENT'); + $vEvent->add('DTSTART'); + $vEvent->DTSTART->setDateTime( + $date + ); + $vEvent->DTSTART['VALUE'] = 'DATE'; + $vEvent->add('DTEND'); + + $dtEndDate = (new \DateTime())->setTimestamp($date->getTimeStamp()); + $dtEndDate->add(new \DateInterval('P1D')); + $vEvent->DTEND->setDateTime( + $dtEndDate + ); + + $vEvent->DTEND['VALUE'] = 'DATE'; + $vEvent->{'UID'} = $doc->UID; + + $leapDay = (2 === (int) $dateParts['month'] + && 29 === (int) $dateParts['date']); + if (null === $dateParts['year'] || $originalYear < 1970) { + $birthday = ($leapDay ? '1972-' : '1970-') + .$dateParts['month'].'-'.$dateParts['date']; + } + + if ($leapDay) { + /* Sabre\VObject supports BYMONTHDAY only if BYMONTH + * is also set */ + $vEvent->{'RRULE'} = 'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1'; + } else { + $vEvent->{'RRULE'} = 'FREQ=YEARLY'; + } + + $vEvent->{'SUMMARY'} = $summary; + $vEvent->{'TRANSP'} = 'TRANSPARENT'; + + // Set a reminder, if needed + if (strtolower($this->birthdayReminderOffset) !== "false") { + $alarm = $vCal->createComponent('VALARM'); + $alarm->add($vCal->createProperty('TRIGGER', $this->birthdayReminderOffset, ['VALUE' => 'DURATION'])); + $alarm->add($vCal->createProperty('ACTION', 'DISPLAY')); + $alarm->add($vCal->createProperty('DESCRIPTION', $vEvent->{'SUMMARY'})); + $vEvent->add($alarm); + } + + $vCal->add($vEvent); + + return $vCal; + } + + public function resetForPrincipal(string $principal): void + { + $calendarInstance = $this->doctrine->getRepository(CalendarInstance::class)->findOneBy(['principalUri' => $principal, 'uri' => Constants::BIRTHDAY_CALENDAR_URI]); + + if (!$calendarInstance) { + return; // The user's birthday calendar doesn't exist, no need to purge it + } + + $calendarObjects = $this->doctrine->getRepository(CalendarObject::class)->findByCalendar($calendarInstance->getCalendar()); + $em = $this->doctrine->getManager(); + + foreach ($calendarObjects as $calendarObject) { + $em->remove($calendarObject); + } + + $em->flush(); + } + + public function syncUser(string $username): void + { + $this->syncPrincipal(Principal::PREFIX.$username); + } + + public function syncPrincipal(string $principal): void + { + if (!$this->shouldBirthdayCalendarExist($principal)) { + $this->deleteBirthdayCalendar($principal); + + return; + } + + $calendarInstance = $this->ensureBirthdayCalendarExists($principal); + + // Reset the calendar + $this->resetForPrincipal($principal); + + // Get all address books that should be included and iterate + $addressbooks = $this->doctrine->getRepository(AddressBook::class)->findBy(['principalUri' => $principal, 'includedInBirthdayCalendar' => true]); + foreach ($addressbooks as $book) { + $cards = $this->doctrine->getRepository(Card::class)->findByAddressBook($book); + + foreach ($cards as $card) { + $this->onCardChanged($book->getId(), $card->getUri(), $card->getCardData()); + } + } + } + + public function birthdayEvenChanged(string $existingCalendarData, VCalendar $newCalendarData): bool + { + try { + $existingBirthday = Reader::read($existingCalendarData); + } catch (\Exception $ex) { + return true; + } + + return + $newCalendarData->VEVENT->DTSTART->getValue() !== $existingBirthday->VEVENT->DTSTART->getValue() + || $newCalendarData->VEVENT->SUMMARY->getValue() !== $existingBirthday->VEVENT->SUMMARY->getValue() + ; + } + + /** + * @throws InvalidDataException + */ + private function updateCalendar(string $cardUri, string $cardData, AddressBook $book, Calendar $calendar): void + { + $objectUid = $book->getUri().'-'.$cardUri; + $objectUri = $objectUid.'.ics'; + $calendarData = $this->buildDataFromContact($cardData); + + $existing = $this->doctrine->getRepository(CalendarObject::class)->findOneBy(['calendar' => $calendar, 'uri' => $objectUri]); + + $em = $this->doctrine->getManager(); + + if (null === $calendarData) { + if (null !== $existing) { + $em->remove($existing); + } + } else { + $serializedCalendarData = $calendarData->serialize(); + $vEvent = $calendarData->getComponents()[0]; + $maxDate = new \DateTime(Constants::MAX_DATE); + + if (null === $existing) { + $calendarObject = (new CalendarObject()) + ->setCalendar($calendar) + ->setUri($objectUri) + ->setComponentType('VEVENT') + ->setUid($objectUid) + ->setLastModified((new \DateTime())->getTimestamp()) + ->setFirstOccurence($vEvent->DTSTART->getDateTime()->getTimeStamp()) + ->setLastOccurence($maxDate->getTimestamp()) + ->setEtag(md5($serializedCalendarData)) + ->setSize(strlen($serializedCalendarData)) + ->setCalendarData($serializedCalendarData); + + $em->persist($calendarObject); + } else { + if ($this->birthdayEvenChanged($existing->getCalendarData(), $calendarData)) { + $existing + ->setLastModified((new \DateTime())->getTimestamp()) + ->setFirstOccurence($vEvent->DTSTART->getDateTime()->getTimeStamp()) + ->setLastOccurence($maxDate->getTimestamp()) + ->setEtag(md5($serializedCalendarData)) + ->setSize(strlen($serializedCalendarData)) + ->setCalendarData($serializedCalendarData); + } + } + } + + $em->flush(); + } +} diff --git a/templates/calendars/index.html.twig b/templates/calendars/index.html.twig index 3c21385..84ad84b 100644 --- a/templates/calendars/index.html.twig +++ b/templates/calendars/index.html.twig @@ -122,6 +122,41 @@ {% endif %} +{% if auto|length > 0 %} +

{{ "calendars.auto"|trans }}

+ +
+ {% for compoundObject in auto %} + {% set calendar = compoundObject.entity %} + {% set davUri = compoundObject.uri %} +
+
+
+ {{ calendar.displayName }} + {{ ('calendar.auto')|trans }} + +   +
+ +
+

{{ calendar.description }}

+ {% if calendar.calendar.components|split(',')|length > 0 %} + {% if constant('\\App\\Entity\\Calendar::COMPONENT_EVENTS') in calendar.calendar.components %}{{ "calendars.component.events"|trans }}{% endif %} + {% if constant('\\App\\Entity\\Calendar::COMPONENT_NOTES') in calendar.calendar.components %}{{ "calendars.component.notes"|trans }}{% endif %} + {% if constant('\\App\\Entity\\Calendar::COMPONENT_TODOS') in calendar.calendar.components %}{{ "calendars.component.todos"|trans }}{% endif %} + {% endif %} + — {{ "calendars.entries"|trans({'%count%': calendar.calendar.objects|length}) }} + + +
+ {% endfor %} +
+{% endif %} + {% if subscriptions|length > 0 %}

{{ "calendars.subscriptions"|trans }}

@@ -140,8 +175,6 @@ {% endfor %} - - {% include '_partials/delete_modal.html.twig' with {flavour: 'revoke'} %} {% endif %} diff --git a/translations/messages+intl-icu.en.xlf b/translations/messages+intl-icu.en.xlf index c82ac56..be02b53 100644 --- a/translations/messages+intl-icu.en.xlf +++ b/translations/messages+intl-icu.en.xlf @@ -361,6 +361,10 @@ form.uri URI + + form.includedInBirthdayCalendar + Included in birthday calendar? + form.description Description @@ -377,17 +381,21 @@ form.name.help.carddav This name will be displayed in your CardDAV client + + form.includedInBirthdayCalendar.help + When selected, all cards with a valid birthday will be included in the principal's birthday calendar, which will be available as a shared calendar in your account + form.name.help.caldav This name will be displayed in your CalDAV client form.uri.help.carddav - This is the unique identifier for this address book. Allowed characters are digits, lowercase letters and the dash symbol '-'. + This is the unique identifier for this address book. Allowed characters are digits, lowercase letters and the dash symbol '-'. form.uri.help.caldav - This is the unique identifier for this calendar. Allowed characters are digits, lowercase letters and the dash symbol '-'. + This is the unique identifier for this calendar. Allowed characters are digits, lowercase letters and the dash symbol '-'. form.public.help.caldav @@ -581,6 +589,10 @@ calendar.share_access.10 public + + calendar.auto + auto + calendar.subscription subscription @@ -589,6 +601,10 @@ calendars.subscriptions Subscriptions + + calendars.auto + Automatically generated +