diff --git a/apps/files/appinfo/routes.php b/apps/files/appinfo/routes.php index d3c74bd424c17..253b92b7c60e7 100644 --- a/apps/files/appinfo/routes.php +++ b/apps/files/appinfo/routes.php @@ -152,6 +152,16 @@ 'url' => '/api/v1/templates/path', 'verb' => 'POST' ], + [ + 'name' => 'Template#getPath', + 'url' => '/api/v1/templates/path', + 'verb' => 'GET', + ], + [ + 'name' => 'Template#setPath', + 'url' => '/api/v1/templates/path', + 'verb' => 'PUT', + ], [ 'name' => 'TransferOwnership#transfer', 'url' => '/api/v1/transferownership', diff --git a/apps/files/lib/Controller/TemplateController.php b/apps/files/lib/Controller/TemplateController.php index 4b4ea3c156985..70c98e43e808f 100644 --- a/apps/files/lib/Controller/TemplateController.php +++ b/apps/files/lib/Controller/TemplateController.php @@ -13,9 +13,15 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCSController; +use OCP\Files\Folder; use OCP\Files\GenericFileException; +use OCP\Files\InvalidPathException; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; use OCP\Files\Template\ITemplateManager; use OCP\Files\Template\Template; use OCP\Files\Template\TemplateFileCreator; @@ -33,10 +39,70 @@ public function __construct( $appName, IRequest $request, protected ITemplateManager $templateManager, + private IRootFolder $rootFolder, + private string $userId, ) { parent::__construct($appName, $request); } + /** + * Get the personal template directory + * + * @return DataResponse + * + * 200: Personal template directory returned + */ + #[NoAdminRequired] + public function getPath(): DataResponse { + $path = $this->templateManager->getTemplatePath(); + $available = false; + if ($path !== '') { + try { + $folder = $this->rootFolder->getUserFolder($this->userId)->get($path); + $available = $folder instanceof Folder && $folder->isReadable(); + } catch (NotFoundException|NotPermittedException|InvalidPathException $e) { + } + } + return new DataResponse(['template_path' => $path, 'available' => $available]); + } + + /** + * Select an existing personal template directory, or clear the selection + * + * @param string $templatePath User-relative folder path, or an empty string to clear the selection + * @return DataResponse + * @throws OCSBadRequestException The path does not refer to an existing folder + * @throws OCSForbiddenException The folder is not readable + * + * 200: Personal template directory updated + */ + #[NoAdminRequired] + public function setPath(string $templatePath): DataResponse { + if ($templatePath !== '') { + try { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $folder = $userFolder->get($templatePath); + if (!$folder instanceof Folder) { + throw new OCSBadRequestException('The template path must be an existing folder'); + } + if (!$folder->isReadable()) { + throw new OCSForbiddenException('The template folder must be readable'); + } + $templatePath = $userFolder->getRelativePath($folder->getPath()); + if ($templatePath === null) { + throw new OCSBadRequestException('Invalid template folder'); + } + $templatePath = '/' . trim($templatePath, '/'); + } catch (NotFoundException|InvalidPathException $e) { + throw new OCSBadRequestException('The template path must be an existing folder'); + } catch (NotPermittedException $e) { + throw new OCSForbiddenException('The template folder must be readable'); + } + } + $this->templateManager->setTemplatePath($templatePath); + return new DataResponse(['template_path' => $templatePath, 'available' => $templatePath !== '']); + } + /** * List the available templates * diff --git a/apps/files/openapi.json b/apps/files/openapi.json index 808a0e07a41a1..3249dc36d20da 100644 --- a/apps/files/openapi.json +++ b/apps/files/openapi.json @@ -1821,6 +1821,279 @@ } } } + }, + "get": { + "operationId": "template-get-path", + "summary": "Get the personal template directory", + "tags": [ + "template" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "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": { + "200": { + "description": "Personal template directory returned", + "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": [ + "template_path", + "available" + ], + "properties": { + "template_path": { + "type": "string" + }, + "available": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + }, + "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": {} + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "template-set-path", + "summary": "Select an existing personal template directory, or clear the selection", + "tags": [ + "template" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "templatePath" + ], + "properties": { + "templatePath": { + "type": "string", + "description": "User-relative folder path, or an empty string to clear the selection" + } + } + } + } + } + }, + "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": { + "200": { + "description": "Personal template directory updated", + "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": [ + "template_path", + "available" + ], + "properties": { + "template_path": { + "type": "string" + }, + "available": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "The path does not refer to an existing folder", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "The folder is not readable", + "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/transferownership": { diff --git a/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts new file mode 100644 index 0000000000000..6706dc8ba4002 --- /dev/null +++ b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.spec.ts @@ -0,0 +1,132 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { FilePickerClosed, getFilePickerBuilder, showError } from '@nextcloud/dialogs' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import FilesAppSettingsTemplates from './FilesAppSettingsTemplates.vue' +import { templateDirectory } from '../../store/templateDirectory.ts' + +vi.mock('@nextcloud/vue/components/NcAppSettingsSection', async () => { + const { h } = await import('vue') + return { default: { setup: (_props, { slots }) => () => h('section', slots.default?.()) } } +}) +vi.mock('@nextcloud/axios', () => ({ default: { get: vi.fn(), put: vi.fn(), post: vi.fn(), delete: vi.fn() } })) +vi.mock('@nextcloud/dialogs', () => ({ + FilePickerClosed: class extends Error {}, + getFilePickerBuilder: vi.fn(), + showError: vi.fn(), +})) + +const pickNodes = vi.fn() +const builder = { + setMultiSelect: vi.fn().mockReturnThis(), + setMimeTypeFilter: vi.fn().mockReturnThis(), + allowDirectories: vi.fn().mockReturnThis(), + setCanPick: vi.fn().mockReturnThis(), + startAt: vi.fn().mockReturnThis(), + addButton: vi.fn().mockReturnThis(), + build: () => ({ pickNodes }), +} + +function response(path: string, available = true) { + return { data: { ocs: { data: { template_path: path, available } } } } +} + +async function mount() { + const component = render(FilesAppSettingsTemplates) + await waitFor(() => expect(component.getByRole('button', { name: 'Personal template folder' })).not.toBeDisabled()) + return component +} + +describe('Personal template folder settings', () => { + beforeEach(() => { + cleanup() + vi.clearAllMocks() + Object.assign(templateDirectory, { template_path: '', available: false }) + vi.mocked(axios.get).mockResolvedValue(response('/Templates')) + vi.mocked(getFilePickerBuilder).mockReturnValue(builder as unknown as ReturnType) + pickNodes.mockResolvedValue([{ path: '/Documents/Templates' }]) + }) + + it('loads the current directory and links to it', async () => { + const component = await mount() + expect(component.getByText('/Templates')).toBeVisible() + expect(component.queryByRole('button', { name: 'Choose folder' })).toBeNull() + expect(component.getByRole('button', { name: 'Open folder' })).toHaveAttribute('href', expect.stringContaining('dir=%2FTemplates')) + }) + + it('selects an existing folder without invoking initialization', async () => { + vi.mocked(axios.put).mockResolvedValue(response('/Documents/Templates')) + const component = await mount() + await fireEvent.click(component.getByRole('button', { name: 'Personal template folder' })) + await waitFor(() => expect(component.getByText('/Documents/Templates')).toBeVisible()) + expect(axios.put).toHaveBeenCalledWith(expect.stringContaining('/templates/path'), { templatePath: '/Documents/Templates' }) + expect(axios.post).not.toHaveBeenCalled() + expect(builder.setMimeTypeFilter).toHaveBeenCalledWith(['httpd/unix-directory']) + expect(builder.startAt).toHaveBeenCalledWith('/Templates') + expect(builder.addButton).toHaveBeenCalledWith(expect.objectContaining({ label: 'Select folder' })) + const canPick = builder.setCanPick.mock.calls[0][0] + expect(canPick({ permissions: 1 })).toBe(true) + expect(canPick({ permissions: 0 })).toBe(false) + }) + + it('clears the preference without deleting files', async () => { + vi.mocked(axios.put).mockResolvedValue(response('', false)) + const component = await mount() + await fireEvent.click(component.getByRole('button', { name: 'Clear selection' })) + await waitFor(() => expect(component.getByText('No folder selected')).toBeVisible()) + expect(axios.put).toHaveBeenCalledWith(expect.any(String), { templatePath: '' }) + expect(axios.delete).not.toHaveBeenCalled() + expect(pickNodes).not.toHaveBeenCalled() + expect(component.queryByRole('button', { name: 'Open folder' })).toBeNull() + }) + + it('keeps the old selection when saving fails', async () => { + vi.mocked(axios.put).mockRejectedValue(new Error('Forbidden')) + const component = await mount() + await fireEvent.click(component.getByRole('button', { name: 'Personal template folder' })) + await waitFor(() => expect(showError).toHaveBeenCalledWith('Unable to update the template folder')) + expect(component.getByText('/Templates')).toBeVisible() + }) + + it('does not save or show an error when the picker is cancelled', async () => { + pickNodes.mockRejectedValue(new FilePickerClosed()) + const component = await mount() + await fireEvent.click(component.getByRole('button', { name: 'Personal template folder' })) + await waitFor(() => expect(pickNodes).toHaveBeenCalled()) + expect(axios.put).not.toHaveBeenCalled() + expect(showError).not.toHaveBeenCalled() + }) + + it('reports picker failures', async () => { + pickNodes.mockRejectedValue(new Error('Picker failed')) + const component = await mount() + await fireEvent.click(component.getByRole('button', { name: 'Personal template folder' })) + await waitFor(() => expect(showError).toHaveBeenCalledWith('Unable to choose a template folder')) + }) + + it('shows unavailable paths and starts the picker at the root', async () => { + vi.mocked(axios.get).mockResolvedValue(response('/Missing', false)) + pickNodes.mockResolvedValue([]) + const component = await mount() + expect(component.getByText(/This folder is no longer available/)).toBeVisible() + expect(component.queryByRole('button', { name: 'Open folder' })).toBeNull() + await fireEvent.click(component.getByRole('button', { name: 'Personal template folder' })) + expect(builder.startAt).toHaveBeenCalledWith('/') + expect(axios.put).not.toHaveBeenCalled() + }) + + it('allows retrying a failed load', async () => { + vi.mocked(axios.get).mockRejectedValueOnce(new Error('Offline')) + const component = render(FilesAppSettingsTemplates) + await waitFor(() => expect(component.getByText('Unable to load the template folder')).toBeVisible()) + expect(component.getByRole('button', { name: 'Personal template folder' })).toBeDisabled() + await fireEvent.click(component.getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(component.getByText('/Templates')).toBeVisible()) + expect(component.getByRole('button', { name: 'Personal template folder' })).not.toBeDisabled() + }) +}) diff --git a/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue new file mode 100644 index 0000000000000..eb6e769707f6a --- /dev/null +++ b/apps/files/src/components/FilesAppSettings/FilesAppSettingsTemplates.vue @@ -0,0 +1,146 @@ + + + + + + + diff --git a/apps/files/src/init.ts b/apps/files/src/init.ts index d63eee4f871ca..8d229f8e8e8fe 100644 --- a/apps/files/src/init.ts +++ b/apps/files/src/init.ts @@ -24,7 +24,6 @@ import { registerModifiedFilter } from './filters/ModifiedFilter.ts' import { registerTypeFilter } from './filters/TypeFilter.ts' import { entry as newFolderEntry } from './newMenu/newFolder.ts' import { registerTemplateEntries } from './newMenu/newFromTemplate.ts' -import { entry as newTemplatesFolder } from './newMenu/newTemplatesFolder.ts' import { initLivePhotos } from './services/LivePhotos.ts' import registerPreviewServiceWorker from './services/ServiceWorker.js' import { registerFavoritesView } from './views/favorites.ts' @@ -49,7 +48,6 @@ registerFileAction(viewInFolderAction) // Register new menu entry addNewFileMenuEntry(newFolderEntry) -addNewFileMenuEntry(newTemplatesFolder) registerTemplateEntries() // Register files views when not on public share diff --git a/apps/files/src/newMenu/newTemplatesFolder.ts b/apps/files/src/newMenu/newTemplatesFolder.ts deleted file mode 100644 index 2e8f3431f84e7..0000000000000 --- a/apps/files/src/newMenu/newTemplatesFolder.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { IFolder, INode, NewMenuEntry } from '@nextcloud/files' - -import PlusSvg from '@mdi/svg/svg/plus.svg?raw' -import { getCurrentUser } from '@nextcloud/auth' -import axios from '@nextcloud/axios' -import { showError } from '@nextcloud/dialogs' -import { Permission, removeNewFileMenuEntry } from '@nextcloud/files' -import { loadState } from '@nextcloud/initial-state' -import { translate as t } from '@nextcloud/l10n' -import { generateOcsUrl } from '@nextcloud/router' -import { join } from 'path' -import { logger } from '../utils/logger.ts' -import { newNodeName } from '../utils/newNodeDialog.ts' - -const templatesEnabled = loadState('files', 'templates_enabled', true) -let templatesPath = loadState('files', 'templates_path', false) -logger.debug('Templates folder enabled', { templatesEnabled }) -logger.debug('Initial templates folder', { templatesPath }) - -/** - * Init template folder - * - * @param directory Folder where to create the templates folder - * @param name Name to use or the templates folder - */ -async function initTemplatesFolder(directory: IFolder, name: string) { - const templatePath = join(directory.path, name) - try { - logger.debug('Initializing the templates directory', { templatePath }) - const { data } = await axios.post(generateOcsUrl('apps/files/api/v1/templates/path'), { - templatePath, - copySystemTemplates: true, - }) - - // Go to template directory - window.OCP.Files.Router.goToRoute( - null, // use default route - { view: 'files', fileid: undefined }, - { dir: templatePath }, - ) - - logger.info('Created new templates folder', { - ...data.ocs.data, - }) - templatesPath = data.ocs.data.templates_path as string - } catch (error) { - logger.error('Unable to initialize the templates directory', { error }) - showError(t('files', 'Unable to initialize the templates directory')) - } -} - -export const entry: NewMenuEntry = { - id: 'template-picker', - displayName: t('files', 'Create templates folder'), - iconSvgInline: PlusSvg, - order: 30, - enabled(context: IFolder): boolean { - // Templates disabled or templates folder already initialized - if (!templatesEnabled || templatesPath) { - return false - } - // Allow creation on your own folders only - if (context.owner !== getCurrentUser()?.uid) { - return false - } - return (context.permissions & Permission.CREATE) !== 0 - }, - async handler(context: IFolder, content: INode[]) { - const name = await newNodeName(t('files', 'Templates'), content, { name: t('files', 'New template folder'), isFolder: true }) - - if (name !== null) { - // Create the template folder - initTemplatesFolder(context, name) - - // Remove the menu entry - removeNewFileMenuEntry('template-picker') - } - }, -} diff --git a/apps/files/src/store/templateDirectory.ts b/apps/files/src/store/templateDirectory.ts new file mode 100644 index 0000000000000..d7428e55c4c19 --- /dev/null +++ b/apps/files/src/store/templateDirectory.ts @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { OCSResponse } from '@nextcloud/typings/ocs' + +import axios from '@nextcloud/axios' +import { loadState } from '@nextcloud/initial-state' +import { generateOcsUrl } from '@nextcloud/router' +import { reactive } from 'vue' + +interface TemplateDirectory { + template_path: string + available: boolean +} + +const initialPath = loadState('files', 'templates_path', false) +export const templateDirectory = reactive({ + template_path: initialPath || '', + available: !!initialPath, +}) + +/** Load the configured folder, including folders that are no longer available. */ +export async function loadTemplateDirectory(): Promise { + const { data } = await axios.get>(generateOcsUrl('apps/files/api/v1/templates/path')) + Object.assign(templateDirectory, data.ocs.data) +} + +/** + * Select an existing folder without creating or copying files. + * + * @param templatePath User-relative folder path, or empty to clear the selection + */ +export async function setTemplateDirectory(templatePath: string): Promise { + const { data } = await axios.put>(generateOcsUrl('apps/files/api/v1/templates/path'), { templatePath }) + Object.assign(templateDirectory, data.ocs.data) +} diff --git a/apps/files/src/views/FilesAppSettings.vue b/apps/files/src/views/FilesAppSettings.vue index 7772b3d89c220..59e405e719a7e 100644 --- a/apps/files/src/views/FilesAppSettings.vue +++ b/apps/files/src/views/FilesAppSettings.vue @@ -12,6 +12,7 @@ import FilesAppSettingsAppearance from '../components/FilesAppSettings/FilesAppS import FilesAppSettingsGeneral from '../components/FilesAppSettings/FilesAppSettingsGeneral.vue' import FilesAppSettingsLegacyApi from '../components/FilesAppSettings/FilesAppSettingsLegacyApi.vue' import FilesAppSettingsShortcuts from '../components/FilesAppSettings/FilesAppSettingsShortcuts.vue' +import FilesAppSettingsTemplates from '../components/FilesAppSettings/FilesAppSettingsTemplates.vue' import FilesAppSettingsWarnings from '../components/FilesAppSettings/FilesAppSettingsWarnings.vue' import FilesAppSettingsWebDav from '../components/FilesAppSettings/FilesAppSettingsWebDav.vue' @@ -54,6 +55,7 @@ async function showKeyboardShortcuts() { @update:open="emit('close')"> + diff --git a/apps/files/tests/Controller/TemplateControllerTest.php b/apps/files/tests/Controller/TemplateControllerTest.php new file mode 100644 index 0000000000000..ce12935ffc5a7 --- /dev/null +++ b/apps/files/tests/Controller/TemplateControllerTest.php @@ -0,0 +1,122 @@ +manager = $this->createMock(ITemplateManager::class); + $this->root = $this->createMock(IRootFolder::class); + $this->userFolder = $this->createMock(Folder::class); + $this->root->method('getUserFolder')->with('alice')->willReturn($this->userFolder); + $this->controller = new TemplateController('files', $this->createMock(IRequest::class), $this->manager, $this->root, 'alice'); + $this->manager->expects(self::never())->method('initializeTemplateDirectory'); + $this->userFolder->expects(self::never())->method('getOrCreateFolder'); + } + + public function testGetUnconfiguredPath(): void { + $this->manager->method('getTemplatePath')->willReturn(''); + $this->userFolder->expects(self::never())->method('get'); + self::assertSame(['template_path' => '', 'available' => false], $this->controller->getPath()->getData()); + } + + public function testGetReadablePath(): void { + $this->manager->method('getTemplatePath')->willReturn('/Templates'); + $folder = $this->createMock(Folder::class); + $folder->method('isReadable')->willReturn(true); + $this->userFolder->method('get')->with('/Templates')->willReturn($folder); + self::assertSame(['template_path' => '/Templates', 'available' => true], $this->controller->getPath()->getData()); + } + + public static function unavailablePaths(): array { + return [ + 'file' => ['file'], + 'unreadable folder' => ['unreadable'], + 'missing folder' => [NotFoundException::class], + 'permission denied' => [NotPermittedException::class], + 'invalid path' => [InvalidPathException::class], + ]; + } + + #[DataProvider('unavailablePaths')] + public function testGetUnavailablePath(string $reason): void { + $this->manager->method('getTemplatePath')->willReturn('/Templates'); + $this->mockUnavailablePath($reason); + $this->manager->expects(self::never())->method('setTemplatePath'); + self::assertSame(['template_path' => '/Templates', 'available' => false], $this->controller->getPath()->getData()); + } + + #[DataProvider('unavailablePaths')] + public function testRejectUnavailableSelection(string $reason): void { + $this->mockUnavailablePath($reason); + $this->manager->expects(self::never())->method('setTemplatePath'); + $this->expectException(in_array($reason, ['unreadable', NotPermittedException::class], true) ? OCSForbiddenException::class : OCSBadRequestException::class); + $this->controller->setPath('/Templates'); + } + + private function mockUnavailablePath(string $reason): void { + if ($reason === 'file') { + $this->userFolder->method('get')->willReturn($this->createMock(File::class)); + } elseif ($reason === 'unreadable') { + $folder = $this->createMock(Folder::class); + $folder->method('isReadable')->willReturn(false); + $this->userFolder->method('get')->willReturn($folder); + } else { + $this->userFolder->method('get')->willThrowException(new $reason()); + } + } + + public function testSelectExistingReadableFolder(): void { + $folder = $this->createMock(Folder::class); + $folder->method('isReadable')->willReturn(true); + $folder->method('getPath')->willReturn('/alice/files/Team/Templates'); + $this->userFolder->method('get')->with('Team/Templates/')->willReturn($folder); + $this->userFolder->method('getRelativePath')->with('/alice/files/Team/Templates')->willReturn('/Team/Templates'); + $this->manager->expects(self::once())->method('setTemplatePath')->with('/Team/Templates'); + self::assertSame(['template_path' => '/Team/Templates', 'available' => true], $this->controller->setPath('Team/Templates/')->getData()); + } + + public function testRejectFolderOutsideUserRoot(): void { + $folder = $this->createMock(Folder::class); + $folder->method('isReadable')->willReturn(true); + $folder->method('getPath')->willReturn('/bob/files/Templates'); + $this->userFolder->method('get')->willReturn($folder); + $this->userFolder->method('getRelativePath')->willReturn(null); + $this->manager->expects(self::never())->method('setTemplatePath'); + $this->expectException(OCSBadRequestException::class); + $this->controller->setPath('../bob/files/Templates'); + } + + public function testClearSelection(): void { + $this->userFolder->expects(self::never())->method('get'); + $this->manager->expects(self::once())->method('setTemplatePath')->with(''); + self::assertSame(['template_path' => '', 'available' => false], $this->controller->setPath('')->getData()); + } +} diff --git a/openapi.json b/openapi.json index b9b1bee0f782a..b49bae45ee8b6 100644 --- a/openapi.json +++ b/openapi.json @@ -25639,6 +25639,279 @@ } } } + }, + "get": { + "operationId": "files-template-get-path", + "summary": "Get the personal template directory", + "tags": [ + "files/template" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "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": { + "200": { + "description": "Personal template directory returned", + "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": [ + "template_path", + "available" + ], + "properties": { + "template_path": { + "type": "string" + }, + "available": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + }, + "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": {} + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "files-template-set-path", + "summary": "Select an existing personal template directory, or clear the selection", + "tags": [ + "files/template" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "templatePath" + ], + "properties": { + "templatePath": { + "type": "string", + "description": "User-relative folder path, or an empty string to clear the selection" + } + } + } + } + } + }, + "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": { + "200": { + "description": "Personal template directory updated", + "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": [ + "template_path", + "available" + ], + "properties": { + "template_path": { + "type": "string" + }, + "available": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "The path does not refer to an existing folder", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "The folder is not readable", + "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/transferownership": {