Completed
Push — master ( afe6b9...084a54 )
by korelstar
02:06
created

NoteUtil   A

Complexity

Total Complexity 29

Size/Duplication

Total Lines 217
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 29
eloc 92
c 1
b 0
f 0
dl 0
loc 217
rs 10

10 Methods

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