Passed
Pull Request — master (#381)
by korelstar
02:09
created

NoteUtil::isNote()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 1
1
<?php
2
3
namespace OCA\Notes\Service;
4
5
use OCP\IL10N;
6
use OCP\ILogger;
7
use OCP\Encryption\Exceptions\GenericEncryptionException;
8
use OCP\Files\IRootFolder;
9
use OCP\Files\FileInfo;
10
use OCP\Files\File;
11
use OCP\Files\Folder;
12
13
use OCA\Notes\Db\Note;
14
15
class NoteUtil {
16
17
	private $l10n;
18
	private $root;
19
	private $logger;
20
	private $appName;
21
22
	/**
23
	 * @param IRootFolder $root
24
	 * @param IL10N $l10n
25
	 * @param ILogger $logger
26
	 * @param String $appName
27
	 */
28
	public function __construct(
29
		IRootFolder $root,
30
		IL10N $l10n,
31
		ILogger $logger,
32
		$appName
33
	) {
34
		$this->root = $root;
35
		$this->l10n = $l10n;
36
		$this->logger = $logger;
37
		$this->appName = $appName;
38
	}
39
40
	/**
41
	 * gather note files in given directory and all subdirectories
42
	 * @param Folder $folder
43
	 * @return array
44
	 */
45
	public function gatherNoteFiles(Folder $folder) {
46
		$notes = [];
47
		$nodes = $folder->getDirectoryListing();
48
		foreach ($nodes as $node) {
49
			if ($node->getType() === FileInfo::TYPE_FOLDER) {
50
				$notes = array_merge($notes, $this->gatherNoteFiles($node));
51
				continue;
52
			}
53
			if ($this->isNote($node)) {
54
				$notes[] = $node;
55
			}
56
		}
57
		return $notes;
58
	}
59
60
61
	/**
62
	 * test if file is a note
63
	 *
64
	 * @param \OCP\Files\File $file
65
	 * @return bool
66
	 */
67
	public function isNote(File $file) {
68
		$allowedExtensions = ['txt', 'org', 'markdown', 'md', 'note'];
69
		$ext = strtolower(pathinfo($file->getName(), PATHINFO_EXTENSION));
70
		return $file->getType() === 'file' && in_array($ext, $allowedExtensions);
71
	}
72
73
	public function moveNote(Folder $notesFolder, File $file, $category, $title) {
74
		$id = $file->getId();
75
		$currentFilePath = $this->root->getFullPath($file->getPath());
76
		$currentBasePath = pathinfo($currentFilePath, PATHINFO_DIRNAME);
77
		$fileSuffix = '.' . pathinfo($file->getName(), PATHINFO_EXTENSION);
78
79
		// detect (new) folder path based on category name
80
		if ($category===null) {
81
			$basePath = $currentBasePath;
82
		} else {
83
			$basePath = $notesFolder->getPath();
84
			if (!empty($category)) {
85
				// sanitise path
86
				$cats = explode('/', $category);
87
				$cats = array_map([$this, 'sanitisePath'], $cats);
88
				$cats = array_filter($cats, function ($str) {
89
					return !empty($str);
90
				});
91
				$basePath .= '/'.implode('/', $cats);
92
			}
93
		}
94
		$folder = $this->getOrCreateFolder($basePath);
95
96
		// assemble new file path
97
		$newFilePath = $basePath . '/' . $this->generateFileName($folder, $title, $fileSuffix, $id);
98
99
		// if the current path is not the new path, the file has to be renamed
100
		if ($currentFilePath !== $newFilePath) {
101
			$file->move($newFilePath);
102
		}
103
		if ($currentBasePath !== $basePath) {
104
			$this->deleteEmptyFolder($notesFolder, $this->root->get($currentBasePath));
105
		}
106
	}
107
108
	/**
109
	 * get path of file and the title.txt and check if they are the same
110
	 * file. If not the title needs to be renamed
111
	 *
112
	 * @param Folder $folder a folder to the notes directory
113
	 * @param string $title the filename which should be used
114
	 * @param string $suffix the suffix (incl. dot) which should be used
115
	 * @param int $id the id of the note for which the title should be generated
116
	 * used to see if the file itself has the title and not a different file for
117
	 * checking for filename collisions
118
	 * @return string the resolved filename to prevent overwriting different
119
	 * files with the same title
120
	 */
121
	public function generateFileName(Folder $folder, $title, $suffix, $id) {
122
		$path = $title . $suffix;
123
124
		// if file does not exist, that name has not been taken. Similar we don't
125
		// need to handle file collisions if it is the filename did not change
126
		if (!$folder->nodeExists($path) || $folder->get($path)->getId() === $id) {
127
			return $path;
128
		} else {
129
			// increments name (2) to name (3)
130
			$match = preg_match('/\((?P<id>\d+)\)$/u', $title, $matches);
131
			if ($match) {
132
				$newId = ((int) $matches['id']) + 1;
133
				$newTitle = preg_replace(
134
					'/(.*)\s\((\d+)\)$/u',
135
					'$1 (' . $newId . ')',
136
					$title
137
				);
138
			} else {
139
				$newTitle = $title . ' (2)';
140
			}
141
			return $this->generateFileName($folder, $newTitle, $suffix, $id);
142
		}
143
	}
144
145
	public function getSafeTitleFromContent($content) {
146
		// prepare content: remove markdown characters and empty spaces
147
		$content = preg_replace("/^\s*[*+-]\s+/mu", "", $content); // list item
148
		$content = preg_replace("/^#+\s+(.*?)\s*#*$/mu", "$1", $content); // headline
149
		$content = preg_replace("/^(=+|-+)$/mu", "", $content); // separate line for headline
150
		$content = preg_replace("/(\*+|_+)(.*?)\\1/mu", "$2", $content); // emphasis
151
152
		// sanitize: prevent directory traversal, illegal characters and unintended file names
153
		$content = $this->sanitisePath($content);
154
155
		// generate title from the first line of the content
156
		$splitContent = preg_split("/\R/u", $content, 2);
157
		$title = trim($splitContent[0]);
158
159
		// using a maximum of 100 chars should be enough
160
		$title = mb_substr($title, 0, 100, "UTF-8");
161
162
		// ensure that title is not empty
163
		if (empty($title)) {
164
			$title = $this->l10n->t('New note');
165
		}
166
167
		return $title;
168
	}
169
170
	/** removes characters that are illegal in a file or folder name on some operating systems */
171
	public function sanitisePath($str) {
172
		// remove characters which are illegal on Windows (includes illegal characters on Unix/Linux)
173
		// prevents also directory traversal by eliminiating slashes
174
		// see also \OC\Files\Storage\Common::verifyPosixPath(...)
175
		$str = str_replace(['*', '|', '/', '\\', ':', '"', '<', '>', '?'], '', $str);
176
177
		// if mysql doesn't support 4byte UTF-8, then remove those characters
178
		// see \OC\Files\Storage\Common::verifyPath(...)
179
		if (!\OC::$server->getDatabaseConnection()->supports4ByteText()) {
180
			$str = preg_replace('%(?:
181
                \xF0[\x90-\xBF][\x80-\xBF]{2}      # planes 1-3
182
              | [\xF1-\xF3][\x80-\xBF]{3}          # planes 4-15
183
              | \xF4[\x80-\x8F][\x80-\xBF]{2}      # plane 16
184
              )%xs', '', $str);
185
		}
186
187
		// prevent file to be hidden
188
		$str = preg_replace("/^[\. ]+/mu", "", $str);
189
		return trim($str);
190
	}
191
192
	/**
193
	 * Finds a folder and creates it if non-existent
194
	 * @param string $path path to the folder
195
	 * @return Folder
196
	 */
197
	public function getOrCreateFolder($path) : Folder {
198
		if ($this->root->nodeExists($path)) {
199
			$folder = $this->root->get($path);
200
		} else {
201
			$folder = $this->root->newFolder($path);
202
		}
203
		if (!($folder instanceof Folder)) {
204
			throw new NotesFolderException($path.' is not a folder');
205
		}
206
		return $folder;
207
	}
208
209
	/*
210
	 * Delete a folder and it's parent(s) if it's/they're empty
211
	 * @param Folder root folder for notes
212
	 * @param Folder folder to delete
213
	 */
214
	public function deleteEmptyFolder(Folder $notesFolder, Folder $folder) {
215
		$content = $folder->getDirectoryListing();
216
		$isEmpty = !count($content);
217
		$isNotesFolder = $folder->getPath()===$notesFolder->getPath();
218
		if ($isEmpty && !$isNotesFolder) {
219
			$this->logger->info('Deleting empty category folder '.$folder->getPath(), ['app' => $this->appName]);
220
			$parent = $folder->getParent();
221
			$folder->delete();
222
			$this->deleteEmptyFolder($notesFolder, $parent);
223
		}
224
	}
225
}
226