20cf5bdde6f283da04bd54caf2ca24a3e48d03fb
[GitHub/WoltLab/WCF.git] /
1 <?php
2
3 namespace wcf\system\package\plugin;
4
5 use wcf\data\application\Application;
6 use wcf\data\package\Package;
7 use wcf\data\package\PackageCache;
8 use wcf\system\application\ApplicationHandler;
9 use wcf\system\database\util\PreparedStatementConditionBuilder;
10 use wcf\system\devtools\pip\IDevtoolsPipEntryList;
11 use wcf\system\devtools\pip\IGuiPackageInstallationPlugin;
12 use wcf\system\devtools\pip\TXmlGuiPackageInstallationPlugin;
13 use wcf\system\form\builder\container\FormContainer;
14 use wcf\system\form\builder\field\SingleSelectionFormField;
15 use wcf\system\form\builder\field\TextFormField;
16 use wcf\system\form\builder\IFormDocument;
17 use wcf\system\WCF;
18 use wcf\util\DOMUtil;
19 use wcf\util\XML;
20
21 /**
22 * Abstract implementation of a package installation plugin deleting a certain type of files.
23 *
24 * @author Matthias Schmidt
25 * @copyright 2001-2021 WoltLab GmbH
26 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
27 * @package WoltLabSuite\Core\System\Package\Plugin
28 * @since 5.5
29 */
30 abstract class AbstractFileDeletePackageInstallationPlugin extends AbstractXMLPackageInstallationPlugin implements
31 IGuiPackageInstallationPlugin
32 {
33 use TXmlGuiPackageInstallationPlugin;
34
35 /**
36 * Returns the name of the database table that logs the installed files.
37 */
38 abstract protected function getLogTableName(): string;
39
40 /**
41 * Returns the name of the column in the log table returned by `getLogTableName()` that contains
42 * the names of the relevant files.
43 */
44 abstract protected function getFilenameTableColumn(): string;
45
46 protected function getPipName(): string
47 {
48 return $this->getXsdFilename();
49 }
50
51 /**
52 * Returns the actual absolute path of the given file.
53 */
54 protected function getFilePath(string $filename, string $application): string
55 {
56 return Application::getDirectory($application) . $filename;
57 }
58
59 /**
60 * @inheritDoc
61 */
62 protected function handleDelete(array $items)
63 {
64 $groupedFiles = [];
65 foreach ($items as $item) {
66 $file = $item['value'];
67 $application = 'wcf';
68 if (!empty($item['attributes']['application'])) {
69 $application = $item['attributes']['application'];
70 } elseif ($this->installation->getPackage()->isApplication) {
71 $application = Package::getAbbreviation($this->installation->getPackage()->package);
72 }
73
74 if (!isset($groupedFiles[$application])) {
75 $groupedFiles[$application] = [];
76 }
77 $groupedFiles[$application][] = $file;
78 }
79
80 $logFiles = [];
81 foreach ($groupedFiles as $application => $files) {
82 $conditions = new PreparedStatementConditionBuilder();
83 $conditions->add("{$this->getFilenameTableColumn()} IN (?)", [$files]);
84 $conditions->add('application = ?', [$application]);
85 $conditions->add('packageID = ?', [$this->installation->getPackageID()]);
86
87 $sql = "SELECT packageID, application, {$this->getFilenameTableColumn()}
88 FROM {$this->getLogTableName()}
89 {$conditions}";
90 $searchStatement = WCF::getDB()->prepare($sql);
91 $searchStatement->execute($conditions->getParameters());
92
93 while ($row = $searchStatement->fetchArray()) {
94 if (!isset($logFiles[$row['application']])) {
95 $logFiles[$row['application']] = [];
96 }
97 $logFiles[$row['application']][$row[$this->getFilenameTableColumn()]] = $row['packageID'];
98 }
99 }
100
101 foreach ($groupedFiles as $application => $files) {
102 foreach ($files as $file) {
103 $filePackageID = $logFiles[$application][$file] ?? null;
104 if ($filePackageID !== null && $filePackageID != $this->installation->getPackageID()) {
105 throw new \UnexpectedValueException(
106 "'{$file}' does not belong to package '{$this->installation->getPackage()->package}'
107 but to package '" . PackageCache::getInstance()->getPackage($filePackageID)->package . "'."
108 );
109 }
110
111 $filePath = $this->getFilePath($file, $application);
112
113 $this->safeDeleteFile($filePath);
114 }
115 }
116
117 WCF::getDB()->beginTransaction();
118 foreach ($logFiles as $application => $files) {
119 $conditions = new PreparedStatementConditionBuilder();
120 $conditions->add("{$this->getFilenameTableColumn()} IN (?)", [\array_keys($files)]);
121 $conditions->add('application = ?', [$application]);
122 $conditions->add('packageID = ?', [$this->installation->getPackageID()]);
123
124 $sql = "DELETE FROM {$this->getLogTableName()}
125 {$conditions}";
126 $statement = WCF::getDB()->prepare($sql);
127 $statement->execute($conditions->getParameters());
128 }
129 WCF::getDB()->commitTransaction();
130 }
131
132 private static function isFilesystemCaseSensitive(): bool
133 {
134 static $isFilesystemCaseSensitive = null;
135
136 if ($isFilesystemCaseSensitive === null) {
137 $testFilePath = __FILE__;
138
139 $invertedCase = \strstr(
140 $testFilePath,
141 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
142 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
143 );
144
145 $isFilesystemCaseSensitive = !\file_exists($invertedCase);
146 }
147
148 return $isFilesystemCaseSensitive;
149 }
150
151 private function safeDeleteFile(string $filePath): void
152 {
153 if (!\file_exists($filePath)) {
154 return;
155 }
156
157 if (self::isFilesystemCaseSensitive()) {
158 \unlink($filePath);
159
160 return;
161 }
162
163 // If the filesystem is case insensitive, we must check, whether the casing of the file
164 // matches the casing of the file, which we want to delete. Therefore, we must iterate
165 // through the whole dir to find the potential file.
166 $pathInfo = \pathinfo($filePath);
167 foreach (\glob($pathInfo['dirname'] . '/*') as $file) {
168 if (\basename($file) === $pathInfo['basename']) {
169 \unlink($filePath);
170 break;
171 }
172 }
173 }
174
175 /**
176 * @inheritDoc
177 */
178 final protected function import(array $row, array $data)
179 {
180 // Does nothing, imports are not supported.
181 }
182
183 /**
184 * @inheritDoc
185 */
186 final protected function prepareImport(array $data)
187 {
188 return $data;
189 }
190
191 /**
192 * @inheritDoc
193 */
194 final protected function findExistingItem(array $data)
195 {
196 return null;
197 }
198
199 /**
200 * @inheritDoc
201 */
202 public static function getSyncDependencies()
203 {
204 return [];
205 }
206
207 /**
208 * @inheritDoc
209 */
210 public function hasUninstall()
211 {
212 // File deletions cannot be reverted.
213 return false;
214 }
215
216 /**
217 * @inheritDoc
218 */
219 public function uninstall()
220 {
221 // File deletions cannot be reverted.
222 }
223
224 /**
225 * Returns the language item with the description of the file field or `null` if no description
226 * should be shown.
227 */
228 protected function getFileFieldDescription(): ?string
229 {
230 $languageItem = "wcf.acp.pip.{$this->getPipName()}.{$this->tagName}.description";
231
232 return WCF::getLanguage()->get($languageItem, true) ?: null;
233 }
234
235 /**
236 * @inheritDoc
237 */
238 protected function addFormFields(IFormDocument $form)
239 {
240 /** @var FormContainer $dataContainer */
241 $dataContainer = $form->getNodeById('data');
242
243 $dataContainer->appendChildren([
244 TextFormField::create($this->tagName)
245 ->label("wcf.acp.pip.{$this->getPipName()}.{$this->tagName}")
246 ->description($this->getFileFieldDescription())
247 ->required(),
248 SingleSelectionFormField::create('application')
249 ->label("wcf.acp.pip.{$this->getPipName()}.application")
250 ->options(static function (): array {
251 $options = [
252 '' => 'wcf.global.noSelection',
253 ];
254
255 $apps = ApplicationHandler::getInstance()->getApplications();
256 \usort($apps, static function (Application $a, Application $b) {
257 return $a->getPackage()->getTitle() <=> $b->getPackage()->getTitle();
258 });
259
260 foreach ($apps as $application) {
261 $options[$application->getAbbreviation()] = $application->getPackage()->getTitle();
262 }
263
264 return $options;
265 })
266 ->nullable(),
267 ]);
268 }
269
270 /**
271 * @inheritDoc
272 */
273 protected function fetchElementData(\DOMElement $element, $saveData)
274 {
275 return [
276 'application' => $element->getAttribute('application') ?? 'wcf',
277 $this->tagName => $element->nodeValue,
278 'packageID' => $this->installation->getPackage()->packageID,
279 ];
280 }
281
282 /**
283 * @inheritDoc
284 */
285 public function getElementIdentifier(\DOMElement $element)
286 {
287 $app = $element->getAttribute('application') ?? 'wcf';
288
289 return \sha1($app . '_' . $element->nodeValue);
290 }
291
292 /**
293 * @inheritDoc
294 */
295 protected function setEntryListKeys(IDevtoolsPipEntryList $entryList)
296 {
297 $entryList->setKeys([
298 $this->tagName => "wcf.acp.pip.{$this->getPipName()}.{$this->tagName}",
299 'application' => "wcf.acp.pip.{$this->getPipName()}.application",
300 ]);
301 }
302
303 /**
304 * @inheritDoc
305 */
306 protected function insertNewXmlElement(XML $xml, \DOMElement $newElement)
307 {
308 $delete = $xml->xpath()->query('/ns:data/ns:delete')->item(0);
309 if ($delete === null) {
310 $data = $xml->xpath()->query('/ns:data')->item(0);
311 $delete = $xml->getDocument()->createElement('delete');
312 DOMUtil::prepend($delete, $data);
313 }
314
315 $delete->appendChild($newElement);
316 }
317
318 /**
319 * @inheritDoc
320 */
321 protected function prepareXmlElement(\DOMDocument $document, IFormDocument $form)
322 {
323 $file = $document->createElement($this->tagName);
324
325 $data = $form->getData()['data'];
326 if (!empty($data['application'])) {
327 $file->setAttribute('application', $data['application']);
328 }
329 $file->nodeValue = $data[$this->tagName];
330
331 return $file;
332 }
333
334 /**
335 * @inheritDoc
336 */
337 final protected function prepareDeleteXmlElement(\DOMElement $element)
338 {
339 return null;
340 }
341
342 /**
343 * @inheritDoc
344 */
345 protected function saveObject(\DOMElement $newElement, ?\DOMElement $oldElement = null)
346 {
347 $newElementData = $this->getElementData($newElement, true);
348
349 $this->handleDelete([[
350 'attributes' => [
351 'application' => $newElementData['application'],
352 ],
353 'value' => $newElementData[$this->tagName],
354 ]]);
355 }
356
357 /**
358 * @inheritDoc
359 */
360 final protected function deleteObject(\DOMElement $element)
361 {
362 // Reverting file deletions is not supported. Use the `file` PIP instead.
363 }
364
365 /**
366 * @inheritDoc
367 */
368 protected function getImportElements(\DOMXPath $xpath)
369 {
370 return $xpath->query('/ns:data/ns:delete/ns:' . $this->tagName);
371 }
372
373 /**
374 * @inheritDoc
375 */
376 protected function getEmptyXml()
377 {
378 $xsdFilename = $this->getXsdFilename();
379 $apiVersion = WSC_API_VERSION;
380
381 return <<<XML
382 <?xml version="1.0" encoding="UTF-8"?>
383 <data xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/{$apiVersion}/{$xsdFilename}.xsd">
384 <delete></delete>
385 </data>
386 XML;
387 }
388 }