Skip to content

Commit cb249a4

Browse files
Merge pull request #61689 from nextcloud/feat/taskprocessing-output-files-with-mimetypes
Store taskprocessing output files with extension to ease mimtype guessing
2 parents 4ad68f0 + 33091a5 commit cb249a4

16 files changed

Lines changed: 204 additions & 19 deletions

core/Controller/TaskProcessingApiController.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
use OCP\TaskProcessing\Exception\PreConditionNotMetException;
3434
use OCP\TaskProcessing\Exception\UnauthorizedException;
3535
use OCP\TaskProcessing\Exception\ValidationException;
36+
use OCP\TaskProcessing\FileShaped;
3637
use OCP\TaskProcessing\IManager;
3738
use OCP\TaskProcessing\ShapeEnumValue;
3839
use OCP\TaskProcessing\Task;
@@ -534,7 +535,8 @@ public function setFileContentsExApp(int $taskId): DataResponse {
534535
if (!$handle) {
535536
return new DataResponse(['message' => $this->l->t('Internal error')], Http::STATUS_INTERNAL_SERVER_ERROR);
536537
}
537-
$fileId = $this->setFileContentsInternal($handle);
538+
$ext = pathinfo(($file['name'] ?? ''), PATHINFO_EXTENSION);
539+
$fileId = $this->setFileContentsInternal($handle, $ext);
538540
return new DataResponse(['fileId' => $fileId], Http::STATUS_CREATED);
539541
} catch (NotFoundException) {
540542
return new DataResponse(['message' => $this->l->t('Not found')], Http::STATUS_NOT_FOUND);
@@ -854,14 +856,15 @@ public function getNextScheduledTaskBatch(array $providerIds, array $taskTypeIds
854856
* @return int
855857
* @throws NotPermittedException
856858
*/
857-
private function setFileContentsInternal($data): int {
859+
private function setFileContentsInternal($data, string $ext = ''): int {
858860
try {
859861
$folder = $this->appData->getFolder('TaskProcessing');
860862
} catch (\OCP\Files\NotFoundException) {
861863
$folder = $this->appData->newFolder('TaskProcessing');
862864
}
865+
$ext = FileShaped::sanitizeExtension($ext);
863866
/** @var SimpleFile $file */
864-
$file = $folder->newFile(time() . '-' . rand(1, 100000), $data);
867+
$file = $folder->newFile(time() . '-' . rand(1, 100000) . ($ext ? '.' . $ext : ''), $data);
865868
return $file->getId();
866869
}
867870

lib/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,7 @@
981981
'OCP\\TaskProcessing\\Exception\\UnauthorizedException' => $baseDir . '/lib/public/TaskProcessing/Exception/UnauthorizedException.php',
982982
'OCP\\TaskProcessing\\Exception\\UserFacingProcessingException' => $baseDir . '/lib/public/TaskProcessing/Exception/UserFacingProcessingException.php',
983983
'OCP\\TaskProcessing\\Exception\\ValidationException' => $baseDir . '/lib/public/TaskProcessing/Exception/ValidationException.php',
984+
'OCP\\TaskProcessing\\FileShaped' => $baseDir . '/lib/public/TaskProcessing/FileShaped.php',
984985
'OCP\\TaskProcessing\\IInternalTaskType' => $baseDir . '/lib/public/TaskProcessing/IInternalTaskType.php',
985986
'OCP\\TaskProcessing\\IManager' => $baseDir . '/lib/public/TaskProcessing/IManager.php',
986987
'OCP\\TaskProcessing\\IProvider' => $baseDir . '/lib/public/TaskProcessing/IProvider.php',

lib/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
10221022
'OCP\\TaskProcessing\\Exception\\UnauthorizedException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/UnauthorizedException.php',
10231023
'OCP\\TaskProcessing\\Exception\\UserFacingProcessingException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/UserFacingProcessingException.php',
10241024
'OCP\\TaskProcessing\\Exception\\ValidationException' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/Exception/ValidationException.php',
1025+
'OCP\\TaskProcessing\\FileShaped' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/FileShaped.php',
10251026
'OCP\\TaskProcessing\\IInternalTaskType' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IInternalTaskType.php',
10261027
'OCP\\TaskProcessing\\IManager' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IManager.php',
10271028
'OCP\\TaskProcessing\\IProvider' => __DIR__ . '/../../..' . '/lib/public/TaskProcessing/IProvider.php',

lib/private/TaskProcessing/Manager.php

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
use OCP\TaskProcessing\Exception\UnauthorizedException;
5959
use OCP\TaskProcessing\Exception\UserFacingProcessingException;
6060
use OCP\TaskProcessing\Exception\ValidationException;
61+
use OCP\TaskProcessing\FileShaped;
6162
use OCP\TaskProcessing\IInternalTaskType;
6263
use OCP\TaskProcessing\IManager;
6364
use OCP\TaskProcessing\IProvider;
@@ -1604,14 +1605,28 @@ public function encapsulateOutputFileData(array $output, ...$specs): array {
16041605
continue;
16051606
}
16061607
if (EShapeType::getScalarType($type) === $type) {
1608+
if ($output[$key] instanceof FileShaped) {
1609+
$data = $output[$key]->getData();
1610+
$ext = FileShaped::sanitizeExtension($output[$key]->getExtension());
1611+
} else {
1612+
$data = $output[$key];
1613+
$ext = '';
1614+
}
16071615
/** @var SimpleFile $file */
1608-
$file = $folder->newFile(time() . '-' . rand(1, 100000), $output[$key]);
1616+
$file = $folder->newFile(time() . '-' . rand(1, 100000) . ($ext ? '.' . $ext : ''), $data);
16091617
$newOutput[$key] = $file->getId(); // polymorphic call to SimpleFile
16101618
} else {
16111619
$newOutput[$key] = [];
16121620
foreach ($output[$key] as $item) {
1621+
if ($item instanceof FileShaped) {
1622+
$data = $item->getData();
1623+
$ext = FileShaped::sanitizeExtension($item->getExtension());
1624+
} else {
1625+
$data = $item;
1626+
$ext = '';
1627+
}
16131628
/** @var SimpleFile $file */
1614-
$file = $folder->newFile(time() . '-' . rand(1, 100000), $item);
1629+
$file = $folder->newFile(time() . '-' . rand(1, 100000) . ($ext ? '.' . $ext : ''), $data);
16151630
$newOutput[$key][] = $file->getId();
16161631
}
16171632
}
@@ -1758,7 +1773,7 @@ private function validateOutputFileIds(array $output, ...$specs): array {
17581773
$newOutput[$key] = $this->validateFileId($output[$key]);
17591774
} else {
17601775
// Is list of file IDs
1761-
$newOutput = [];
1776+
$newOutput[$key] = [];
17621777
foreach ($output[$key] as $item) {
17631778
$newOutput[$key][] = $this->validateFileId($item);
17641779
}

lib/public/TaskProcessing/EShapeType.php

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99

1010
namespace OCP\TaskProcessing;
1111

12+
use OCP\AppFramework\Attribute\Consumable;
1213
use OCP\TaskProcessing\Exception\ValidationException;
1314

1415
/**
1516
* The input and output Shape types
1617
*
1718
* @since 30.0.0
1819
*/
20+
#[Consumable(since: '30.0.0')]
1921
enum EShapeType: int {
2022
/**
2123
* @since 30.0.0
@@ -146,7 +148,7 @@ public function validateInput(mixed $value): void {
146148
throw new ValidationException('Non-file item provided for File slot');
147149
}
148150
if ($this === EShapeType::ListOfFiles && (!is_array($value) || count(array_filter($value, fn ($item) => !is_numeric($item))) > 0)) {
149-
throw new ValidationException('Non-audio list item provided for ListOfFiles slot');
151+
throw new ValidationException('Non-file list item provided for ListOfFiles slot');
150152
}
151153
}
152154

@@ -156,29 +158,29 @@ public function validateInput(mixed $value): void {
156158
*/
157159
public function validateOutputWithFileData(mixed $value): void {
158160
$this->validateNonFileType($value);
159-
if ($this === EShapeType::Image && !is_string($value)) {
161+
if ($this === EShapeType::Image && !is_string($value) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::Image)) {
160162
throw new ValidationException('Non-image item provided for Image slot');
161163
}
162-
if ($this === EShapeType::ListOfImages && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) {
164+
if ($this === EShapeType::ListOfImages && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item) && !($item instanceof FileShaped && $item->getShapeType() === EShapeType::Image))) > 0)) {
163165
throw new ValidationException('Non-image list item provided for ListOfImages slot');
164166
}
165-
if ($this === EShapeType::Audio && !is_string($value)) {
167+
if ($this === EShapeType::Audio && !is_string($value) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::Audio)) {
166168
throw new ValidationException('Non-audio item provided for Audio slot');
167169
}
168-
if ($this === EShapeType::ListOfAudios && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) {
170+
if ($this === EShapeType::ListOfAudios && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item) && !($item instanceof FileShaped && $item->getShapeType() === EShapeType::Audio))) > 0)) {
169171
throw new ValidationException('Non-audio list item provided for ListOfAudio slot');
170172
}
171-
if ($this === EShapeType::Video && !is_string($value)) {
173+
if ($this === EShapeType::Video && !is_string($value) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::Video)) {
172174
throw new ValidationException('Non-video item provided for Video slot');
173175
}
174-
if ($this === EShapeType::ListOfVideos && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) {
175-
throw new ValidationException('Non-video list item provided for ListOfTexts slot');
176+
if ($this === EShapeType::ListOfVideos && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item) && !($item instanceof FileShaped && $item->getShapeType() === EShapeType::Video))) > 0)) {
177+
throw new ValidationException('Non-video list item provided for ListOfVideos slot');
176178
}
177-
if ($this === EShapeType::File && !is_string($value)) {
179+
if ($this === EShapeType::File && !is_string($value) && !($value instanceof FileShaped && $value->getShapeType() === EShapeType::File)) {
178180
throw new ValidationException('Non-file item provided for File slot');
179181
}
180-
if ($this === EShapeType::ListOfFiles && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item))) > 0)) {
181-
throw new ValidationException('Non-audio list item provided for ListOfFiles slot');
182+
if ($this === EShapeType::ListOfFiles && (!is_array($value) || count(array_filter($value, fn ($item) => !is_string($item) && !($item instanceof FileShaped && $item->getShapeType() === EShapeType::File))) > 0)) {
183+
throw new ValidationException('Non-file list item provided for ListOfFiles slot');
182184
}
183185
}
184186

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCP\TaskProcessing;
9+
10+
use OCP\AppFramework\Attribute\Consumable;
11+
12+
/**
13+
* Data object for file-shaped output entries
14+
*
15+
* @since 35.0.0
16+
*/
17+
#[Consumable(since: '35.0.0')]
18+
final class FileShaped {
19+
/**
20+
* @param EShapeType $shapeType
21+
* @param string $data
22+
* @param string $extension (optional)
23+
*
24+
* @since 35.0.0
25+
*/
26+
public function __construct(
27+
private EShapeType $shapeType,
28+
private string $data,
29+
private string $extension = '',
30+
) {
31+
$this->extension = self::sanitizeExtension($this->extension);
32+
}
33+
34+
/**
35+
* @return string
36+
* @since 35.0.0
37+
*/
38+
public function getData(): string {
39+
return $this->data;
40+
}
41+
42+
/**
43+
* @since 35.0.0
44+
*/
45+
public function getShapeType(): EShapeType {
46+
return $this->shapeType;
47+
}
48+
49+
/**
50+
* @since 35.0.0
51+
*/
52+
public function getExtension(): string {
53+
return $this->extension;
54+
}
55+
56+
/**
57+
* @since 35.0.0
58+
*/
59+
public static function sanitizeExtension(string $ext): string {
60+
if ($ext === '') {
61+
return '';
62+
}
63+
$ext = str_replace(['.', '/'], '', $ext);
64+
$ext = preg_replace('/[^A-Za-z0-9]/', '', $ext) ?? $ext;
65+
$ext = strtolower($ext);
66+
$ext = substr($ext, 0, 16);
67+
68+
if ($ext === 'php' || $ext === 'htaccess' || $ext === 'phar') {
69+
return '';
70+
}
71+
72+
return $ext;
73+
}
74+
}

