Completed
Push — master ( 30665d...7e1cb7 )
by korelstar
135:58 queued 123:35
created

NotesService   C

Complexity

Total Complexity 53

Size/Duplication

Total Lines 377
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 3

Importance

Changes 10
Bugs 2 Features 0
Metric Value
wmc 53
c 10
b 2
f 0
lcom 1
cbo 3
dl 0
loc 377
rs 6.96

18 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 6 1
A getAll() 0 21 5
A get() 0 4 1
A getTags() 0 9 3
B getNote() 0 15 7
A create() 0 12 1
B update() 0 49 7
A favorite() 0 15 4
A delete() 0 5 1
A sanitisePath() 0 20 2
A getSafeTitleFromContent() 0 24 2
A getFileById() 0 8 3
A checkNotesFolder() 0 4 1
A getFolderForUser() 0 9 2
A getOrCreateFolder() 0 8 2
A generateFileName() 0 20 4
A gatherNoteFiles() 0 14 4
A isNote() 0 11 3

How to fix   Complexity   

Complex Class

Complex classes like NotesService often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use NotesService, and based on these observations, apply Extract Interface, too.

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 OC\Encryption\Exceptions\DecryptionFailedException;
20
use League\Flysystem\FileNotFoundException;
21
use OCA\Notes\Db\Note;
22
23
/**
24
 * Class NotesService
25
 *
26
 * @package OCA\Notes\Service
27
 */
28
class NotesService {
29
30
    private $l10n;
31
    private $root;
32
    private $logger;
33
    private $appName;
34
35
    /**
36
     * @param IRootFolder $root
37
     * @param IL10N $l10n
38
     * @param ILogger $logger
39
     * @param String $appName
40
     */
41
    public function __construct (IRootFolder $root, IL10N $l10n, ILogger $logger, $appName) {
42
        $this->root = $root;
43
        $this->l10n = $l10n;
44
        $this->logger = $logger;
45
        $this->appName = $appName;
46
    }
47
48
49
    /**
50
     * @param string $userId
51
     * @return array with all notes in the current directory
52
     */
53
    public function getAll ($userId){
54
        $notesFolder = $this->getFolderForUser($userId);
55
        $notes = $this->gatherNoteFiles($notesFolder);
56
        $filesById = [];
57
        foreach($notes as $note) {
58
            $filesById[$note->getId()] = $note;
59
        }
60
        $tagger = \OC::$server->getTagManager()->load('files');
61
        if($tagger===null) {
62
            $tags = [];
63
        } else {
64
            $tags = $tagger->getTagsForObjects(array_keys($filesById));
65
        }
66
67
        $notes = [];
68
        foreach($filesById as $id=>$file) {
69
            $notes[] = $this->getNote($file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []);
70
        }
71
72
        return $notes;
73
    }
74
75
76
    /**
77
     * Used to get a single note by id
78
     * @param int $id the id of the note to get
79
     * @param string $userId
80
     * @throws NoteDoesNotExistException if note does not exist
81
     * @return Note
82
     */
83
    public function get ($id, $userId) {
84
        $folder = $this->getFolderForUser($userId);
85
        return $this->getNote($this->getFileById($folder, $id), $folder, $this->getTags($id));
86
    }
87
88
    private function getTags ($id) {
89
        $tagger = \OC::$server->getTagManager()->load('files');
90
        if($tagger===null) {
91
            $tags = [];
92
        } else {
93
            $tags = $tagger->getTagsForObjects([$id]);
94
        }
95
        return array_key_exists($id, $tags) ? $tags[$id] : [];
96
    }
97
    private function getNote($file,$notesFolder,$tags=[]){
98
99
        $id=$file->getId();
100
101
        try{
102
            $note=Note::fromFile($file, $notesFolder, $tags);
103
        }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...
104
            $note = Note::fromException($this->l10n->t('File error').': ('.$file->getName().') '.$e->getMessage(), $file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []);
105
        }catch(DecryptionFailedException $e){
0 ignored issues
show
Bug introduced by
The class OC\Encryption\Exceptions\DecryptionFailedException 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...
106
            $note = Note::fromException($this->l10n->t('Encryption Error').': ('.$file->getName().') '.$e->getMessage(), $file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []);
107
        }catch(\Exception $e){
108
            $note = Note::fromException($this->l10n->t('Error').': ('.$file->getName().') '.$e->getMessage(), $file, $notesFolder, array_key_exists($id, $tags) ? $tags[$id] : []);
109
        }
110
        return $note;
111
    }
