diff options
-rw-r--r-- | core/Controller/TaskProcessingApiController.php | 57 | ||||
-rw-r--r-- | core/openapi-ex_app.json | 197 | ||||
-rw-r--r-- | core/openapi-full.json | 197 | ||||
-rw-r--r-- | lib/private/TaskProcessing/Manager.php | 193 | ||||
-rw-r--r-- | lib/private/TaskProcessing/SynchronousBackgroundJob.php | 3 | ||||
-rw-r--r-- | lib/public/TaskProcessing/EShapeType.php | 36 | ||||
-rw-r--r-- | lib/public/TaskProcessing/IManager.php | 6 | ||||
-rw-r--r-- | tests/lib/TaskProcessing/TaskProcessingTest.php | 83 |
8 files changed, 703 insertions, 69 deletions
diff --git a/core/Controller/TaskProcessingApiController.php b/core/Controller/TaskProcessingApiController.php index 90ee650f1ed..d9bcbd5da45 100644 --- a/core/Controller/TaskProcessingApiController.php +++ b/core/Controller/TaskProcessingApiController.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace OC\Core\Controller; use OC\Core\ResponseDefinitions; +use OC\Files\SimpleFS\SimpleFile; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\AnonRateLimit; use OCP\AppFramework\Http\Attribute\ApiRoute; @@ -22,6 +23,7 @@ use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\DataResponse; use OCP\Files\File; use OCP\Files\GenericFileException; +use OCP\Files\IAppData; use OCP\Files\IRootFolder; use OCP\Files\NotPermittedException; use OCP\IL10N; @@ -50,6 +52,7 @@ class TaskProcessingApiController extends \OCP\AppFramework\OCSController { private IL10N $l, private ?string $userId, private IRootFolder $rootFolder, + private IAppData $appData, ) { parent::__construct($appName, $request); } @@ -287,6 +290,40 @@ class TaskProcessingApiController extends \OCP\AppFramework\OCSController { } /** + * Upload a file so it can be referenced in a task result (ExApp route version) + * + * Use field 'file' for the file upload + * + * @param int $taskId The id of the task + * @return DataResponse<Http::STATUS_CREATED, array{fileId: int}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> + * + * 201: File created + * 400: File upload failed or no file was uploaded + * 404: Task not found + */ + #[ExAppRequired] + #[ApiRoute(verb: 'POST', url: '/tasks_provider/{taskId}/file', root: '/taskprocessing')] + public function setFileContentsExApp(int $taskId): DataResponse { + try { + $task = $this->taskProcessingManager->getTask($taskId); + $file = $this->request->getUploadedFile('file'); + if (!isset($file['tmp_name'])) { + return new DataResponse(['message' => $this->l->t('Bad request')], Http::STATUS_BAD_REQUEST); + } + $handle = fopen($file['tmp_name'], 'r'); + if (!$handle) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + $fileId = $this->setFileContentsInternal($handle); + return new DataResponse(['fileId' => $fileId], Http::STATUS_CREATED); + } catch (NotFoundException) { + return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND); + } catch (Exception) { + return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** * @throws NotPermittedException * @throws NotFoundException * @throws GenericFileException @@ -384,7 +421,7 @@ class TaskProcessingApiController extends \OCP\AppFramework\OCSController { * Sets the task result * * @param int $taskId The id of the task - * @param array<string,mixed>|null $output The resulting task output + * @param array<string,mixed>|null $output The resulting task output, files are represented by their IDs * @param string|null $errorMessage An error message if the task failed * @return DataResponse<Http::STATUS_OK, array{task: CoreTaskProcessingTask}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}> * @@ -396,7 +433,7 @@ class TaskProcessingApiController extends \OCP\AppFramework\OCSController { public function setResult(int $taskId, ?array $output = null, ?string $errorMessage = null): DataResponse { try { // set result - $this->taskProcessingManager->setTaskResult($taskId, $errorMessage, $output); + $this->taskProcessingManager->setTaskResult($taskId, $errorMessage, $output, true); $task = $this->taskProcessingManager->getTask($taskId); /** @var CoreTaskProcessingTask $json */ @@ -493,4 +530,20 @@ class TaskProcessingApiController extends \OCP\AppFramework\OCSController { return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR); } } + + /** + * @param resource $data + * @return int + * @throws NotPermittedException + */ + private function setFileContentsInternal($data): 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); + return $file->getId(); + } } diff --git a/core/openapi-ex_app.json b/core/openapi-ex_app.json index e0cf06753de..3f5de516172 100644 --- a/core/openapi-ex_app.json +++ b/core/openapi-ex_app.json @@ -339,6 +339,201 @@ } } }, + "/ocs/v2.php/taskprocessing/tasks_provider/{taskId}/file": { + "post": { + "operationId": "task_processing_api-set-file-contents-ex-app", + "summary": "Upload a file so it can be referenced in a task result (ExApp route version)", + "description": "Use field 'file' for the file upload\nThis endpoint requires admin access", + "tags": [ + "task_processing_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "taskId", + "in": "path", + "description": "The id of the task", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "File created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "fileId" + ], + "properties": { + "fileId": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "File upload failed or no file was uploaded", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Task not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/taskprocessing/tasks_provider/{taskId}/progress": { "post": { "operationId": "task_processing_api-set-progress", @@ -541,7 +736,7 @@ "output": { "type": "object", "nullable": true, - "description": "The resulting task output", + "description": "The resulting task output, files are represented by their IDs", "additionalProperties": { "type": "object" } diff --git a/core/openapi-full.json b/core/openapi-full.json index a62e587bf06..290dc462cdc 100644 --- a/core/openapi-full.json +++ b/core/openapi-full.json @@ -8597,6 +8597,201 @@ } } }, + "/ocs/v2.php/taskprocessing/tasks_provider/{taskId}/file": { + "post": { + "operationId": "task_processing_api-set-file-contents-ex-app", + "summary": "Upload a file so it can be referenced in a task result (ExApp route version)", + "description": "Use field 'file' for the file upload\nThis endpoint requires admin access", + "tags": [ + "task_processing_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "taskId", + "in": "path", + "description": "The id of the task", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "File created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "fileId" + ], + "properties": { + "fileId": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "File upload failed or no file was uploaded", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Task not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/taskprocessing/tasks_provider/{taskId}/progress": { "post": { "operationId": "task_processing_api-set-progress", @@ -8799,7 +8994,7 @@ "output": { "type": "object", "nullable": true, - "description": "The resulting task output", + "description": "The resulting task output, files are represented by their IDs", "additionalProperties": { "type": "object" } diff --git a/lib/private/TaskProcessing/Manager.php b/lib/private/TaskProcessing/Manager.php index f720776a239..d5a09ff2472 100644 --- a/lib/private/TaskProcessing/Manager.php +++ b/lib/private/TaskProcessing/Manager.php @@ -18,10 +18,13 @@ use OCP\BackgroundJob\IJobList; use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\Config\IUserMountCache; use OCP\Files\File; use OCP\Files\GenericFileException; use OCP\Files\IAppData; +use OCP\Files\InvalidPathException; use OCP\Files\IRootFolder; +use OCP\Files\Node; use OCP\Files\NotPermittedException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\IConfig; @@ -79,7 +82,7 @@ class Manager implements IManager { private \OCP\TextProcessing\IManager $textProcessingManager, private \OCP\TextToImage\IManager $textToImageManager, private \OCP\SpeechToText\ISpeechToTextManager $speechToTextManager, - private \OCP\Share\IManager $shareManager, + private IUserMountCache $userMountCache, ) { $this->appData = $appDataFactory->get('core'); } @@ -456,7 +459,7 @@ class Manager implements IManager { * @return void * @throws ValidationException */ - private function validateOutput(array $spec, array $io, bool $optional = false): void { + private function validateOutputWithFileIds(array $spec, array $io, bool $optional = false): void { foreach ($spec as $key => $descriptor) { $type = $descriptor->getShapeType(); if (!isset($io[$key])) { @@ -466,7 +469,31 @@ class Manager implements IManager { throw new ValidationException('Missing key: "' . $key . '"'); } try { - $type->validateOutput($io[$key]); + $type->validateOutputWithFileIds($io[$key]); + } catch (ValidationException $e) { + throw new ValidationException('Failed to validate output key "' . $key . '": ' . $e->getMessage()); + } + } + } + + /** + * @param ShapeDescriptor[] $spec + * @param array $io + * @param bool $optional + * @return void + * @throws ValidationException + */ + private function validateOutputWithFileData(array $spec, array $io, bool $optional = false): void { + foreach ($spec as $key => $descriptor) { + $type = $descriptor->getShapeType(); + if (!isset($io[$key])) { + if ($optional) { + continue; + } + throw new ValidationException('Missing key: "' . $key . '"'); + } + try { + $type->validateOutputWithFileData($io[$key]); } catch (ValidationException $e) { throw new ValidationException('Failed to validate output key "' . $key . '": ' . $e->getMessage()); } @@ -575,19 +602,8 @@ class Manager implements IManager { } } foreach ($ids as $fileId) { - $node = $this->rootFolder->getFirstNodeById($fileId); - if ($node === null) { - $node = $this->rootFolder->getFirstNodeByIdInPath($fileId, '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); - if ($node === null) { - throw new ValidationException('Could not find file ' . $fileId); - } - } - /** @var array{users:array<string,array{node_id:int, node_path: string}>, remote: array<string,array{node_id:int, node_path: string}>, mail: array<string,array{node_id:int, node_path: string}>} $accessList */ - $accessList = $this->shareManager->getAccessList($node, true, true); - $userIds = array_map(fn ($id) => strval($id), array_keys($accessList['users'])); - if (!in_array($task->getUserId(), $userIds)) { - throw new UnauthorizedException('User ' . $task->getUserId() . ' does not have access to file ' . $fileId); - } + $this->validateFileId($fileId); + $this->validateUserAccessToFile($fileId, $task->getUserId()); } // remove superfluous keys and set input $task->setInput($this->removeSuperfluousArrayKeys($task->getInput(), $inputShape, $optionalInputShape)); @@ -657,7 +673,7 @@ class Manager implements IManager { return true; } - public function setTaskResult(int $id, ?string $error, ?array $result): void { + public function setTaskResult(int $id, ?string $error, ?array $result, bool $isUsingFileIds = false): void { // TODO: Not sure if we should rather catch the exceptions of getTask here and fail silently $task = $this->getTask($id); if ($task->getStatus() === Task::STATUS_CANCELLED) { @@ -674,11 +690,29 @@ class Manager implements IManager { $optionalOutputShape = $taskTypes[$task->getTaskTypeId()]['optionalOutputShape']; try { // validate output - $this->validateOutput($outputShape, $result); - $this->validateOutput($optionalOutputShape, $result, true); + if (!$isUsingFileIds) { + $this->validateOutputWithFileData($outputShape, $result); + $this->validateOutputWithFileData($optionalOutputShape, $result, true); + } else { + $this->validateOutputWithFileIds($outputShape, $result); + $this->validateOutputWithFileIds($optionalOutputShape, $result, true); + } $output = $this->removeSuperfluousArrayKeys($result, $outputShape, $optionalOutputShape); // extract raw data and put it in files, replace it with file ids - $output = $this->encapsulateOutputFileData($output, $outputShape, $optionalOutputShape); + if (!$isUsingFileIds) { + $output = $this->encapsulateOutputFileData($output, $outputShape, $optionalOutputShape); + } else { + $this->validateOutputFileIds($output, $outputShape, $optionalOutputShape); + } + // Turn file objects into IDs + foreach ($output as $key => $value) { + if ($value instanceof Node) { + $output[$key] = $value->getId(); + } + if (is_array($value) && $value[0] instanceof Node) { + $output[$key] = array_map(fn ($node) => $node->getId(), $value); + } + } $task->setOutput($output); $task->setProgress(1); $task->setStatus(Task::STATUS_SUCCESSFUL); @@ -694,7 +728,12 @@ class Manager implements IManager { $error = 'The task was processed successfully but storing the output in a file failed'; $task->setErrorMessage($error); $this->logger->error($error, ['exception' => $e]); - + } catch (InvalidPathException|\OCP\Files\NotFoundException $e) { + $task->setProgress(1); + $task->setStatus(Task::STATUS_FAILED); + $error = 'The task was processed successfully but the result file could not be found'; + $task->setErrorMessage($error); + $this->logger->error($error, ['exception' => $e]); } } $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task); @@ -725,16 +764,13 @@ class Manager implements IManager { } /** - * Takes task input or output data and replaces fileIds with base64 data + * Takes task input data and replaces fileIds with File objects * * @param string|null $userId * @param array<array-key, list<numeric|string>|numeric|string> $input * @param ShapeDescriptor[] ...$specs the specs * @return array<array-key, list<File|numeric|string>|numeric|string|File> - * @throws GenericFileException - * @throws LockedException - * @throws NotPermittedException - * @throws ValidationException + * @throws GenericFileException|LockedException|NotPermittedException|ValidationException|UnauthorizedException */ public function fillInputFileData(?string $userId, array $input, ...$specs): array { if ($userId !== null) { @@ -751,31 +787,17 @@ class Manager implements IManager { $newInputOutput[$key] = $input[$key]; continue; } - if ($type->value < 10) { - $node = $this->rootFolder->getFirstNodeById((int)$input[$key]); - if ($node === null) { - $node = $this->rootFolder->getFirstNodeByIdInPath((int)$input[$key], '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); - if (!$node instanceof File) { - throw new ValidationException('File id given for key "' . $key . '" is not a file'); - } - } elseif (!$node instanceof File) { - throw new ValidationException('File id given for key "' . $key . '" is not a file'); - } - // TODO: Validate if userId has access to this file + if (EShapeType::getScalarType($type) === $type) { + // is scalar + $node = $this->validateFileId((int)$input[$key]); + $this->validateUserAccessToFile($input[$key], $userId); $newInputOutput[$key] = $node; } else { + // is list $newInputOutput[$key] = []; foreach ($input[$key] as $item) { - $node = $this->rootFolder->getFirstNodeById((int)$item); - if ($node === null) { - $node = $this->rootFolder->getFirstNodeByIdInPath((int)$item, '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); - if (!$node instanceof File) { - throw new ValidationException('File id given for key "' . $key . '" is not a file'); - } - } elseif (!$node instanceof File) { - throw new ValidationException('File id given for key "' . $key . '" is not a file'); - } - // TODO: Validate if userId has access to this file + $node = $this->validateFileId((int)$item); + $this->validateUserAccessToFile($item, $userId); $newInputOutput[$key][] = $node; } } @@ -843,15 +865,15 @@ class Manager implements IManager { $newOutput[$key] = $output[$key]; continue; } - if ($type->value < 10) { + if (EShapeType::getScalarType($type) === $type) { /** @var SimpleFile $file */ - $file = $folder->newFile((string) rand(0, 10000000), $output[$key]); + $file = $folder->newFile(time() . '-' . rand(1, 100000), $output[$key]); $newOutput[$key] = $file->getId(); // polymorphic call to SimpleFile } else { $newOutput = []; foreach ($output[$key] as $item) { /** @var SimpleFile $file */ - $file = $folder->newFile((string) rand(0, 10000000), $item); + $file = $folder->newFile(time() . '-' . rand(1, 100000), $item); $newOutput[$key][] = $file->getId(); } } @@ -865,7 +887,7 @@ class Manager implements IManager { * @throws GenericFileException * @throws LockedException * @throws NotPermittedException - * @throws ValidationException + * @throws ValidationException|UnauthorizedException */ public function prepareInputData(Task $task): array { $taskTypes = $this->getAvailableTaskTypes(); @@ -898,4 +920,73 @@ class Manager implements IManager { $taskEntity = \OC\TaskProcessing\Db\Task::fromPublicTask($task); $this->taskMapper->update($taskEntity); } + + /** + * @param array $output + * @param ShapeDescriptor[] ...$specs the specs that define which keys to keep + * @return array + * @throws NotPermittedException + */ + private function validateOutputFileIds(array $output, ...$specs): array { + $newOutput = []; + $spec = array_reduce($specs, fn ($carry, $spec) => $carry + $spec, []); + foreach($spec as $key => $descriptor) { + $type = $descriptor->getShapeType(); + if (!isset($output[$key])) { + continue; + } + if (!in_array(EShapeType::getScalarType($type), [EShapeType::Image, EShapeType::Audio, EShapeType::Video, EShapeType::File], true)) { + $newOutput[$key] = $output[$key]; + continue; + } + if (EShapeType::getScalarType($type) === $type) { + // Is scalar file ID + $newOutput[$key] = $this->validateFileId($output[$key]); + } else { + // Is list of file IDs + $newOutput = []; + foreach ($output[$key] as $item) { + $newOutput[$key][] = $this->validateFileId($item); + } + } + } + return $newOutput; + } + + /** + * @param mixed $id + * @return File + * @throws ValidationException + */ + private function validateFileId(mixed $id): File { + $node = $this->rootFolder->getFirstNodeById($id); + if ($node === null) { + $node = $this->rootFolder->getFirstNodeByIdInPath($id, '/' . $this->rootFolder->getAppDataDirectoryName() . '/'); + if ($node === null) { + throw new ValidationException('Could not find file ' . $id); + } elseif (!$node instanceof File) { + throw new ValidationException('File with id "' . $id . '" is not a file'); + } + } elseif (!$node instanceof File) { + throw new ValidationException('File with id "' . $id . '" is not a file'); + } + return $node; + } + + /** + * @param mixed $fileId + * @param string $userId + * @return void + * @throws UnauthorizedException + */ + private function validateUserAccessToFile(mixed $fileId, ?string $userId): void { + if ($userId === null) { + throw new UnauthorizedException('User does not have access to file ' . $fileId); + } + $mounts = $this->userMountCache->getMountsForFileId($fileId); + $userIds = array_map(fn ($mount) => $mount->getUser()->getUID(), $mounts); + if (!in_array($userId, $userIds)) { + throw new UnauthorizedException('User ' . $userId . ' does not have access to file ' . $fileId); + } + } } diff --git a/lib/private/TaskProcessing/SynchronousBackgroundJob.php b/lib/private/TaskProcessing/SynchronousBackgroundJob.php index 7f1ab623190..093882d4c1e 100644 --- a/lib/private/TaskProcessing/SynchronousBackgroundJob.php +++ b/lib/private/TaskProcessing/SynchronousBackgroundJob.php @@ -15,6 +15,7 @@ use OCP\Lock\LockedException; use OCP\TaskProcessing\Exception\Exception; use OCP\TaskProcessing\Exception\NotFoundException; use OCP\TaskProcessing\Exception\ProcessingException; +use OCP\TaskProcessing\Exception\UnauthorizedException; use OCP\TaskProcessing\Exception\ValidationException; use OCP\TaskProcessing\IManager; use OCP\TaskProcessing\ISynchronousProvider; @@ -54,7 +55,7 @@ class SynchronousBackgroundJob extends QueuedJob { try { try { $input = $this->taskProcessingManager->prepareInputData($task); - } catch (GenericFileException|NotPermittedException|LockedException|ValidationException $e) { + } catch (GenericFileException|NotPermittedException|LockedException|ValidationException|UnauthorizedException $e) { $this->logger->warning('Failed to prepare input data for a TaskProcessing task with synchronous provider ' . $provider->getId(), ['exception' => $e]); $this->taskProcessingManager->setTaskResult($task->getId(), $e->getMessage(), null); // Schedule again diff --git a/lib/public/TaskProcessing/EShapeType.php b/lib/public/TaskProcessing/EShapeType.php index d66de6e01a8..059f9d0c3c7 100644 --- a/lib/public/TaskProcessing/EShapeType.php +++ b/lib/public/TaskProcessing/EShapeType.php @@ -89,7 +89,7 @@ enum EShapeType: int { * @throws ValidationException * @since 30.0.0 */ - public function validateOutput(mixed $value) { + public function validateOutputWithFileData(mixed $value): void { $this->validateNonFileType($value); if ($this === EShapeType::Image && !is_string($value)) { throw new ValidationException('Non-image item provided for Image slot'); @@ -118,6 +118,40 @@ enum EShapeType: int { } /** + * @param mixed $value + * @return void + * @throws ValidationException + * @since 30.0.0 + */ + public function validateOutputWithFileIds(mixed $value): void { + $this->validateNonFileType($value); + if ($this === EShapeType::Image && !is_numeric($value)) { + throw new ValidationException('Non-image item provided for Image slot'); + } + if ($this === EShapeType::ListOfImages && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) { + throw new ValidationException('Non-image list item provided for ListOfImages slot'); + } + if ($this === EShapeType::Audio && !is_string($value)) { + throw new ValidationException('Non-audio item provided for Audio slot'); + } + if ($this === EShapeType::ListOfAudios && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) { + throw new ValidationException('Non-audio list item provided for ListOfAudio slot'); + } + if ($this === EShapeType::Video && !is_string($value)) { + throw new ValidationException('Non-video item provided for Video slot'); + } + if ($this === EShapeType::ListOfVideos && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) { + throw new ValidationException('Non-video list item provided for ListOfTexts 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_numeric($item))) > 0)) { + throw new ValidationException('Non-audio list item provided for ListOfFiles slot'); + } + } + + /** * @param EShapeType $type * @return EShapeType * @since 30.0.0 diff --git a/lib/public/TaskProcessing/IManager.php b/lib/public/TaskProcessing/IManager.php index 599bd244d8a..c68ad1afbac 100644 --- a/lib/public/TaskProcessing/IManager.php +++ b/lib/public/TaskProcessing/IManager.php @@ -91,11 +91,12 @@ interface IManager { * @param int $id The id of the task * @param string|null $error * @param array|null $result + * @param bool $isUsingFileIds * @throws Exception If the query failed * @throws NotFoundException If the task could not be found * @since 30.0.0 */ - public function setTaskResult(int $id, ?string $error, ?array $result): void; + public function setTaskResult(int $id, ?string $error, ?array $result, bool $isUsingFileIds = false): void; /** * @param int $id @@ -152,7 +153,7 @@ interface IManager { /** * Prepare the task's input data, so it can be processed by the provider - * ie. this replaces file ids with base64 data + * ie. this replaces file ids with File objects * * @param Task $task * @return array<array-key, list<numeric|string|File>|numeric|string|File> @@ -160,6 +161,7 @@ interface IManager { * @throws GenericFileException * @throws LockedException * @throws ValidationException + * @throws UnauthorizedException * @since 30.0.0 */ public function prepareInputData(Task $task): array; diff --git a/tests/lib/TaskProcessing/TaskProcessingTest.php b/tests/lib/TaskProcessing/TaskProcessingTest.php index a1857fad1b3..27f46bed17c 100644 --- a/tests/lib/TaskProcessing/TaskProcessingTest.php +++ b/tests/lib/TaskProcessing/TaskProcessingTest.php @@ -16,11 +16,13 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\AppData\IAppDataFactory; -use OCP\Files\IAppData; +use OCP\Files\Config\ICachedMountInfo; +use OCP\Files\Config\IUserMountCache; use OCP\Files\IRootFolder; use OCP\IConfig; use OCP\IDBConnection; use OCP\IServerContainer; +use OCP\IUser; use OCP\IUserManager; use OCP\SpeechToText\ISpeechToTextManager; use OCP\TaskProcessing\EShapeType; @@ -295,8 +297,7 @@ class TaskProcessingTest extends \Test\TestCase { private RegistrationContext $registrationContext; private TaskMapper $taskMapper; private IJobList $jobList; - private IAppData $appData; - private \OCP\Share\IManager $shareManager; + private IUserMountCache $userMountCache; private IRootFolder $rootFolder; public const TEST_USER = 'testuser'; @@ -370,7 +371,7 @@ class TaskProcessingTest extends \Test\TestCase { \OC::$server->get(IAppDataFactory::class), ); - $this->shareManager = $this->createMock(\OCP\Share\IManager::class); + $this->userMountCache = $this->createMock(IUserMountCache::class); $this->manager = new Manager( \OC::$server->get(IConfig::class), @@ -385,7 +386,7 @@ class TaskProcessingTest extends \Test\TestCase { $textProcessingManager, $text2imageManager, \OC::$server->get(ISpeechToTextManager::class), - $this->shareManager, + $this->userMountCache, ); } @@ -416,17 +417,21 @@ class TaskProcessingTest extends \Test\TestCase { } public function testProviderShouldBeRegisteredAndTaskWithFilesFailValidation() { - $this->shareManager->expects($this->any())->method('getAccessList')->willReturn(['users' => []]); $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) ]); - $this->shareManager->expects($this->any())->method('getAccessList')->willReturn(['users' => [null]]); - self::assertCount(1, $this->manager->getAvailableTaskTypes()); + $user = $this->createMock(IUser::class); + $user->expects($this->any())->method('getUID')->willReturn(null); + $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::assertTrue($this->manager->hasProviders()); + $audioId = $this->getFile('audioInput', 'Hello')->getId(); $task = new Task(AudioToImage::ID, ['audio' => $audioId], 'test', null); self::assertNull($task->getId()); @@ -537,14 +542,20 @@ class TaskProcessingTest extends \Test\TestCase { self::assertEquals(1, $task->getProgress()); } - public function testAsyncProviderWithFilesShouldBeRegisteredAndRun() { + public function testAsyncProviderWithFilesShouldBeRegisteredAndRunReturningRawFileData() { $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) ]); - $this->shareManager->expects($this->any())->method('getAccessList')->willReturn(['users' => ['testuser' => 1]]); + + $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::assertTrue($this->manager->hasProviders()); @@ -583,6 +594,58 @@ class TaskProcessingTest extends \Test\TestCase { self::assertEquals('World', $node->getContent()); } + public function testAsyncProviderWithFilesShouldBeRegisteredAndRunReturningFileIds() { + $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::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(\OCP\Files\File::class, $input['audio']); + self::assertEquals($audioId, $input['audio']->getId()); + + $outputFileId = $this->getFile('audioOutput', 'World')->getId(); + + $this->manager->setTaskResult($task2->getId(), null, ['spectrogram' => $outputFileId], true); + + $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->getFirstNodeById($task->getOutput()['spectrogram']); + self::assertNotNull($node, 'fileId:' . $task->getOutput()['spectrogram']); + self::assertInstanceOf(\OCP\Files\File::class, $node); + self::assertEquals('World', $node->getContent()); + } + public function testNonexistentTask() { $this->expectException(\OCP\TaskProcessing\Exception\NotFoundException::class); $this->manager->getTask(2147483646); |