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');
+ }
}