diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index 72811eb044cdf..58b3270cc3c60 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -33,6 +33,7 @@ use OCP\TaskProcessing\Exception\PreConditionNotMetException; use OCP\TaskProcessing\Exception\UnauthorizedException; use OCP\TaskProcessing\Exception\ValidationException; +use OCP\TaskProcessing\FileShaped; use OCP\TaskProcessing\IManager; use OCP\TaskProcessing\ShapeEnumValue; use OCP\TaskProcessing\Task; @@ -534,7 +535,8 @@ public function setFileContentsExApp(int $taskId): DataResponse { if (!$handle) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } - $fileId = $this->setFileContentsInternal($handle); + $ext = pathinfo(($file['name'] ?? ''), PATHINFO_EXTENSION); + $fileId = $this->setFileContentsInternal($handle, $ext); return new DataResponse(['fileId' => $fileId], Http::STATUS_CREATED); } catch (NotFoundException) { return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); @@ -854,14 +856,15 @@ public function getNextScheduledTaskBatch(array $providerIds, array $taskTypeIds * @return int * @throws NotPermittedException */ - private function setFileContentsInternal($data): int { + private function setFileContentsInternal($data, string $ext = ''): int { try { $folder = $this->appData->getFolder('TaskProcessing'); } catch (\OCP\Files\NotFoundException) { $folder = $this->appData->newFolder('TaskProcessing'); } + $ext = FileShaped::sanitizeExtension($ext); /** @var SimpleFile $file */ - $file = $folder->newFile(time() . '-' . rand(1, 100000), $data); + $file = $folder->newFile(time() . '-' . rand(1, 100000) . ($ext ? '.' . $ext : ''), $data); return $file->getId(); } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 83b5dbeb66a9e..657adc33ee673 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -928,6 +928,7 @@ 'OCP\\TaskProcessing\\Exception\\UnauthorizedException' => $baseDir . '/lib/public/TaskProcessing/Exception/UnauthorizedException.php', 'OCP\\TaskProcessing\\Exception\\UserFacingProcessingException' => $baseDir . '/lib/public/TaskProcessing/Exception/UserFacingProcessingException.php', 'OCP\\TaskProcessing\\Exception\\ValidationException' => $baseDir . '/lib/public/TaskProcessing/Exception/ValidationException.php', + 'OCP\\TaskProcessing\\FileShaped' => $baseDir . '/lib/public/TaskProcessing/FileShaped.php', 'OCP\\TaskProcessing\\IInternalTaskType' => $baseDir . '/lib/public/TaskProcessing/IInternalTaskType.php', 'OCP\\TaskProcessing\\IManager' => $baseDir . '/lib/public/TaskProcessing/IManager.php', 'OCP\\TaskProcessing\\IProvider' => $baseDir . '/lib/public/TaskProcessing/IProvider.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 1b0383a23142e..38268c6a5edcc 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -969,6 +969,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\TaskProcessing\\Exception\\UnauthorizedException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/UnauthorizedException.php', 'OCP\\TaskProcessing\\Exception\\UserFacingProcessingException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/UserFacingProcessingException.php', 'OCP\\TaskProcessing\\Exception\\ValidationException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/ValidationException.php', + 'OCP\\TaskProcessing\\FileShaped' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/FileShaped.php', 'OCP\\TaskProcessing\\IInternalTaskType' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IInternalTaskType.php', 'OCP\\TaskProcessing\\IManager' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IManager.php', 'OCP\\TaskProcessing\\IProvider' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IProvider.php', diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 608856fd27017..21a2c6b45796c 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -57,6 +57,7 @@ use OCP\TaskProcessing\Exception\UnauthorizedException; use OCP\TaskProcessing\Exception\UserFacingProcessingException; use OCP\TaskProcessing\Exception\ValidationException; +use OCP\TaskProcessing\FileShaped; use OCP\TaskProcessing\IInternalTaskType; use OCP\TaskProcessing\IManager; use OCP\TaskProcessing\IProvider; @@ -1603,14 +1604,28 @@ public function encapsulateOutputFileData(array $output, ...$specs): array { continue; } if (EShapeType::getScalarType($type) === $type) { + if ($output[$key] instanceof FileShaped) { + $data = $output[$key]->getData(); + $ext = FileShaped::sanitizeExtension($output[$key]->getExtension()); + } else { + $data = $output[$key]; + $ext = ''; + } /** @var SimpleFile $file */ - $file = $folder->newFile(time() . '-' . rand(1, 100000), $output[$key]); + $file = $folder->newFile(time() . '-' . rand(1, 100000) . ($ext ? '.' . $ext : ''), $data); $newOutput[$key] = $file->getId(); // polymorphic call to SimpleFile } else { - $newOutput = []; + $newOutput[$key] = []; foreach ($output[$key] as $item) { + if ($item instanceof FileShaped) { + $data = $item->getData(); + $ext = FileShaped::sanitizeExtension($item->getExtension()); + } else { + $data = $item; + $ext = ''; + } /** @var SimpleFile $file */ - $file = $folder->newFile(time() . '-' . rand(1, 100000), $item); + $file = $folder->newFile(time() . '-' . rand(1, 100000) . ($ext ? '.' . $ext : ''), $data); $newOutput[$key][] = $file->getId(); } } @@ -1757,7 +1772,7 @@ private function validateOutputFileIds(array $output, ...$specs): array { $newOutput[$key] = $this->validateFileId($output[$key]); } else { // Is list of file IDs - $newOutput = []; + $newOutput[$key] = []; foreach ($output[$key] as $item) { $newOutput[$key][] = $this->validateFileId($item); } diff --git a/lib/public/TaskProcessing/EShapeType.php b/lib/public/TaskProcessing/EShapeType.php index c1c0a1ccd4de3..2ea771733012a 100644 --- a/lib/public/TaskProcessing/EShapeType.php +++ b/lib/public/TaskProcessing/EShapeType.php @@ -9,6 +9,7 @@ namespace OCP\TaskProcessing; +use OCP\AppFramework\Attribute\Consumable; use OCP\TaskProcessing\Exception\ValidationException; /** @@ -16,6 +17,7 @@ * * @since 30.0.0 */ +#[Consumable(since: '30.0.0')] enum EShapeType: int { /** * @since 30.0.0 @@ -146,7 +148,7 @@ public function validateInput(mixed $value): void { throw new ValidationException('Non-file item provided for File slot'); } if ($this === EShapeType::ListOfFiles && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) { - throw new ValidationException('Non-audio list item provided for ListOfFiles slot'); + throw new ValidationException('Non-file list item provided for ListOfFiles slot'); } } @@ -156,29 +158,29 @@ public function validateInput(mixed $value): void { */ public function validateOutputWithFileData(mixed $value): void { $this->validateNonFileType($value); - if ($this === EShapeType::Image && !is_string($value)) { + if ($this === EShapeType::Image && !is_string($value) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::Image)) { throw new ValidationException('Non-image item provided for Image slot'); } - if ($this === EShapeType::ListOfImages && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) { + if ($this === EShapeType::ListOfImages && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item) && !($item instanceof FileShaped && $item->getShapeType() === EShapeType::Image))) > 0)) { throw new ValidationException('Non-image list item provided for ListOfImages slot'); } - if ($this === EShapeType::Audio && !is_string($value)) { + if ($this === EShapeType::Audio && !is_string($value) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::Audio)) { throw new ValidationException('Non-audio item provided for Audio slot'); } - if ($this === EShapeType::ListOfAudios && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) { + if ($this === EShapeType::ListOfAudios && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item) && !($item instanceof FileShaped && $item->getShapeType() === EShapeType::Audio))) > 0)) { throw new ValidationException('Non-audio list item provided for ListOfAudio slot'); } - if ($this === EShapeType::Video && !is_string($value)) { + if ($this === EShapeType::Video && !is_string($value) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::Video)) { throw new ValidationException('Non-video item provided for Video slot'); } - if ($this === EShapeType::ListOfVideos && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) { - throw new ValidationException('Non-video list item provided for ListOfTexts slot'); + if ($this === EShapeType::ListOfVideos && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item) && !($item instanceof FileShaped && $item->getShapeType() === EShapeType::Video))) > 0)) { + throw new ValidationException('Non-video list item provided for ListOfVideos slot'); } - if ($this === EShapeType::File && !is_string($value)) { + if ($this === EShapeType::File && !is_string($value) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::File)) { throw new ValidationException('Non-file item provided for File slot'); } - if ($this === EShapeType::ListOfFiles && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) { - throw new ValidationException('Non-audio list item provided for ListOfFiles slot'); + if ($this === EShapeType::ListOfFiles && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item) && !($item instanceof FileShaped && $item->getShapeType() === EShapeType::File))) > 0)) { + throw new ValidationException('Non-file list item provided for ListOfFiles slot'); } } diff --git a/lib/public/TaskProcessing/FileShaped.php b/lib/public/TaskProcessing/FileShaped.php new file mode 100644 index 0000000000000..ac4f88df3fe69 --- /dev/null +++ b/lib/public/TaskProcessing/FileShaped.php @@ -0,0 +1,74 @@ +extension = self::sanitizeExtension($this->extension); + } + + /** + * @return string + * @since 35.0.0 + */ + public function getData(): string { + return $this->data; + } + + /** + * @since 35.0.0 + */ + public function getShapeType(): EShapeType { + return $this->shapeType; + } + + /** + * @since 35.0.0 + */ + public function getExtension(): string { + return $this->extension; + } + + /** + * @since 35.0.0 + */ + public static function sanitizeExtension(string $ext): string { + if ($ext === '') { + return ''; + } + $ext = str_replace(['.', '/'], '', $ext); + $ext = preg_replace('/[^A-Za-z0-9]/', '', $ext) ?? $ext; + $ext = strtolower($ext); + $ext = substr($ext, 0, 16); + + if ($ext === 'php' || $ext === 'htaccess' || $ext === 'phar') { + return ''; + } + + return $ext; + } +} diff --git a/lib/public/TaskProcessing/IInternalTaskType.php b/lib/public/TaskProcessing/IInternalTaskType.php index 56c53d5a8420b..1950023134f0d 100644 --- a/lib/public/TaskProcessing/IInternalTaskType.php +++ b/lib/public/TaskProcessing/IInternalTaskType.php @@ -9,11 +9,15 @@ namespace OCP\TaskProcessing; +use OCP\AppFramework\Attribute\Implementable; + /** * This is a task type interface that is implemented by task processing * task types that should not show up in the assistant UI + * * @since 33.0.0 */ +#[Implementable(since: '33.0.0')] interface IInternalTaskType extends ITaskType { } diff --git a/lib/public/TaskProcessing/IProvider.php b/lib/public/TaskProcessing/IProvider.php index b79762d0583ee..1e05e2855fb99 100644 --- a/lib/public/TaskProcessing/IProvider.php +++ b/lib/public/TaskProcessing/IProvider.php @@ -9,11 +9,15 @@ namespace OCP\TaskProcessing; +use OCP\AppFramework\Attribute\Implementable; + /** * This is the interface that is implemented by apps that * implement a task processing provider + * * @since 30.0.0 */ +#[Implementable(since: '30.0.0')] interface IProvider { /** * The unique id of this provider diff --git a/lib/public/TaskProcessing/ISynchronousOptionsAwareProvider.php b/lib/public/TaskProcessing/ISynchronousOptionsAwareProvider.php index 5f2c403404f6c..3c85f72379e46 100644 --- a/lib/public/TaskProcessing/ISynchronousOptionsAwareProvider.php +++ b/lib/public/TaskProcessing/ISynchronousOptionsAwareProvider.php @@ -9,6 +9,7 @@ namespace OCP\TaskProcessing; +use OCP\AppFramework\Attribute\Implementable; use OCP\Files\File; use OCP\TaskProcessing\Exception\ProcessingException; @@ -17,6 +18,7 @@ * implement a task processing provider * @since 35.0.0 */ +#[Implementable(since: '35.0.0')] interface ISynchronousOptionsAwareProvider extends ISynchronousProvider { /** @@ -26,7 +28,7 @@ interface ISynchronousOptionsAwareProvider extends ISynchronousProvider { * @param array|numeric|string|File> $input The task input * @param callable(float):bool $reportProgress Report the task progress. If this returns false, that means the task was cancelled and processing should be stopped. * @param SynchronousProviderOptions $options The task options - * @psalm-return array|numeric|string> + * @psalm-return array|numeric|string|FileShaped> * @throws ProcessingException * @since 35.0.0 */ diff --git a/lib/public/TaskProcessing/ISynchronousProvider.php b/lib/public/TaskProcessing/ISynchronousProvider.php index 9184b2c26977b..ac786177c2625 100644 --- a/lib/public/TaskProcessing/ISynchronousProvider.php +++ b/lib/public/TaskProcessing/ISynchronousProvider.php @@ -9,6 +9,7 @@ namespace OCP\TaskProcessing; +use OCP\AppFramework\Attribute\Implementable; use OCP\Files\File; use OCP\TaskProcessing\Exception\ProcessingException; @@ -17,6 +18,7 @@ * implement a task processing provider * @since 30.0.0 */ +#[Implementable(since: '30.0.0')] interface ISynchronousProvider extends IProvider { /** @@ -25,9 +27,10 @@ interface ISynchronousProvider extends IProvider { * @param null|string $userId The user that created the current task * @param array|numeric|string|File> $input The task input * @param callable(float):bool $reportProgress Report the task progress. If this returns false, that means the task was cancelled and processing should be stopped. - * @psalm-return array|numeric|string> + * @psalm-return array|numeric|string|FileShaped> * @throws ProcessingException * @since 30.0.0 + * @since 35.0.0 You can now also return a FileShaped object or a list of FileShaped objects */ public function process(?string $userId, array $input, callable $reportProgress): array; } diff --git a/lib/public/TaskProcessing/ITaskType.php b/lib/public/TaskProcessing/ITaskType.php index c562861f91d91..f71f5fc282ba5 100644 --- a/lib/public/TaskProcessing/ITaskType.php +++ b/lib/public/TaskProcessing/ITaskType.php @@ -9,11 +9,15 @@ namespace OCP\TaskProcessing; +use OCP\AppFramework\Attribute\Implementable; + /** * This is a task type interface that is implemented by task processing * task types + * * @since 30.0.0 */ +#[Implementable(since: '30.0.0')] interface ITaskType { /** * Returns the unique id of this task type diff --git a/lib/public/TaskProcessing/ITriggerableProvider.php b/lib/public/TaskProcessing/ITriggerableProvider.php index 87f3195802ca9..581cf0522ade6 100644 --- a/lib/public/TaskProcessing/ITriggerableProvider.php +++ b/lib/public/TaskProcessing/ITriggerableProvider.php @@ -9,11 +9,15 @@ namespace OCP\TaskProcessing; +use OCP\AppFramework\Attribute\Implementable; + /** * This is the interface that is implemented by apps that * implement a task processing provider with support for being triggered + * * @since 33.0.0 */ +#[Implementable(since: '33.0.0')] interface ITriggerableProvider extends IProvider { /** diff --git a/lib/public/TaskProcessing/ShapeDescriptor.php b/lib/public/TaskProcessing/ShapeDescriptor.php index af4bb1acbd47f..3c92d45d63c6c 100644 --- a/lib/public/TaskProcessing/ShapeDescriptor.php +++ b/lib/public/TaskProcessing/ShapeDescriptor.php @@ -7,10 +7,14 @@ namespace OCP\TaskProcessing; +use OCP\AppFramework\Attribute\Consumable; + /** * Data object for input output shape entries + * * @since 30.0.0 */ +#[Consumable(since: '30.0.0')] class ShapeDescriptor implements \JsonSerializable { /** * @param string $name diff --git a/lib/public/TaskProcessing/ShapeEnumValue.php b/lib/public/TaskProcessing/ShapeEnumValue.php index fddeab223f7a6..47370ae29c8e7 100644 --- a/lib/public/TaskProcessing/ShapeEnumValue.php +++ b/lib/public/TaskProcessing/ShapeEnumValue.php @@ -7,10 +7,14 @@ namespace OCP\TaskProcessing; +use OCP\AppFramework\Attribute\Consumable; + /** * Data object for input output shape enum slot value + * * @since 30.0.0 */ +#[Consumable(since: '30.0.0')] class ShapeEnumValue implements \JsonSerializable { /** * @param string $name diff --git a/lib/public/TaskProcessing/SynchronousProviderOptions.php b/lib/public/TaskProcessing/SynchronousProviderOptions.php index 1381040b5d31c..56afcdc32760d 100644 --- a/lib/public/TaskProcessing/SynchronousProviderOptions.php +++ b/lib/public/TaskProcessing/SynchronousProviderOptions.php @@ -7,9 +7,12 @@ namespace OCP\TaskProcessing; +use OCP\AppFramework\Attribute\Consumable; + /** * @since 35.0.0 */ +#[Consumable(since: '35.0.0')] class SynchronousProviderOptions { private \Closure $reportIntermediateOutput; diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index 8dc361dd20fb4..674e12f270566 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -43,6 +43,7 @@ use OCP\TaskProcessing\Exception\UnauthorizedException; use OCP\TaskProcessing\Exception\UserFacingProcessingException; use OCP\TaskProcessing\Exception\ValidationException; +use OCP\TaskProcessing\FileShaped; use OCP\TaskProcessing\IManager; use OCP\TaskProcessing\IProvider; use OCP\TaskProcessing\ISynchronousProvider; @@ -1155,6 +1156,62 @@ public function testAsyncProviderWithFilesShouldBeRegisteredAndRunReturningRawFi self::assertEquals('World', $node->getContent()); } + public function testAsyncProviderWithFilesShouldBeRegisteredAndRunReturningFileShapedData(): void { + $this->registrationContext->expects($this->any())->method('getTaskProcessingTaskTypes')->willReturn([ + new ServiceRegistration('test', AudioToImage::class) + ]); + $this->registrationContext->expects($this->any())->method('getTaskProcessingProviders')->willReturn([ + new ServiceRegistration('test', AsyncProvider::class) + ]); + + $user = $this->createMock(IUser::class); + $user->expects($this->any())->method('getUID')->willReturn('testuser'); + $mount = $this->createMock(ICachedMountInfo::class); + $mount->expects($this->any())->method('getUser')->willReturn($user); + $this->userMountCache->expects($this->any())->method('getMountsForFileId')->willReturn([$mount]); + + self::assertCount(1, $this->manager->getAvailableTaskTypes()); + self::assertCount(1, $this->manager->getAvailableTaskTypeIds()); + + self::assertTrue($this->manager->hasProviders()); + $audioId = $this->getFile('audioInput', 'Hello')->getId(); + $task = new Task(AudioToImage::ID, ['audio' => $audioId], 'test', 'testuser'); + self::assertNull($task->getId()); + self::assertEquals(Task::STATUS_UNKNOWN, $task->getStatus()); + $this->manager->scheduleTask($task); + self::assertNotNull($task->getId()); + self::assertEquals(Task::STATUS_SCHEDULED, $task->getStatus()); + + // Task object retrieved from db is up-to-date + $task2 = $this->manager->getTask($task->getId()); + self::assertEquals($task->getId(), $task2->getId()); + self::assertEquals(['audio' => $audioId], $task2->getInput()); + self::assertNull($task2->getOutput()); + self::assertEquals(Task::STATUS_SCHEDULED, $task2->getStatus()); + + $this->eventDispatcher->expects($this->once())->method('dispatchTyped')->with(new IsInstanceOf(TaskSuccessfulEvent::class)); + + $this->manager->setTaskProgress($task2->getId(), 0.1); + $input = $this->manager->prepareInputData($task2); + self::assertTrue(isset($input['audio'])); + self::assertInstanceOf(File::class, $input['audio']); + self::assertEquals($audioId, $input['audio']->getId()); + + // Provider returns the raw file contents wrapped in a FileShaped object, including a file extension + $this->manager->setTaskResult($task2->getId(), null, ['spectrogram' => new FileShaped(EShapeType::Image, 'World', 'png')]); + + $task = $this->manager->getTask($task->getId()); + self::assertEquals(Task::STATUS_SUCCESSFUL, $task->getStatus()); + self::assertEquals(1, $task->getProgress()); + self::assertTrue(isset($task->getOutput()['spectrogram'])); + $node = $this->rootFolder->getFirstNodeByIdInPath($task->getOutput()['spectrogram'], '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); + self::assertNotNull($node); + self::assertInstanceOf(File::class, $node); + self::assertEquals('World', $node->getContent()); + // The extension carried by the FileShaped object is applied to the stored file name + self::assertStringEndsWith('.png', $node->getName()); + } + public function testAsyncProviderWithFilesShouldBeRegisteredAndRunReturningFileIds(): void { $this->registrationContext->expects($this->any())->method('getTaskProcessingTaskTypes')->willReturn([ new ServiceRegistration('test', AudioToImage::class)