diff --git a/Makefile b/Makefile index 986c88996..d9c4fff6f 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ app_name=notes project_dir=$(CURDIR)/../$(app_name) build_dir=$(CURDIR)/build/artifacts cert_dir=$(HOME)/.nextcloud/certificates +php_dirs=appinfo/ lib/ tests/api/ all: dev-setup build @@ -99,7 +100,7 @@ lint-phpfast: lint-php-lint lint-php-ncversion lint-php-cs-fixer lint-php-phpcs lint-php-lint: # Check PHP syntax errors - @! find lib/ -name "*.php" | xargs -I{} php -l '{}' | grep -v "No syntax errors detected" + @! find $(php_dirs) -name "*.php" | xargs -I{} php -l '{}' | grep -v "No syntax errors detected" lint-php-ncversion: # Check min-version consistency @@ -111,7 +112,7 @@ lint-php-phan: lint-php-phpcs: # PHP CodeSniffer - vendor/bin/phpcs --standard=tests/phpcs.xml appinfo/ lib/ tests/api/ --report=checkstyle | vendor/bin/cs2pr --graceful-warnings --colorize + vendor/bin/phpcs --standard=tests/phpcs.xml $(php_dirs) --report=checkstyle | vendor/bin/cs2pr --graceful-warnings --colorize lint-php-cs-fixer: # PHP Coding Standards Fixer (with Nextcloud coding standards) @@ -136,7 +137,7 @@ lint-xml: lint-fix: lint-php-fix lint-js-fix lint-css-fix lint-php-fix: - vendor/bin/phpcbf --standard=tests/phpcs.xml appinfo/ lib/ tests/api/ + vendor/bin/phpcbf --standard=tests/phpcs.xml $(php_dirs) vendor/bin/php-cs-fixer fix lint-js-fix: diff --git a/appinfo/routes.php b/appinfo/routes.php index b399c381d..fa05e3aa7 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -150,6 +150,22 @@ 'id' => '\d+', ], ], + [ + 'name' => 'notes_api#setSettings', + 'url' => '/api/{apiVersion}/settings', + 'verb' => 'PUT', + 'requirements' => [ + 'apiVersion' => '(v1)', + ], + ], + [ + 'name' => 'notes_api#getSettings', + 'url' => '/api/{apiVersion}/settings', + 'verb' => 'GET', + 'requirements' => [ + 'apiVersion' => '(v1)', + ], + ], [ 'name' => 'notes_api#fail', 'url' => '/api/{catchAll}', diff --git a/docs/api/v1.md b/docs/api/v1.md index d88639ced..f53f99478 100644 --- a/docs/api/v1.md +++ b/docs/api/v1.md @@ -27,6 +27,16 @@ The app and the API is mainly about notes. So, let's have a look about the attri | `modified` | integer | Unix timestamp for the last modified date/time of the note. If not provided on note creation or content update, the current time is used. | 1.0 | +## Settings + +Since API version 1.2, it is possible to change app settings using the API. The following settings attributes exist: + +| Attribute | Type | Description | since API version | +|:----------|:-----|:------------|:------------------| +| `notesPath` | string | Path to the folder, where note's files are stored in Nextcloud. The path must be relative to the user folder. Default is the localized string `Notes`. | 1.2 | +| `fileSuffix` | string | Newly created note's files will have this file suffix. Only the values `.txt` or `.md` are allowed. Default is `.txt`. | 1.2 | + + ## Endpoints and Operations The base URL for all calls is: @@ -180,3 +190,59 @@ No valid authentication credentials supplied. ##### 404 Not Found Note not found. + + +### Get settings (`GET /settings`) +
Details + +*(since API v1.2)* + +#### Request parameters +None. + +#### Response +##### 200 OK +- **Body**: user's app settings (see section [Settings](#settings)), example: +```js +{ + "notesPath": "Notes", + "fileSuffix": ".txt" +} +``` + +##### 400 Bad Request +Endpoint not supported by installed notes app version (requires API version 1.2). + +##### 401 Unauthorized +No valid authentication credentials supplied. +
+ + +### Change settings (`PUT /settings`) +
Details + +*(since API v1.2)* + +#### Request parameters +- **Body**: some or all settings attributes (see section [Settings](#settings)). +Omitted settings attributes are not changed. +Empty values are replaced by the settings attribute's default value. +All values are sanitized (e.g. prevent path traversal attacks, check allowed suffixes), so the result can differ from the request (the request will still succeed). +The client may show an information to the user if the response differs from the request. +Example: +```js +{ + "fileSuffix": ".md" +} +``` + +#### Response +##### 200 OK +- **Body**: user's app settings after validation (see section [Settings](#settings)), example see section [Get settings](#get-settings-get-settings). + +##### 400 Bad Request +Endpoint not supported by installed notes app version (requires API version 1.2). + +##### 401 Unauthorized +No valid authentication credentials supplied. +
diff --git a/lib/Controller/NotesApiController.php b/lib/Controller/NotesApiController.php index dc98c2bfe..2de4d44b5 100644 --- a/lib/Controller/NotesApiController.php +++ b/lib/Controller/NotesApiController.php @@ -6,6 +6,7 @@ use OCA\Notes\Service\NotesService; use OCA\Notes\Service\MetaService; +use OCA\Notes\Service\SettingsService; use OCP\AppFramework\ApiController; use OCP\AppFramework\Http; @@ -18,6 +19,8 @@ class NotesApiController extends ApiController { private $service; /** @var MetaService */ private $metaService; + /** @var SettingsService */ + private $settingsService; /** @var Helper */ private $helper; @@ -26,11 +29,13 @@ public function __construct( IRequest $request, NotesService $service, MetaService $metaService, + SettingsService $settingsService, Helper $helper ) { parent::__construct($AppName, $request); $this->service = $service; $this->metaService = $metaService; + $this->settingsService = $settingsService; $this->helper = $helper; } @@ -207,6 +212,28 @@ public function destroy(int $id) : JSONResponse { }); } + /** + * @NoAdminRequired + * @CORS + * @NoCSRFRequired + */ + public function setSettings() { + return $this->helper->handleErrorResponse(function () { + $this->settingsService->set($this->helper->getUID(), $this->request->getParams()); + return $this->getSettings(); + }); + } + + /** + * @NoAdminRequired + * @CORS + * @NoCSRFRequired + */ + public function getSettings() { + return $this->helper->handleErrorResponse(function () { + return $this->settingsService->getAll($this->helper->getUID()); + }); + } /** * @NoAdminRequired * @NoCSRFRequired diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index af25d76de..bc03cacfc 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -15,8 +15,8 @@ class SettingsService { private $l10n; private $root; - /* Default values */ - private $defaults; + /* Allowed attributes */ + private $attrs; public function __construct( IConfig $config, @@ -26,11 +26,35 @@ public function __construct( $this->config = $config; $this->l10n = $l10n; $this->root = $root; - $this->defaults = [ - 'fileSuffix' => '.txt', - 'notesPath' => function (string $uid) { - return $this->getDefaultNotesPath($uid); - }, + $this->attrs = [ + 'fileSuffix' => [ + 'default' => '.txt', + 'validate' => function ($value) { + if (in_array($value, [ '.txt', '.md' ])) { + return $value; + } else { + return '.txt'; + } + }, + ], + 'notesPath' => [ + 'default' => function (string $uid) { + return $this->getDefaultNotesPath($uid); + }, + 'validate' => function ($value) { + $value = str_replace([ '/', '\\' ], DIRECTORY_SEPARATOR, $value); + $parts = explode(DIRECTORY_SEPARATOR, $value); + $path = []; + foreach ($parts as $part) { + if ($part === '..') { + array_pop($path); + } elseif (strlen($part) && $part !== '.') { + array_push($path, $part); + } + } + return implode(DIRECTORY_SEPARATOR, $path); + }, + ], ]; } @@ -48,11 +72,21 @@ private function getDefaultNotesPath(string $uid) : string { * @throws \OCP\PreConditionNotMetException */ public function set(string $uid, array $settings) : void { + // load existing values for missing attributes + $oldSettings = $this->getSettingsFromDB($uid); + foreach ($oldSettings as $name => $value) { + if (!array_key_exists($name, $settings)) { + $settings[$name] = $value; + } + } // remove illegal, empty and default settings foreach ($settings as $name => $value) { - if (!array_key_exists($name, $this->defaults) + if ($value !== null && array_key_exists($name, $this->attrs)) { + $settings[$name] = $value = $this->attrs[$name]['validate']($value); + } + if (!array_key_exists($name, $this->attrs) || empty($value) - || $value === $this->defaults[$name] + || $value === $this->attrs[$name]['default'] ) { unset($settings[$name]); } @@ -60,15 +94,21 @@ public function set(string $uid, array $settings) : void { $this->config->setUserValue($uid, Application::APP_ID, 'settings', json_encode($settings)); } - public function getAll(string $uid) : \stdClass { + private function getSettingsFromDB(string $uid) : \stdClass { $settings = json_decode($this->config->getUserValue($uid, Application::APP_ID, 'settings')); if (!is_object($settings)) { $settings = new \stdClass(); } + return $settings; + } + + public function getAll(string $uid) : \stdClass { + $settings = $this->getSettingsFromDB($uid); // use default for empty settings $toBeSaved = false; - foreach ($this->defaults as $name => $defaultValue) { + foreach ($this->attrs as $name => $attr) { if (!property_exists($settings, $name) || empty($settings->{$name})) { + $defaultValue = $attr['default']; if (is_callable($defaultValue)) { $settings->{$name} = $defaultValue($uid); $toBeSaved = true; diff --git a/src/App.vue b/src/App.vue index c767c7c22..d2cd30896 100644 --- a/src/App.vue +++ b/src/App.vue @@ -156,6 +156,7 @@ export default { this.$router.push('/') } store.commit('removeAllNotes') + store.commit('clearSyncCache') this.loading.notes = true this.loadNotes() }, diff --git a/src/store/sync.js b/src/store/sync.js index dbc38e1d2..159106d7c 100644 --- a/src/store/sync.js +++ b/src/store/sync.js @@ -22,6 +22,11 @@ const mutations = { } }, + clearSyncCache(state) { + state.etag = null + state.lastModified = 0 + }, + setSyncActive(state, active) { state.active = active }, diff --git a/tests/api/AbstractAPITest.php b/tests/api/AbstractAPITest.php index c51427c54..e7a4eee23 100644 --- a/tests/api/AbstractAPITest.php +++ b/tests/api/AbstractAPITest.php @@ -200,4 +200,41 @@ protected function updateNote(\stdClass &$note, \stdClass $request, \stdClass $e } $this->checkReferenceNote($note, $responseNote, 'Updated note'); } + + protected function checkObject( + \stdClass $ref, + \stdClass $obj, + string $messagePrefix + ) : void { + foreach (get_object_vars($ref) as $key => $val) { + $this->assertObjectHasAttribute( + $key, + $obj, + $messagePrefix.': Object has property '.$key + ); + $this->assertEquals( + $ref->$key, + $obj->$key, + $messagePrefix.': Property '.$key + ); + } + } + + protected function updateSettings( + \stdClass &$settings, + \stdClass $request, + \stdClass $expected, + string $messagePrefix + ) { + $response = $this->http->request('PUT', 'settings', [ 'json' => $request ]); + $this->checkResponse($response, $messagePrefix, 200); + $responseSettings = json_decode($response->getBody()->getContents()); + foreach (get_object_vars($request) as $key => $val) { + $settings->$key = $val; + } + foreach (get_object_vars($expected) as $key => $val) { + $settings->$key = $val; + } + $this->checkObject($settings, $responseSettings, $messagePrefix); + } } diff --git a/tests/api/CommonAPITest.php b/tests/api/CommonAPITest.php index 3930bb121..e1ee384da 100644 --- a/tests/api/CommonAPITest.php +++ b/tests/api/CommonAPITest.php @@ -14,6 +14,11 @@ abstract class CommonAPITest extends AbstractAPITest { 'favorite' => 'boolean', ]; + private $requiredSettings = [ + 'notesPath' => 'string', + 'fileSuffix' => 'string', + ]; + private $autotitle; public function __construct(string $apiVersion, bool $autotitle) { @@ -244,4 +249,56 @@ public function testUnauthorized() { $response = $this->http->request('GET', 'notes', [ 'auth' => $auth ]); $this->checkResponse($response, 'Get existing notes', 401); } + + public function testGetSettings() : \stdClass { + if ($this->getAPIMajorVersion() < 1) { + $this->markTestSkipped('Settings API requires API v1'); + } + $response = $this->http->request('GET', 'settings'); + $this->checkResponse($response, 'Get settings', 200); + $settings = json_decode($response->getBody()->getContents()); + foreach ($this->requiredSettings as $key => $type) { + $this->assertObjectHasAttribute($key, $settings, 'Settings has property '.$key); + $this->assertEquals($type, gettype($settings->$key), 'Property type of '.$key); + } + return $settings; + } + + /** + * @depends testCheckForReferenceNotes + * @depends testGetSettings + */ + public function testSettings(array $refNotes, \stdClass $settings) : void { + if ($this->getAPIMajorVersion() < 1) { + $this->markTestSkipped('Settings API requires API v1'); + } + $this->checkGetReferenceNotes($refNotes, 'Pre-condition'); + $originalPath = $settings->notesPath; + $this->updateSettings($settings, (object)[ + 'notesPath' => 'New-Test-Notes-Folder1', + 'fileSuffix' => '.md', + ], (object)[], 'Update both settings'); + $this->checkGetReferenceNotes([], 'Notes are gone after changing notes path'); + $this->updateSettings($settings, (object)[ + 'notesPath' => '../../Test/./../New-Test-Notes-Folder2', + ], (object)[ + 'notesPath' => 'New-Test-Notes-Folder2', + ], 'Update notesPath with path traversal check'); + $this->updateSettings($settings, (object)[ + 'fileSuffix' => 'illegal value', + ], (object)[ + 'fileSuffix' => '.txt', + ], 'Update fileSuffix with illegal value'); + $this->updateSettings($settings, (object)[ + 'notesPath' => null, + 'fileSuffix' => null, + ], (object)[ + 'notesPath' => 'Notes', + 'fileSuffix' => '.txt', + ], 'Update settings with default values'); + $this->updateSettings($settings, (object)[ + 'notesPath' => $originalPath, + ], (object)[], 'Update notesPath to original value'); + $this->checkGetReferenceNotes($refNotes, 'Post-condition'); + } }