Passed
Pull Request — master (#314)
by korelstar
01:56
created

NotesService::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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