lib/public/TaskProcessing/IInternalTaskType.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@
99

1010
namespace OCP\TaskProcessing;
1111

12+
use OCP\AppFramework\Attribute\Implementable;
13+
1214
/**
1315
* This is a task type interface that is implemented by task processing
1416
* task types that should not show up in the assistant UI
17+
*
1518
* @since 33.0.0
1619
*/
20+
#[Implementable(since: '33.0.0')]
1721
interface IInternalTaskType extends ITaskType {
1822

1923
}

lib/public/TaskProcessing/IProvider.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@
99

1010
namespace OCP\TaskProcessing;
1111

12+
use OCP\AppFramework\Attribute\Implementable;
13+
1214
/**
1315
* This is the interface that is implemented by apps that
1416
* implement a task processing provider
17+
*
1518
* @since 30.0.0
1619
*/
20+
#[Implementable(since: '30.0.0')]
1721
interface IProvider {
1822
/**
1923
* The unique id of this provider

lib/public/TaskProcessing/ISynchronousOptionsAwareProvider.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
namespace OCP\TaskProcessing;
1111

12+
use OCP\AppFramework\Attribute\Implementable;
1213
use OCP\Files\File;
1314
use OCP\TaskProcessing\Exception\ProcessingException;
1415

@@ -17,6 +18,7 @@
1718
* implement a task processing provider
1819
* @since 35.0.0
1920
*/
21+
#[Implementable(since: '35.0.0')]
2022
interface ISynchronousOptionsAwareProvider extends ISynchronousProvider {
2123

2224
/**
@@ -26,7 +28,7 @@ interface ISynchronousOptionsAwareProvider extends ISynchronousProvider {
2628
* @param array<string, list<numeric|string|File>|numeric|string|File> $input The task input
2729
* @param callable(float):bool $reportProgress Report the task progress. If this returns false, that means the task was cancelled and processing should be stopped.
2830
* @param SynchronousProviderOptions $options The task options
29-
* @psalm-return array<string, list<numeric|string>|numeric|string>
31+
* @psalm-return array<string, list<numeric|string|FileShaped>|numeric|string|FileShaped>
3032
* @throws ProcessingException
3133
* @since 35.0.0
3234
*/

lib/public/TaskProcessing/ISynchronousProvider.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
namespace OCP\TaskProcessing;
1111

12+
use OCP\AppFramework\Attribute\Implementable;
1213
use OCP\Files\File;
1314
use OCP\TaskProcessing\Exception\ProcessingException;
1415

@@ -17,6 +18,7 @@
1718
* implement a task processing provider
1819
* @since 30.0.0
1920
*/
21+
#[Implementable(since: '30.0.0')]
2022
interface ISynchronousProvider extends IProvider {
2123

2224
/**
@@ -25,9 +27,10 @@ interface ISynchronousProvider extends IProvider {
2527
* @param null|string $userId The user that created the current task
2628
* @param array<string, list<numeric|string|File>|numeric|string|File> $input The task input
2729
* @param callable(float):bool $reportProgress Report the task progress. If this returns false, that means the task was cancelled and processing should be stopped.
28-
* @psalm-return array<string, list<numeric|string>|numeric|string>
30+
* @psalm-return array<string, list<numeric|string|FileShaped>|numeric|string|FileShaped>
2931
* @throws ProcessingException
3032
* @since 30.0.0
33+
* @since 35.0.0 You can now also return a FileShaped object or a list of FileShaped objects
3134
*/
3235
public function process(?string $userId, array $input, callable $reportProgress): array;
3336
}

0 commit comments

Comments
 (0)