Completed
Pull Request — master (#148)
by korelstar
30:42
created

NotesService::sanitisePath()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 20
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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