NoteUtil::ensureSufficientStorage()   A
last analyzed

Complexity

Conditions 3
Paths 2

Size

Total Lines 10
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
eloc 8
c 1
b 1
f 0
dl 0
loc 10
rs 10
cc 3
nc 2
nop 2
1
<?php
2
3
namespace OCA\Notes\Service;
4
5
use OCP\IDBConnection;
6
use OCP\IL10N;
7
use OCP\ILogger;
8
use OCP\Files\IRootFolder;
9
use OCP\Files\FileInfo;
10
use OCP\Files\File;
11
use OCP\Files\Folder;
12
13
class NoteUtil {
14
15
	private $db;
16
	private $l10n;
17
	private $root;
18
	private $logger;
19
	private $appName;
20
21
	/**
22
	 * @param IDBConnection $db
23
	 * @param IRootFolder $root
24
	 * @param IL10N $l10n
25
	 * @param ILogger $logger
26
	 * @param String $appName
27
	 */
28
	public function __construct(
29
		IDBConnection $db,
30
		IRootFolder $root,
31
		IL10N $l10n,
32
		ILogger $logger,
33
		$appName
34
	) {
35
		$this->db = $db;
36
		$this->root = $root;
37
		$this->l10n = $l10n;
38
		$this->logger = $logger;
39
		$this->appName = $appName;
40
	}
41
42
	/**
43
	 * gather note files in given directory and all subdirectories
44
	 */
45
	public function gatherNoteFiles(Folder $folder) : array {
46
		$notes = [];
47
		$nodes = $folder->getDirectoryListing();
48
		foreach ($nodes as $node) {
49
			if ($node->getType() === FileInfo::TYPE_FOLDER && $node instanceof 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
	public function isNote(FileInfo $file) : bool {
65
		$allowedExtensions = ['txt', 'org', 'markdown', 'md', 'note'];
66
		$ext = strtolower(pathinfo($file->getName(), PATHINFO_EXTENSION));
67
		return $file->getType() === 'file' && in_array($ext, $allowedExtensions);
68
	}
69
70
	public function moveNote(Folder $notesFolder, File $file, $category, string $title) : void {
71
		$id = $file->getId();
72
		$currentFilePath = $this->root->getFullPath($file->getPath());
73
		$currentBasePath = pathinfo($currentFilePath, PATHINFO_DIRNAME);
74
		$fileSuffix = '.' . pathinfo($file->getName(), PATHINFO_EXTENSION);
75
76
		// detect (new) folder path based on category name
77
		if ($category===null) {
78
			$basePath = $currentBasePath;
79
		} else {
80
			$basePath = $notesFolder->getPath();
81
			if (!empty($category)) {
82
				// sanitise path
83
				$cats = explode('/', $category);
84
				$cats = array_map([$this, 'sanitisePath'], $cats);
85
				$cats = array_filter($cats, function ($str) {
86
					return !empty($str);
87
				});
88
				$basePath .= '/'.implode('/', $cats);
89
			}
90
		}
91
		$folder = $this->getOrCreateFolder($basePath);
92
93
		// assemble new file path
94
		$newFilePath = $basePath . '/' . $this->generateFileName($folder, $title, $fileSuffix, $id);
95
96
		// if the current path is not the new path, the file has to be renamed
97
		if ($currentFilePath !== $newFilePath) {
98
			$file->move($newFilePath);
99
		}
100
		if ($currentBasePath !== $basePath) {
101
			$fileBasePath = $this->root->get($currentBasePath);
102
			if ($fileBasePath instanceof Folder) {
103
				$this->deleteEmptyFolder($notesFolder, $fileBasePath);
104
			}
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, string $title, string $suffix, int $id) : string {
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(string $content) : string {
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(string $str) : string {
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 (!$this->db->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(string $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 $notesFolder root folder for notes
212
	 * @param Folder $folder folder to delete
213
	 */
214
	public function deleteEmptyFolder(Folder $notesFolder, Folder $folder) : void {
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
	/**
227
	 * Checks if there is enough space left on storage. Throws an Exception if storage is not sufficient.
228
	 * @param Folder $folder that needs storage
229
	 * @param int $requiredBytes amount of storage needed in $folder
230
	 * @throws InsufficientStorageException
231
	 */
232
	public function ensureSufficientStorage(Folder $folder, $requiredBytes) : void {
233
		$availableBytes = $folder->getFreeSpace();
234
		if ($availableBytes >= 0 && $availableBytes < $requiredBytes) {
235
			$this->logger->error(
236
				'Insufficient storage in '.$folder->getPath().': '.
237
				'available are '.$availableBytes.'; '.
238
				'required are '.$requiredBytes,
239
				['app' => $this->appName]
240
			);
241
			throw new InsufficientStorageException($requiredBytes.' are required in '.$folder->getPath());
242
		}
243
	}
244
}
245