112
    /**
113
     * Creates a note and returns the empty note
114
     * @param string $userId
115
     * @see update for setting note content
116
     * @return Note the newly created note
117
     */
118
    public function create ($userId) {
119
        $title = $this->l10n->t('New note');
120
        $folder = $this->getFolderForUser($userId);
121
122
        // check new note exists already and we need to number it
123
        // pass -1 because no file has id -1 and that will ensure
124
        // to only return filenames that dont yet exist
125
        $path = $this->generateFileName($folder, $title, "txt", -1);
126
        $file = $folder->newFile($path);
127
128
        return $this->getNote($file, $folder);
129
    }
130
131
132
    /**
133
     * Updates a note. Be sure to check the returned note since the title is
134
     * dynamically generated and filename conflicts are resolved
135
     * @param int $id the id of the note used to update
136
     * @param string $content the content which will be written into the note
137
     * the title is generated from the first line of the content
138
     * @param int $mtime time of the note modification (optional)
139
     * @throws NoteDoesNotExistException if note does not exist
140
     * @return \OCA\Notes\Db\Note the updated note
141
     */
142
    public function update ($id, $content, $userId, $category=null, $mtime=0) {
143
        $notesFolder = $this->getFolderForUser($userId);
144
        $file = $this->getFileById($notesFolder, $id);
145
        $folder = $file->getParent();
146
        $title = $this->getSafeTitleFromContent($content);
147
148
149
        // rename/move file with respect to title/category
150
        // this can fail if access rights are not sufficient or category name is illegal
151
        try {
152
            $currentFilePath = $this->root->getFullPath($file->getPath());
153
            $fileExtension = pathinfo($file->getName(), PATHINFO_EXTENSION);
154
155
            // detect (new) folder path based on category name
156
            if($category===null) {
157
                $basePath = pathinfo($currentFilePath, PATHINFO_DIRNAME);
158
            } else {
159
                $basePath = $notesFolder->getPath();
160
                if(!empty($category)) {
161
                    // sanitise path
162
                    $cats = explode('/', $category);
163
                    $cats = array_map([$this, 'sanitisePath'], $cats);
164
                    $cats = array_filter($cats, function($str) { return !empty($str); });
165
                    $basePath .= '/'.implode('/', $cats);
166
                }
167
                $this->getOrCreateFolder($basePath);
168
            }
169
170
            // assemble new file path
171
            $newFilePath = $basePath . '/' . $this->generateFileName($folder, $title, $fileExtension, $id);
172
173
            // if the current path is not the new path, the file has to be renamed
174
            if($currentFilePath !== $newFilePath) {
175
                $file->move($newFilePath);
176
            }
177
        } 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...
178
            $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]);
179
        } catch(\Exception $e) {
180
            $this->logger->error('Moving note '.$id.' ('.$title.') to the desired target has failed with a '.get_class($e).': '.$e->getMessage(), ['app' => $this->appName]);
181
        }
182
183
        $file->putContent($content);
184
185
        if($mtime) {
186
            $file->touch($mtime);
187
        }
188
189
        return $this->getNote($file, $notesFolder, $this->getTags($id));
190
    }
