diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index f5e13706..3ac1a695 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -14,6 +14,11 @@ framework: name: 'DAVIS_SESSION' storage_factory_id: session.storage.factory.native + # The CSS and JS only ever change with a release, so the version is the release. It also makes + # `asset()` prepend the base path, which a hard-coded `/css/…` does not on a sub-directory install. + assets: + version: !php/const App\Version::VERSION + property_info: with_constructor_extractor: false diff --git a/docker/configurations/Caddyfile b/docker/configurations/Caddyfile index eb109e92..7702552f 100644 --- a/docker/configurations/Caddyfile +++ b/docker/configurations/Caddyfile @@ -37,4 +37,9 @@ Permissions-Policy "camera=(), microphone=(), geolocation=()" } + # Every URL Davis emits for these carries a `?` that changes with the release, so a + # stale copy can never be served: a new release is a new URL + @static path *.css *.js *.png *.jpg *.jpeg *.gif *.svg *.ico *.woff *.woff2 + header @static Cache-Control "public, max-age=31536000, immutable" + } diff --git a/docker/configurations/nginx.conf b/docker/configurations/nginx.conf index 89291f8f..6de54a32 100644 --- a/docker/configurations/nginx.conf +++ b/docker/configurations/nginx.conf @@ -28,6 +28,21 @@ server { return 404; } + # Every URL Davis emits for these carries a `?` that changes with the release, so a + # stale copy can never be served: a new release is a new URL + location ~* \.(css|js|png|jpe?g|gif|svg|ico|woff2?)$ { + add_header Cache-Control "public, max-age=31536000, immutable" always; + + # An `add_header` here replaces the whole inherited set, so the server-level headers above + # have to be repeated or static files would be served without them + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + + try_files $uri =404; + } + location / { try_files $uri $uri/ /index.php$is_args$args; } diff --git a/migrations/Version20260930120000.php b/migrations/Version20260930120000.php new file mode 100644 index 00000000..38795dbf --- /dev/null +++ b/migrations/Version20260930120000.php @@ -0,0 +1,43 @@ +connection->getDatabasePlatform() instanceof SqlitePlatform) { + return; + } + + if ($this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform) { + $this->addSql('ALTER TABLE cards CHANGE lastmodified lastmodified BIGINT DEFAULT NULL'); + } + + if ($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { + $this->addSql('ALTER TABLE cards ALTER COLUMN lastmodified TYPE BIGINT'); + } + } + + public function down(Schema $schema): void + { + // Narrowing back to INT would truncate post-2038 timestamps, like Version20250409193948 + } +} diff --git a/public/.htaccess b/public/.htaccess index 2776637c..11b2a20b 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -5,6 +5,14 @@ # to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl). DirectoryIndex index.php +# Every URL Davis emits for a static file carries a `?` that changes with the release, so +# a stale copy can never be served: a new release is a new URL. + + + Header set Cache-Control "public, max-age=31536000, immutable" + + + # By default, Apache does not evaluate symbolic links if you did not enable this # feature in your server configuration. Uncomment the following line if you # install assets as symlinks or if you experience problems related to symlinks diff --git a/src/Controller/Admin/UserController.php b/src/Controller/Admin/UserController.php index 4943bfca..75b92a85 100644 --- a/src/Controller/Admin/UserController.php +++ b/src/Controller/Admin/UserController.php @@ -202,14 +202,18 @@ public function userDelete(ManagerRegistry $doctrine, Request $request, #[MapEnt public function userDelegates(ManagerRegistry $doctrine, #[MapEntity(id: 'userId')] User $user, int $userId): Response { $principalUri = $user->getPrincipalUri(); + $readProxyUri = $principalUri.Principal::READ_PROXY_SUFFIX; + $writeProxyUri = $principalUri.Principal::WRITE_PROXY_SUFFIX; - $principal = $doctrine->getRepository(Principal::class)->findOneByUri($principalUri); + // Delegates are not linked to the principal itself but to its proxies, so the three + // principals and the delegees the template reads off the proxies come back together + $principals = $doctrine->getRepository(Principal::class)->findWithDelegeesByUris([$principalUri, $readProxyUri, $writeProxyUri]); - $allPrincipalsExcept = $doctrine->getRepository(Principal::class)->findAllExceptPrincipal($principalUri); + $principal = $principals[$principalUri] ?? null; + $principalProxyRead = $principals[$readProxyUri] ?? null; + $principalProxyWrite = $principals[$writeProxyUri] ?? null; - // Get delegates. They are not linked to the principal in itself, but to its proxies - $principalProxyRead = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::READ_PROXY_SUFFIX); - $principalProxyWrite = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX); + $allPrincipalsExcept = $doctrine->getRepository(Principal::class)->findAllExceptPrincipal($principalUri); return $this->render('users/delegates.html.twig', [ 'principal' => $principal, diff --git a/src/Controller/Api/ApiController.php b/src/Controller/Api/ApiController.php index 597b08a1..d2d4ff63 100644 --- a/src/Controller/Api/ApiController.php +++ b/src/Controller/Api/ApiController.php @@ -171,10 +171,14 @@ public function getUserCalendars(Request $request, int $userId, ManagerRegistry $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUriWithCalendars($principalUri); $allSubscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri($principalUri); + $allObjectCounts = $doctrine->getRepository(CalendarInstance::class)->countObjectsByComponentType( + array_map(fn (CalendarInstance $instance) => $instance->getCalendar()->getId(), $allCalendars) + ); + $calendars = []; $sharedCalendars = []; foreach ($allCalendars as $calendar) { - $objectCounts = $doctrine->getRepository(CalendarInstance::class)->getObjectCountsByComponentType($calendar->getCalendar()->getId()); + $objectCounts = $allObjectCounts[$calendar->getCalendar()->getId()]; $eventsCount = $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_EVENTS) ? $objectCounts['events'] : null; $notesCount = $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_NOTES) ? $objectCounts['notes'] : null; $tasksCount = $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_TODOS) ? $objectCounts['tasks'] : null; @@ -243,31 +247,32 @@ public function getUserCalendarDetails(Request $request, int $userId, int $calen return $this->json(['status' => 'error', 'message' => 'Principal Not Found', 'timestamp' => $this->getTimestamp()], 404); } - $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUriWithCalendars($principalUri); + // A shared instance or an id belonging to someone else resolves to nothing, which is the + // empty payload this endpoint has always answered with + $calendar = $this->resolveOwnerInstance($doctrine, $calendar_id, $principalUri); $calendar_details = []; - foreach ($allCalendars as $calendar) { - if (!$calendar->isShared() && $calendar->getId() === $calendar_id) { - $objectCounts = $doctrine->getRepository(CalendarInstance::class)->getObjectCountsByComponentType($calendar->getCalendar()->getId()); - $calendar_details = [ - 'id' => $calendar->getId(), - 'uri' => $calendar->getUri(), - 'displayname' => $calendar->getDisplayName(), - 'description' => $calendar->getDescription(), - 'events' => [ - 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_EVENTS), - 'count' => $objectCounts['events'], - ], - 'notes' => [ - 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_NOTES), - 'count' => $objectCounts['notes'], - ], - 'tasks' => [ - 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_TODOS), - 'count' => $objectCounts['tasks'], - ], - ]; - } + if ($calendar) { + $calendarRowId = $calendar->getCalendar()->getId(); + $objectCounts = $doctrine->getRepository(CalendarInstance::class)->countObjectsByComponentType([$calendarRowId])[$calendarRowId]; + $calendar_details = [ + 'id' => $calendar->getId(), + 'uri' => $calendar->getUri(), + 'displayname' => $calendar->getDisplayName(), + 'description' => $calendar->getDescription(), + 'events' => [ + 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_EVENTS), + 'count' => $objectCounts['events'], + ], + 'notes' => [ + 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_NOTES), + 'count' => $objectCounts['notes'], + ], + 'tasks' => [ + 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_TODOS), + 'count' => $objectCounts['tasks'], + ], + ]; } $response = [ diff --git a/src/Entity/AddressBook.php b/src/Entity/AddressBook.php index dc208aa7..e631e3eb 100644 --- a/src/Entity/AddressBook.php +++ b/src/Entity/AddressBook.php @@ -27,7 +27,8 @@ class AddressBook private $displayName; #[ORM\Column(type: 'string', length: 255)] - #[Assert\Regex("/[0-9a-z\-]+/")] + #[Assert\NotBlank] + #[Assert\Regex("/^[0-9a-zA-Z_\-]+$/")] #[Assert\Length(max: 255)] private $uri; diff --git a/src/Entity/CalendarInstance.php b/src/Entity/CalendarInstance.php index f53620ad..6df4de64 100644 --- a/src/Entity/CalendarInstance.php +++ b/src/Entity/CalendarInstance.php @@ -41,7 +41,8 @@ public static function getOwnerAccesses(): array private $displayName; #[ORM\Column(type: 'string', length: 255, nullable: true)] - #[Assert\Regex("/[0-9a-z\-]+/")] + #[Assert\NotBlank] + #[Assert\Regex("/^[0-9a-zA-Z_\-]+$/")] #[Assert\Length(max: 255)] private $uri; @@ -52,7 +53,7 @@ public static function getOwnerAccesses(): array private $calendarOrder; #[ORM\Column(name: 'calendarcolor', type: 'string', length: 10, nullable: true)] - #[Assert\Regex("/\#[0-9A-F]{6}/")] + #[Assert\Regex('/^#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/')] #[Assert\Length(max: 10)] private $calendarColor; diff --git a/src/Entity/Card.php b/src/Entity/Card.php index 20a96de0..8b031d8c 100644 --- a/src/Entity/Card.php +++ b/src/Entity/Card.php @@ -26,7 +26,7 @@ class Card #[ORM\Column(type: 'string', length: 255, nullable: true)] private $uri; - #[ORM\Column(name: 'lastmodified', type: 'integer', nullable: true)] + #[ORM\Column(name: 'lastmodified', type: 'bigint', nullable: true)] private $lastModified; #[ORM\Column(type: 'string', length: 32, nullable: true)] diff --git a/src/Repository/CalendarInstanceRepository.php b/src/Repository/CalendarInstanceRepository.php index 61ac3246..a5e3d4dd 100644 --- a/src/Repository/CalendarInstanceRepository.php +++ b/src/Repository/CalendarInstanceRepository.php @@ -168,22 +168,30 @@ public function countObjectsByCalendar(array $calendarIds): array } /** - * Get counts of calendar objects by component type for a calendar instance. + * Counts the objects of several calendars at once, split by component type, so that listing a + * principal's calendars costs a single query instead of one per calendar. Shared calendars + * share the `calendars` row their instances point at, so duplicate ids cost nothing extra. * - * @param int $calendarId The ID of the calendar + * @param int[] $calendarIds * - * @return array An associative array with keys 'events', 'notes', 'tasks' containing their respective counts + * @return array per calendar id, including the calendars that hold nothing */ - public function getObjectCountsByComponentType(int $calendarId): array + public function countObjectsByComponentType(array $calendarIds): array { - $objectRepository = $this->getEntityManager()->getRepository(CalendarObject::class); - - // Instead of three separate queries, get all counts in a single query - $results = $objectRepository->createQueryBuilder('o') - ->select('o.componentType, COUNT(o.id) as count') - ->where('o.calendar = :calendarId') - ->setParameter('calendarId', $calendarId) - ->groupBy('o.componentType') + $calendarIds = array_values(array_unique(array_map('intval', $calendarIds))); + $counts = array_fill_keys($calendarIds, ['events' => 0, 'notes' => 0, 'tasks' => 0]); + + if (!$calendarIds) { + return $counts; + } + + $results = $this->getEntityManager()->getRepository(CalendarObject::class) + ->createQueryBuilder('o') + ->select('IDENTITY(o.calendar) AS calendarId, o.componentType, COUNT(o.id) AS count') + ->where('o.calendar IN (:calendarIds)') + ->setParameter('calendarIds', $calendarIds) + ->groupBy('o.calendar') + ->addGroupBy('o.componentType') ->getQuery() ->getResult(); @@ -193,16 +201,10 @@ public function getObjectCountsByComponentType(int $calendarId): array Calendar::COMPONENT_TODOS => 'tasks', ]; - $counts = [ - 'events' => 0, - 'notes' => 0, - 'tasks' => 0, - ]; - - // Map query results to the expected keys foreach ($results as $result) { - if (isset($componentTypeMap[$result['componentType']])) { - $counts[$componentTypeMap[$result['componentType']]] = (int) $result['count']; + $key = $componentTypeMap[$result['componentType']] ?? null; + if (null !== $key) { + $counts[(int) $result['calendarId']][$key] = (int) $result['count']; } } diff --git a/src/Repository/PrincipalRepository.php b/src/Repository/PrincipalRepository.php index 4fce0d08..2eed943a 100644 --- a/src/Repository/PrincipalRepository.php +++ b/src/Repository/PrincipalRepository.php @@ -33,6 +33,32 @@ public function findAllExceptPrincipal(string $principalUri) ->getResult(); } + /** + * The delegates page needs a principal and both of its proxies, and reads the `delegees` of each + * proxy, which is six lookups done one at a time. + * + * @param string[] $uris + * + * @return array keyed by uri, missing uris simply absent + */ + public function findWithDelegeesByUris(array $uris): array + { + $principals = $this->createQueryBuilder('p') + ->leftJoin('p.delegees', 'd') + ->addSelect('d') + ->andWhere('p.uri IN (:uris)') + ->setParameter('uris', $uris) + ->getQuery() + ->getResult(); + + $byUri = []; + foreach ($principals as $principal) { + $byUri[$principal->getUri()] = $principal; + } + + return $byUri; + } + /** * @return array */ diff --git a/templates/_partials/navigation.html.twig b/templates/_partials/navigation.html.twig index 24dee276..f8264386 100644 --- a/templates/_partials/navigation.html.twig +++ b/templates/_partials/navigation.html.twig @@ -1,7 +1,7 @@