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