Completed
Push — master ( 9898a4...8be02d )
by korelstar
02:19
created

NotesService::deleteEmptyFolder()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

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