From f961edf5e5f97a92c06d28d848418a9d4fe46df0 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 1 Jul 2026 13:25:50 +0200 Subject: [PATCH 01/15] feat(TaskProcessing): Store output files with extension to ease mimetype guessing Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 19 +++++- lib/public/TaskProcessing/EShapeType.php | 18 +++--- lib/public/TaskProcessing/FileShaped.php | 63 +++++++++++++++++++ .../ISynchronousOptionsAwareProvider.php | 4 +- .../TaskProcessing/ISynchronousProvider.php | 4 +- 5 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 lib/public/TaskProcessing/FileShaped.php diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 608856fd27017..127b2e5d9fadf 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 = $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, $data); $newOutput[$key] = $file->getId(); // polymorphic call to SimpleFile } else { $newOutput = []; foreach ($output[$key] as $item) { + if ($item instanceof FileShaped) { + $data = $item->getData(); + $ext = $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, $data); $newOutput[$key][] = $file->getId(); } } diff --git a/lib/public/TaskProcessing/EShapeType.php b/lib/public/TaskProcessing/EShapeType.php index c1c0a1ccd4de3..81bb45fe3c4bc 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 @@ -156,28 +158,28 @@ 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) && !($value instanceof FileShaped && $value->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) && !($value instanceof FileShaped && $value->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) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::Video))) > 0)) { + throw new ValidationException('Non-video list item provided for ListOfVideos slot'); } if ($this === EShapeType::File && !is_string($value)) { 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)) { + if ($this === EShapeType::ListOfFiles && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::File))) > 0)) { throw new ValidationException('Non-audio 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..fdfcb12c92c89 --- /dev/null +++ b/lib/public/TaskProcessing/FileShaped.php @@ -0,0 +1,63 @@ +data; + } + + /** + * @since 35.0.0 + */ + public function getShapeType(): EShapeType { + return $this->shapeType; + } + + /** + * @since 35.0.0 + */ + public function getMimeType(): string { + return $this->mimeType; + } + + /** + * @since 35.0.0 + */ + public function getExtension(): string { + return $this->extension; + } +} 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..e536e99944679 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,7 +27,7 @@ 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 */ From 1a5d59937aa29fe2c56e3a2008fd3050be17c37b Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 1 Jul 2026 13:26:25 +0200 Subject: [PATCH 02/15] fix: Add dot before file extension Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 4 ++-- lib/public/TaskProcessing/IInternalTaskType.php | 4 ++++ lib/public/TaskProcessing/IProvider.php | 4 ++++ lib/public/TaskProcessing/ITaskType.php | 4 ++++ lib/public/TaskProcessing/ITriggerableProvider.php | 4 ++++ lib/public/TaskProcessing/ShapeDescriptor.php | 4 ++++ lib/public/TaskProcessing/ShapeEnumValue.php | 4 ++++ lib/public/TaskProcessing/SynchronousProviderOptions.php | 3 +++ 8 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 127b2e5d9fadf..029915a7ce19a 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -1612,7 +1612,7 @@ public function encapsulateOutputFileData(array $output, ...$specs): array { $ext = ''; } /** @var SimpleFile $file */ - $file = $folder->newFile(time() . '-' . rand(1, 100000) . $ext, $data); + $file = $folder->newFile(time() . '-' . rand(1, 100000) . ($ext ? '.' . $ext : ''), $data); $newOutput[$key] = $file->getId(); // polymorphic call to SimpleFile } else { $newOutput = []; @@ -1625,7 +1625,7 @@ public function encapsulateOutputFileData(array $output, ...$specs): array { $ext = ''; } /** @var SimpleFile $file */ - $file = $folder->newFile(time() . '-' . rand(1, 100000) . $ext, $data); + $file = $folder->newFile(time() . '-' . rand(1, 100000) . ($ext ? '.' . $ext : ''), $data); $newOutput[$key][] = $file->getId(); } } diff --git a/lib/public/TaskProcessing/IInternalTaskType.php b/lib/public/TaskProcessing/IInternalTaskType.php index 56c53d5a8420b..4399570bfac97 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/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..953b016ae6b97 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: '30.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; From de4de30aa2b8da872cb6098468df0d3a3b207296 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 1 Jul 2026 14:06:53 +0200 Subject: [PATCH 03/15] fix(TaskProcessing): Add file extension for files uploaded from ex apps Signed-off-by: Marcel Klehr --- core/Controller/TaskProcessingApiController.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index 72811eb044cdf..df8639bcaca37 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -534,7 +534,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 +855,14 @@ 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'); } /** @var SimpleFile $file */ - $file = $folder->newFile(time() . '-' . rand(1, 100000), $data); + $file = $folder->newFile(time() . '-' . rand(1, 100000) . ($ext ? '.' . $ext : ''), $data); return $file->getId(); } From 4e01aaaf01b92d03b6e6607a2f88586d72870faf Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 1 Jul 2026 14:22:16 +0200 Subject: [PATCH 04/15] fix: Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Marcel Klehr --- lib/public/TaskProcessing/EShapeType.php | 6 +++--- lib/public/TaskProcessing/FileShaped.php | 6 +++--- lib/public/TaskProcessing/IInternalTaskType.php | 2 +- lib/public/TaskProcessing/ITriggerableProvider.php | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/public/TaskProcessing/EShapeType.php b/lib/public/TaskProcessing/EShapeType.php index 81bb45fe3c4bc..9730706f59cc8 100644 --- a/lib/public/TaskProcessing/EShapeType.php +++ b/lib/public/TaskProcessing/EShapeType.php @@ -161,19 +161,19 @@ public function validateOutputWithFileData(mixed $value): void { 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) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::Image))) > 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) && !($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) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::Audio))) > 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) && !($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) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::Video))) > 0)) { + 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)) { diff --git a/lib/public/TaskProcessing/FileShaped.php b/lib/public/TaskProcessing/FileShaped.php index fdfcb12c92c89..5e4e77bcfad95 100644 --- a/lib/public/TaskProcessing/FileShaped.php +++ b/lib/public/TaskProcessing/FileShaped.php @@ -12,7 +12,7 @@ /** * Data object for file-shaped output entries * - * @since 50.0.0 + * @since 35.0.0 */ #[Consumable(since: '35.0.0')] class FileShaped { @@ -22,13 +22,13 @@ class FileShaped { * @param string $mimeType (optional) * @param string $extension (optional) * - * @since 30.0.0 + * @since 35.0.0 */ public function __construct( private EShapeType $shapeType, private string $data, private string $mimeType = 'application/octet-stream', - private string $extension = '.bin', + private string $extension = 'bin', ) { } diff --git a/lib/public/TaskProcessing/IInternalTaskType.php b/lib/public/TaskProcessing/IInternalTaskType.php index 4399570bfac97..1950023134f0d 100644 --- a/lib/public/TaskProcessing/IInternalTaskType.php +++ b/lib/public/TaskProcessing/IInternalTaskType.php @@ -17,7 +17,7 @@ * * @since 33.0.0 */ -#[Implementable(since:'33.0.0')] +#[Implementable(since: '33.0.0')] interface IInternalTaskType extends ITaskType { } diff --git a/lib/public/TaskProcessing/ITriggerableProvider.php b/lib/public/TaskProcessing/ITriggerableProvider.php index 953b016ae6b97..581cf0522ade6 100644 --- a/lib/public/TaskProcessing/ITriggerableProvider.php +++ b/lib/public/TaskProcessing/ITriggerableProvider.php @@ -17,7 +17,7 @@ * * @since 33.0.0 */ -#[Implementable(since: '30.0.0')] +#[Implementable(since: '33.0.0')] interface ITriggerableProvider extends IProvider { /** From 9a983cca76a31e97b948cd7d315d8039d04501a5 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 1 Jul 2026 14:24:24 +0200 Subject: [PATCH 05/15] fix(TaskProcessing): Fix EShapeType validation Signed-off-by: Marcel Klehr --- lib/public/TaskProcessing/EShapeType.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/public/TaskProcessing/EShapeType.php b/lib/public/TaskProcessing/EShapeType.php index 9730706f59cc8..ddebaa2a73300 100644 --- a/lib/public/TaskProcessing/EShapeType.php +++ b/lib/public/TaskProcessing/EShapeType.php @@ -176,10 +176,10 @@ public function validateOutputWithFileData(mixed $value): void { 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) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::File))) > 0)) { + 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-audio list item provided for ListOfFiles slot'); } } From 950ef2dcd57218165bc0dba1402dca29b9d0be65 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 1 Jul 2026 14:25:26 +0200 Subject: [PATCH 06/15] fix(TaskProcessing): Fix file output validation Signed-off-by: Marcel Klehr --- lib/private/TaskProcessing/Manager.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 029915a7ce19a..233aa9f992ed1 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -1615,7 +1615,7 @@ public function encapsulateOutputFileData(array $output, ...$specs): array { $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(); @@ -1772,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); } From c5c7b98e91966ef078f00dfa774c2e3b4bbc810e Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 1 Jul 2026 14:32:00 +0200 Subject: [PATCH 07/15] fix(TaskProcessing): Sanitize file extension and remove mimetype property Signed-off-by: Marcel Klehr --- core/Controller/TaskProcessingApiController.php | 2 ++ lib/private/TaskProcessing/Manager.php | 4 ++-- lib/public/TaskProcessing/FileShaped.php | 17 ++++++++--------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index df8639bcaca37..ae4bad2d63a5f 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; @@ -861,6 +862,7 @@ private function setFileContentsInternal($data, string $ext = ''): int { } catch (\OCP\Files\NotFoundException) { $folder = $this->appData->newFolder('TaskProcessing'); } + $ext = FileShaped::sanitizeExtension($ext); /** @var SimpleFile $file */ $file = $folder->newFile(time() . '-' . rand(1, 100000) . ($ext ? '.' . $ext : ''), $data); return $file->getId(); diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index 233aa9f992ed1..21a2c6b45796c 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -1606,7 +1606,7 @@ public function encapsulateOutputFileData(array $output, ...$specs): array { if (EShapeType::getScalarType($type) === $type) { if ($output[$key] instanceof FileShaped) { $data = $output[$key]->getData(); - $ext = $output[$key]->getExtension(); + $ext = FileShaped::sanitizeExtension($output[$key]->getExtension()); } else { $data = $output[$key]; $ext = ''; @@ -1619,7 +1619,7 @@ public function encapsulateOutputFileData(array $output, ...$specs): array { foreach ($output[$key] as $item) { if ($item instanceof FileShaped) { $data = $item->getData(); - $ext = $item->getExtension(); + $ext = FileShaped::sanitizeExtension($item->getExtension()); } else { $data = $item; $ext = ''; diff --git a/lib/public/TaskProcessing/FileShaped.php b/lib/public/TaskProcessing/FileShaped.php index 5e4e77bcfad95..51b28778d4897 100644 --- a/lib/public/TaskProcessing/FileShaped.php +++ b/lib/public/TaskProcessing/FileShaped.php @@ -19,7 +19,6 @@ class FileShaped { /** * @param EShapeType $shapeType * @param string $data - * @param string $mimeType (optional) * @param string $extension (optional) * * @since 35.0.0 @@ -27,7 +26,6 @@ class FileShaped { public function __construct( private EShapeType $shapeType, private string $data, - private string $mimeType = 'application/octet-stream', private string $extension = 'bin', ) { } @@ -47,17 +45,18 @@ public function getShapeType(): EShapeType { return $this->shapeType; } - /** - * @since 35.0.0 - */ - public function getMimeType(): string { - return $this->mimeType; - } - /** * @since 35.0.0 */ public function getExtension(): string { return $this->extension; } + + public static function sanitizeExtension(string $ext): string { + if ($ext === '') { + return ''; + } + $ext = str_replace(array('.', '/'), '', $ext); + return $ext; + } } From 980f23641c9468a134039868ee86203eb84b685f Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 14 Jul 2026 11:30:29 +0200 Subject: [PATCH 08/15] test: Add TaskProcessing test for FileShaped output Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Marcel Klehr --- .../lib/TaskProcessing/TaskProcessingTest.php | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index 8dc361dd20fb4..e264e5f39e7cb 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; @@ -626,7 +627,7 @@ public function getOptionalInputShape(): array { #[\Override] public function getOptionalOutputShape(): array { return []; - } + }Supercloud? #[\Override] public function getInputShapeEnumValues(): array { return []; @@ -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) From a5014168c25b2600388eaa2ce73ca9fc7ad35ffc Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 14 Jul 2026 11:39:00 +0200 Subject: [PATCH 09/15] fix: Fun cs:fix Signed-off-by: Marcel Klehr --- lib/public/TaskProcessing/FileShaped.php | 2 +- tests/lib/TaskProcessing/TaskProcessingTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/public/TaskProcessing/FileShaped.php b/lib/public/TaskProcessing/FileShaped.php index 51b28778d4897..a6e3d08b189a6 100644 --- a/lib/public/TaskProcessing/FileShaped.php +++ b/lib/public/TaskProcessing/FileShaped.php @@ -56,7 +56,7 @@ public static function sanitizeExtension(string $ext): string { if ($ext === '') { return ''; } - $ext = str_replace(array('.', '/'), '', $ext); + $ext = str_replace(['.', '/'], '', $ext); return $ext; } } diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index e264e5f39e7cb..674e12f270566 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -627,7 +627,7 @@ public function getOptionalInputShape(): array { #[\Override] public function getOptionalOutputShape(): array { return []; - }Supercloud? + } #[\Override] public function getInputShapeEnumValues(): array { return []; From b8db1f079c5e6e99133dba29fde09a4423c103c8 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 14 Jul 2026 12:21:36 +0200 Subject: [PATCH 10/15] fix: Fix psalm issue Signed-off-by: Marcel Klehr --- lib/public/TaskProcessing/FileShaped.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/public/TaskProcessing/FileShaped.php b/lib/public/TaskProcessing/FileShaped.php index a6e3d08b189a6..e40ee528b2c81 100644 --- a/lib/public/TaskProcessing/FileShaped.php +++ b/lib/public/TaskProcessing/FileShaped.php @@ -52,6 +52,9 @@ public function getExtension(): string { return $this->extension; } + /** + * @since 35.0.0 + */ public static function sanitizeExtension(string $ext): string { if ($ext === '') { return ''; From 70e7fea1bb23caf037b126546f5f9b349d0d95e5 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Tue, 14 Jul 2026 12:24:45 +0200 Subject: [PATCH 11/15] fix: Fix autoloaders Signed-off-by: Marcel Klehr --- lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + 2 files changed, 2 insertions(+) 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', From 326b94a571dcbdc921921b345bc6effd0cc57cd0 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Wed, 15 Jul 2026 07:18:34 +0200 Subject: [PATCH 12/15] fix: Address review comments Signed-off-by: Marcel Klehr --- lib/public/TaskProcessing/FileShaped.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/public/TaskProcessing/FileShaped.php b/lib/public/TaskProcessing/FileShaped.php index e40ee528b2c81..f949d1e3a2e00 100644 --- a/lib/public/TaskProcessing/FileShaped.php +++ b/lib/public/TaskProcessing/FileShaped.php @@ -1,7 +1,7 @@ extension = self::sanitizeExtension($this->extension); } /** @@ -60,6 +61,9 @@ public static function sanitizeExtension(string $ext): string { return ''; } $ext = str_replace(['.', '/'], '', $ext); + $ext = preg_replace('/[^A-Za-z0-9]/', '', $ext) ?? $ext; + $ext = strtolower($ext); + $ext = substr($ext, 0, 16); return $ext; } } From 52fababa20838581f67b87e238173f19d9ba775e Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 16 Jul 2026 14:53:22 +0200 Subject: [PATCH 13/15] fix: Address review comments Signed-off-by: Marcel Klehr --- core/Controller/TaskProcessingApiController.php | 2 +- lib/public/TaskProcessing/EShapeType.php | 4 ++-- lib/public/TaskProcessing/FileShaped.php | 2 +- lib/public/TaskProcessing/ISynchronousProvider.php | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index ae4bad2d63a5f..58b3270cc3c60 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -535,7 +535,7 @@ public function setFileContentsExApp(int $taskId): DataResponse { if (!$handle) { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } - $ext = pathinfo($file['name'], PATHINFO_EXTENSION); + $ext = pathinfo(($file['name'] ?? ''), PATHINFO_EXTENSION); $fileId = $this->setFileContentsInternal($handle, $ext); return new DataResponse(['fileId' => $fileId], Http::STATUS_CREATED); } catch (NotFoundException) { diff --git a/lib/public/TaskProcessing/EShapeType.php b/lib/public/TaskProcessing/EShapeType.php index ddebaa2a73300..2ea771733012a 100644 --- a/lib/public/TaskProcessing/EShapeType.php +++ b/lib/public/TaskProcessing/EShapeType.php @@ -148,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'); } } @@ -180,7 +180,7 @@ public function validateOutputWithFileData(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_string($item) && !($item instanceof FileShaped && $item->getShapeType() === EShapeType::File))) > 0)) { - throw new ValidationException('Non-audio list item provided for ListOfFiles slot'); + 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 index f949d1e3a2e00..09b44705d7b0c 100644 --- a/lib/public/TaskProcessing/FileShaped.php +++ b/lib/public/TaskProcessing/FileShaped.php @@ -57,7 +57,7 @@ public function getExtension(): string { * @since 35.0.0 */ public static function sanitizeExtension(string $ext): string { - if ($ext === '') { + if ($ext === '' || $ext === 'php' || $ext === 'htaccess' || $ext === 'phar') { return ''; } $ext = str_replace(['.', '/'], '', $ext); diff --git a/lib/public/TaskProcessing/ISynchronousProvider.php b/lib/public/TaskProcessing/ISynchronousProvider.php index e536e99944679..ac786177c2625 100644 --- a/lib/public/TaskProcessing/ISynchronousProvider.php +++ b/lib/public/TaskProcessing/ISynchronousProvider.php @@ -30,6 +30,7 @@ interface ISynchronousProvider extends IProvider { * @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; } From eca8b57710efae5ca1ddd5a1ddbc3a90f7ad2db5 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 16 Jul 2026 15:32:51 +0200 Subject: [PATCH 14/15] fix: Address review comments Signed-off-by: Marcel Klehr --- lib/public/TaskProcessing/FileShaped.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/public/TaskProcessing/FileShaped.php b/lib/public/TaskProcessing/FileShaped.php index 09b44705d7b0c..1b2630af37666 100644 --- a/lib/public/TaskProcessing/FileShaped.php +++ b/lib/public/TaskProcessing/FileShaped.php @@ -57,7 +57,7 @@ public function getExtension(): string { * @since 35.0.0 */ public static function sanitizeExtension(string $ext): string { - if ($ext === '' || $ext === 'php' || $ext === 'htaccess' || $ext === 'phar') { + if ($ext === '' || strtolower($ext) === 'php' || strtolower($ext) === 'htaccess' || strtolower($ext) === 'phar') { return ''; } $ext = str_replace(['.', '/'], '', $ext); From 33091a5008cb5684cbf279b4f955f774720d7266 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 16 Jul 2026 15:41:15 +0200 Subject: [PATCH 15/15] fix: Address review comments Signed-off-by: Marcel Klehr --- lib/public/TaskProcessing/FileShaped.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/public/TaskProcessing/FileShaped.php b/lib/public/TaskProcessing/FileShaped.php index 1b2630af37666..ac4f88df3fe69 100644 --- a/lib/public/TaskProcessing/FileShaped.php +++ b/lib/public/TaskProcessing/FileShaped.php @@ -57,13 +57,18 @@ public function getExtension(): string { * @since 35.0.0 */ public static function sanitizeExtension(string $ext): string { - if ($ext === '' || strtolower($ext) === 'php' || strtolower($ext) === 'htaccess' || strtolower($ext) === 'phar') { + 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; } }