Completed
Push — master ( b325c1...56c243 )
by korelstar
02:58
created

NotesService::getAll()   A

Complexity

Conditions 5
Paths 8

Size

Total Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 21
rs 9.2728
c 0
b 0
f 0
cc 5
nc 8
nop 2
1
<?php
2
/**
3
 * Nextcloud - Notes
4
 *
5
 * This file is licensed under the Affero General Public License version 3 or
6
 * later. See the COPYING file.
7
 *
8
 * @author Bernhard Posselt <[email protected]>
9
 * @copyright Bernhard Posselt 2012, 2014
10
 */
11
12
namespace OCA\Notes\Service;
13
14
use OCP\Files\FileInfo;
15
use OCP\IL10N;
16
use OCP\Files\IRootFolder;
17
use OCP\Files\Folder;
18
use OCP\ILogger;
19
use OCP\Encryption\Exceptions\GenericEncryptionException;
20
use League\Flysystem\FileNotFoundException;
21
use OCA\Notes\Db\Note;
22
use OCA\Notes\Service\SettingsService;
23
use OCP\IConfig;
24
use OCP\IUserSession;
25
26
27
/**
28
 * Class NotesService
29
 *
30
 * @package OCA\Notes\Service
31
 */
32
class NotesService {
33
34
    private $l10n;
35
    private $root;
36
    private $logger;
37
    private $config;
38
    private $settings;
39
    private $appName;
40
41
	/**
42
	 * @param IRootFolder $root
43
	 * @param IL10N $l10n
44
	 * @param ILogger $logger
45
	 * @param IConfig $config
46
	 * @param \OCA\Notes\Service\SettingsService $settings
47
	 * @param String $appName
48
	 */
49
    public function __construct (IRootFolder $root, IL10N $l10n, ILogger $logger, IConfig $config, SettingsService $settings, $appName) {
50
        $this->root = $root;
51
        $this->l10n = $l10n;
52
        $this->logger = $logger;
53
        $this->config = $config;
54
        $this->settings = $settings;
55
        $this->appName = $appName;
56
    }
57
58
59
    /**
60
     * @param string $userId
61
     * @return array with all notes in the current directory
62
     */
63
    public function getAll ($userId, $onlyMeta=false) {
64
        $notesFolder = $this->getFolderForUser($userId);
65
        $notes = $this->gatherNoteFiles($notesFolder);
66
        $filesById = [];
67
        foreach($notes as $note) {
68
            $filesById[$note->getId()] = $note;
69
        }
70
        $tagger = \OC::$server->getTagManager()->load('files');
71
        if($tagger===null) {
72
            $tags = [];
73
        } else {
74
            $tags = $tagger->getTagsForObjects(array_keys($filesById));
75
        }
76
77
        $notes = [];
78
        foreach($filesById as $id=>$file) {
79
            $notes[] = $this->getNote($file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : [], $onlyMeta);
80
        }
81
82
        return $notes;
83
    }
84
85
86
    /**
87
     * Used to get a single note by id
88
     * @param int $id the id of the note to get
89
     * @param string $userId
90
     * @throws NoteDoesNotExistException if note does not exist
91
     * @return Note
92
     */
93
    public function get ($id, $userId) {
94
        $folder = $this->getFolderForUser($userId);
95
        return $this->getNote($this->getFileById($folder, $id), $folder, $this->getTags($id));
96
    }
97
98
    private function getTags ($id) {
99
        $tagger = \OC::$server->getTagManager()->load('files');
100
        if($tagger===null) {
101
            $tags = [];
102
        } else {
103
            $tags = $tagger->getTagsForObjects([$id]);
104
        }
105
        return array_key_exists($id, $tags) ? $tags[$id] : [];
106
    }
107
108
    private function getNote($file, $notesFolder, $tags=[], $onlyMeta=false) {
109
        $id = $file->getId();
110
        try {
111
            $note = Note::fromFile($file, $notesFolder, $tags, $onlyMeta);
112
        } catch(FileNotFoundException $e){
0 ignored issues
show
Bug introduced by
The class League\Flysystem\FileNotFoundException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
113
            $note = Note::fromException($this->l10n->t('File error').': ('.$file->getName().') '.$e->getMessage(), $file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []);
114
        } catch(GenericEncryptionException $e) {
0 ignored issues
show
Bug introduced by
The class OCP\Encryption\Exception...ericEncryptionException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
115
            $note = Note::fromException($this->l10n->t('Encryption Error').': ('.$file->getName().') '.$e->getMessage(), $file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []);
116
        } catch(\Exception $e) {
117
            $note = Note::fromException($this->l10n->t('Error').': ('.$file->getName().') '.$e->getMessage(), $file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []);
118
        }
119
        return $note;
120
    }
121
122
123
    /**
124
     * Creates a note and returns the empty note
125
     * @param string $userId
126
     * @see update for setting note content
127
     * @return Note the newly created note
128
     */
129
    public function create ($userId) {
130
        $title = $this->l10n->t('New note');
131
        $folder = $this->getFolderForUser($userId);
132
133
        // check new note exists already and we need to number it
134
        // pass -1 because no file has id -1 and that will ensure
135
        // to only return filenames that dont yet exist
136
        $path = $this->generateFileName($folder, $title, $this->settings->get($userId, 'fileSuffix'), -1);
137
        $file = $folder->newFile($path);
138
139
        // If server-side encryption is activated, the server creates an empty file without signature
140
        // which leads to an GenericEncryptionException('Missing Signature') afterwards.
141
        // Saving a space-char (and removing it later) is a working work-around.
142
        $file->putContent(' ');
143
144
        return $this->getNote($file, $folder);
145
    }
146
147
148
    /**
149
     * Updates a note. Be sure to check the returned note since the title is
150
     * dynamically generated and filename conflicts are resolved
151
     * @param int $id the id of the note used to update
152
     * @param string $content the content which will be written into the note
153
     * the title is generated from the first line of the content
154
     * @param int $mtime time of the note modification (optional)
155
     * @throws NoteDoesNotExistException if note does not exist
156
     * @return \OCA\Notes\Db\Note the updated note
157
     */
158
    public function update ($id, $content, $userId, $category=null, $mtime=0) {
159
        $notesFolder = $this->getFolderForUser($userId);
160
        $file = $this->getFileById($notesFolder, $id);
161
        $title = $this->getSafeTitleFromContent( $content===null ? $file->getContent() : $content );
162
163
164
        // rename/move file with respect to title/category
165
        // this can fail if access rights are not sufficient or category name is illegal
166
        try {
167
            $currentFilePath = $this->root->getFullPath($file->getPath());
168
            $currentBasePath = pathinfo($currentFilePath, PATHINFO_DIRNAME);
169
            $fileSuffix = '.' . pathinfo($file->getName(), PATHINFO_EXTENSION);
170
171
            // detect (new) folder path based on category name
172
            if($category===null) {
173
                $basePath = $currentBasePath;
174
            } else {
175
                $basePath = $notesFolder->getPath();
176
                if(!empty($category)) {
177
                    // sanitise path
178
                    $cats = explode('/', $category);
179
                    $cats = array_map([$this, 'sanitisePath'], $cats);
180
                    $cats = array_filter($cats, function($str) { return !empty($str); });
181
                    $basePath .= '/'.implode('/', $cats);
182
                }
183
            }
184
            $folder = $this->getOrCreateFolder($basePath);
185
186
            // assemble new file path
187
            $newFilePath = $basePath . '/' . $this->generateFileName($folder, $title, $fileSuffix, $id);
188
189
            // if the current path is not the new path, the file has to be renamed
190
            if($currentFilePath !== $newFilePath) {
191
                $file->move($newFilePath);
192
            }
193
            if($currentBasePath !== $basePath) {
194
                $this->deleteEmptyFolder($notesFolder, $this->root->get($currentBasePath));
195
            }
196
        } catch(\OCP\Files\NotPermittedException $e) {
0 ignored issues
show
Bug introduced by
The class OCP\Files\NotPermittedException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
197
            $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]);
198
        } catch(\Exception $e) {
199
            $this->logger->error('Moving note '.$id.' ('.$title.') to the desired target has failed with a '.get_class($e).': '.$e->getMessage(), ['app' => $this->appName]);
200
        }
