diff --git a/appinfo/routes.php b/appinfo/routes.php index 8387cb9d6d..3965c11013 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -44,6 +44,16 @@ ['name' => 'settings#getUserFileList', 'url' => 'settings/user-files.json', 'verb' => 'GET'], ['name' => 'settings#downloadUserFile', 'url' => 'settings/user-files/{fileName}', 'verb' => 'GET'], ['name' => 'settings#deleteSystemFile', 'url' => 'settings/system-files/{fileName}', 'verb' => 'DELETE'], + [ + 'name' => 'settings#getSettingsFile', + 'url' => 'settings/{type}/{category}/{name}', + 'verb' => 'GET', + 'requirements' => [ + 'type' => '[a-zA-Z0-9_\-]+', + 'category' => '[a-zA-Z0-9_\-]+', + 'name' => '.+', + ], + ], // Direct Editing: Webview ['name' => 'directView#show', 'url' => '/direct/{token}', 'verb' => 'GET'], diff --git a/composer/composer/autoload_classmap.php b/composer/composer/autoload_classmap.php index 1bfbe4352b..c2fa2c1eda 100644 --- a/composer/composer/autoload_classmap.php +++ b/composer/composer/autoload_classmap.php @@ -93,6 +93,7 @@ 'OCA\\Richdocuments\\TokenManager' => $baseDir . '/../lib/TokenManager.php', 'OCA\\Richdocuments\\UploadException' => $baseDir . '/../lib/UploadException.php', 'OCA\\Richdocuments\\WOPI\\Parser' => $baseDir . '/../lib/WOPI/Parser.php', + 'OCA\\Richdocuments\\WOPI\\SettingsUrl' => $baseDir . '/../lib/WOPI/SettingsUrl.php', 'mikehaertl\\pdftk\\Command' => $vendorDir . '/mikehaertl/php-pdftk/src/Command.php', 'mikehaertl\\pdftk\\DataFields' => $vendorDir . '/mikehaertl/php-pdftk/src/DataFields.php', 'mikehaertl\\pdftk\\FdfFile' => $vendorDir . '/mikehaertl/php-pdftk/src/FdfFile.php', diff --git a/composer/composer/autoload_static.php b/composer/composer/autoload_static.php index 8ca63eda2c..b5b02a2e3a 100644 --- a/composer/composer/autoload_static.php +++ b/composer/composer/autoload_static.php @@ -126,6 +126,7 @@ class ComposerStaticInitRichdocuments 'OCA\\Richdocuments\\TokenManager' => __DIR__ . '/..' . '/../lib/TokenManager.php', 'OCA\\Richdocuments\\UploadException' => __DIR__ . '/..' . '/../lib/UploadException.php', 'OCA\\Richdocuments\\WOPI\\Parser' => __DIR__ . '/..' . '/../lib/WOPI/Parser.php', + 'OCA\\Richdocuments\\WOPI\\SettingsUrl' => __DIR__ . '/..' . '/../lib/WOPI/SettingsUrl.php', 'mikehaertl\\pdftk\\Command' => __DIR__ . '/..' . '/mikehaertl/php-pdftk/src/Command.php', 'mikehaertl\\pdftk\\DataFields' => __DIR__ . '/..' . '/mikehaertl/php-pdftk/src/DataFields.php', 'mikehaertl\\pdftk\\FdfFile' => __DIR__ . '/..' . '/mikehaertl/php-pdftk/src/FdfFile.php', diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 56406912bd..858f1eefca 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -30,6 +30,7 @@ use OCP\IURLGenerator; use OCP\PreConditionNotMetException; use OCP\Util; +use OCA\Richdocuments\WOPI\SettingsUrl; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Output\NullOutput; @@ -623,6 +624,35 @@ public function downloadUserFile(string $fileName) { } } + /** + * @param string $type + * @param string $category + * @param string $name + * + * @return DataDisplayResponse + * + * @NoAdminRequired + * @PublicPage + * @NoCSRFRequired + **/ + public function getSettingsFile(string $type, string $category, string $name) { + try { + $systemFile = $this->settingsService->getSettingsFile($type, $category, $name); + return new DataDisplayResponse( + $systemFile->getContent(), + 200, + [ + 'Content-Type' => $systemFile->getMimeType() ?: 'application/octet-stream' + ] + ); + } catch (NotFoundException $e) { + return new DataDisplayResponse('File not found.', 404); + } catch (\Exception $e) { + return new DataDisplayResponse('Something went wrong', 500); + } + } + + /** * @param string $key * @return array diff --git a/lib/Controller/WopiController.php b/lib/Controller/WopiController.php index 57ddb4619e..4b64849833 100644 --- a/lib/Controller/WopiController.php +++ b/lib/Controller/WopiController.php @@ -57,6 +57,7 @@ use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; use OCA\Richdocuments\Service\SettingsService; +use \OCA\Richdocuments\WOPI\SettingsUrl; #[RestrictToWopiServer] class WopiController extends Controller { @@ -374,30 +375,24 @@ public function getFile(string $fileId, string $access_token): JSONResponse|Stre #[PublicPage] #[FrontpageRoute(verb: 'GET', url: 'wopi/settings')] public function getSettings(string $type, string $access_token): JSONResponse { - if ($type !== 'UserSettingsUri') { + if (empty($type)) { return new JSONResponse(['error' => 'Invalid type parameter'], Http::STATUS_BAD_REQUEST); } try { $wopi = $this->wopiMapper->getWopiForToken($access_token); - if ($wopi->getTokenType() !== Wopi::TOKEN_TYPE_SETTING_AUTH) { return new JSONResponse(['error' => 'Invalid token type'], Http::STATUS_FORBIDDEN); } - // user admin check $user = $this->userManager->get($wopi->getEditorUid()); if (!$user || !$this->groupManager->isAdmin($user->getUID())) { return new JSONResponse(['error' => 'Access denied'], Http::STATUS_FORBIDDEN); } - $systemFiles = $this->settingsService->getSystemFiles(); - $formattedList = $this->settingsService->getSystemFileList($systemFiles); - - $response = new JSONResponse($formattedList); - - return $response; - } catch (UnknownTokenException|ExpiredTokenException $e) { + $userConfig = $this->settingsService->generateSettingsConfig($type); + return new JSONResponse($userConfig, Http::STATUS_OK); + } catch (UnknownTokenException | ExpiredTokenException $e) { $this->logger->debug($e->getMessage(), ['exception' => $e]); return new JSONResponse(['error' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); } catch (\Exception $e) { @@ -422,16 +417,17 @@ public function handleSettingsFile(string $fileId, string $access_token): JSONRe if (!$content) { throw new \Exception("Failed to read input stream."); } - + $fileContent = stream_get_contents($content); fclose($content); - - // TODO: JSON based upload - $result = $this->settingsService->uploadSystemFile($fileId, $fileContent); - + + // Use the fileId as a file path URL (e.g., "/settings/systemconfig/wordbook/en_US%20(1).dic") + $settingsUrl = new SettingsUrl($fileId); + $result = $this->settingsService->uploadFile($settingsUrl, $fileContent); + return new JSONResponse([ 'status' => 'success', - 'filename' => $newFileName, + 'filename' => $settingsUrl->getFileName(), 'details' => $result, ], Http::STATUS_OK); diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 35a7fd78dc..5c237e56df 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -10,6 +10,7 @@ use OCP\Files\NotFoundException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\Files\SimpleFS\ISimpleFolder; +use OCA\Richdocuments\WOPI\SettingsUrl; use OCP\ICacheFactory; use OCP\IConfig; use OCP\IURLGenerator; @@ -36,6 +37,170 @@ public function __construct( $this->cache = $cacheFactory->createDistributed(Application::APPNAME); } + // TODO: Implement file caching... + + /** + * Ensure the settings directory exists, if it doesn't exist then create it. + * + * @param SettingsUrl $settingsUrl + * @return ISimpleFolder + */ + + public function ensureDirectory(SettingsUrl $settingsUrl): ISimpleFolder { + $type = $settingsUrl->getType(); + $category = $settingsUrl->getCategory(); + + try { + $baseFolder = $this->appData->getFolder($type); + } catch (NotFoundException $e) { + $baseFolder = $this->appData->newFolder($type); + } + + try { + $categoryFolder = $baseFolder->getFolder($category); + } catch (NotFoundException $e) { + $categoryFolder = $baseFolder->newFolder($category); + } + + return $categoryFolder; + } + + /** + * Upload a file to the settings directory. + * ex. $type/$category/$filename + * + * @param SettingsUrl $settingsUrl + * @param resource $fileData + * @return array ['stamp' => string, 'uri' => string] + */ + + public function uploadFile(SettingsUrl $settingsUrl, $fileData): array { + $categoryFolder = $this->ensureDirectory($settingsUrl); + $fileName = $settingsUrl->getFileName(); + $newFile = $categoryFolder->newFile($fileName, $fileData); + $fileUri = $this->generateFileUri($settingsUrl->getType(), $settingsUrl->getCategory(), $fileName); + + return [ + 'stamp' => $newFile->getETag(), + 'uri' => $fileUri, + ]; + } + + /** + * Get list of files in a setting category. + * + * @param string $type + * @param string $category + * @return array Each item has 'stamp' and 'uri'. + */ + public function getCategoryFileList(string $type, string $category): array { + try { + $categoryFolder = $this->appData->getFolder($type . '/' . $category); + } catch (NotFoundException $e) { + return []; + } + + $files = $categoryFolder->getDirectoryListing(); + + return array_map(function(ISimpleFile $file) use ($type, $category) { + return [ + 'stamp' => $file->getETag(), + 'uri' => $this->generateFileUri($type, $category, $file->getName()), + ]; + }, $files); + } + + /** + * generate setting config + * + * @param string $type + * @return array + */ + public function generateSettingsConfig(string $type): array { + $kind = $type === 'userconfig' ? 'user' : 'shared'; + + $config = [ + 'kind' => $kind, + ]; + + $categories = $this->getAllCategories($type); + + foreach ($categories as $category) { + $files = $this->getCategoryFileList($type, $category); + $config[$category] = $files; + } + + return $config; + } + + /** + * Get all setting categories for a setting type. + * + * @param string $type + * @return string[] + */ + private function getAllCategories(string $type): array { + try { + $categories = []; + $directories = $this->appData->getFolder($type)->getFullDirectoryListing(); + foreach ($directories as $dir) { + if ($dir instanceof ISimpleFolder) { + $categories[] = $dir->getName(); + } + } + return $categories; + } catch (NotFoundException $e) { + return []; + } + } + + /** + * Generate file URL. + * + * @param string $type + * @param string $category + * @param string $fileName + * @return string + */ + private function generateFileUri(string $type, string $category, string $fileName): string { + return $this->urlGenerator->linkToRouteAbsolute( + 'richdocuments.settings.getSettingsFile', + [ + 'type' => $type, + 'category' => $category, + 'name' => $fileName, + ] + ); + } + + /** + * Get a specific settings file. + * + * @param string $type + * @param string $category + * @param string $name + * @return ISimpleFile + */ + public function getSettingsFile(string $type, string $category, string $name): ISimpleFile { + try { + $baseFolder = $this->appData->getFolder($type); + } catch (NotFoundException $e) { + throw new NotFoundException("Type folder '{$type}' not found."); + } + + try { + $categoryFolder = $baseFolder->getFolder($category); + } catch (NotFoundException $e) { + throw new NotFoundException("Category folder '{$category}' not found in type '{$type}'."); + } + + try { + return $categoryFolder->getFile($name); + } catch (NotFoundException $e) { + throw new NotFoundException("File '{$name}' not found in category '{$category}' for type '{$type}'."); + } + } + /** * Get or create the system-wide folder in app data. * diff --git a/lib/WOPI/SettingsUrl.php b/lib/WOPI/SettingsUrl.php new file mode 100644 index 0000000000..c130b5a488 --- /dev/null +++ b/lib/WOPI/SettingsUrl.php @@ -0,0 +1,99 @@ +rawUrl = $url; + $this->parseUrl($url); + } + + /** + * Factory method to create a SettingsUrl instance based on individual parameters. + */ + public static function fromComponents(string $type, string $category, string $fileName): self { + $rawUrl = "settings/$type/$category/$fileName"; + return new self($rawUrl); + } + + /** + * Parses the settings URL and extracts type, category, and filename. + * + * @param string + * @throws InvalidArgumentException + */ + private function parseUrl(string $url): void { + $decodedUrl = urldecode($url); + + $parsedUrl = parse_url($decodedUrl); + if (!isset($parsedUrl['path'])) { + throw new InvalidArgumentException("Invalid URL: Path not found."); + } + + $path = $parsedUrl['path']; + + $settingsIndex = strpos($path, '/settings/'); + if ($settingsIndex === false) { + throw new InvalidArgumentException("Invalid settings URL format: '/settings/' segment missing."); + } + + $relevantPath = substr($path, $settingsIndex + strlen('/settings/')); + + $pathParts = explode('/', $relevantPath); + + if (count($pathParts) < 3) { + throw new InvalidArgumentException("Invalid settings URL format: Expected 'type/category/fileName'."); + } + + $this->type = $pathParts[0]; + $this->category = $pathParts[1]; + $this->fileName = implode('/', array_slice($pathParts, 2)); + } + + /** + * Get the setting type from the URL. + * + * @return string + */ + public function getType(): string { + return $this->type; + } + + /** + * Get the setting category from the URL. + * + * @return string + */ + public function getCategory(): string { + return $this->category; + } + + /** + * Get the original filename from the URL. + * + * @return string + */ + public function getFileName(): string { + return $this->fileName; + } + + /** + * Get the raw URL. + * + * @return string + */ + public function getRawUrl(): string { + return $this->rawUrl; + } +} diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 81a432d72c..b91cb8f1ba 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -125,4 +125,9 @@ 0]]> + + + 0]]> + +