191
192
193
    /**
194
     * Set or unset a note as favorite.
195
     * @param int $id the id of the note used to update
196
     * @param boolean $favorite whether the note should be a favorite or not
197
     * @throws NoteDoesNotExistException if note does not exist
198
     * @return boolean the new favorite state of the note
199
     */
200
    public function favorite ($id, $favorite, $userId){
201
        $folder = $this->getFolderForUser($userId);
202
        $file = $this->getFileById($folder, $id);
203
        if(!$this->isNote($file)) {
204
            throw new NoteDoesNotExistException();
205
        }
206
        $tagger = \OC::$server->getTagManager()->load('files');
207
        if($favorite)
208
            $tagger->addToFavorites($id);
209
        else
210
            $tagger->removeFromFavorites($id);
211
212
        $tags = $tagger->getTagsForObjects([$id]);
213
        return array_key_exists($id, $tags) && in_array(\OC\Tags::TAG_FAVORITE, $tags[$id]);
214
    }
215
216
217
    /**
218
     * Deletes a note
219
     * @param int $id the id of the note which should be deleted
220
     * @param string $userId
221
     * @throws NoteDoesNotExistException if note does not
222
     * exist
223
     */
224
    public function delete ($id, $userId) {
225
        $folder = $this->getFolderForUser($userId);
226
        $file = $this->getFileById($folder, $id);
227
        $file->delete();
228
    }
229
230
    // removes characters that are illegal in a file or folder name on some operating systems
231
    private function sanitisePath($str) {
232
        // remove characters which are illegal on Windows (includes illegal characters on Unix/Linux)
233
        // prevents also directory traversal by eliminiating slashes
234
        // see also \OC\Files\Storage\Common::verifyPosixPath(...)
235
        $str = str_replace(['*', '|', '/', '\\', ':', '"', '<', '>', '?'], '', $str);
236
237
        // if mysql doesn't support 4byte UTF-8, then remove those characters
238
        // see \OC\Files\Storage\Common::verifyPath(...)
239
        if (!\OC::$server->getDatabaseConnection()->supports4ByteText()) {
240
            $str = preg_replace('%(?:
241
                \xF0[\x90-\xBF][\x80-\xBF]{2}      # planes 1-3
242
              | [\xF1-\xF3][\x80-\xBF]{3}          # planes 4-15
243
              | \xF4[\x80-\x8F][\x80-\xBF]{2}      # plane 16
244
              )%xs', '', $str);
245
        }
246
247
        // prevent file to be hidden
248
        $str = preg_replace("/^[\. ]+/mu", "", $str);
249
        return trim($str);
250
    }
251
252
    private function getSafeTitleFromContent($content) {
253
        // prepare content: remove markdown characters and empty spaces
254
        $content = preg_replace("/^\s*[*+-]\s+/mu", "", $content); // list item
255
        $content = preg_replace("/^#+\s+(.*?)\s*#*$/mu", "$1", $content); // headline
256
        $content = preg_replace("/^(=+|-+)$/mu", "", $content); // separate line for headline
257
        $content = preg_replace("/(\*+|_+)(.*?)\\1/mu", "$2", $content); // emphasis
258
259
        // sanitize: prevent directory traversal, illegal characters and unintended file names
260
        $content = $this->sanitisePath($content);
261
262
        // generate title from the first line of the content
263
        $splitContent = preg_split("/\R/u", $content, 2);
264
        $title = trim($splitContent[0]);
265
266
        // ensure that title is not empty
267
        if(empty($title)) {
268
            $title = $this->l10n->t('New note');
269
        }
270
271
        // using a maximum of 100 chars should be enough
272
        $title = mb_substr($title, 0, 100, "UTF-8");
273
274
        return $title;
275
    }
276
277
    /**
278
     * @param Folder $folder
279
     * @param int $id
280
     * @throws NoteDoesNotExistException
281
     * @return \OCP\Files\File
282
     */
