Passed
Pull Request — master (#314)
by korelstar
02:09
created

NotesService::getAll()   A

Complexity

Conditions 5
Paths 8

Size

Total Lines 20
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 14
dl 0
loc 20
c 0
b 0
f 0
rs 9.4888
cc 5
nc 8
nop 2
1
<?php
2
3
namespace OCA\Notes\Service;
4
5
use OCP\Files\FileInfo;
6
use OCP\IL10N;
7
use OCP\Files\IRootFolder;
8
use OCP\Files\Folder;
9
use OCP\ILogger;
10
use OCP\Encryption\Exceptions\GenericEncryptionException;
11
use OCA\Notes\Db\Note;
12
use OCA\Notes\Service\SettingsService;
13
use OCP\IConfig;
14
use OCP\IUserSession;
15
16
/**
17
 * Class NotesService
18
 *
19
 * @package OCA\Notes\Service
20
 */
21
class NotesService {
22
23
	private $l10n;
24
	private $root;
25
	private $logger;
26
	private $config;
27
	private $settings;
28
	private $appName;
29
30
	/**
31
	 * @param IRootFolder $root
32
	 * @param IL10N $l10n
33
	 * @param ILogger $logger
34
	 * @param IConfig $config
35
	 * @param \OCA\Notes\Service\SettingsService $settings
36
	 * @param String $appName
37
	 */
38
	public function __construct(IRootFolder $root, IL10N $l10n, ILogger $logger, IConfig $config, SettingsService $settings, $appName) {
39
		$this->root = $root;
40
		$this->l10n = $l10n;
41
		$this->logger = $logger;
42
		$this->config = $config;
43
		$this->settings = $settings;
44
		$this->appName = $appName;
45
	}
46
47
48
	/**
49
	 * @param string $userId
50
	 * @return array with all notes in the current directory
51
	 */
52
	public function getAll($userId, $onlyMeta = false) {
53
		$notesFolder = $this->getFolderForUser($userId);
54
		$notes = $this->gatherNoteFiles($notesFolder);
55
		$filesById = [];
56
		foreach ($notes as $note) {
57
			$filesById[$note->getId()] = $note;
58
		}
59
		$tagger = \OC::$server->getTagManager()->load('files');
60
		if ($tagger===null) {
61
			$tags = [];
62
		} else {
63
			$tags = $tagger->getTagsForObjects(array_keys($filesById));
64
		}
65
66
		$notes = [];
67
		foreach ($filesById as $id => $file) {
68
			$notes[] = $this->getNote($file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : [], $onlyMeta);
69
		}
70
71
		return $notes;
72
	}
73
74
75
	/**
76
	 * Used to get a single note by id
77
	 * @param int $id the id of the note to get
78
	 * @param string $userId
79
	 * @throws NoteDoesNotExistException if note does not exist
80
	 * @return Note
81
	 */
82
	public function get($id, $userId) {
83
		$folder = $this->getFolderForUser($userId);
84
		return $this->getNote($this->getFileById($folder, $id), $folder, $this->getTags($id));
85
	}
86
87
	private function getTags($id) {
88
		$tagger = \OC::$server->getTagManager()->load('files');
89
		if ($tagger===null) {
90
			$tags = [];
91
		} else {
92
			$tags = $tagger->getTagsForObjects([$id]);
93
		}
94
		return array_key_exists($id, $tags) ? $tags[$id] : [];
95
	}
96
97
	private function getNote($file, $notesFolder, $tags = [], $onlyMeta = false) {
98
		$id = $file->getId();
99
		try {
100
			$note = Note::fromFile($file, $notesFolder, $tags, $onlyMeta);
101
		} catch (GenericEncryptionException $e) {
102
			$note = Note::fromException($this->l10n->t('Encryption Error').': ('.$file->getName().') '.$e->getMessage(), $file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []);
103
		} catch (\Exception $e) {
104
			$note = Note::fromException($this->l10n->t('Error').': ('.$file->getName().') '.$e->getMessage(), $file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []);
105
		}
106
		return $note;
107
	}
108
109
110
	/**
111
	 * Creates a note and returns the empty note
112
	 * @param string $userId
113
	 * @see update for setting note content
114
	 * @return Note the newly created note
115
	 */
116
	public function create($userId) {
117
		$title = $this->l10n->t('New note');
118
		$folder = $this->getFolderForUser($userId);
119
120
		// check new note exists already and we need to number it
121
		// pass -1 because no file has id -1 and that will ensure
122
		// to only return filenames that dont yet exist
123
		$path = $this->generateFileName($folder, $title, $this->settings->get($userId, 'fileSuffix'), -1);
124
		$file = $folder->newFile($path);
125
126
		// If server-side encryption is activated, the server creates an empty file without signature
127
		// which leads to an GenericEncryptionException('Missing Signature') afterwards.
128
		// Saving a space-char (and removing it later) is a working work-around.
129
		$file->putContent(' ');
130
131
		return $this->getNote($file, $folder);
132
	}
133
134
135
	/**
136
	 * Updates a note. Be sure to check the returned note since the title is
137
	 * dynamically generated and filename conflicts are resolved
138
	 * @param int $id the id of the note used to update
139
	 * @param string|null $content the content which will be written into the note
140
	 * the title is generated from the first line of the content
141
	 * @param string|null $category the category in which the note should be saved
142
	 * @param int $mtime time of the note modification (optional)
143
	 * @throws NoteDoesNotExistException if note does not exist
144
	 * @return \OCA\Notes\Db\Note the updated note
145
	 */
146
	public function update($id, $content, $userId, $category = null, $mtime = 0) {
147
		$notesFolder = $this->getFolderForUser($userId);
148
		$file = $this->getFileById($notesFolder, $id);
149
		$title = $this->getSafeTitleFromContent($content===null ? $file->getContent() : $content);
150
151
152
		// rename/move file with respect to title/category
153
		// this can fail if access rights are not sufficient or category name is illegal
154
		try {
155
			$currentFilePath = $this->root->getFullPath($file->getPath());
156
			$currentBasePath = pathinfo($currentFilePath, PATHINFO_DIRNAME);
157
			$fileSuffix = '.' . pathinfo($file->getName(), PATHINFO_EXTENSION);
158
159
			// detect (new) folder path based on category name
160
			if ($category===null) {
161
				$basePath = $currentBasePath;
162
			} else {
163
				$basePath = $notesFolder->getPath();
164
				if (!empty($category)) {
165
					// sanitise path
166
					$cats = explode('/', $category);
167
					$cats = array_map([$this, 'sanitisePath'], $cats);
168
					$cats = array_filter($cats, function ($str) {
169
						return !empty($str);
170
					});
171
					$basePath .= '/'.implode('/', $cats);
172
				}
173
			}
174
			$folder = $this->getOrCreateFolder($basePath);
175
176
			// assemble new file path
177
			$newFilePath = $basePath . '/' . $this->generateFileName($folder, $title, $fileSuffix, $id);
178
179
			// if the current path is not the new path, the file has to be renamed
180
			if ($currentFilePath !== $newFilePath) {
181
				$file->move($newFilePath);
182
			}
183
			if ($currentBasePath !== $basePath) {
184
				$this->deleteEmptyFolder($notesFolder, $this->root->get($currentBasePath));
185
			}
186
		} catch (\OCP\Files\NotPermittedException $e) {
187
			$this->logger->error('Moving note '.$id.' ('.$title.') to the desired target is not allowed. Please check the note\'s target category ('.$category.').', ['app' => $this->appName]);
188
		} catch (\Exception $e) {
189
			$this->logger->error('Moving note '.$id.' ('.$title.') to the desired target has failed with a '.get_class($e).': '.$e->getMessage(), ['app' => $this->appName]);
190
		}
191
192
		if ($content !== null) {
193
			$file->putContent($content);
194
		}
195
196
		if ($mtime) {
197
			$file->touch($mtime);
198
		}
199
200
		return $this->getNote($file, $notesFolder, $this->getTags($id));
201
	}
202
203
	/**
204
	 * Set or unset a note as favorite.
205
	 * @param int $id the id of the note used to update
206
	 * @param boolean $favorite whether the note should be a favorite or not
207
	 * @throws NoteDoesNotExistException if note does not exist
208
	 * @return boolean the new favorite state of the note
209
	 */
210
	public function favorite($id, $favorite, $userId) {
211
		$folder = $this->getFolderForUser($userId);
212
		$file = $this->getFileById($folder, $id);
213
		if (!$this->isNote($file)) {
214
			throw new NoteDoesNotExistException();
215
		}
216
		$tagger = \OC::$server->getTagManager()->load('files');
217
		if ($favorite) {
218
			$tagger->addToFavorites($id);
219
		} else {
220
			$tagger->removeFromFavorites($id);
221
		}
222
223
		$tags = $tagger->getTagsForObjects([$id]);
224
		return array_key_exists($id, $tags) && in_array(\OC\Tags::TAG_FAVORITE, $tags[$id]);
0 ignored issues
show
Bug introduced by
The type OC\Tags was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
225
	}
226
227
228
	/**
229
	 * Deletes a note
230
	 * @param int $id the id of the note which should be deleted
231
	 * @param string $userId
232
	 * @throws NoteDoesNotExistException if note does not
233
	 * exist
234
	 */
235
	public function delete($id, $userId) {
236
		$notesFolder = $this->getFolderForUser($userId);
237
		$file = $this->getFileById($notesFolder, $id);
238
		$parent = $file->getParent();
239
		$file->delete();
240
		$this->deleteEmptyFolder($notesFolder, $parent);
241
	}
242
243
	// removes characters that are illegal in a file or folder name on some operating systems
244
	private function sanitisePath($str) {
245
		// remove characters which are illegal on Windows (includes illegal characters on Unix/Linux)
246
		// prevents also directory traversal by eliminiating slashes
247
		// see also \OC\Files\Storage\Common::verifyPosixPath(...)
248
		$str = str_replace(['*', '|', '/', '\\', ':', '"', '<', '>', '?'], '', $str);
249
250
		// if mysql doesn't support 4byte UTF-8, then remove those characters
251
		// see \OC\Files\Storage\Common::verifyPath(...)
252
		if (!\OC::$server->getDatabaseConnection()->supports4ByteText()) {
253
			$str = preg_replace('%(?:
254
                \xF0[\x90-\xBF][\x80-\xBF]{2}      # planes 1-3
255
              | [\xF1-\xF3][\x80-\xBF]{3}          # planes 4-15
256
              | \xF4[\x80-\x8F][\x80-\xBF]{2}      # plane 16
257
              )%xs', '', $str);
258
		}
259
260
		// prevent file to be hidden
261
		$str = preg_replace("/^[\. ]+/mu", "", $str);
262
		return trim($str);
263
	}
264
265
	private function getSafeTitleFromContent($content) {
266
		// prepare content: remove markdown characters and empty spaces
267
		$content = preg_replace("/^\s*[*+-]\s+/mu", "", $content); // list item
268
		$content = preg_replace("/^#+\s+(.*?)\s*#*$/mu", "$1", $content); // headline
269
		$content = preg_replace("/^(=+|-+)$/mu", "", $content); // separate line for headline
270
		$content = preg_replace("/(\*+|_+)(.*?)\\1/mu", "$2", $content); // emphasis
271
272
		// sanitize: prevent directory traversal, illegal characters and unintended file names
273
		$content = $this->sanitisePath($content);
274
275
		// generate title from the first line of the content
276
		$splitContent = preg_split("/\R/u", $content, 2);
277
		$title = trim($splitContent[0]);
278
279
		// ensure that title is not empty
280
		if (empty($title)) {
281
			$title = $this->l10n->t('New note');
282
		}
283
284
		// using a maximum of 100 chars should be enough
285
		$title = mb_substr($title, 0, 100, "UTF-8");
286
287
		return $title;
288
	}
289
290
	/**
291
	 * @param Folder $folder
292
	 * @param int $id
293
	 * @throws NoteDoesNotExistException
294
	 * @return \OCP\Files\File
295
	 */
296
	private function getFileById($folder, $id) {
297
		$file = $folder->getById($id);
298
299
		if (count($file) <= 0 || !$this->isNote($file[0])) {
300
			throw new NoteDoesNotExistException();
301
		}
302
		return $file[0];
303
	}
304
305
	/**
306
	 * @param string $userId the user id
307
	 * @return boolean true if folder is accessible, or Exception otherwise
308
	 */
309
	public function checkNotesFolder($userId) {
310
		$this->getFolderForUser($userId);
311
		return true;
312
	}
313
314
	/**
315
	 * @param string $userId the user id
316
	 * @return Folder
317
	 */
318
	private function getFolderForUser($userId) {
319
		$path = '/' . $userId . '/files/' . $this->settings->get($userId, 'notesPath');
320
		try {
321
			$folder = $this->getOrCreateFolder($path);
322
		} catch (\Exception $e) {
323
			throw new NotesFolderException($path);
324
		}
325
		return $folder;
326
	}
327
328
329
	/**
330
	 * Finds a folder and creates it if non-existent
331
	 * @param string $path path to the folder
332
	 * @return Folder
333
	 */
334
	private function getOrCreateFolder($path) {
335
		if ($this->root->nodeExists($path)) {
336
			$folder = $this->root->get($path);
337
		} else {
338
			$folder = $this->root->newFolder($path);
339
		}
340
		return $folder;
341
	}
342
343
	/*
344
	 * Delete a folder and it's parent(s) if it's/they're empty
345
	 * @param Folder root folder for notes
346
	 * @param Folder folder to delete
347
	 */
348
	private function deleteEmptyFolder(Folder $notesFolder, Folder $folder) {
349
		$content = $folder->getDirectoryListing();
350
		$isEmpty = !count($content);
351
		$isNotesFolder = $folder->getPath()===$notesFolder->getPath();
352
		if ($isEmpty && !$isNotesFolder) {
353
			$this->logger->info('Deleting empty category folder '.$folder->getPath(), ['app' => $this->appName]);
354
			$parent = $folder->getParent();
355
			$folder->delete();
356
			$this->deleteEmptyFolder($notesFolder, $parent);
357
		}
358
	}
359
360
	/**
361
	 * get path of file and the title.txt and check if they are the same
362
	 * file. If not the title needs to be renamed
363
	 *
364
	 * @param Folder $folder a folder to the notes directory
365
	 * @param string $title the filename which should be used
366
	 * @param string $suffix the suffix (incl. dot) which should be used
367
	 * @param int $id the id of the note for which the title should be generated
368
	 * used to see if the file itself has the title and not a different file for
369
	 * checking for filename collisions
370
	 * @return string the resolved filename to prevent overwriting different
371
	 * files with the same title
372
	 */
373
	private function generateFileName(Folder $folder, $title, $suffix, $id) {
374
		$path = $title . $suffix;
375
376
		// if file does not exist, that name has not been taken. Similar we don't
377
		// need to handle file collisions if it is the filename did not change
378
		if (!$folder->nodeExists($path) || $folder->get($path)->getId() === $id) {
379
			return $path;
380
		} else {
381
			// increments name (2) to name (3)
382
			$match = preg_match('/\((?P<id>\d+)\)$/u', $title, $matches);
383
			if ($match) {
384
				$newId = ((int) $matches['id']) + 1;
385
				$newTitle = preg_replace(
386
					'/(.*)\s\((\d+)\)$/u',
387
					'$1 (' . $newId . ')',
388
					$title
389
				);
390
			} else {
391
				$newTitle = $title . ' (2)';
392
			}
393
			return $this->generateFileName($folder, $newTitle, $suffix, $id);
394
		}
395
	}
396
397
	/**
398
	 * gather note files in given directory and all subdirectories
399
	 * @param Folder $folder
400
	 * @return array
401
	 */
402
	private function gatherNoteFiles($folder) {
403
		$notes = [];
404
		$nodes = $folder->getDirectoryListing();
405
		foreach ($nodes as $node) {
406
			if ($node->getType() === FileInfo::TYPE_FOLDER) {
407
				$notes = array_merge($notes, $this->gatherNoteFiles($node));
408
				continue;
409
			}
410
			if ($this->isNote($node)) {
411
				$notes[] = $node;
412
			}
413
		}
414
		return $notes;
415
	}
416
417
418
	/**
419
	 * test if file is a note
420
	 *
421
	 * @param \OCP\Files\File $file
422
	 * @return bool
423
	 */
424
	private function isNote($file) {
425
		$allowedExtensions = ['txt', 'org', 'markdown', 'md', 'note'];
426
427
		if ($file->getType() !== 'file') {
428
			return false;
429
		}
430
431
		$ext = pathinfo($file->getName(), PATHINFO_EXTENSION);
432
		$iext = strtolower($ext);
433
		if (!in_array($iext, $allowedExtensions)) {
434
			return false;
435
		}
436
		return true;
437
	}
438
}
439