Skip to content

Commit 2fc664d

Browse files
committed
feat(dav): allow CalDAV updates for existing Deck cards
Map SUMMARY, DESCRIPTION, DUE, and STATUS/COMPLETED/PERCENT-COMPLETE to existing Deck cards. Ignore CATEGORIES, ATTENDEE, RRULE, ATTACH, DTSTART, RELATED-TO, and raw VTODO data. This intentionally accepts round-trip loss for unsupported properties in this first step. Reject CREATE with 403, DELETE with 403, and Stack writes with 403 by filtering write-content on CalendarObject ACLs. Return isShared(): false so Nextcloud's Schedule plugin does not treat Deck external calendars as shared scheduling calendars during writable DAV hooks. Map StatusException to 403, DoesNotExistException to 404, and invalid calendar payloads to InvalidDataException for 400 responses. Tested with Thunderbird 148.0.1 and macOS Reminders 26.4.1. Refs #2399: partial write access for existing items only. Signed-off-by: Jaggob <37583151+Jaggob@users.noreply.github.com>
1 parent 5bba6ba commit 2fc664d

8 files changed

Lines changed: 900 additions & 20 deletions

File tree

lib/DAV/Calendar.php

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ class Calendar extends ExternalCalendar {
2929
private $backend;
3030
/** @var Board */
3131
private $board;
32+
/** @var array|null */
33+
private $acl;
3234

3335
public function __construct(string $principalUri, string $calendarUri, Board $board, DeckCalendarBackend $backend) {
3436
parent::__construct('deck', $calendarUri);
@@ -43,9 +45,22 @@ public function getOwner() {
4345
return $this->principalUri;
4446
}
4547

48+
/**
49+
* Nextcloud's DAV Schedule Plugin calls this for writable external
50+
* calendars. Deck calendars are not shared scheduling calendars, and Deck
51+
* does not process iTIP/ATTENDEE scheduling data here.
52+
*/
53+
public function isShared(): bool {
54+
return false;
55+
}
56+
4657
public function getACL() {
47-
// the calendar should always have the read and the write-properties permissions
48-
// write-properties is needed to allow the user to toggle the visibility of shared deck calendars
58+
if ($this->acl !== null) {
59+
return $this->acl;
60+
}
61+
62+
// write-content covers PUT on existing objects but not bind/unbind, so
63+
// CREATE and DELETE are rejected at the ACL layer before any hooks run.
4964
$acl = [
5065
[
5166
'privilege' => '{DAV:}read',
@@ -58,8 +73,16 @@ public function getACL() {
5873
'protected' => true,
5974
]
6075
];
76+
if ($this->backend->checkBoardPermission($this->board->getId(), Acl::PERMISSION_EDIT)) {
77+
$acl[] = [
78+
'privilege' => '{DAV:}write-content',
79+
'principal' => $this->getOwner(),
80+
'protected' => true,
81+
];
82+
}
6183

62-
return $acl;
84+
$this->acl = $acl;
85+
return $this->acl;
6386
}
6487

6588
public function setACL(array $acl) {

lib/DAV/CalendarObject.php

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77

88
namespace OCA\Deck\DAV;
99

10+
use OCA\Deck\BadRequestException;
1011
use OCA\Deck\Db\Card;
1112
use OCA\Deck\Db\Stack;
13+
use OCA\Deck\StatusException;
14+
use OCP\AppFramework\Db\DoesNotExistException;
1215
use Sabre\CalDAV\ICalendarObject;
16+
use Sabre\DAV\Exception\BadRequest;
1317
use Sabre\DAV\Exception\Forbidden;
18+
use Sabre\DAV\Exception\NotFound;
1419
use Sabre\DAVACL\IACL;
1520
use Sabre\VObject\Component\VCalendar;
1621

@@ -44,7 +49,14 @@ public function getGroup() {
4449
}
4550

4651
public function getACL() {
47-
return $this->calendar->getACL();
52+
$acl = $this->calendar->getACL();
53+
if ($this->sourceItem instanceof Stack) {
54+
return array_values(array_filter($acl, static function (array $entry): bool {
55+
return $entry['privilege'] !== '{DAV:}write-content';
56+
}));
57+
}
58+
59+
return $acl;
4860
}
4961

5062
public function setACL(array $acl) {
@@ -56,7 +68,35 @@ public function getSupportedPrivilegeSet() {
5668
}
5769

5870
public function put($data) {
59-
throw new Forbidden('This calendar-object is read-only');
71+
if (!($this->sourceItem instanceof Card)) {
72+
throw new Forbidden('This calendar-object is read-only');
73+
}
74+
75+
// MultipleObjectsReturnedException is intentionally not caught:
76+
// a primary-key lookup returning multiple rows is a data integrity bug
77+
// and should surface as a 500 in the log.
78+
try {
79+
$this->sourceItem = $this->backend->updateCardFromCalendarObject($this->sourceItem, $this->readPutData($data));
80+
} catch (DoesNotExistException $e) {
81+
throw new NotFound($e->getMessage(), 0, $e);
82+
} catch (BadRequestException $e) {
83+
throw new BadRequest($e->getMessage(), 0, $e);
84+
} catch (StatusException $e) {
85+
throw new Forbidden($e->getMessage(), 0, $e);
86+
}
87+
$this->calendarObject = $this->sourceItem->getCalendarObject();
88+
}
89+
90+
private function readPutData($data): string {
91+
if (is_resource($data)) {
92+
$content = stream_get_contents($data);
93+
if ($content === false) {
94+
throw new BadRequest('Could not read calendar-object data');
95+
}
96+
return $content;
97+
}
98+
99+
return (string)$data;
60100
}
61101

62102
public function get() {
@@ -78,7 +118,7 @@ public function getSize() {
78118
}
79119

80120
public function delete() {
81-
throw new Forbidden('This calendar-object is read-only');
121+
throw new Forbidden('Deleting tasks via CalDAV is not supported');
82122
}
83123

84124
public function getName() {

lib/DAV/DeckCalendarBackend.php

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,16 @@
1111

1212
use OCA\Deck\Db\Board;
1313
use OCA\Deck\Db\BoardMapper;
14+
use OCA\Deck\Db\Card;
15+
use OCA\Deck\Model\OptionalNullableValue;
1416
use OCA\Deck\Service\BoardService;
1517
use OCA\Deck\Service\CardService;
1618
use OCA\Deck\Service\PermissionService;
1719
use OCA\Deck\Service\StackService;
1820
use Sabre\DAV\Exception\NotFound;
21+
use Sabre\VObject\Component\VTodo;
22+
use Sabre\VObject\InvalidDataException;
23+
use Sabre\VObject\Reader;
1924

2025
class DeckCalendarBackend {
2126

@@ -29,6 +34,8 @@ class DeckCalendarBackend {
2934
private $permissionService;
3035
/** @var BoardMapper */
3136
private $boardMapper;
37+
/** @var array<int, array<int, bool>> */
38+
private $permissionCache = [];
3239

3340
public function __construct(
3441
BoardService $boardService, StackService $stackService, CardService $cardService, PermissionService $permissionService,
@@ -54,8 +61,11 @@ public function getBoard(int $id): Board {
5461
}
5562

5663
public function checkBoardPermission(int $id, int $permission): bool {
57-
$permissions = $this->permissionService->getPermissions($id);
58-
return isset($permissions[$permission]) ? $permissions[$permission] : false;
64+
if (!isset($this->permissionCache[$id])) {
65+
$this->permissionCache[$id] = $this->permissionService->getPermissions($id);
66+
}
67+
68+
return $this->permissionCache[$id][$permission] ?? false;
5969
}
6070

6171
public function updateBoard(Board $board): bool {
@@ -69,4 +79,78 @@ public function getChildren(int $id): array {
6979
$this->stackService->findCalendarEntries($id)
7080
);
7181
}
82+
83+
public function updateCardFromCalendarObject(Card $sourceCard, string $data): Card {
84+
$todo = $this->extractTodo($data);
85+
$card = $this->cardService->find($sourceCard->getId());
86+
87+
$title = trim((string)($todo->SUMMARY ?? ''));
88+
if ($title === '') {
89+
$title = $card->getTitle();
90+
}
91+
92+
$description = isset($todo->DESCRIPTION) ? (string)$todo->DESCRIPTION : $card->getDescription();
93+
$dueDate = isset($todo->DUE) ? $todo->DUE->getDateTime()->format('c') : null;
94+
$startDate = $card->getStartdate() ? $card->getStartdate()->format('c') : null;
95+
96+
return $this->cardService->update(
97+
id: $card->getId(),
98+
title: $title,
99+
stackId: $card->getStackId(),
100+
type: $card->getType(),
101+
owner: $card->getOwner() ?? '',
102+
description: $description,
103+
order: $card->getOrder(),
104+
duedate: $dueDate,
105+
deletedAt: $card->getDeletedAt(),
106+
archived: $card->getArchived(),
107+
done: $this->mapDoneFromTodo($todo, $card),
108+
startdate: $startDate,
109+
color: $card->getColor()
110+
);
111+
}
112+
113+
private function extractTodo(string $data): VTodo {
114+
try {
115+
$vObject = Reader::read($data);
116+
} catch (\Exception $e) {
117+
throw new InvalidDataException('Invalid calendar payload', 0, $e);
118+
}
119+
120+
$todos = $vObject->select('VTODO');
121+
if (count($todos) !== 1 || !($todos[0] instanceof VTodo)) {
122+
throw new InvalidDataException('Calendar payload must contain exactly one VTODO');
123+
}
124+
125+
return $todos[0];
126+
}
127+
128+
private function mapDoneFromTodo(VTodo $todo, Card $card): OptionalNullableValue {
129+
$done = $card->getDone();
130+
$percentComplete = isset($todo->{'PERCENT-COMPLETE'}) ? (int)((string)$todo->{'PERCENT-COMPLETE'}) : null;
131+
$status = isset($todo->STATUS) ? strtoupper((string)$todo->STATUS) : null;
132+
133+
// Deck only has a binary done state. IN-PROCESS maps to not done;
134+
// statuses without a Deck equivalent, such as CANCELLED, keep the
135+
// existing done value instead of inventing a new state.
136+
if ($status === 'COMPLETED') {
137+
$done = $this->computeDoneTimestamp($todo);
138+
} elseif ($status === 'NEEDS-ACTION' || $status === 'IN-PROCESS') {
139+
$done = null;
140+
} elseif ($status === null) {
141+
if (isset($todo->COMPLETED) || ($percentComplete !== null && $percentComplete >= 100)) {
142+
$done = $this->computeDoneTimestamp($todo);
143+
} elseif ($percentComplete !== null && $percentComplete === 0) {
144+
$done = null;
145+
}
146+
}
147+
148+
return new OptionalNullableValue($done);
149+
}
150+
151+
private function computeDoneTimestamp(VTodo $todo): \DateTime {
152+
return isset($todo->COMPLETED)
153+
? \DateTime::createFromInterface($todo->COMPLETED->getDateTime())
154+
: new \DateTime();
155+
}
72156
}

tests/psalm-baseline.xml

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,6 @@
3535
<code><![CDATA[NotFound]]></code>
3636
</UndefinedClass>
3737
</file>
38-
<file src="lib/Db/Card.php">
39-
<UndefinedClass>
40-
<code><![CDATA[VCalendar]]></code>
41-
<code><![CDATA[VCalendar]]></code>
42-
</UndefinedClass>
43-
</file>
44-
<file src="lib/Db/Stack.php">
45-
<UndefinedClass>
46-
<code><![CDATA[VCalendar]]></code>
47-
<code><![CDATA[VCalendar]]></code>
48-
</UndefinedClass>
49-
</file>
5038
<file src="lib/Service/FileService.php">
5139
<RedundantCondition>
5240
<code><![CDATA[is_resource($content)]]></code>

tests/stub.phpstub

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,3 +725,45 @@ namespace OCA\NotifyPush\Queue {
725725
namespace OCA\Text\Event {
726726
class LoadEditor extends \OCP\EventDispatcher\Event {}
727727
}
728+
729+
namespace Sabre\VObject {
730+
class InvalidDataException extends \Exception {}
731+
732+
class Reader {
733+
public static function read(string $data): Component\VCalendar {}
734+
}
735+
}
736+
737+
namespace Sabre\VObject\Component {
738+
class VCalendar {
739+
/** @return array<int, mixed> */
740+
public function select(string $name): array {}
741+
public function createComponent(string $name): VTodo {}
742+
public function add(mixed $value): void {}
743+
public function serialize(): string {}
744+
public function destroy(): void {}
745+
}
746+
747+
class VTodo {
748+
public function __get(string $name): mixed {}
749+
public function __set(string $name, mixed $value): void {}
750+
public function __isset(string $name): bool {}
751+
public function add(string $name, mixed $value): void {}
752+
/** @var mixed */
753+
public $SUMMARY;
754+
/** @var mixed */
755+
public $DESCRIPTION;
756+
/** @var mixed */
757+
public $DUE;
758+
/** @var mixed */
759+
public $COMPLETED;
760+
/** @var mixed */
761+
public $STATUS;
762+
}
763+
}
764+
765+
namespace Sabre\VObject\Property {
766+
class DateTime {
767+
public function getDateTime(): \DateTimeInterface {}
768+
}
769+
}

0 commit comments

Comments
 (0)