Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>pull/55178/head
parent
afb421ea33
commit
06ea3906eb
@ -0,0 +1,35 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
/** |
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
|
||||
namespace OCA\DAV\CalDAV\Import; |
||||
|
||||
final readonly class ImportCountEvent implements ImportEvent { |
||||
public function __construct( |
||||
public int $vevent, |
||||
public int $vtodo, |
||||
public int $vjournal, |
||||
) { |
||||
} |
||||
|
||||
public function total(): int { |
||||
return $this->vevent + $this->vtodo + $this->vjournal; |
||||
} |
||||
|
||||
/** |
||||
* @return array{type: 'count', vevent: int, vtodo: int, vjournal: int} |
||||
*/ |
||||
#[\Override] |
||||
public function jsonSerialize(): array { |
||||
return [ |
||||
'type' => 'count', |
||||
'vevent' => $this->vevent, |
||||
'vtodo' => $this->vtodo, |
||||
'vjournal' => $this->vjournal, |
||||
]; |
||||
} |
||||
} |
||||
@ -0,0 +1,16 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
/** |
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
|
||||
namespace OCA\DAV\CalDAV\Import; |
||||
|
||||
enum ImportDisposition: string { |
||||
case Created = 'created'; |
||||
case Updated = 'updated'; |
||||
case Exists = 'exists'; |
||||
case Error = 'error'; |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
/** |
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
|
||||
namespace OCA\DAV\CalDAV\Import; |
||||
|
||||
use JsonSerializable; |
||||
|
||||
interface ImportEvent extends JsonSerializable { |
||||
} |
||||
@ -0,0 +1,40 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
/** |
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
|
||||
namespace OCA\DAV\CalDAV\Import; |
||||
|
||||
final readonly class ImportObjectEvent implements ImportEvent { |
||||
/** |
||||
* @param list<string> $errors |
||||
*/ |
||||
public function __construct( |
||||
public ?string $identifier, |
||||
public ImportDisposition $disposition, |
||||
public array $errors = [], |
||||
) { |
||||
} |
||||
|
||||
public function isError(): bool { |
||||
return $this->disposition === ImportDisposition::Error; |
||||
} |
||||
|
||||
/** |
||||
* @return array{type: 'object', identifier: ?string, disposition: string, errors: list<string>} |
||||
*/ |
||||
#[\Override] |
||||
public function jsonSerialize(): array { |
||||
$result = [ |
||||
'type' => 'object', |
||||
'identifier' => $this->identifier, |
||||
'disposition' => $this->disposition->value, |
||||
'errors' => $this->errors, |
||||
]; |
||||
|
||||
return $result; |
||||
} |
||||
} |
||||
@ -0,0 +1,107 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
/** |
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
|
||||
namespace OCA\DAV\Controller; |
||||
|
||||
use OCA\DAV\AppInfo\Application; |
||||
use OCA\DAV\CalDAV\Export\ExportService; |
||||
use OCP\AppFramework\Http; |
||||
use OCP\AppFramework\Http\Attribute\ApiRoute; |
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired; |
||||
use OCP\AppFramework\Http\Attribute\UserRateLimit; |
||||
use OCP\AppFramework\Http\DataResponse; |
||||
use OCP\AppFramework\Http\StreamGeneratorResponse; |
||||
use OCP\AppFramework\OCSController; |
||||
use OCP\Calendar\CalendarExportOptions; |
||||
use OCP\Calendar\ICalendarExport; |
||||
use OCP\Calendar\IManager; |
||||
use OCP\IGroupManager; |
||||
use OCP\IRequest; |
||||
use OCP\IUserManager; |
||||
use OCP\IUserSession; |
||||
|
||||
class CalendarExportController extends OCSController { |
||||
|
||||
public function __construct( |
||||
IRequest $request, |
||||
private IUserSession $userSession, |
||||
private IUserManager $userManager, |
||||
private IGroupManager $groupManager, |
||||
private IManager $calendarManager, |
||||
private ExportService $exportService, |
||||
) { |
||||
parent::__construct(Application::APP_ID, $request); |
||||
} |
||||
|
||||
/** |
||||
* Export calendar data |
||||
* |
||||
* @param string $target calendar id |
||||
* @param string|null $type data format |
||||
* @param array{rangeStart:string,rangeCount:positive-int} $options configuration options |
||||
* @param string|null $user system user id |
||||
* |
||||
* @return StreamGeneratorResponse<Http::STATUS_OK, array{Content-Type:'text/calendar; charset=UTF-8'|'application/calendar+json; charset=UTF-8'|'application/calendar+xml; charset=UTF-8'}> | DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED, array{error?: non-empty-string}, array{}> |
||||
* |
||||
* 200: data in requested format |
||||
* 400: invalid parameters |
||||
* 401: user not authorized |
||||
*/ |
||||
#[ApiRoute(verb: 'POST', url: '/export', root: '/calendar')] |
||||
#[UserRateLimit(limit: 1, period: 60)] |
||||
#[NoAdminRequired] |
||||
public function export(string $target, ?string $type = null, ?array $options = null, ?string $user = null) { |
||||
$calendarId = $target; |
||||
$format = $type ?? 'ical'; |
||||
$rangeStart = isset($options['rangeStart']) ? (string)$options['rangeStart'] : null; |
||||
$rangeCount = isset($options['rangeCount']) ? (int)$options['rangeCount'] : null; |
||||
// evaluate if user is logged in and has permissions |
||||
if (!$this->userSession->isLoggedIn()) { |
||||
return new DataResponse([], Http::STATUS_UNAUTHORIZED); |
||||
} |
||||
if ($user !== null) { |
||||
if ($this->userSession->getUser()->getUID() !== $user |
||||
&& $this->groupManager->isAdmin($this->userSession->getUser()->getUID()) === false) { |
||||
return new DataResponse([], Http::STATUS_UNAUTHORIZED); |
||||
} |
||||
if (!$this->userManager->userExists($user)) { |
||||
return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST); |
||||
} |
||||
} else { |
||||
$userId = $this->userSession->getUser()->getUID(); |
||||
} |
||||
// retrieve calendar and evaluate if export is supported |
||||
$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); |
||||
if ($calendars === []) { |
||||
return new DataResponse(['error' => 'calendar not found'], Http::STATUS_BAD_REQUEST); |
||||
} |
||||
$calendar = $calendars[0]; |
||||
if (!$calendar instanceof ICalendarExport) { |
||||
return new DataResponse(['error' => 'calendar export not supported'], Http::STATUS_BAD_REQUEST); |
||||
} |
||||
// construct options object |
||||
$options = new CalendarExportOptions(); |
||||
$options->setRangeStart($rangeStart); |
||||
$options->setRangeCount($rangeCount); |
||||
// evaluate if provided format is supported |
||||
if (!in_array($format, ExportService::FORMATS, true)) { |
||||
return new DataResponse(['error' => "Format <$format> is not valid."], Http::STATUS_BAD_REQUEST); |
||||
} |
||||
$options->setFormat($format); |
||||
// construct response |
||||
$contentType = match (strtolower($options->getFormat())) { |
||||
'jcal' => 'application/calendar+json; charset=UTF-8', |
||||
'xcal' => 'application/calendar+xml; charset=UTF-8', |
||||
default => 'text/calendar; charset=UTF-8' |
||||
}; |
||||
$response = new StreamGeneratorResponse($this->exportService->export($calendar, $options), $contentType, Http::STATUS_OK); |
||||
$response->cacheFor(0); |
||||
|
||||
return $response; |
||||
} |
||||
} |
||||
@ -0,0 +1,149 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
/** |
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
|
||||
namespace OCA\DAV\Controller; |
||||
|
||||
use InvalidArgumentException; |
||||
/** |
||||
* @psalm-type CalendarImportResult = array{items: list<string>, total: non-negative-int} |
||||
*/ |
||||
use OCA\DAV\AppInfo\Application; |
||||
use OCA\DAV\CalDAV\CalendarImpl; |
||||
use OCA\DAV\CalDAV\Import\ImportService; |
||||
use OCP\AppFramework\Http; |
||||
use OCP\AppFramework\Http\Attribute\ApiRoute; |
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired; |
||||
use OCP\AppFramework\Http\Attribute\UserRateLimit; |
||||
use OCP\AppFramework\Http\DataResponse; |
||||
use OCP\AppFramework\Http\StreamGeneratorResponse; |
||||
use OCP\AppFramework\OCSController; |
||||
use OCP\Calendar\CalendarImportOptions; |
||||
use OCP\Calendar\IManager; |
||||
use OCP\IGroupManager; |
||||
use OCP\IRequest; |
||||
use OCP\ITempManager; |
||||
use OCP\IUserManager; |
||||
use OCP\IUserSession; |
||||
|
||||
class CalendarImportController extends OCSController { |
||||
|
||||
public function __construct( |
||||
IRequest $request, |
||||
private IUserSession $userSession, |
||||
private IUserManager $userManager, |
||||
private IGroupManager $groupManager, |
||||
private ITempManager $tempManager, |
||||
private IManager $calendarManager, |
||||
private ImportService $importService, |
||||
) { |
||||
parent::__construct(Application::APP_ID, $request); |
||||
} |
||||
|
||||
/** |
||||
* Import calendar data |
||||
* |
||||
* @param string $transaction client generated transaction id |
||||
* @param string $target calendar id |
||||
* @param array{format?:string, validation?:0|1|2, errors?:0|1, supersede?:bool, showCreated?:bool, showUpdated?:bool, showSkipped?:bool, showErrors?:bool} $options configuration options |
||||
* @param string $data calendar data |
||||
* @param string|null $user system user id |
||||
* |
||||
* @return StreamGeneratorResponse<Http::STATUS_OK, array{Content-Type:'application/x-ndjson'}> | DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED, array{error?: non-empty-string}, array{}> |
||||
* |
||||
* 200: NDJSON stream of import event objects |
||||
* 400: invalid parameters |
||||
* 401: user not authorized |
||||
*/ |
||||
#[ApiRoute(verb: 'POST', url: '/import', root: '/calendar')] |
||||
#[UserRateLimit(limit: 10, period: 3600)] |
||||
#[NoAdminRequired] |
||||
public function import(string $transaction, string $target, array $options, string $data, ?string $user = null): DataResponse|StreamGeneratorResponse { |
||||
$calendarId = $target; |
||||
$format = isset($options['format']) ? $options['format'] : null; |
||||
$validation = isset($options['validation']) ? (int)$options['validation'] : null; |
||||
$errors = isset($options['errors']) ? (int)$options['errors'] : null; |
||||
$supersede = $options['supersede'] ?? false; |
||||
// evaluate if user is logged in and has permissions |
||||
if (!$this->userSession->isLoggedIn()) { |
||||
return new DataResponse([], Http::STATUS_UNAUTHORIZED); |
||||
} |
||||
if ($user !== null) { |
||||
if ($this->userSession->getUser()->getUID() !== $user |
||||
&& $this->groupManager->isAdmin($this->userSession->getUser()->getUID()) === false) { |
||||
return new DataResponse([], Http::STATUS_UNAUTHORIZED); |
||||
} |
||||
if (!$this->userManager->userExists($user)) { |
||||
return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST); |
||||
} |
||||
$userId = $user; |
||||
} else { |
||||
$userId = $this->userSession->getUser()->getUID(); |
||||
} |
||||
// retrieve calendar and evaluate if import is supported and writeable |
||||
$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); |
||||
if ($calendars === []) { |
||||
return new DataResponse(['error' => "Calendar <$calendarId> not found"], Http::STATUS_BAD_REQUEST); |
||||
} |
||||
$calendar = $calendars[0]; |
||||
if (!$calendar instanceof CalendarImpl) { |
||||
return new DataResponse(['error' => "Calendar <$calendarId> does not support this function"], Http::STATUS_BAD_REQUEST); |
||||
} |
||||
if (!$calendar->isWritable()) { |
||||
return new DataResponse(['error' => "Calendar <$calendarId> is not writeable"], Http::STATUS_BAD_REQUEST); |
||||
} |
||||
if ($calendar->isDeleted()) { |
||||
return new DataResponse(['error' => "Calendar <$calendarId> is deleted"], Http::STATUS_BAD_REQUEST); |
||||
} |
||||
// construct options object |
||||
$options = new CalendarImportOptions(); |
||||
$options->setSupersede($supersede); |
||||
if ($errors !== null) { |
||||
try { |
||||
$options->setErrors($errors); |
||||
} catch (InvalidArgumentException) { |
||||
return new DataResponse(['error' => 'Invalid errors option specified'], Http::STATUS_BAD_REQUEST); |
||||
} |
||||
} |
||||
if ($validation !== null) { |
||||
try { |
||||
$options->setValidate($validation); |
||||
} catch (InvalidArgumentException) { |
||||
return new DataResponse(['error' => 'Invalid validation option specified'], Http::STATUS_BAD_REQUEST); |
||||
} |
||||
} |
||||
try { |
||||
$options->setFormat($format ?? 'ical'); |
||||
} catch (InvalidArgumentException) { |
||||
return new DataResponse(['error' => 'Invalid format option specified'], Http::STATUS_BAD_REQUEST); |
||||
} |
||||
$options->setCounts(true); |
||||
// process the data |
||||
$tempPath = $this->tempManager->getTemporaryFile(); |
||||
$tempFile = fopen($tempPath, 'w+'); |
||||
fwrite($tempFile, $data); |
||||
unset($data); |
||||
fseek($tempFile, 0); |
||||
|
||||
$importGenerator = $this->importService->import($tempFile, $calendar, $options); |
||||
$stream = (function () use ($importGenerator, $tempFile, $transaction): \Generator { |
||||
yield json_encode(['type' => 'control', 'transaction' => $transaction, 'disposition' => 'start']) . "\n"; |
||||
try { |
||||
foreach ($importGenerator as $result) { |
||||
$data = $result->jsonSerialize(); |
||||
$data['transaction'] = $transaction; |
||||
yield json_encode($data) . PHP_EOL; |
||||
} |
||||
} finally { |
||||
yield json_encode(['type' => 'control', 'transaction' => $transaction, 'disposition' => 'end']) . "\n"; |
||||
fclose($tempFile); |
||||
} |
||||
})(); |
||||
|
||||
return new StreamGeneratorResponse($stream, 'application/x-ndjson'); |
||||
} |
||||
} |
||||
@ -0,0 +1,63 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/** |
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
|
||||
namespace OCP\AppFramework\Http; |
||||
|
||||
use Generator; |
||||
use OCP\AppFramework\Http; |
||||
|
||||
/** |
||||
* @since 35.0.0 |
||||
* |
||||
* @template S of Http::STATUS_* |
||||
* @template H of array<string, mixed> |
||||
* @template-extends Response<Http::STATUS_*, array<string, mixed>> |
||||
*/ |
||||
class StreamGeneratorResponse extends Response implements ICallbackResponse { |
||||
protected $generator; |
||||
|
||||
/** |
||||
* @since 35.0.0 |
||||
* |
||||
* @param Generator $generator the function to call to generate the response |
||||
* @param string $contentType http response content type e.g. 'application/json; charset=UTF-8' |
||||
* @param S $status http response status |
||||
* @param array|null $headers additional headers |
||||
*/ |
||||
public function __construct(Generator $generator, string $contentType, int $status = Http::STATUS_OK, ?array $headers = []) { |
||||
parent::__construct(); |
||||
|
||||
$this->generator = $generator; |
||||
|
||||
$this->setStatus($status); |
||||
$this->addHeader('Content-Type', $contentType); |
||||
|
||||
foreach ($headers as $key => $value) { |
||||
$this->addHeader($key, $value); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Streams content directly to client |
||||
* |
||||
* @since 35.0.0 |
||||
* |
||||
* @param IOutput $output a small wrapper that handles output |
||||
*/ |
||||
#[\Override] |
||||
public function callback(IOutput $output): void { |
||||
|
||||
foreach ($this->generator as $chunk) { |
||||
print($chunk); |
||||
flush(); |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,46 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
/** |
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
|
||||
namespace Test\AppFramework\Http; |
||||
|
||||
use OCP\AppFramework\Http\StreamGeneratorResponse; |
||||
|
||||
class StreamGeneratorResponseTest extends \Test\TestCase { |
||||
|
||||
protected function setUp(): void { |
||||
parent::setUp(); |
||||
} |
||||
|
||||
public function testConstructor() { |
||||
$generator = function () { |
||||
yield 'chunk1'; |
||||
yield 'chunk2'; |
||||
}; |
||||
$response = new StreamGeneratorResponse($generator(), 'text/plain'); |
||||
|
||||
$headers = $response->getHeaders(); |
||||
$this->assertEquals('text/plain', $headers['Content-Type']); |
||||
$this->assertEquals(200, $response->getStatus()); |
||||
} |
||||
|
||||
public function testCallback() { |
||||
$count = 0; |
||||
$generator = function () use (&$count) { |
||||
$count++; |
||||
yield 'chunk1'; |
||||
$count++; |
||||
yield 'chunk2'; |
||||
}; |
||||
$response = new StreamGeneratorResponse($generator(), 'text/plain'); |
||||
$output = $this->createMock(\OCP\AppFramework\Http\IOutput::class); |
||||
|
||||
$response->callback($output); |
||||
$this->assertEquals($count, 2); |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue