Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/packages/framework.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions docker/configurations/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,9 @@
Permissions-Policy "camera=(), microphone=(), geolocation=()"
}

# Every URL Davis emits for these carries a `?<version>` 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"

}
15 changes: 15 additions & 0 deletions docker/configurations/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ server {
return 404;
}

# Every URL Davis emits for these carries a `?<version>` 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;
}
Expand Down
43 changes: 43 additions & 0 deletions migrations/Version20260930120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SqlitePlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Version20250409193948 scaled every timestamp to BIGINT for the Year 2038 problem except this one.
*/
final class Version20260930120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Scale cards.lastmodified to big int for the Year 2038 problem';
}

public function up(Schema $schema): void
{
// SQLite stores every INTEGER as a 64-bit value, so it was never affected
if ($this->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
}
}
8 changes: 8 additions & 0 deletions public/.htaccess
Original file line number Diff line number Diff line change
Expand Up @@ -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 `?<version>` that changes with the release, so
# a stale copy can never be served: a new release is a new URL.
<IfModule mod_headers.c>
<FilesMatch "\.(css|js|png|jpe?g|gif|svg|ico|woff2?)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>

# 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
Expand Down
14 changes: 9 additions & 5 deletions src/Controller/Admin/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 29 additions & 24 deletions src/Controller/Api/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [
Expand Down
3 changes: 2 additions & 1 deletion src/Entity/AddressBook.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 3 additions & 2 deletions src/Entity/CalendarInstance.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion src/Entity/Card.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
44 changes: 23 additions & 21 deletions src/Repository/CalendarInstanceRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, array{events: int, notes: int, tasks: int}> 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();

Expand All @@ -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'];
}
}

Expand Down
26 changes: 26 additions & 0 deletions src/Repository/PrincipalRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Principal> 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<array{Principal, userId: int}>
*/
Expand Down
2 changes: 1 addition & 1 deletion templates/_partials/navigation.html.twig
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<nav class="navbar fixed-top navbar-expand-lg bg-body-tertiary">
<div class="container">
<a class="navbar-brand" href="{{ path('dashboard') }}">
<img src="/images/logo.png" width="30" height="30" alt=""> Davis
<img src="{{ asset('images/logo.png') }}" width="30" height="30" alt=""> Davis
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="{{ "toggle.navigation"|trans }}">
<span class="navbar-toggler-icon"></span>
Expand Down
10 changes: 5 additions & 5 deletions templates/base.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@
<meta charset="UTF-8">
<meta name="csrf-token" content="{{ csrf_token('admin_action') }}">
<title>{% block title %}Davis{% endblock %}</title>
<script type="text/javascript" src="/js/color.mode.toggler.js"></script>
<link rel="stylesheet" href="/css/bootstrap.min.css" />
<link rel="stylesheet" type="text/css" href="/css/style.css">
<script type="text/javascript" src="{{ asset('js/color.mode.toggler.js') }}"></script>
<link rel="stylesheet" href="{{ asset('css/bootstrap.min.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ asset('css/style.css') }}">
</head>
<body>
{% include '_partials/navigation.html.twig' %}
{% include '_partials/flashes.html.twig' %}
<div class="container">
{% block body %}{% endblock %}
</div>
<script type="text/javascript" src="/js/bootstrap.bundle.min.js"></script>
<script type="text/javascript" src="/js/app.js"></script>
<script type="text/javascript" src="{{ asset('js/bootstrap.bundle.min.js') }}"></script>
<script type="text/javascript" src="{{ asset('js/app.js') }}"></script>
</body>
</html>
14 changes: 7 additions & 7 deletions templates/index.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Davis{% endblock %}</title>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="stylesheet" href="/css/bootstrap.min.css" />
<link rel="apple-touch-icon" sizes="180x180" href="{{ asset('apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ asset('favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ asset('favicon-16x16.png') }}">
<link rel="manifest" href="{{ asset('site.webmanifest') }}">
<link rel="stylesheet" href="{{ asset('css/bootstrap.min.css') }}" />
<style type="text/css">
.hero {
height: 100vh;
Expand All @@ -22,11 +22,11 @@
width: 240px;
}
</style>
<script type="text/javascript" src="/js/color.mode.toggler.js"></script>
<script type="text/javascript" src="{{ asset('js/color.mode.toggler.js') }}"></script>
</head>
<body>
<div class="hero">
<img class="mb-2" src="/images/logo.png" width="60px">
<img class="mb-2" src="{{ asset('images/logo.png') }}" width="60px">
<h3 class="mb-4">{{ "davis"|trans }}</h3>
<ul class="list-group">
<li class="caldav list-group-item d-flex justify-content-between align-items-center">
Expand Down
Loading