diff options
author | Elizabeth Danzberger <lizzy7128@tutanota.de> | 2024-12-18 16:42:16 -0500 |
---|---|---|
committer | Elizabeth Danzberger <lizzy7128@tutanota.de> | 2025-01-15 16:38:18 -0500 |
commit | fdfeb7f265bfaef46bab8f3b506df6f69807d435 (patch) | |
tree | 64b158214c9a2edd341f9b0dbcb92b55a3a74042 | |
parent | 6da58974a1a75275d99a75417ddd8f5d47851845 (diff) | |
download | nextcloud-server-fdfeb7f265bfaef46bab8f3b506df6f69807d435.tar.gz nextcloud-server-fdfeb7f265bfaef46bab8f3b506df6f69807d435.zip |
feat(api): File conversion API
Signed-off-by: Elizabeth Danzberger <lizzy7128@tutanota.de>
21 files changed, 732 insertions, 2 deletions
diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index 7975c6f3d83..5922560f521 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -42,6 +42,7 @@ return array( 'OCA\\Files\\Command\\ScanAppData' => $baseDir . '/../lib/Command/ScanAppData.php', 'OCA\\Files\\Command\\TransferOwnership' => $baseDir . '/../lib/Command/TransferOwnership.php', 'OCA\\Files\\Controller\\ApiController' => $baseDir . '/../lib/Controller/ApiController.php', + 'OCA\\Files\\Controller\\ConversionApiController' => $baseDir . '/../lib/Controller/ConversionApiController.php', 'OCA\\Files\\Controller\\DirectEditingController' => $baseDir . '/../lib/Controller/DirectEditingController.php', 'OCA\\Files\\Controller\\DirectEditingViewController' => $baseDir . '/../lib/Controller/DirectEditingViewController.php', 'OCA\\Files\\Controller\\OpenLocalEditorController' => $baseDir . '/../lib/Controller/OpenLocalEditorController.php', diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index f2cb858f313..bf489b037f7 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -57,6 +57,7 @@ class ComposerStaticInitFiles 'OCA\\Files\\Command\\ScanAppData' => __DIR__ . '/..' . '/../lib/Command/ScanAppData.php', 'OCA\\Files\\Command\\TransferOwnership' => __DIR__ . '/..' . '/../lib/Command/TransferOwnership.php', 'OCA\\Files\\Controller\\ApiController' => __DIR__ . '/..' . '/../lib/Controller/ApiController.php', + 'OCA\\Files\\Controller\\ConversionApiController' => __DIR__ . '/..' . '/../lib/Controller/ConversionApiController.php', 'OCA\\Files\\Controller\\DirectEditingController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingController.php', 'OCA\\Files\\Controller\\DirectEditingViewController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingViewController.php', 'OCA\\Files\\Controller\\OpenLocalEditorController' => __DIR__ . '/..' . '/../lib/Controller/OpenLocalEditorController.php', diff --git a/apps/files/lib/Capabilities.php b/apps/files/lib/Capabilities.php index 16ea42eae22..88efb4fcaf0 100644 --- a/apps/files/lib/Capabilities.php +++ b/apps/files/lib/Capabilities.php @@ -10,18 +10,21 @@ namespace OCA\Files; use OC\Files\FilenameValidator; use OCA\Files\Service\ChunkedUploadConfig; use OCP\Capabilities\ICapability; +use OCP\Files\Conversion\ConversionMimeTuple; +use OCP\Files\Conversion\IConversionManager; class Capabilities implements ICapability { public function __construct( protected FilenameValidator $filenameValidator, + protected IConversionManager $fileConversionManager, ) { } /** * Return this classes capabilities * - * @return array{files: array{'$comment': ?string, bigfilechunking: bool, blacklisted_files: list<mixed>, forbidden_filenames: list<string>, forbidden_filename_basenames: list<string>, forbidden_filename_characters: list<string>, forbidden_filename_extensions: list<string>, chunked_upload: array{max_size: int, max_parallel_count: int}}} + * @return array{files: array{'$comment': ?string, bigfilechunking: bool, blacklisted_files: list<mixed>, forbidden_filenames: list<string>, forbidden_filename_basenames: list<string>, forbidden_filename_characters: list<string>, forbidden_filename_extensions: list<string>, chunked_upload: array{max_size: int, max_parallel_count: int}, file_conversions: list<array{from: string, to: list<array{mime: string, name: string}>}>}} */ public function getCapabilities(): array { return [ @@ -38,6 +41,10 @@ class Capabilities implements ICapability { 'max_size' => ChunkedUploadConfig::getMaxChunkSize(), 'max_parallel_count' => ChunkedUploadConfig::getMaxParallelCount(), ], + + 'file_conversions' => array_map(function (ConversionMimeTuple $mimeTuple) { + return $mimeTuple->jsonSerialize(); + }, $this->fileConversionManager->getMimeTypes()), ], ]; } diff --git a/apps/files/lib/Controller/ConversionApiController.php b/apps/files/lib/Controller/ConversionApiController.php new file mode 100644 index 00000000000..1a1b7da1787 --- /dev/null +++ b/apps/files/lib/Controller/ConversionApiController.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Controller; + +use OC\Files\Utils\PathHelper; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\AppFramework\OCSController; +use OCP\Files\Conversion\IConversionManager; +use OCP\Files\File; +use OCP\Files\IRootFolder; +use OCP\IL10N; +use OCP\IRequest; + +class ConversionApiController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private IConversionManager $fileConversionManager, + private IRootFolder $rootFolder, + private IL10N $l10n, + private ?string $userId, + ) { + parent::__construct($appName, $request); + } + + /** + * Converts a file from one MIME type to another + * + * @param int $fileId ID of the file to be converted + * @param string $targetMimeType The MIME type to which you want to convert the file + * @param string|null $destination The target path of the converted file. Written to a temporary file if left empty + * + * @return DataResponse<Http::STATUS_CREATED, array{path: string}, array{}> + * + * 201: File was converted and written to the destination or temporary file + * + * @throws OCSException The file was unable to be converted + * @throws OCSNotFoundException The file to be converted was not found + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 25, period: 120)] + #[ApiRoute(verb: 'POST', url: '/api/v1/convert')] + public function convert(int $fileId, string $targetMimeType, ?string $destination = null): DataResponse { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $file = $userFolder->getFirstNodeById($fileId); + + if (!($file instanceof File)) { + throw new OCSNotFoundException($this->l10n->t('The file cannot be found')); + } + + if ($destination !== null) { + $destination = PathHelper::normalizePath($destination); + $parentDir = dirname($destination); + + if (!$userFolder->nodeExists($parentDir)) { + throw new OCSNotFoundException($this->l10n->t('The destination path does not exist: %1$s', [$parentDir])); + } + + if (!$userFolder->get($parentDir)->isCreatable()) { + throw new OCSForbiddenException(); + } + + $destination = $userFolder->getFullPath($destination); + } + + try { + $convertedFile = $this->fileConversionManager->convert($file, $targetMimeType, $destination); + } catch (\Exception $e) { + throw new OCSException($e->getMessage()); + } + + $convertedFileRelativePath = $userFolder->getRelativePath($convertedFile); + if ($convertedFileRelativePath === null) { + throw new OCSNotFoundException($this->l10n->t('Could not get relative path to converted file')); + } + + return new DataResponse([ + 'path' => $convertedFileRelativePath, + ], Http::STATUS_CREATED); + } +} diff --git a/apps/files/openapi.json b/apps/files/openapi.json index 2c33097438f..c196353cdd1 100644 --- a/apps/files/openapi.json +++ b/apps/files/openapi.json @@ -37,6 +37,7 @@ "forbidden_filename_characters", "forbidden_filename_extensions", "chunked_upload", + "file_conversions", "directEditing" ], "properties": { @@ -94,6 +95,27 @@ } } }, + "file_conversions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, "directEditing": { "type": "object", "required": [ @@ -2221,6 +2243,133 @@ } } } + }, + "/ocs/v2.php/apps/files/api/v1/convert": { + "post": { + "operationId": "conversion_api-convert", + "summary": "Converts a file from one MIME type to another", + "tags": [ + "conversion_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "fileId", + "targetMimeType" + ], + "properties": { + "fileId": { + "type": "integer", + "format": "int64", + "description": "ID of the file to be converted" + }, + "targetMimeType": { + "type": "string", + "description": "The MIME type to which you want to convert the file" + }, + "destination": { + "type": "string", + "nullable": true, + "description": "The target path of the converted file. Written to a temporary file if left empty" + } + } + } + } + } + }, + "parameters": [ + { + "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 was converted and written to the destination or temporary file", + "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": [ + "path" + ], + "properties": { + "path": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "The file to be converted was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } } }, "tags": [] diff --git a/apps/files/tests/Controller/ConversionApiControllerTest.php b/apps/files/tests/Controller/ConversionApiControllerTest.php new file mode 100644 index 00000000000..d2e68a8a32c --- /dev/null +++ b/apps/files/tests/Controller/ConversionApiControllerTest.php @@ -0,0 +1,91 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files\Controller; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\Files\Conversion\IConversionManager; +use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\IL10N; +use OCP\IRequest; +use PHPUnit\Framework\MockObject\MockObject; +use Test\TestCase; + +/** + * Class ConversionApiController + * + * @package OCA\Files\Controller + */ +class ConversionApiControllerTest extends TestCase { + private string $appName = 'files'; + private ConversionApiController $conversionApiController; + private IRequest&MockObject $request; + private IConversionManager&MockObject $fileConversionManager; + private IRootFolder&MockObject $rootFolder; + private File&MockObject $file; + private Folder&MockObject $userFolder; + private IL10N&MockObject $l10n; + private string $user; + + protected function setUp(): void { + parent::setUp(); + + $this->request = $this->createMock(IRequest::class); + $this->fileConversionManager = $this->createMock(IConversionManager::class); + $this->file = $this->createMock(File::class); + $this->l10n = $this->createMock(IL10N::class); + $this->user = 'userid'; + + $this->userFolder = $this->createMock(Folder::class); + + $this->rootFolder = $this->createMock(IRootFolder::class); + $this->rootFolder->method('getUserFolder')->with($this->user)->willReturn($this->userFolder); + + $this->conversionApiController = new ConversionApiController( + $this->appName, + $this->request, + $this->fileConversionManager, + $this->rootFolder, + $this->l10n, + $this->user, + ); + } + + public function testThrowsNotFoundException() { + $this->expectException(OCSNotFoundException::class); + $this->conversionApiController->convert(42, 'image/png'); + } + + public function testThrowsOcsException() { + $this->userFolder->method('getFirstNodeById')->with(42)->willReturn($this->file); + $this->fileConversionManager->method('convert')->willThrowException(new \Exception()); + + $this->expectException(OCSException::class); + $this->conversionApiController->convert(42, 'image/png'); + } + + public function testConvert() { + $convertedFileAbsolutePath = $this->user . '/files/test.png'; + + $this->userFolder->method('getFirstNodeById')->with(42)->willReturn($this->file); + $this->userFolder->method('getRelativePath')->with($convertedFileAbsolutePath)->willReturn('/test.png'); + + $this->fileConversionManager->method('convert')->with($this->file, 'image/png', null)->willReturn($convertedFileAbsolutePath); + + $actual = $this->conversionApiController->convert(42, 'image/png', null); + $expected = new DataResponse([ + 'path' => '/test.png', + ], Http::STATUS_CREATED); + + $this->assertEquals($expected, $actual); + } +} diff --git a/apps/testing/composer/composer/autoload_classmap.php b/apps/testing/composer/composer/autoload_classmap.php index 83d1fc771fc..e7f1ce74466 100644 --- a/apps/testing/composer/composer/autoload_classmap.php +++ b/apps/testing/composer/composer/autoload_classmap.php @@ -12,6 +12,7 @@ return array( 'OCA\\Testing\\Controller\\ConfigController' => $baseDir . '/../lib/Controller/ConfigController.php', 'OCA\\Testing\\Controller\\LockingController' => $baseDir . '/../lib/Controller/LockingController.php', 'OCA\\Testing\\Controller\\RateLimitTestController' => $baseDir . '/../lib/Controller/RateLimitTestController.php', + 'OCA\\Testing\\Conversion\\ConversionProvider' => $baseDir . '/../lib/Conversion/ConversionProvider.php', 'OCA\\Testing\\Listener\\GetDeclarativeSettingsValueListener' => $baseDir . '/../lib/Listener/GetDeclarativeSettingsValueListener.php', 'OCA\\Testing\\Listener\\RegisterDeclarativeSettingsListener' => $baseDir . '/../lib/Listener/RegisterDeclarativeSettingsListener.php', 'OCA\\Testing\\Listener\\SetDeclarativeSettingsValueListener' => $baseDir . '/../lib/Listener/SetDeclarativeSettingsValueListener.php', diff --git a/apps/testing/composer/composer/autoload_static.php b/apps/testing/composer/composer/autoload_static.php index 3dc4bfe2fd6..f87a822aaf2 100644 --- a/apps/testing/composer/composer/autoload_static.php +++ b/apps/testing/composer/composer/autoload_static.php @@ -27,6 +27,7 @@ class ComposerStaticInitTesting 'OCA\\Testing\\Controller\\ConfigController' => __DIR__ . '/..' . '/../lib/Controller/ConfigController.php', 'OCA\\Testing\\Controller\\LockingController' => __DIR__ . '/..' . '/../lib/Controller/LockingController.php', 'OCA\\Testing\\Controller\\RateLimitTestController' => __DIR__ . '/..' . '/../lib/Controller/RateLimitTestController.php', + 'OCA\\Testing\\Conversion\\ConversionProvider' => __DIR__ . '/..' . '/../lib/Conversion/ConversionProvider.php', 'OCA\\Testing\\Listener\\GetDeclarativeSettingsValueListener' => __DIR__ . '/..' . '/../lib/Listener/GetDeclarativeSettingsValueListener.php', 'OCA\\Testing\\Listener\\RegisterDeclarativeSettingsListener' => __DIR__ . '/..' . '/../lib/Listener/RegisterDeclarativeSettingsListener.php', 'OCA\\Testing\\Listener\\SetDeclarativeSettingsValueListener' => __DIR__ . '/..' . '/../lib/Listener/SetDeclarativeSettingsValueListener.php', diff --git a/apps/testing/lib/AppInfo/Application.php b/apps/testing/lib/AppInfo/Application.php index 3502b78402e..bbd9e288cc1 100644 --- a/apps/testing/lib/AppInfo/Application.php +++ b/apps/testing/lib/AppInfo/Application.php @@ -7,6 +7,7 @@ namespace OCA\Testing\AppInfo; use OCA\Testing\AlternativeHomeUserBackend; +use OCA\Testing\Conversion\ConversionProvider; use OCA\Testing\Listener\GetDeclarativeSettingsValueListener; use OCA\Testing\Listener\RegisterDeclarativeSettingsListener; use OCA\Testing\Listener\SetDeclarativeSettingsValueListener; @@ -49,6 +50,8 @@ class Application extends App implements IBootstrap { $context->registerTaskProcessingProvider(FakeTranscribeProvider::class); $context->registerTaskProcessingProvider(FakeContextWriteProvider::class); + $context->registerFileConversionProvider(ConversionProvider::class); + $context->registerDeclarativeSettings(DeclarativeSettingsForm::class); $context->registerEventListener(DeclarativeSettingsRegisterFormEvent::class, RegisterDeclarativeSettingsListener::class); $context->registerEventListener(DeclarativeSettingsGetValueEvent::class, GetDeclarativeSettingsValueListener::class); diff --git a/apps/testing/lib/Conversion/ConversionProvider.php b/apps/testing/lib/Conversion/ConversionProvider.php new file mode 100644 index 00000000000..15e468b0756 --- /dev/null +++ b/apps/testing/lib/Conversion/ConversionProvider.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Testing\Conversion; + +use OCP\Files\Conversion\ConversionMimeTuple; +use OCP\Files\Conversion\IConversionProvider; +use OCP\Files\File; +use OCP\IL10N; + +class ConversionProvider implements IConversionProvider { + public function __construct( + private IL10N $l10n, + ) { + } + + public function getSupportedMimeTypes(): array { + return [ + new ConversionMimeTuple('image/jpeg', [ + ['mime' => 'image/png', 'name' => $this->l10n->t('Image (.png)')], + ]) + ]; + } + + public function convertFile(File $file, string $targetMimeType): mixed { + $image = imagecreatefromstring($file->getContent()); + + imagepalettetotruecolor($image); + + ob_start(); + imagepng($image); + return ob_get_clean(); + } +} diff --git a/config/config.sample.php b/config/config.sample.php index ebc8427558f..b0b0f21853b 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -1429,6 +1429,14 @@ $CONFIG = [ 'metadata_max_filesize' => 256, /** + * Maximum file size for file conversion. + * If a file exceeds this size, the file will not be converted. + * + * Default: 100 MiB + */ +'max_file_conversion_filesize' => 100, + +/** * LDAP * * Global settings used by LDAP User and Group Backend diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 9809ea53508..d6a8ac73c5f 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -377,6 +377,9 @@ return array( 'OCP\\Files\\Config\\IRootMountProvider' => $baseDir . '/lib/public/Files/Config/IRootMountProvider.php', 'OCP\\Files\\Config\\IUserMountCache' => $baseDir . '/lib/public/Files/Config/IUserMountCache.php', 'OCP\\Files\\ConnectionLostException' => $baseDir . '/lib/public/Files/ConnectionLostException.php', + 'OCP\\Files\\Conversion\\ConversionMimeTuple' => $baseDir . '/lib/public/Files/Conversion/ConversionMimeTuple.php', + 'OCP\\Files\\Conversion\\IConversionManager' => $baseDir . '/lib/public/Files/Conversion/IConversionManager.php', + 'OCP\\Files\\Conversion\\IConversionProvider' => $baseDir . '/lib/public/Files/Conversion/IConversionProvider.php', 'OCP\\Files\\DavUtil' => $baseDir . '/lib/public/Files/DavUtil.php', 'OCP\\Files\\EmptyFileNameException' => $baseDir . '/lib/public/Files/EmptyFileNameException.php', 'OCP\\Files\\EntityTooLargeException' => $baseDir . '/lib/public/Files/EntityTooLargeException.php', @@ -1575,6 +1578,7 @@ return array( 'OC\\Files\\Config\\MountProviderCollection' => $baseDir . '/lib/private/Files/Config/MountProviderCollection.php', 'OC\\Files\\Config\\UserMountCache' => $baseDir . '/lib/private/Files/Config/UserMountCache.php', 'OC\\Files\\Config\\UserMountCacheListener' => $baseDir . '/lib/private/Files/Config/UserMountCacheListener.php', + 'OC\\Files\\Conversion\\ConversionManager' => $baseDir . '/lib/private/Files/Conversion/ConversionManager.php', 'OC\\Files\\FileInfo' => $baseDir . '/lib/private/Files/FileInfo.php', 'OC\\Files\\FilenameValidator' => $baseDir . '/lib/private/Files/FilenameValidator.php', 'OC\\Files\\Filesystem' => $baseDir . '/lib/private/Files/Filesystem.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index f612e0d5fc6..0fba81fd3cd 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -418,6 +418,9 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Files\\Config\\IRootMountProvider' => __DIR__ . '/../../..' . '/lib/public/Files/Config/IRootMountProvider.php', 'OCP\\Files\\Config\\IUserMountCache' => __DIR__ . '/../../..' . '/lib/public/Files/Config/IUserMountCache.php', 'OCP\\Files\\ConnectionLostException' => __DIR__ . '/../../..' . '/lib/public/Files/ConnectionLostException.php', + 'OCP\\Files\\Conversion\\ConversionMimeTuple' => __DIR__ . '/../../..' . '/lib/public/Files/Conversion/ConversionMimeTuple.php', + 'OCP\\Files\\Conversion\\IConversionManager' => __DIR__ . '/../../..' . '/lib/public/Files/Conversion/IConversionManager.php', + 'OCP\\Files\\Conversion\\IConversionProvider' => __DIR__ . '/../../..' . '/lib/public/Files/Conversion/IConversionProvider.php', 'OCP\\Files\\DavUtil' => __DIR__ . '/../../..' . '/lib/public/Files/DavUtil.php', 'OCP\\Files\\EmptyFileNameException' => __DIR__ . '/../../..' . '/lib/public/Files/EmptyFileNameException.php', 'OCP\\Files\\EntityTooLargeException' => __DIR__ . '/../../..' . '/lib/public/Files/EntityTooLargeException.php', @@ -1616,6 +1619,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Files\\Config\\MountProviderCollection' => __DIR__ . '/../../..' . '/lib/private/Files/Config/MountProviderCollection.php', 'OC\\Files\\Config\\UserMountCache' => __DIR__ . '/../../..' . '/lib/private/Files/Config/UserMountCache.php', 'OC\\Files\\Config\\UserMountCacheListener' => __DIR__ . '/../../..' . '/lib/private/Files/Config/UserMountCacheListener.php', + 'OC\\Files\\Conversion\\ConversionManager' => __DIR__ . '/../../..' . '/lib/private/Files/Conversion/ConversionManager.php', 'OC\\Files\\FileInfo' => __DIR__ . '/../../..' . '/lib/private/Files/FileInfo.php', 'OC\\Files\\FilenameValidator' => __DIR__ . '/../../..' . '/lib/private/Files/FilenameValidator.php', 'OC\\Files\\Filesystem' => __DIR__ . '/../../..' . '/lib/private/Files/Filesystem.php', diff --git a/lib/private/AppFramework/Bootstrap/RegistrationContext.php b/lib/private/AppFramework/Bootstrap/RegistrationContext.php index 77cc58c6468..c3b829825c2 100644 --- a/lib/private/AppFramework/Bootstrap/RegistrationContext.php +++ b/lib/private/AppFramework/Bootstrap/RegistrationContext.php @@ -154,6 +154,9 @@ class RegistrationContext { /** @var ServiceRegistration<\OCP\TaskProcessing\ITaskType>[] */ private array $taskProcessingTaskTypes = []; + + /** @var ServiceRegistration<\OCP\Files\Conversion\IConversionProvider>[] */ + private array $fileConversionProviders = []; /** @var ServiceRegistration<IMailProvider>[] */ private $mailProviders = []; @@ -421,6 +424,13 @@ class RegistrationContext { ); } + public function registerFileConversionProvider(string $class): void { + $this->context->registerFileConversionProvider( + $this->appId, + $class + ); + } + public function registerMailProvider(string $class): void { $this->context->registerMailProvider( $this->appId, @@ -626,6 +636,14 @@ class RegistrationContext { public function registerTaskProcessingTaskType(string $appId, string $taskProcessingTaskTypeClass) { $this->taskProcessingTaskTypes[] = new ServiceRegistration($appId, $taskProcessingTaskTypeClass); } + + /** + * @psalm-param class-string<\OCP\Files\Conversion\IConversionProvider> $class + */ + public function registerFileConversionProvider(string $appId, string $class): void { + $this->fileConversionProviders[] = new ServiceRegistration($appId, $class); + } + /** * @psalm-param class-string<IMailProvider> $migratorClass */ @@ -986,6 +1004,13 @@ class RegistrationContext { } /** + * @return ServiceRegistration<\OCP\Files\Conversion\IConversionProvider>[] + */ + public function getFileConversionProviders(): array { + return $this->fileConversionProviders; + } + + /** * @return ServiceRegistration<IMailProvider>[] */ public function getMailProviders(): array { diff --git a/lib/private/CapabilitiesManager.php b/lib/private/CapabilitiesManager.php index d7bf25f078a..07076d9aacd 100644 --- a/lib/private/CapabilitiesManager.php +++ b/lib/private/CapabilitiesManager.php @@ -32,7 +32,7 @@ class CapabilitiesManager { } /** - * Get an array of al the capabilities that are registered at this manager + * Get an array of all the capabilities that are registered at this manager * * @param bool $public get public capabilities only * @throws \InvalidArgumentException diff --git a/lib/private/Files/Conversion/ConversionManager.php b/lib/private/Files/Conversion/ConversionManager.php new file mode 100644 index 00000000000..37fc9a6c754 --- /dev/null +++ b/lib/private/Files/Conversion/ConversionManager.php @@ -0,0 +1,152 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Files\Conversion; + +use OC\AppFramework\Bootstrap\Coordinator; +use OC\SystemConfig; +use OCP\Files\Conversion\ConversionMimeTuple; +use OCP\Files\Conversion\IConversionManager; +use OCP\Files\Conversion\IConversionProvider; +use OCP\Files\File; +use OCP\Files\GenericFileException; +use OCP\Files\IRootFolder; +use OCP\ITempManager; +use OCP\PreConditionNotMetException; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; + +class ConversionManager implements IConversionManager { + /** @var string[] */ + private array $preferredApps = [ + 'richdocuments', + ]; + + /** @var IConversionProvider[] */ + private array $preferredProviders = []; + + /** @var IConversionProvider[] */ + private array $providers = []; + + public function __construct( + private Coordinator $coordinator, + private ContainerInterface $serverContainer, + private IRootFolder $rootFolder, + private ITempManager $tempManager, + private LoggerInterface $logger, + private SystemConfig $config, + ) { + } + + public function hasProviders(): bool { + $context = $this->coordinator->getRegistrationContext(); + return !empty($context->getFileConversionProviders()); + } + + public function getMimeTypes(): array { + $mimeTypes = []; + + foreach ($this->getProviders() as $provider) { + $mimeTypes[] = $provider->getSupportedMimetypes(); + } + + /** @var list<ConversionMimeTuple> */ + $mimeTypes = array_merge(...$mimeTypes); + return $mimeTypes; + } + + public function convert(File $file, string $targetMimeType, ?string $destination = null): string { + if (!$this->hasProviders()) { + throw new PreConditionNotMetException('No file conversion providers available'); + } + + // Operate in mebibytes + $fileSize = $file->getSize() / (1024 * 1024); + $threshold = $this->config->getValue('max_file_conversion_filesize', 100); + if ($fileSize > $threshold) { + throw new GenericFileException('File is too large to convert'); + } + + $fileMimeType = $file->getMimetype(); + $validProvider = $this->getValidProvider($fileMimeType, $targetMimeType); + + if ($validProvider !== null) { + $convertedFile = $validProvider->convertFile($file, $targetMimeType); + + if ($destination !== null) { + $convertedFile = $this->writeToDestination($destination, $convertedFile); + return $convertedFile->getPath(); + } + + $tmp = $this->tempManager->getTemporaryFile(); + file_put_contents($tmp, $convertedFile); + + return $tmp; + } + + throw new RuntimeException('Could not convert file'); + } + + public function getProviders(): array { + if (count($this->providers) > 0) { + return $this->providers; + } + + $context = $this->coordinator->getRegistrationContext(); + foreach ($context->getFileConversionProviders() as $providerRegistration) { + $class = $providerRegistration->getService(); + $appId = $providerRegistration->getAppId(); + + try { + if (in_array($appId, $this->preferredApps)) { + $this->preferredProviders[$class] = $this->serverContainer->get($class); + continue; + } + + $this->providers[$class] = $this->serverContainer->get($class); + } catch (NotFoundExceptionInterface|ContainerExceptionInterface|Throwable $e) { + $this->logger->error('Failed to load file conversion provider ' . $class, [ + 'exception' => $e, + ]); + } + } + + return array_merge([], $this->preferredProviders, $this->providers); + } + + private function writeToDestination(string $destination, mixed $content): File { + return $this->rootFolder->newFile($destination, $content); + } + + private function getValidProvider(string $fileMimeType, string $targetMimeType): ?IConversionProvider { + $validProvider = null; + foreach ($this->getProviders() as $provider) { + $suitableMimeTypes = array_filter( + $provider->getSupportedMimeTypes(), + function (ConversionMimeTuple $mimeTuple) use ($fileMimeType, $targetMimeType) { + ['from' => $from, 'to' => $to] = $mimeTuple->jsonSerialize(); + + $supportsTargetMimeType = in_array($targetMimeType, array_column($to, 'mime')); + return ($from === $fileMimeType) && $supportsTargetMimeType; + } + ); + + if (!empty($suitableMimeTypes)) { + $validProvider = $provider; + break; + } + } + + return $validProvider; + } +} diff --git a/lib/private/Server.php b/lib/private/Server.php index a20c37732a7..f806c368eaf 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -45,6 +45,7 @@ use OC\Files\Cache\FileAccess; use OC\Files\Config\MountProviderCollection; use OC\Files\Config\UserMountCache; use OC\Files\Config\UserMountCacheListener; +use OC\Files\Conversion\ConversionManager; use OC\Files\Lock\LockManager; use OC\Files\Mount\CacheMountProvider; use OC\Files\Mount\LocalHomeMountProvider; @@ -155,6 +156,7 @@ use OCP\Federation\ICloudIdManager; use OCP\Files\Cache\IFileAccess; use OCP\Files\Config\IMountProviderCollection; use OCP\Files\Config\IUserMountCache; +use OCP\Files\Conversion\IConversionManager; use OCP\Files\IMimeTypeDetector; use OCP\Files\IMimeTypeLoader; use OCP\Files\IRootFolder; @@ -1258,6 +1260,8 @@ class Server extends ServerContainer implements IServerContainer { $this->registerAlias(ITranslationManager::class, TranslationManager::class); + $this->registerAlias(IConversionManager::class, ConversionManager::class); + $this->registerAlias(ISpeechToTextManager::class, SpeechToTextManager::class); $this->registerAlias(IEventSourceFactory::class, EventSourceFactory::class); diff --git a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php index 8a18ec8ae9d..011d1520414 100644 --- a/lib/public/AppFramework/Bootstrap/IRegistrationContext.php +++ b/lib/public/AppFramework/Bootstrap/IRegistrationContext.php @@ -415,6 +415,19 @@ interface IRegistrationContext { public function registerTaskProcessingTaskType(string $taskProcessingTaskTypeClass): void; /** + * Register an implementation of \OCP\Files\Conversion\IConversionProvider + * that will handle the conversion of files from one MIME type to another + * + * @param string $class + * @psalm-param class-string<\OCP\Files\Conversion\IConversionProvider> $class + * + * @return void + * + * @since 31.0.0 + */ + public function registerFileConversionProvider(string $class): void; + + /** * Register a mail provider * * @param string $class diff --git a/lib/public/Files/Conversion/ConversionMimeTuple.php b/lib/public/Files/Conversion/ConversionMimeTuple.php new file mode 100644 index 00000000000..0180f3311f3 --- /dev/null +++ b/lib/public/Files/Conversion/ConversionMimeTuple.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Files\Conversion; + +use JsonSerializable; + +/** + * A tuple-like object representing both an original and target + * MIME type for a file conversion + * + * @since 31.0.0 + */ +class ConversionMimeTuple implements JsonSerializable { + /** + * @param string $from The original MIME type of a file + * @param list<array{mime: string, name: string}> $to The desired MIME type for the file mapped to its translated name + * + * @since 31.0.0 + */ + public function __construct( + private string $from, + private array $to, + ) { + } + + /** + * @return array{from: string, to: list<array{mime: string, name: string}>} + * + * @since 31.0.0 + */ + public function jsonSerialize(): array { + return [ + 'from' => $this->from, + 'to' => $this->to, + ]; + } +} diff --git a/lib/public/Files/Conversion/IConversionManager.php b/lib/public/Files/Conversion/IConversionManager.php new file mode 100644 index 00000000000..59ff580fdf1 --- /dev/null +++ b/lib/public/Files/Conversion/IConversionManager.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Files\Conversion; + +use OCP\Files\File; + +/** + * @since 31.0.0 + */ +interface IConversionManager { + /** + * Determines whether or not conversion providers are available + * + * @since 31.0.0 + */ + public function hasProviders(): bool; + + /** + * Gets all supported MIME type conversions + * + * @return list<ConversionMimeTuple> + * + * @since 31.0.0 + */ + public function getMimeTypes(): array; + + /** + * Convert a file to a given MIME type + * + * @param File $file The file to be converted + * @param string $targetMimeType The MIME type to convert the file to + * @param ?string $destination The destination to save the converted file + * + * @return string Path to the converted file + * + * @since 31.0.0 + */ + public function convert(File $file, string $targetMimeType, ?string $destination = null): string; +} diff --git a/lib/public/Files/Conversion/IConversionProvider.php b/lib/public/Files/Conversion/IConversionProvider.php new file mode 100644 index 00000000000..b0a09fc93a4 --- /dev/null +++ b/lib/public/Files/Conversion/IConversionProvider.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Files\Conversion; + +use OCP\Files\File; + +/** + * This interface is implemented by apps that provide + * a file conversion provider + * + * @since 31.0.0 + */ +interface IConversionProvider { + /** + * Get an array of MIME type tuples this conversion provider supports + * + * @return list<ConversionMimeTuple> + * + * @since 31.0.0 + */ + public function getSupportedMimeTypes(): array; + + /** + * Convert a file to a given MIME type + * + * @param File $file The file to be converted + * @param string $targetMimeType The MIME type to convert the file to + * + * @return resource|string Resource or string content of the file + * + * @since 31.0.0 + */ + public function convertFile(File $file, string $targetMimeType): mixed; +} |