283
    private function getFileById ($folder, $id) {
284
        $file = $folder->getById($id);
285
286
        if(count($file) <= 0 || !$this->isNote($file[0])) {
287
            throw new NoteDoesNotExistException();
288
        }
289
        return $file[0];
290
    }
291
292
    /**
293
     * @param string $userId the user id
294
     * @return boolean true if folder is accessible, or Exception otherwise
295
     */
296
    public function checkNotesFolder($userId) {
297
        $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...
298
        return true;
299
    }
300
301
    /**
302
     * @param string $userId the user id
303
     * @return Folder
304
     */
305
    private function getFolderForUser ($userId) {
306
        $path = '/' . $userId . '/files/Notes';
307
        try {
308
            $folder = $this->getOrCreateFolder($path);
309
        } catch(\Exception $e) {
310
            throw new NotesFolderException($path);
311
        }
312
        return $folder;
313
    }
314
315
316
    /**
317
     * Finds a folder and creates it if non-existent
318
     * @param string $path path to the folder
319
     * @return Folder
320
     */
321
    private function getOrCreateFolder($path) {
322
        if ($this->root->nodeExists($path)) {
323
            $folder = $this->root->get($path);
324
        } else {
325
            $folder = $this->root->newFolder($path);
326
        }
327
        return $folder;
328
    }
329
330
331
    /**
332
     * get path of file and the title.txt and check if they are the same
333
     * file. If not the title needs to be renamed
334
     *
335
     * @param Folder $folder a folder to the notes directory
336
     * @param string $title the filename which should be used
337
     * @param string $extension the extension which should be used
338
     * @param int $id the id of the note for which the title should be generated
339
     * used to see if the file itself has the title and not a different file for
340
     * checking for filename collisions
341
     * @return string the resolved filename to prevent overwriting different
342
     * files with the same title
343
     */
344
    private function generateFileName (Folder $folder, $title, $extension, $id) {
345
        $path = $title . '.' . $extension;
346
347
        // if file does not exist, that name has not been taken. Similar we don't
348
        // need to handle file collisions if it is the filename did not change
349
        if (!$folder->nodeExists($path) || $folder->get($path)->getId() === $id) {
350
            return $path;
351
        } else {
352
            // increments name (2) to name (3)
353
            $match = preg_match('/\((?P<id>\d+)\)$/u', $title, $matches);
354
            if($match) {
355
                $newId = ((int) $matches['id']) + 1;
356
                $newTitle = preg_replace('/(.*)\s\((\d+)\)$/u',
357
                    '$1 (' . $newId . ')', $title);
358
            } else {
359
                $newTitle = $title . ' (2)';
360
            }
361
            return $this->generateFileName($folder, $newTitle, $extension, $id);
362
        }
363
    }
364
365
	/**
366
	 * gather note files in given directory and all subdirectories
367
	 * @param Folder $folder
368
	 * @return array
369
	 */
370
	private function gatherNoteFiles ($folder) {
371
		$notes = [];
372
		$nodes = $folder->getDirectoryListing();
373
		foreach($nodes as $node) {
374
			if($node->getType() === FileInfo::TYPE_FOLDER) {
375
				$notes = array_merge($notes, $this->gatherNoteFiles($node));
376
				continue;
377
			}
378
			if($this->isNote($node)) {
379
				$notes[] = $node;
380
			}
381
		}
382
		return $notes;
383
	}
384
385
386
    /**
387
     * test if file is a note
388
     *
389
     * @param \OCP\Files\File $file
390
     * @return bool
391
     */
392
    private function isNote($file) {
393
        $allowedExtensions = ['txt', 'org', 'markdown', 'md', 'note'];
394
395
        if($file->getType() !== 'file') return false;
396
        if(!in_array(
397
            pathinfo($file->getName(), PATHINFO_EXTENSION),
398
            $allowedExtensions
399
        )) return false;
400
401
        return true;
402
    }
403
404
}
405