Skip to content
Open
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
29 changes: 26 additions & 3 deletions lib/DAV/Calendar.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ class Calendar extends ExternalCalendar {
private $backend;
/** @var Board */
private $board;
/** @var array|null */
private $acl;

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

/**
* Nextcloud's DAV Schedule Plugin calls this for writable external
* calendars. Deck calendars are not shared scheduling calendars, and Deck
* does not process iTIP/ATTENDEE scheduling data here.
*/
public function isShared(): bool {
return false;
}

public function getACL() {
// the calendar should always have the read and the write-properties permissions
// write-properties is needed to allow the user to toggle the visibility of shared deck calendars
if ($this->acl !== null) {
return $this->acl;
}

// write-content covers PUT on existing objects but not bind/unbind, so
// CREATE and DELETE are rejected at the ACL layer before any hooks run.
$acl = [
[
'privilege' => '{DAV:}read',
Expand All @@ -57,8 +72,16 @@ public function getACL() {
'protected' => true,
]
];
if ($this->backend->checkBoardPermission($this->board->getId(), Acl::PERMISSION_EDIT)) {
$acl[] = [
'privilege' => '{DAV:}write-content',
'principal' => $this->getOwner(),
'protected' => true,
];
}

return $acl;
$this->acl = $acl;
return $this->acl;
}

public function setACL(array $acl) {
Expand Down
46 changes: 43 additions & 3 deletions lib/DAV/CalendarObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
*/
namespace OCA\Deck\DAV;

use OCA\Deck\BadRequestException;
use OCA\Deck\Db\Card;
use OCA\Deck\Db\Stack;
use OCA\Deck\StatusException;
use OCP\AppFramework\Db\DoesNotExistException;
use Sabre\CalDAV\ICalendarObject;
use Sabre\DAV\Exception\BadRequest;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAVACL\IACL;
use Sabre\VObject\Component\VCalendar;

Expand Down Expand Up @@ -43,7 +48,14 @@ public function getGroup() {
}

public function getACL() {
return $this->calendar->getACL();
$acl = $this->calendar->getACL();
if ($this->sourceItem instanceof Stack) {
return array_values(array_filter($acl, static function (array $entry): bool {
return $entry['privilege'] !== '{DAV:}write-content';
}));
}

return $acl;
}

public function setACL(array $acl) {
Expand All @@ -55,7 +67,35 @@ public function getSupportedPrivilegeSet() {
}

public function put($data) {
throw new Forbidden('This calendar-object is read-only');
if (!($this->sourceItem instanceof Card)) {
throw new Forbidden('This calendar-object is read-only');
}

// MultipleObjectsReturnedException is intentionally not caught:
// a primary-key lookup returning multiple rows is a data integrity bug
// and should surface as a 500 in the log.
try {
$this->sourceItem = $this->backend->updateCardFromCalendarObject($this->sourceItem, $this->readPutData($data));
} catch (DoesNotExistException $e) {
throw new NotFound($e->getMessage(), 0, $e);
} catch (BadRequestException $e) {
throw new BadRequest($e->getMessage(), 0, $e);
} catch (StatusException $e) {
throw new Forbidden($e->getMessage(), 0, $e);
}
$this->calendarObject = $this->sourceItem->getCalendarObject();
}

private function readPutData($data): string {
if (is_resource($data)) {
$content = stream_get_contents($data);
if ($content === false) {
throw new BadRequest('Could not read calendar-object data');
}
return $content;
}

return (string)$data;
}

public function get() {
Expand All @@ -77,7 +117,7 @@ public function getSize() {
}

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

public function getName() {
Expand Down
88 changes: 86 additions & 2 deletions lib/DAV/DeckCalendarBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@

use OCA\Deck\Db\Board;
use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Db\Card;
use OCA\Deck\Model\OptionalNullableValue;
use OCA\Deck\Service\BoardService;
use OCA\Deck\Service\CardService;
use OCA\Deck\Service\PermissionService;
use OCA\Deck\Service\StackService;
use Sabre\DAV\Exception\NotFound;
use Sabre\VObject\Component\VTodo;
use Sabre\VObject\InvalidDataException;
use Sabre\VObject\Reader;

class DeckCalendarBackend {

Expand All @@ -30,6 +35,8 @@ class DeckCalendarBackend {
private $permissionService;
/** @var BoardMapper */
private $boardMapper;
/** @var array<int, array<int, bool>> */
private $permissionCache = [];

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

public function checkBoardPermission(int $id, int $permission): bool {
$permissions = $this->permissionService->getPermissions($id);
return isset($permissions[$permission]) ? $permissions[$permission] : false;
if (!isset($this->permissionCache[$id])) {
$this->permissionCache[$id] = $this->permissionService->getPermissions($id);
}

return $this->permissionCache[$id][$permission] ?? false;
}

public function updateBoard(Board $board): bool {
Expand All @@ -70,4 +80,78 @@ public function getChildren(int $id): array {
$this->stackService->findCalendarEntries($id)
);
}

public function updateCardFromCalendarObject(Card $sourceCard, string $data): Card {
$todo = $this->extractTodo($data);
$card = $this->cardService->find($sourceCard->getId());

$title = trim((string)($todo->SUMMARY ?? ''));
if ($title === '') {
$title = $card->getTitle();
}

$description = isset($todo->DESCRIPTION) ? (string)$todo->DESCRIPTION : $card->getDescription();
$dueDate = isset($todo->DUE) ? $todo->DUE->getDateTime()->format('c') : null;
$startDate = $card->getStartdate() ? $card->getStartdate()->format('c') : null;

return $this->cardService->update(
id: $card->getId(),
title: $title,
stackId: $card->getStackId(),
type: $card->getType(),
owner: $card->getOwner() ?? '',
description: $description,
order: $card->getOrder(),
duedate: $dueDate,
deletedAt: $card->getDeletedAt(),
archived: $card->getArchived(),
done: $this->mapDoneFromTodo($todo, $card),
startdate: $startDate,
color: $card->getColor()
);
}

private function extractTodo(string $data): VTodo {
try {
$vObject = Reader::read($data);
} catch (\Exception $e) {
throw new InvalidDataException('Invalid calendar payload', 0, $e);
}

$todos = $vObject->select('VTODO');
if (count($todos) !== 1 || !($todos[0] instanceof VTodo)) {
throw new InvalidDataException('Calendar payload must contain exactly one VTODO');
}

return $todos[0];
}

private function mapDoneFromTodo(VTodo $todo, Card $card): OptionalNullableValue {
$done = $card->getDone();
$percentComplete = isset($todo->{'PERCENT-COMPLETE'}) ? (int)((string)$todo->{'PERCENT-COMPLETE'}) : null;
$status = isset($todo->STATUS) ? strtoupper((string)$todo->STATUS) : null;

// Deck only has a binary done state. IN-PROCESS maps to not done;
// statuses without a Deck equivalent, such as CANCELLED, keep the
// existing done value instead of inventing a new state.
if ($status === 'COMPLETED') {
$done = $this->computeDoneTimestamp($todo);
} elseif ($status === 'NEEDS-ACTION' || $status === 'IN-PROCESS') {
$done = null;
} elseif ($status === null) {
if (isset($todo->COMPLETED) || ($percentComplete !== null && $percentComplete >= 100)) {
$done = $this->computeDoneTimestamp($todo);
} elseif ($percentComplete !== null && $percentComplete === 0) {
$done = null;
}
}

return new OptionalNullableValue($done);
}

private function computeDoneTimestamp(VTodo $todo): \DateTime {
return isset($todo->COMPLETED)
? \DateTime::createFromInterface($todo->COMPLETED->getDateTime())
: new \DateTime();
}
}
2 changes: 1 addition & 1 deletion lib/Db/Card.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ public function getCalendarObject(): VCalendar {
$event->UID = 'deck-card-' . $this->getId();
if ($this->getDuedate()) {
$creationDate = new DateTime();
$creationDate->setTimestamp($this->createdAt);
$creationDate->setTimestamp($this->getCreatedAt());
$event->DTSTAMP = $creationDate;
$event->DUE = new DateTime($this->getDuedate()->format('c'), new DateTimeZone('UTC'));
}
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/base-query-count.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
93102
96882
12 changes: 0 additions & 12 deletions tests/psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,6 @@
<code><![CDATA[NotFound]]></code>
</UndefinedClass>
</file>
<file src="lib/Db/Card.php">
<UndefinedClass>
<code><![CDATA[VCalendar]]></code>
<code><![CDATA[VCalendar]]></code>
</UndefinedClass>
</file>
<file src="lib/Db/Stack.php">
<UndefinedClass>
<code><![CDATA[VCalendar]]></code>
<code><![CDATA[VCalendar]]></code>
</UndefinedClass>
</file>
<file src="lib/Service/FileService.php">
<RedundantCondition>
<code><![CDATA[is_resource($content)]]></code>
Expand Down
42 changes: 42 additions & 0 deletions tests/stub.phpstub
Original file line number Diff line number Diff line change
Expand Up @@ -725,3 +725,45 @@ namespace OCA\NotifyPush\Queue {
namespace OCA\Text\Event {
class LoadEditor extends \OCP\EventDispatcher\Event {}
}

namespace Sabre\VObject {
class InvalidDataException extends \Exception {}

class Reader {
public static function read(string $data): Component\VCalendar {}
}
}

namespace Sabre\VObject\Component {
class VCalendar {
/** @return array<int, mixed> */
public function select(string $name): array {}
public function createComponent(string $name): VTodo {}
public function add(mixed $value): void {}
public function serialize(): string {}
public function destroy(): void {}
}

class VTodo {
public function __get(string $name): mixed {}
public function __set(string $name, mixed $value): void {}
public function __isset(string $name): bool {}
public function add(string $name, mixed $value): void {}
/** @var mixed */
public $SUMMARY;
/** @var mixed */
public $DESCRIPTION;
/** @var mixed */
public $DUE;
/** @var mixed */
public $COMPLETED;
/** @var mixed */
public $STATUS;
}
}

namespace Sabre\VObject\Property {
class DateTime {
public function getDateTime(): \DateTimeInterface {}
}
}
Loading