201
202
        if($content !== null) {
203
            $file->putContent($content);
204
        }
205
206
        if($mtime) {
207
            $file->touch($mtime);
208
        }
209
210
        return $this->getNote($file, $notesFolder, $this->getTags($id));
211
    }
212
213
    /**
214
     * Set or unset a note as favorite.
215
     * @param int $id the id of the note used to update
216
     * @param boolean $favorite whether the note should be a favorite or not
217
     * @throws NoteDoesNotExistException if note does not exist
218
     * @return boolean the new favorite state of the note
219
     */
220
    public function favorite ($id, $favorite, $userId){
221
        $folder = $this->getFolderForUser($userId);
222
        $file = $this->getFileById($folder, $id);
223
        if(!$this->isNote($file)) {
224
            throw new NoteDoesNotExistException();
225
        }
226
        $tagger = \OC::$server->getTagManager()->load('files');
227
        if($favorite)
228
            $tagger->addToFavorites($id);
229
        else
230
            $tagger->removeFromFavorites($id);
231
232
        $tags = $tagger->getTagsForObjects([$id]);
233
        return array_key_exists($id, $tags) && in_array(\OC\Tags::TAG_FAVORITE, $tags[$id]);
234
    }
235
236
237
    /**
238
     * Deletes a note
239
     * @param int $id the id of the note which should be deleted
240
     * @param string $userId
241
     * @throws NoteDoesNotExistException if note does not
242
     * exist
243
     */
