3 namespace wcf\system\package\plugin;
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;
22 * Abstract implementation of a package installation plugin deleting a certain type of files.
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
30 abstract class AbstractFileDeletePackageInstallationPlugin extends AbstractXMLPackageInstallationPlugin implements
31 IGuiPackageInstallationPlugin
33 use TXmlGuiPackageInstallationPlugin;
36 * Returns the name of the database table that logs the installed files.
38 abstract protected function getLogTableName(): string;
41 * Returns the name of the column in the log table returned by `getLogTableName()` that contains
42 * the names of the relevant files.
44 abstract protected function getFilenameTableColumn(): string;
46 protected function getPipName(): string
48 return $this->getXsdFilename();
52 * Returns the actual absolute path of the given file.
54 protected function getFilePath(string $filename, string $application): string
56 return Application::getDirectory($application) . $filename;
62 protected function handleDelete(array $items)
65 foreach ($items as $item) {
66 $file = $item['value'];
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);
74 if (!isset($groupedFiles[$application])) {
75 $groupedFiles[$application] = [];
77 $groupedFiles[$application][] = $file;
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()]);
87 $sql = "SELECT packageID, application, {$this->getFilenameTableColumn()}
88 FROM {$this->getLogTableName()}
90 $searchStatement = WCF::getDB()->prepare($sql);
91 $searchStatement->execute($conditions->getParameters());
93 while ($row = $searchStatement->fetchArray()) {
94 if (!isset($logFiles[$row['application']])) {
95 $logFiles[$row['application']] = [];
97 $logFiles[$row['application']][$row[$this->getFilenameTableColumn()]] = $row['packageID'];
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 . "'."
111 $filePath = $this->getFilePath($file, $application);
113 $this->safeDeleteFile($filePath);
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()]);
124 $sql = "DELETE FROM {$this->getLogTableName()}
126 $statement = WCF::getDB()->prepare($sql);
127 $statement->execute($conditions->getParameters());
129 WCF::getDB()->commitTransaction();
132 private static function isFilesystemCaseSensitive(): bool
134 static $isFilesystemCaseSensitive = null;
136 if ($isFilesystemCaseSensitive === null) {
137 $testFilePath = __FILE__;
139 $invertedCase = \strstr(
141 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
142 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
145 $isFilesystemCaseSensitive = !\file_exists($invertedCase);
148 return $isFilesystemCaseSensitive;
151 private function safeDeleteFile(string $filePath): void
153 if (!\file_exists($filePath)) {
157 if (self::isFilesystemCaseSensitive()) {
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']) {
178 final protected function import(array $row, array $data)
180 // Does nothing, imports are not supported.
186 final protected function prepareImport(array $data)
194 final protected function findExistingItem(array $data)
202 public static function getSyncDependencies()
210 public function hasUninstall()
212 // File deletions cannot be reverted.
219 public function uninstall()
221 // File deletions cannot be reverted.
225 * Returns the language item with the description of the file field or `null` if no description
228 protected function getFileFieldDescription(): ?string
230 $languageItem = "wcf.acp.pip.{$this->getPipName()}.{$this->tagName}.description";
232 return WCF::getLanguage()->get($languageItem, true) ?: null;
238 protected function addFormFields(IFormDocument $form)
240 /** @var FormContainer $dataContainer */
241 $dataContainer = $form->getNodeById('data');
243 $dataContainer->appendChildren([
244 TextFormField::create($this->tagName)
245 ->label("wcf.acp.pip.{$this->getPipName()}.{$this->tagName}")
246 ->description($this->getFileFieldDescription())
248 SingleSelectionFormField::create('application')
249 ->label("wcf.acp.pip.{$this->getPipName()}.application")
250 ->options(static function (): array {
252 '' => 'wcf.global.noSelection',
255 $apps = ApplicationHandler::getInstance()->getApplications();
256 \usort($apps, static function (Application $a, Application $b) {
257 return $a->getPackage()->getTitle() <=> $b->getPackage()->getTitle();
260 foreach ($apps as $application) {
261 $options[$application->getAbbreviation()] = $application->getPackage()->getTitle();
273 protected function fetchElementData(\DOMElement $element, $saveData)
276 'application' => $element->getAttribute('application') ?? 'wcf',
277 $this->tagName => $element->nodeValue,
278 'packageID' => $this->installation->getPackage()->packageID,
285 public function getElementIdentifier(\DOMElement $element)
287 $app = $element->getAttribute('application') ?? 'wcf';
289 return \sha1($app . '_' . $element->nodeValue);
295 protected function setEntryListKeys(IDevtoolsPipEntryList $entryList)
297 $entryList->setKeys([
298 $this->tagName => "wcf.acp.pip.{$this->getPipName()}.{$this->tagName}",
299 'application' => "wcf.acp.pip.{$this->getPipName()}.application",
306 protected function insertNewXmlElement(XML $xml, \DOMElement $newElement)
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);
315 $delete->appendChild($newElement);
321 protected function prepareXmlElement(\DOMDocument $document, IFormDocument $form)
323 $file = $document->createElement($this->tagName);
325 $data = $form->getData()['data'];
326 if (!empty($data['application'])) {
327 $file->setAttribute('application', $data['application']);
329 $file->nodeValue = $data[$this->tagName];
337 final protected function prepareDeleteXmlElement(\DOMElement $element)
345 protected function saveObject(\DOMElement $newElement, ?\DOMElement $oldElement = null)
347 $newElementData = $this->getElementData($newElement, true);
349 $this->handleDelete([[
351 'application' => $newElementData['application'],
353 'value' => $newElementData[$this->tagName],
360 final protected function deleteObject(\DOMElement $element)
362 // Reverting file deletions is not supported. Use the `file` PIP instead.
368 protected function getImportElements(\DOMXPath $xpath)
370 return $xpath->query('/ns:data/ns:delete/ns:' . $this->tagName);
376 protected function getEmptyXml()
378 $xsdFilename = $this->getXsdFilename();
379 $apiVersion = WSC_API_VERSION;
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">