Skip to content

Commit bff7725

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 327ec9c commit bff7725

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
@@ -28,6 +28,8 @@ class Calendar extends ExternalCalendar {
2828
private $backend;
2929
/** @var Board */
3030
private $board;
31+
/** @var array|null */
32+
private $acl;
3133

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

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

61-
return $acl;
83+
$this->acl = $acl;
84+
return $this->acl;
6285
}
6386

6487
public function setACL(array $acl) {

lib/DAV/CalendarObject.php

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@
66
*/
77
namespace OCA\Deck\DAV;
88

9+
use OCA\Deck\BadRequestException;
910
use OCA\Deck\Db\Card;
1011
use OCA\Deck\Db\Stack;
12+
use OCA\Deck\StatusException;
13+
use OCP\AppFramework\Db\DoesNotExistException;
1114
use Sabre\CalDAV\ICalendarObject;
15+
use Sabre\DAV\Exception\BadRequest;
1216
use Sabre\DAV\Exception\Forbidden;
17+
use Sabre\DAV\Exception\NotFound;
1318
use Sabre\DAVACL\IACL;
1419
use Sabre\VObject\Component\VCalendar;
1520

@@ -43,7 +48,14 @@ public function getGroup() {
4348
}
4449

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

4961
public function setACL(array $acl) {
@@ -55,7 +67,35 @@ public function getSupportedPrivilegeSet() {
5567
}
5668

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

61101
public function get() {
@@ -77,7 +117,7 @@ public function getSize() {
77117
}
78118

79119
public function delete() {
80-
throw new Forbidden('This calendar-object is read-only');
120+
throw new Forbidden('Deleting tasks via CalDAV is not supported');
81121
}
82122

83123
public function getName() {

lib/DAV/DeckCalendarBackend.php

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

1313
use OCA\Deck\Db\Board;
1414
use OCA\Deck\Db\BoardMapper;
15+
use OCA\Deck\Db\Card;
16+
use OCA\Deck\Model\OptionalNullableValue;
1517
use OCA\Deck\Service\BoardService;
1618
use OCA\Deck\Service\CardService;
1719
use OCA\Deck\Service\PermissionService;
1820
use OCA\Deck\Service\StackService;
1921
use Sabre\DAV\Exception\NotFound;
22+
use Sabre\VObject\Component\VTodo;
23+
use Sabre\VObject\InvalidDataException;
24+
use Sabre\VObject\Reader;
2025

2126
class DeckCalendarBackend {
2227

@@ -30,6 +35,8 @@ class DeckCalendarBackend {
3035
private $permissionService;
3136
/** @var BoardMapper */
3237
private $boardMapper;
38+
/** @var array<int, array<int, bool>> */
39+
private $permissionCache = [];
3340

3441
public function __construct(
3542
BoardService $boardService, StackService $stackService, CardService $cardService, PermissionService $permissionService,
@@ -55,8 +62,11 @@ public function getBoard(int $id): Board {
5562
}
5663

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

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

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)