244
    public function delete ($id, $userId) {
245
        $notesFolder = $this->getFolderForUser($userId);
246
        $file = $this->getFileById($notesFolder, $id);
247
        $parent = $file->getParent();
248
        $file->delete();
249
        $this->deleteEmptyFolder($notesFolder, $parent);
250
    }
251
252
    // removes characters that are illegal in a file or folder name on some operating systems
253
    private function sanitisePath($str) {
254
        // remove characters which are illegal on Windows (includes illegal characters on Unix/Linux)
255
        // prevents also directory traversal by eliminiating slashes
256
        // see also \OC\Files\Storage\Common::verifyPosixPath(...)
257
        $str = str_replace(['*', '|', '/', '\\', ':', '"', '<', '>', '?'], '', $str);
258
259
        // if mysql doesn't support 4byte UTF-8, then remove those characters
260
        // see \OC\Files\Storage\Common::verifyPath(...)
261
        if (!\OC::$server->getDatabaseConnection()->supports4ByteText()) {
262
            $str = preg_replace('%(?:
263
                \xF0[\x90-\xBF][\x80-\xBF]{2}      # planes 1-3
264
              | [\xF1-\xF3][\x80-\xBF]{3}          # planes 4-15
265
              | \xF4[\x80-\x8F][\x80-\xBF]{2}      # plane 16
266
              )%xs', '', $str);
267
        }
268
269
        // prevent file to be hidden
270
        $str = preg_replace("/^[\. ]+/mu", "", $str);
271
        return trim($str);
272
    }
273
274
    private function getSafeTitleFromContent($content) {
275
        // prepare content: remove markdown characters and empty spaces
276
        $content = preg_replace("/^\s*[*+-]\s+/mu", "", $content); // list item
277
        $content = preg_replace("/^#+\s+(.*?)\s*#*$/mu", "$1", $content); // headline
278
        $content = preg_replace("/^(=+|-+)$/mu", "", $content); // separate line for headline
279
        $content = preg_replace("/(\*+|_+)(.*?)\\1/mu", "$2", $content); // emphasis
280
281
        // sanitize: prevent directory traversal, illegal characters and unintended file names
282
        $content = $this->sanitisePath($content);
283
284
        // generate title from the first line of the content
285
        $splitContent = preg_split("/\R/u", $content, 2);
286
        $title = trim($splitContent[0]);
287
288
        // ensure that title is not empty
289
        if(empty($title)) {
290
            $title = $this->l10n->t('New note');
291
        }
292
293
        // using a maximum of 100 chars should be enough
294
        $title = mb_substr($title, 0, 100, "UTF-8");
295
296
        return $title;
297
    }
298
299
    /**
300
     * @param Folder $folder
301
     * @param int $id
302
     * @throws NoteDoesNotExistException
303
     * @return \OCP\Files\File
304
     */
305
    private function getFileById ($folder, $id) {
306
        $file = $folder->getById($id);
307
308
        if(count($file) <= 0 || !$this->isNote($file[0])) {
309
            throw new NoteDoesNotExistException();
310
        }
311
        return $file[0];
312
    }
313
314
    /**
315
     * @param string $userId the user id
316
     * @return boolean true if folder is accessible, or Exception otherwise
317
     */
318
    public function checkNotesFolder($userId) {
319
        $folder = $this->getFolderForUser($userId);
0 ignored issues
show
Unused Code introduced by
$folder is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
320
        return true;
321
    }
