Merge pull request #61689 from nextcloud/feat/taskprocessing-output-files-with-mimetypes

Store taskprocessing output files with extension to ease mimtype guessing
pull/62360/head
Joas Schilling 1 month ago committed by GitHub
commit cb249a49bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 9
      core/Controller/TaskProcessingApiController.php
  2. 1
      lib/composer/composer/autoload_classmap.php
  3. 1
      lib/composer/composer/autoload_static.php
  4. 21
      lib/private/TaskProcessing/Manager.php
  5. 24
      lib/public/TaskProcessing/EShapeType.php
  6. 74
      lib/public/TaskProcessing/FileShaped.php
  7. 4
      lib/public/TaskProcessing/IInternalTaskType.php
  8. 4
      lib/public/TaskProcessing/IProvider.php
  9. 4
      lib/public/TaskProcessing/ISynchronousOptionsAwareProvider.php
  10. 5
      lib/public/TaskProcessing/ISynchronousProvider.php
  11. 4
      lib/public/TaskProcessing/ITaskType.php
  12. 4
      lib/public/TaskProcessing/ITriggerableProvider.php
  13. 4
      lib/public/TaskProcessing/ShapeDescriptor.php
  14. 4
      lib/public/TaskProcessing/ShapeEnumValue.php
  15. 3
      lib/public/TaskProcessing/SynchronousProviderOptions.php
  16. 57
      tests/lib/TaskProcessing/TaskProcessingTest.php

@ -33,6 +33,7 @@ use OCP\TaskProcessing\Exception\NotFoundException;
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 @@ class TaskProcessingApiController extends OCSController {
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 @@ class TaskProcessingApiController extends OCSController {
* @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();
}

@ -981,6 +981,7 @@ return array(
'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',

@ -1022,6 +1022,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',

@ -58,6 +58,7 @@ use OCP\TaskProcessing\Exception\ProcessingException;
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;
@ -1604,14 +1605,28 @@ class Manager implements IManager {
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[$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();
}
}
@ -1758,7 +1773,7 @@ class Manager implements IManager {
$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);
}

@ -9,6 +9,7 @@ declare(strict_types=1);
namespace OCP\TaskProcessing;
use OCP\AppFramework\Attribute\Consumable;
use OCP\TaskProcessing\Exception\ValidationException;
/**
@ -16,6 +17,7 @@ use OCP\TaskProcessing\Exception\ValidationException;
*
* @since 30.0.0
*/
#[Consumable(since: '30.0.0')]
enum EShapeType: int {
/**
* @since 30.0.0
@ -146,7 +148,7 @@ enum EShapeType: int {
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 @@ enum EShapeType: int {
*/
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');
}
}

@ -0,0 +1,74 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\TaskProcessing;
use OCP\AppFramework\Attribute\Consumable;
/**
* Data object for file-shaped output entries
*
* @since 35.0.0
*/
#[Consumable(since: '35.0.0')]
final class FileShaped {
/**
* @param EShapeType $shapeType
* @param string $data
* @param string $extension (optional)
*
* @since 35.0.0
*/
public function __construct(
private EShapeType $shapeType,
private string $data,
private string $extension = '',
) {
$this->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;
}
}

@ -9,11 +9,15 @@ declare(strict_types=1);
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 {
}

@ -9,11 +9,15 @@ declare(strict_types=1);
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

@ -9,6 +9,7 @@ declare(strict_types=1);
namespace OCP\TaskProcessing;
use OCP\AppFramework\Attribute\Implementable;
use OCP\Files\File;
use OCP\TaskProcessing\Exception\ProcessingException;
@ -17,6 +18,7 @@ use OCP\TaskProcessing\Exception\ProcessingException;
* 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<string, list<numeric|string|File>|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<string, list<numeric|string>|numeric|string>
* @psalm-return array<string, list<numeric|string|FileShaped>|numeric|string|FileShaped>
* @throws ProcessingException
* @since 35.0.0
*/

@ -9,6 +9,7 @@ declare(strict_types=1);
namespace OCP\TaskProcessing;
use OCP\AppFramework\Attribute\Implementable;
use OCP\Files\File;
use OCP\TaskProcessing\Exception\ProcessingException;
@ -17,6 +18,7 @@ use OCP\TaskProcessing\Exception\ProcessingException;
* 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<string, list<numeric|string|File>|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<string, list<numeric|string>|numeric|string>
* @psalm-return array<string, list<numeric|string|FileShaped>|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;
}

@ -9,11 +9,15 @@ declare(strict_types=1);
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

@ -9,11 +9,15 @@ declare(strict_types=1);
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 {
/**

@ -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

@ -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

@ -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;

@ -43,6 +43,7 @@ use OCP\TaskProcessing\Exception\ProcessingException;
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 @@ class TaskProcessingTest extends \Test\TestCase {
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)

Loading…
Cancel
Save