diff --git a/apps/files/appinfo/routes.php b/apps/files/appinfo/routes.php index 253b92b7c60e7..01542659a5ceb 100644 --- a/apps/files/appinfo/routes.php +++ b/apps/files/appinfo/routes.php @@ -132,6 +132,21 @@ 'url' => '/api/v1/directEditing/create', 'verb' => 'POST' ], + [ + 'name' => 'OrganizationTemplate#getPath', + 'url' => '/api/v1/templates/organization', + 'verb' => 'GET', + ], + [ + 'name' => 'OrganizationTemplate#setPath', + 'url' => '/api/v1/templates/organization', + 'verb' => 'PUT', + ], + [ + 'name' => 'OrganizationTemplate#preview', + 'url' => '/api/v1/templates/organization/{id}/preview', + 'verb' => 'GET', + ], [ 'name' => 'Template#list', 'url' => '/api/v1/templates', diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index e894a4c212046..1090f48ea9b0b 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -61,6 +61,7 @@ 'OCA\\Files\\Controller\\DirectEditingViewController' => $baseDir . '/../lib/Controller/DirectEditingViewController.php', 'OCA\\Files\\Controller\\FilenamesController' => $baseDir . '/../lib/Controller/FilenamesController.php', 'OCA\\Files\\Controller\\OpenLocalEditorController' => $baseDir . '/../lib/Controller/OpenLocalEditorController.php', + 'OCA\\Files\\Controller\\OrganizationTemplateController' => $baseDir . '/../lib/Controller/OrganizationTemplateController.php', 'OCA\\Files\\Controller\\TemplateController' => $baseDir . '/../lib/Controller/TemplateController.php', 'OCA\\Files\\Controller\\TransferOwnershipController' => $baseDir . '/../lib/Controller/TransferOwnershipController.php', 'OCA\\Files\\Controller\\ViewController' => $baseDir . '/../lib/Controller/ViewController.php', @@ -107,4 +108,5 @@ 'OCA\\Files\\Sharing\\Property\\NodeGridViewSharePropertyType' => $baseDir . '/../lib/Sharing/Property/NodeGridViewSharePropertyType.php', 'OCA\\Files\\Sharing\\Source\\NodeShareSourceMetadata' => $baseDir . '/../lib/Sharing/Source/NodeShareSourceMetadata.php', 'OCA\\Files\\Sharing\\Source\\NodeShareSourceType' => $baseDir . '/../lib/Sharing/Source/NodeShareSourceType.php', + 'OCA\\Files\\Template\\OrganizationTemplateProvider' => $baseDir . '/../lib/Template/OrganizationTemplateProvider.php', ); diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index 342bc63e2fe04..c0892488a3e3d 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -76,6 +76,7 @@ class ComposerStaticInitFiles 'OCA\\Files\\Controller\\DirectEditingViewController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingViewController.php', 'OCA\\Files\\Controller\\FilenamesController' => __DIR__ . '/..' . '/../lib/Controller/FilenamesController.php', 'OCA\\Files\\Controller\\OpenLocalEditorController' => __DIR__ . '/..' . '/../lib/Controller/OpenLocalEditorController.php', + 'OCA\\Files\\Controller\\OrganizationTemplateController' => __DIR__ . '/..' . '/../lib/Controller/OrganizationTemplateController.php', 'OCA\\Files\\Controller\\TemplateController' => __DIR__ . '/..' . '/../lib/Controller/TemplateController.php', 'OCA\\Files\\Controller\\TransferOwnershipController' => __DIR__ . '/..' . '/../lib/Controller/TransferOwnershipController.php', 'OCA\\Files\\Controller\\ViewController' => __DIR__ . '/..' . '/../lib/Controller/ViewController.php', @@ -122,6 +123,7 @@ class ComposerStaticInitFiles 'OCA\\Files\\Sharing\\Property\\NodeGridViewSharePropertyType' => __DIR__ . '/..' . '/../lib/Sharing/Property/NodeGridViewSharePropertyType.php', 'OCA\\Files\\Sharing\\Source\\NodeShareSourceMetadata' => __DIR__ . '/..' . '/../lib/Sharing/Source/NodeShareSourceMetadata.php', 'OCA\\Files\\Sharing\\Source\\NodeShareSourceType' => __DIR__ . '/..' . '/../lib/Sharing/Source/NodeShareSourceType.php', + 'OCA\\Files\\Template\\OrganizationTemplateProvider' => __DIR__ . '/..' . '/../lib/Template/OrganizationTemplateProvider.php', ); public static function getInitializer(ClassLoader $loader) diff --git a/apps/files/lib/AppInfo/Application.php b/apps/files/lib/AppInfo/Application.php index 2a61519da737f..26a04e738a5d2 100644 --- a/apps/files/lib/AppInfo/Application.php +++ b/apps/files/lib/AppInfo/Application.php @@ -75,6 +75,7 @@ public function register(IRegistrationContext $context): void { /* * Register capabilities */ + $context->registerTemplateProvider(\OCA\Files\Template\OrganizationTemplateProvider::class); $context->registerCapability(Capabilities::class); $context->registerCapability(AdvancedCapabilities::class); $context->registerCapability(DirectEditingCapabilities::class); diff --git a/apps/files/lib/ConfigLexicon.php b/apps/files/lib/ConfigLexicon.php index 3d3c59886858c..717bd1659ee2e 100644 --- a/apps/files/lib/ConfigLexicon.php +++ b/apps/files/lib/ConfigLexicon.php @@ -27,6 +27,7 @@ class ConfigLexicon implements ILexicon { public const RECENT_FILES_GROUP_MIME_TYPES = 'recent_files_group_mime_types'; public const RECENT_FILES_GROUP_TIMESPAN_MINUTES = 'recent_files_group_timespan_minutes'; public const LOCAL_CLIENT_INTEGRATION = 'local_client_integration'; + public const ORGANIZATION_TEMPLATE_FOLDER = 'organization_template_folder'; #[\Override] public function getStrictness(): Strictness { @@ -36,6 +37,13 @@ public function getStrictness(): Strictness { #[\Override] public function getAppConfigs(): array { return [ + new Entry( + self::ORGANIZATION_TEMPLATE_FOLDER, + ValueType::ARRAY, + defaultRaw: [], + definition: 'Owner and folder ID of the template directory published to all users', + lazy: false, + ), new Entry( self::OVERWRITES_HOME_FOLDERS, ValueType::ARRAY, diff --git a/apps/files/lib/Controller/OrganizationTemplateController.php b/apps/files/lib/Controller/OrganizationTemplateController.php new file mode 100644 index 0000000000000..72933665eb892 --- /dev/null +++ b/apps/files/lib/Controller/OrganizationTemplateController.php @@ -0,0 +1,113 @@ + + * + * 200: Organization template folder returned + */ + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] + public function getPath(): DataResponse { + $selection = $this->provider->getSelection(); + $path = ''; + $available = false; + try { + $folder = $this->provider->getFolder(); + $path = $this->rootFolder->getUserFolder($selection['owner'])->getRelativePath($folder->getPath()) ?? ''; + $available = true; + } catch (NotFoundException|NotPermittedException|NoUserException $e) { + } + return new DataResponse(['template_path' => $path, 'available' => $available, 'owner' => $selection['owner']]); + } + + /** + * Publish a folder's contents as templates for every authenticated user + * + * @param string $templatePath Folder in the administrator's Files, or empty to disable + * @return DataResponse + * @throws OCSBadRequestException The folder is unavailable + * + * 200: Organization template folder updated + */ + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] + public function setPath(string $templatePath): DataResponse { + if ($templatePath === '') { + $this->provider->setSelection('', 0); + } else { + try { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $folder = $userFolder->get($templatePath); + if (!$folder instanceof Folder || !$folder->isReadable() || $userFolder->getRelativePath($folder->getPath()) === null) { + throw new OCSBadRequestException('Choose a readable folder'); + } + $this->provider->setSelection($this->userId, $folder->getId()); + } catch (NotFoundException|NotPermittedException|InvalidPathException $e) { + throw new OCSBadRequestException('Choose a readable folder'); + } + } + return $this->getPath(); + } + + /** + * Preview a published organization template + * + * @param string $id Template file ID + * @return FileDisplayResponse + * @throws OCSNotFoundException Template or preview unavailable + * + * 200: Preview returned + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function preview(string $id): FileDisplayResponse { + try { + $file = $this->provider->getCustomTemplate($id); + $preview = $this->preview->getPreview($file, 512, 512); + return new FileDisplayResponse($preview, Http::STATUS_OK, ['Content-Type' => $preview->getMimeType()]); + } catch (NotFoundException|NotPermittedException|NoUserException $e) { + throw new OCSNotFoundException('Template preview unavailable'); + } + } +} diff --git a/apps/files/lib/Controller/TemplateController.php b/apps/files/lib/Controller/TemplateController.php index 70c98e43e808f..d9e0c79f9d5ae 100644 --- a/apps/files/lib/Controller/TemplateController.php +++ b/apps/files/lib/Controller/TemplateController.php @@ -106,30 +106,33 @@ public function setPath(string $templatePath): DataResponse { /** * List the available templates * + * @param ?string $targetPath Destination folder relative to the user root + * * @return DataResponse, array{}> * * 200: Available templates returned */ #[NoAdminRequired] - public function list(): DataResponse { + public function list(?string $targetPath = null): DataResponse { /* Convert embedded Template instances to arrays to match return type */ return new DataResponse(array_map(static function (array $templateFileCreator) { $templateFileCreator['templates'] = array_map(static fn (Template $template) => $template->jsonSerialize(), $templateFileCreator['templates']); return $templateFileCreator; - }, $this->templateManager->listTemplates())); + }, $this->templateManager->listTemplates($targetPath))); } /** * List the fields for the template specified by the given file ID * * @param int $fileId File ID of the template + * @param ?string $targetPath Destination folder relative to the user root * @return DataResponse, array{}> * * 200: Fields returned */ #[NoAdminRequired] - public function listTemplateFields(int $fileId): DataResponse { - $fields = $this->templateManager->listTemplateFields($fileId); + public function listTemplateFields(int $fileId, ?string $targetPath = null): DataResponse { + $fields = $this->templateManager->listTemplateFields($fileId, $targetPath); return new DataResponse( array_merge([], ...$fields), diff --git a/apps/files/lib/Template/OrganizationTemplateProvider.php b/apps/files/lib/Template/OrganizationTemplateProvider.php new file mode 100644 index 0000000000000..ded092b1df580 --- /dev/null +++ b/apps/files/lib/Template/OrganizationTemplateProvider.php @@ -0,0 +1,85 @@ +config->getValueArray('files', ConfigLexicon::ORGANIZATION_TEMPLATE_FOLDER, []); + return ['owner' => (string)($selection['owner'] ?? ''), 'folder' => (int)($selection['folder'] ?? 0)]; + } + + public function setSelection(string $owner, int $folder): void { + $this->config->setValueArray('files', ConfigLexicon::ORGANIZATION_TEMPLATE_FOLDER, ['owner' => $owner, 'folder' => $folder]); + } + + public function getFolder(): Folder { + $selection = $this->getSelection(); + if ($selection['owner'] !== '' && $selection['folder'] > 0) { + foreach ($this->rootFolder->getUserFolder($selection['owner'])->getById($selection['folder']) as $folder) { + if ($folder instanceof Folder && $folder->isReadable()) { + return $folder; + } + } + } + throw new NotFoundException('No organization template folder available'); + } + + public function getCustomTemplates(string $mimetype): array { + try { + $folder = $this->getFolder(); + $templates = []; + foreach ($folder->searchByMime($mimetype) as $file) { + if ($file instanceof File && $file->isReadable()) { + $template = new Template(self::class, (string)$file->getId(), $file); + $template->setHasPreview(true); + $template->setCustomPreviewUrl($this->urlGenerator->linkToOCSRouteAbsolute('files.OrganizationTemplate.preview', ['id' => (string)$file->getId(), 'etag' => $file->getEtag()])); + $templates[] = $template; + } + } + return $templates; + } catch (NotFoundException|NotPermittedException|NoUserException $e) { + return []; + } + } + + public function getCustomTemplate(string $template): File { + if (!ctype_digit($template)) { + throw new NotFoundException('Invalid template identifier'); + } + $folder = $this->getFolder(); + foreach ($folder->getById((int)$template) as $file) { + if ($file instanceof File && $file->isReadable() && $folder->getRelativePath($file->getPath()) !== null) { + return $file; + } + } + throw new NotFoundException('Template no longer available'); + } +} diff --git a/apps/files/openapi.json b/apps/files/openapi.json index 3249dc36d20da..fba963ba9c576 100644 --- a/apps/files/openapi.json +++ b/apps/files/openapi.json @@ -1321,6 +1321,113 @@ } } }, + "/ocs/v2.php/apps/files/api/v1/templates/organization/{id}/preview": { + "get": { + "operationId": "organization_template-preview", + "summary": "Preview a published organization template", + "tags": [ + "organization_template" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Template file ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Preview returned", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Template or preview unavailable", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/files/api/v1/templates": { "get": { "operationId": "template-list", @@ -1337,6 +1444,16 @@ } ], "parameters": [ + { + "name": "targetPath", + "in": "query", + "description": "Destination folder relative to the user root", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -1439,6 +1556,16 @@ "format": "int64" } }, + { + "name": "targetPath", + "in": "query", + "description": "Destination folder relative to the user root", + "schema": { + "type": "string", + "nullable": true, + "default": null + } + }, { "name": "OCS-APIRequest", "in": "header", diff --git a/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts index 6706dc8ba4002..a68d1b890c717 100644 --- a/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts +++ b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts @@ -130,3 +130,31 @@ describe('Personal template folder settings', () => { expect(component.getByRole('button', { name: 'Personal template folder' })).not.toBeDisabled() }) }) + +describe('Organization template folder settings', () => { + beforeEach(() => { + cleanup() + vi.clearAllMocks() + vi.mocked(getFilePickerBuilder).mockReturnValue(builder as unknown as ReturnType) + }) + + it('loads the admin setting and explains publication to all users', async () => { + vi.mocked(axios.get).mockResolvedValue({ data: { ocs: { data: { template_path: '/Team templates', available: true, owner: 'another-admin' } } } }) + const component = render(FilesAppSettingsTemplates, { props: { organization: true } }) + await waitFor(() => expect(component.getByText('/Team templates')).toBeVisible()) + expect(axios.get).toHaveBeenCalledWith(expect.stringContaining('/templates/organization')) + expect(component.getByText(/every user/)).toBeVisible() + expect(component.getByText('Published by another-admin')).toBeVisible() + expect(component.queryByRole('button', { name: 'Open folder' })).toBeNull() + }) + + it('allows clearing a deleted organization folder', async () => { + vi.mocked(axios.get).mockResolvedValue({ data: { ocs: { data: { template_path: '', available: false, owner: 'admin' } } } }) + vi.mocked(axios.put).mockResolvedValue({ data: { ocs: { data: { template_path: '', available: false, owner: '' } } } }) + const component = render(FilesAppSettingsTemplates, { props: { organization: true } }) + await waitFor(() => expect(component.getByRole('button', { name: 'Clear selection' })).not.toBeDisabled()) + await fireEvent.click(component.getByRole('button', { name: 'Clear selection' })) + await waitFor(() => expect(axios.put).toHaveBeenCalledWith(expect.stringContaining('/templates/organization'), { templatePath: '' })) + expect(axios.delete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue index eb6e769707f6a..484facbd2d413 100644 --- a/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue +++ b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue @@ -5,20 +5,28 @@