322
323
    /**
324
     * @param string $userId the user id
325
     * @return Folder
326
     */
327
    private function getFolderForUser ($userId) {
328
        $path = '/' . $userId . '/files/' . $this->settings->get($userId, 'notesPath');
329
        try {
330
            $folder = $this->getOrCreateFolder($path);
331
        } catch(\Exception $e) {
332
            throw new NotesFolderException($path);
333
        }
334
        return $folder;
335
    }
336
337
338
    /**
339
     * Finds a folder and creates it if non-existent
340
     * @param string $path path to the folder
341
     * @return Folder
342
     */
343
    private function getOrCreateFolder($path) {
344
        if ($this->root->nodeExists($path)) {
345
            $folder = $this->root->get($path);
346
        } else {
347
            $folder = $this->root->newFolder($path);
348
        }
349
        return $folder;
350
    }
351
352
    /*
353
     * Delete a folder and it's parent(s) if it's/they're empty
354
     * @param Folder root folder for notes
355
     * @param Folder folder to delete
356
     */
357
    private function deleteEmptyFolder(Folder $notesFolder, Folder $folder) {
358
        $content = $folder->getDirectoryListing();
359
        $isEmpty = !count($content);
360
        $isNotesFolder = $folder->getPath()===$notesFolder->getPath();
361
        if($isEmpty && !$isNotesFolder) {
362
            $this->logger->info('Deleting empty category folder '.$folder->getPath(), ['app' => $this->appName]);
363
            $parent = $folder->getParent();
364
            $folder->delete();
365
            $this->deleteEmptyFolder($notesFolder, $parent);
366
        }
367
    }
368
369
    /**
370
     * get path of file and the title.txt and check if they are the same
371
     * file. If not the title needs to be renamed
372
     *
373
     * @param Folder $folder a folder to the notes directory
374
     * @param string $title the filename which should be used
375
     * @param string $suffix the suffix (incl. dot) which should be used
376
     * @param int $id the id of the note for which the title should be generated
377
     * used to see if the file itself has the title and not a different file for
378
     * checking for filename collisions
379
     * @return string the resolved filename to prevent overwriting different
380
     * files with the same title
381
     */
382
    private function generateFileName (Folder $folder, $title, $suffix, $id) {
383
        $path = $title . $suffix;
384
385
        // if file does not exist, that name has not been taken. Similar we don't
386
        // need to handle file collisions if it is the filename did not change
387
        if (!$folder->nodeExists($path) || $folder->get($path)->getId() === $id) {
388
            return $path;
389
        } else {
390
            // increments name (2) to name (3)
391
            $match = preg_match('/\((?P<id>\d+)\)$/u', $title, $matches);
392
            if($match) {
393
                $newId = ((int) $matches['id']) + 1;
394
                $newTitle = preg_replace('/(.*)\s\((\d+)\)$/u',
395
                    '$1 (' . $newId . ')', $title);
396
            } else {
397
                $newTitle = $title . ' (2)';
398
            }
399
            return $this->generateFileName($folder, $newTitle, $suffix, $id);
400
        }
401
    }
402
403
	/**
404
	 * gather note files in given directory and all subdirectories
405
	 * @param Folder $folder
406
	 * @return array
407
	 */
408
	private function gatherNoteFiles ($folder) {
409
		$notes = [];
410
		$nodes = $folder->getDirectoryListing();
411
		foreach($nodes as $node) {
412
			if($node->getType() === FileInfo::TYPE_FOLDER) {
413
				$notes = array_merge($notes, $this->gatherNoteFiles($node));
414
				continue;
415
			}
416
			if($this->isNote($node)) {
417
				$notes[] = $node;
418
			}
419
		}
420
		return $notes;
421
	}
422
423
424
    /**
425
     * test if file is a note
426
     *
427
     * @param \OCP\Files\File $file
428
     * @return bool
429
     */
430
    private function isNote($file) {
431
        $allowedExtensions = ['txt', 'org', 'markdown', 'md', 'note'];
432
433
        if($file->getType() !== 'file') return false;
434
435
        $ext = pathinfo($file->getName(), PATHINFO_EXTENSION);
436
        $iext = strtolower($ext);
437
        if(!in_array($iext, $allowedExtensions)) {
438
            return false;
439
        }
440
        return true;
441
    }
442
443
}
444