From c4da214f4870869aecb6b267b46e4ea0d1c25cf2 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 25 May 2026 23:23:20 +0200 Subject: [PATCH] feat(preferences): generic per-user preferences endpoint Backs the shared @conduction/nextcloud-vue CnSupportDialog auto-mount's per-user seen-flag. Adds GET/PUT /api/preferences/{key} backed by Nextcloud IConfig user values (NoAdminRequired + NoCSRFRequired). Keys are sanitised to a safe charset and namespaced under pref_. When the caller is unauthenticated the widget falls back to localStorage. --- appinfo/routes.php | 3 + lib/Controller/PreferencesController.php | 152 +++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 lib/Controller/PreferencesController.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 37466a3f..e224cf13 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -29,6 +29,9 @@ ['name' => 'settings#index', 'url' => '/api/settings', 'verb' => 'GET'], ['name' => 'settings#create', 'url' => '/api/settings', 'verb' => 'POST'], ['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'], + // Generic per-user preferences (used by shared nextcloud-vue widgets, e.g. CnSupportDialog). + ['name' => 'preferences#getPreference', 'url' => '/api/preferences/{key}', 'verb' => 'GET'], + ['name' => 'preferences#setPreference', 'url' => '/api/preferences/{key}', 'verb' => 'PUT'], // AI-Assisted Processing (specific endpoints precede wildcard routes). ['name' => 'ai#classify', 'url' => '/api/ai/classify', 'verb' => 'POST'], diff --git a/lib/Controller/PreferencesController.php b/lib/Controller/PreferencesController.php new file mode 100644 index 00000000..f4c31bdd --- /dev/null +++ b/lib/Controller/PreferencesController.php @@ -0,0 +1,152 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://github.com/ConductionNL/procest + */ + +declare(strict_types=1); + +namespace OCA\Procest\Controller; + +use OCA\Procest\AppInfo\Application; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IConfig; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Per-user preferences controller. + */ +class PreferencesController extends Controller +{ + /** + * Constructor. + * + * @param IRequest $request The request. + * @param IConfig $config The Nextcloud config (user values). + * @param IUserSession $userSession The user session. + */ + public function __construct( + IRequest $request, + private readonly IConfig $config, + private readonly IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + + }//end __construct() + + /** + * Read a per-user preference value. + * + * @param string $key The preference key (kebab/alphanumeric). + * + * @return JSONResponse `{value: string|null}`. + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function getPreference(string $key): JSONResponse + { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Not logged in'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + $safeKey = $this->sanitizeKey(key: $key); + if ($safeKey === '') { + return new JSONResponse(data: ['message' => 'Invalid key'], statusCode: Http::STATUS_BAD_REQUEST); + } + + $value = $this->config->getUserValue( + userId: $user->getUID(), + appName: Application::APP_ID, + key: 'pref_'.$safeKey, + default: '' + ); + + $stored = null; + if ($value !== '') { + $stored = $value; + } + + return new JSONResponse(data: ['value' => $stored]); + + }//end getPreference() + + /** + * Write a per-user preference value. An empty value clears it. + * + * @param string $key The preference key (kebab/alphanumeric). + * @param string $value The value to store (empty string clears it). + * + * @return JSONResponse `{value: string|null}`. + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function setPreference(string $key, string $value=''): JSONResponse + { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Not logged in'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + $safeKey = $this->sanitizeKey(key: $key); + if ($safeKey === '') { + return new JSONResponse(data: ['message' => 'Invalid key'], statusCode: Http::STATUS_BAD_REQUEST); + } + + $stored = null; + if ($value === '') { + $this->config->deleteUserValue( + userId: $user->getUID(), + appName: Application::APP_ID, + key: 'pref_'.$safeKey + ); + } else { + $this->config->setUserValue( + userId: $user->getUID(), + appName: Application::APP_ID, + key: 'pref_'.$safeKey, + value: $value + ); + $stored = $value; + } + + return new JSONResponse(data: ['value' => $stored]); + + }//end setPreference() + + /** + * Restrict keys to a safe charset so callers cannot reach arbitrary + * IConfig user values outside the `pref_` namespace. + * + * @param string $key The raw key. + * + * @return string The sanitised key, or '' when nothing safe remains. + */ + private function sanitizeKey(string $key): string + { + $safe = preg_replace(pattern: '/[^a-z0-9-]/', replacement: '', subject: strtolower($key)); + return substr((string) $safe, offset: 0, length: 64); + + }//end sanitizeKey() +}//end class