API: new attribute "readonly" for read-only notes

This commit is contained in:
korelstar 2021-05-02 18:29:50 +02:00
parent c4b7dfe6ec
commit 7c3e4bbec1
13 changed files with 91 additions and 6 deletions

View File

@ -66,6 +66,7 @@ jobs:
mkdir -p server/data/quotatest/files/
cp -r notes/tests/reference-notes server/data/test/files/Notes
cp -r notes/tests/reference-notes server/data/quotatest/files/Notes
chmod 444 server/data/test/files/Notes/ReadOnly/ReadOnly-Note.txt
php server/occ files:scan --all
- name: Start Nextcloud server
working-directory: ../server/

View File

@ -21,6 +21,7 @@ The app and the API is mainly about notes. So, let's have a look about the attri
|:----------|:-----|:-------------------------|:-------------------|
| `id` | integer (readonly) | Every note has a unique identifier which is created by the server. It can be used to query and update a specific note. | 1.0 |
| `etag` | string (readonly) | The note's entity tag (ETag) indicates if a note's attribute has changed. I.e., if the note changes, the ETag changes, too. Clients can use the ETag for detecting if the local note has to be updated from server and for optimistic concurrency control (see section [Preventing lost updates and conflict solution](#preventing-lost-updates-and-conflict-solution)). | 1.2 |
| `readonly` | boolean (readonly) | Indicates if the note is read-only. This is `true`, e.g., if a file or folder was shared by another user without allowing editing. If this attribute is `true`, then all read/write attributes become read-only; except for the `favorite` attribute. | 1.2 |
| `content` | string (read/write) | Notes can contain arbitrary text. Formatting should be done using Markdown, but not every markup can be supported by every client. Therefore, markup should be used with care. | 1.0 |
| `title` | string (read/write) | The note's title is also used as filename for the note's file. Therefore, some special characters are automatically removed and a sequential number is added if a note with the same title in the same category exists. When saving a title, the sanitized value is returned and should be adopted by your client. | 1.0 |
| `category` | string (read/write) | Every note is assigned to a category. By default, the category is an empty string (not null), which means the note is uncategorized. Categories are mapped to folders in the file backend. Illegal characters are automatically removed and the respective folder is automatically created. Sub-categories (mapped to sub-folders) can be created by using `/` as delimiter. | 1.0 |
@ -70,6 +71,7 @@ All defined routes in the specification are appended to this url. To access all
{
"id": 76,
"etag": "be284e00488c61c101ee28309d235e0b",
"readonly": false,
"modified": 1376753464,
"title": "New note",
"category": "sub-directory",
@ -101,6 +103,7 @@ No valid authentication credentials supplied.
{
"id": 76,
"etag": "be284e00488c61c101ee28309d235e0b",
"readonly": false,
"modified": 1376753464,
"title": "New note",
"category": "sub-directory",
@ -167,6 +170,9 @@ Invalid ID supplied.
##### 401 Unauthorized
No valid authentication credentials supplied.
##### 403 Forbidden
The note is read-only.
##### 404 Not Found
Note not found.
@ -174,7 +180,6 @@ Note not found.
*(since API v1.2)*
Update cannot be performed since the note has been changed on the server in the meanwhile (concurrent change). The body contains the current note's state from server (see section [Note attributes](#note-attributes)), example see section [Get single note](#get-single-note-get-notesid). The client should use this response data in order to perform a conflict solution (see section [Preventing lost updates and conflict solution](#preventing-lost-updates-and-conflict-solution)).
##### 507 Insufficient Storage
Not enough free storage for saving the note's content.
</details>
@ -198,6 +203,9 @@ Invalid ID supplied.
##### 401 Unauthorized
No valid authentication credentials supplied.
##### 403 Forbidden
The note is read-only.
##### 404 Not Found
Note not found.
</details>

View File

@ -119,6 +119,9 @@ class Helper {
} catch (\OCA\Notes\Service\InsufficientStorageException $e) {
$this->logException($e);
$response = $this->createErrorResponse($e, Http::STATUS_INSUFFICIENT_STORAGE);
} catch (\OCA\Notes\Service\NoteNotWritableException $e) {
$this->logException($e);
$response = $this->createErrorResponse($e, Http::STATUS_FORBIDDEN);
} catch (\OCP\Lock\LockedException $e) {
$this->logException($e);
$response = $this->createErrorResponse($e, Http::STATUS_LOCKED);

View File

@ -147,18 +147,18 @@ class NotesApiController extends ApiController {
$favorite
) {
$note = $this->helper->getNoteWithETagCheck($id, $this->request);
if ($content !== null) {
if ($content !== null && $content !== $note->getContent()) {
$note->setContent($content);
}
if ($modified !== null) {
if ($modified !== null && $modified !== $note->getModified()) {
$note->setModified($modified);
}
if ($title !== null) {
if ($title !== null && $title !== $note->getTitle()) {
$note->setTitleCategory($title, $category);
} elseif ($category !== null) {
} elseif ($category !== null && $category !== $note->getCategory()) {
$note->setCategory($category);
}
if ($favorite !== null) {
if ($favorite !== null && $favorite !== $note->getFavorite()) {
$note->setFavorite($favorite);
}
return $this->helper->getNoteData($note);

View File

@ -179,6 +179,7 @@ class MetaService {
$note->getModified(),
$note->getCategory(),
$note->getFavorite(),
$note->getReadOnly(),
$meta->getContentEtag(),
];
return md5(json_encode($data));

View File

@ -82,6 +82,10 @@ class Note {
return $this->noteUtil->getTagService()->isFavorite($this->getId());
}
public function getReadOnly() : bool {
return !$this->file->isUpdateable();
}
public function getData(array $exclude = []) : array {
$data = [];
@ -100,6 +104,9 @@ class Note {
if (!in_array('favorite', $exclude)) {
$data['favorite'] = $this->getFavorite();
}
if (!in_array('readonly', $exclude)) {
$data['readonly'] = $this->getReadOnly();
}
$data['error'] = false;
$data['errorMessage'] = '';
if (!in_array('content', $exclude)) {
@ -125,10 +132,12 @@ class Note {
public function setTitle(string $title) : void {
$this->noteUtil->ensureNoteIsWritable($this->file);
$this->setTitleCategory($title);
}
public function setCategory(string $category) : void {
$this->noteUtil->ensureNoteIsWritable($this->file);
$this->setTitleCategory($this->getTitle(), $category);
}
@ -136,6 +145,7 @@ class Note {
* @throws \OCP\Files\NotPermittedException
*/
public function setTitleCategory(string $title, ?string $category = null) : void {
$this->noteUtil->ensureNoteIsWritable($this->file);
if ($category === null) {
$category = $this->getCategory();
}
@ -155,11 +165,13 @@ class Note {
}
public function setContent(string $content) : void {
$this->noteUtil->ensureNoteIsWritable($this->file);
$this->noteUtil->ensureSufficientStorage($this->file->getParent(), strlen($content));
$this->file->putContent($content);
}
public function setModified(int $modified) : void {
$this->noteUtil->ensureNoteIsWritable($this->file);
$this->file->touch($modified);
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace OCA\Notes\Service;
use Exception;
class NoteNotWritableException extends Exception {
}

View File

@ -6,6 +6,7 @@ namespace OCA\Notes\Service;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\IDBConnection;
class NoteUtil {
@ -192,4 +193,15 @@ class NoteUtil {
throw new InsufficientStorageException($requiredBytes.' are required in '.$folder->getPath());
}
}
/**
* Checks if the file/folder is writable. Throws an Exception if not.
* @param Node $node to be checked
* @throws NoteNotWritableException
*/
public function ensureNoteIsWritable(Node $node) : void {
if (!$node->isUpdateable()) {
throw new NoteNotWritableException();
}
}
}

View File

@ -118,6 +118,7 @@ class NotesService {
public function delete(string $userId, int $id) {
$notesFolder = $this->getNotesFolder($userId);
$file = $this->getFileById($notesFolder, $id);
$this->noteUtil->ensureNoteIsWritable($file);
$parent = $file->getParent();
$file->delete();
$this->noteUtil->deleteEmptyFolder($parent, $notesFolder);

View File

@ -8,4 +8,34 @@ class APIv1Test extends CommonAPITest {
public function __construct() {
parent::__construct('1.2', false);
}
/** @depends testCheckForReferenceNotes */
public function testReadOnlyNote(array $refNotes) : void {
$readOnlyNotes = array_values(array_filter($refNotes, function ($note) {
return $note->readonly;
}));
$this->assertNotEmpty($readOnlyNotes, 'List of read only notes');
$note = clone $readOnlyNotes[0];
unset($note->etag);
$favorite = $note->favorite;
// request with all attributes (unchanged) and just change favorite should succeed
$upd = clone $note;
$upd->favorite = !$favorite;
$this->updateNote($note, $upd, (object)[]);
// changing other attributes should fail
$this->updateNote($note, (object)[ 'content' => 'New content' ], (object)[], null, 403);
$this->updateNote($note, (object)[ 'title' => 'New title' ], (object)[], null, 403);
$this->updateNote($note, (object)[ 'category' => 'New category' ], (object)[], null, 403);
$this->updateNote($note, (object)[ 'modified' => 700 ], (object)[], null, 403);
// change favorite back to origin
$this->updateNote($note, (object)[
'favorite' => $favorite,
], (object)[
]);
// delete should fail
$response = $this->http->request('DELETE', 'notes/'.$note->id);
$this->checkResponse($response, 'Delete read-only note note', 403);
// test if nothing has changed
$this->checkGetReferenceNotes($refNotes, 'After read-only tests');
}
}

View File

@ -201,6 +201,9 @@ abstract class AbstractAPITest extends TestCase {
}
$response = $this->http->request('PUT', 'notes/'.$note->id, $requestOptions);
$this->checkResponse($response, 'Update note '.$note->title, $statusExp);
if ($statusExp === 403) {
return $note;
}
$responseNote = json_decode($response->getBody()->getContents());
foreach (get_object_vars($request) as $key => $val) {
$note->$key = $val;

View File

@ -7,6 +7,7 @@ namespace OCA\Notes\Tests\API;
abstract class CommonAPITest extends AbstractAPITest {
private $requiredAttributes = [
'id' => 'integer',
'readonly' => 'boolean',
'content' => 'string',
'title' => 'string',
'category' => 'string',
@ -122,6 +123,7 @@ abstract class CommonAPITest extends AbstractAPITest {
], (object)[
'title' => $this->autotitle ? 'First test note' : 'First manual title',
'category' => 'Test/New Category',
'readonly' => false,
]);
$testNotes[] = $this->createNote((object)[
'content' => 'Note with Defaults'.PHP_EOL.'This is some body content with some data.',
@ -130,6 +132,7 @@ abstract class CommonAPITest extends AbstractAPITest {
'favorite' => false,
'category' => '',
'modified' => time(),
'readonly' => false,
]);
$this->checkGetReferenceNotes(array_merge($refNotes, $testNotes), 'After creating notes');
return $testNotes;

View File

@ -0,0 +1 @@
This is a read-only note.