PageRenderTime 44ms CodeModel.GetById 16ms RepoModel.GetById 0ms app.codeStats 0ms

/src/Backend/Modules/Extensions/Actions/UploadTheme.php

http://github.com/forkcms/forkcms
PHP | 286 lines | 157 code | 49 blank | 80 comment | 24 complexity | c323309d6ec4486a5f5fd88aaa4c6e5a MD5 | raw file
Possible License(s): MPL-2.0-no-copyleft-exception, MIT, AGPL-3.0, LGPL-2.1, BSD-3-Clause
  1. <?php
  2. namespace Backend\Modules\Extensions\Actions;
  3. use Backend\Core\Engine\Base\ActionAdd as BackendBaseActionAdd;
  4. use Backend\Core\Engine\Form as BackendForm;
  5. use Backend\Core\Language\Language as BL;
  6. use Backend\Core\Engine\Model as BackendModel;
  7. use Backend\Modules\Extensions\Engine\Model as BackendExtensionsModel;
  8. use Exception;
  9. use Symfony\Component\Filesystem\Filesystem;
  10. use ZipArchive;
  11. /**
  12. * This is the theme upload-action.
  13. * It will install a theme via a compressed zip file.
  14. */
  15. class UploadTheme extends BackendBaseActionAdd
  16. {
  17. const INFO_FILE = 'info.xml';
  18. private $ignoreList = ['__MACOSX'];
  19. /**
  20. * @var array
  21. */
  22. private $info;
  23. /**
  24. * @var string
  25. */
  26. private $infoFilePath;
  27. /**
  28. * @var string
  29. */
  30. private $themeName;
  31. /**
  32. * @var string
  33. */
  34. private $parentFolderName;
  35. public function execute(): void
  36. {
  37. // Call parent, this will probably add some general CSS/JS or other required files
  38. parent::execute();
  39. // Zip extension is required for theme upload
  40. if (!extension_loaded('zlib')) {
  41. $this->template->assign('zlibIsMissing', true);
  42. }
  43. if (!$this->isWritable()) {
  44. // we need write rights to upload files
  45. $this->template->assign('notWritable', true);
  46. } else {
  47. // everything allright, we can upload
  48. $this->buildForm();
  49. $this->validateForm();
  50. $this->parse();
  51. }
  52. // display the page
  53. $this->display();
  54. }
  55. /**
  56. * Do we have write rights to the modules folders?
  57. *
  58. * @return bool
  59. */
  60. private function isWritable(): bool
  61. {
  62. return BackendExtensionsModel::isWritable(FRONTEND_PATH . '/Themes');
  63. }
  64. private function buildForm(): void
  65. {
  66. // create form
  67. $this->form = new BackendForm('upload');
  68. // create and add elements
  69. $this->form->addFile('file');
  70. }
  71. private function validateForm(): void
  72. {
  73. // The form is submitted
  74. if (!$this->form->isSubmitted()) {
  75. return;
  76. }
  77. /** @var $fileFile \SpoonFormFile */
  78. $fileFile = $this->form->getField('file');
  79. $zip = null;
  80. $zipFiles = null;
  81. // Validate the file. Check if the file field is filled and if it's a zip.
  82. if ($fileFile->isFilled(BL::err('FieldIsRequired')) &&
  83. $fileFile->isAllowedExtension(['zip'], sprintf(BL::getError('ExtensionNotAllowed'), 'zip'))
  84. ) {
  85. // Create ziparchive instance
  86. $zip = new ZipArchive();
  87. // Try and open it
  88. if ($zip->open($fileFile->getTempFileName()) === true) {
  89. // zip file needs to contain some files
  90. if ($zip->numFiles > 0) {
  91. $infoXml = $this->findInfoFileInZip($zip);
  92. // Throw error if info.xml is not found
  93. if ($infoXml === null) {
  94. $fileFile->addError(
  95. sprintf(BL::getError('NoInformationFile'), $fileFile->getFileName())
  96. );
  97. return;
  98. }
  99. // Parse xml
  100. try {
  101. // Load info.xml
  102. $infoXml = @new \SimpleXMLElement($infoXml, LIBXML_NOCDATA, false);
  103. // Convert xml to useful array
  104. $this->info = BackendExtensionsModel::processThemeXml($infoXml);
  105. // Empty data (nothing useful)
  106. if (empty($this->info)) {
  107. $fileFile->addError(BL::getMessage('InformationFileIsEmpty'));
  108. return;
  109. }
  110. // Define the theme name, based on the info.xml file.
  111. $this->themeName = $this->info['name'];
  112. } catch (Exception $e) {
  113. // Warning that the information file is corrupt
  114. $fileFile->addError(BL::getMessage('InformationFileCouldNotBeLoaded'));
  115. return;
  116. }
  117. // Wow wow, you are trying to upload an already existing theme
  118. if (BackendExtensionsModel::existsTheme($this->themeName)) {
  119. $fileFile->addError(sprintf(BL::getError('ThemeAlreadyExists'), $this->themeName));
  120. return;
  121. }
  122. $zipFiles = $this->getValidatedFilesList($zip);
  123. } else {
  124. // Empty zip file
  125. $fileFile->addError(BL::getError('FileIsEmpty'));
  126. }
  127. } else {
  128. // Something went very wrong, probably corrupted
  129. $fileFile->addError(BL::getError('CorruptedFile'));
  130. return;
  131. }
  132. }
  133. // Passed all validation
  134. if ($zip !== null && $this->form->isCorrect()) {
  135. // Unpack the zip. If the files were not found inside a parent directory, we create the theme directory.
  136. $themePath = FRONTEND_PATH . '/Themes';
  137. if ($this->parentFolderName === null) {
  138. $themePath .= "/{$this->themeName}";
  139. }
  140. $zip->extractTo($themePath, $zipFiles);
  141. // Rename the original name of the parent folder from the zip to the correct theme foldername.
  142. $fs = new Filesystem();
  143. $parentZipFolderPath = $themePath . '/' . $this->parentFolderName;
  144. if ($this->parentFolderName !== $this->themeName &&
  145. $this->parentFolderName !== null &&
  146. $fs->exists($parentZipFolderPath)
  147. ) {
  148. $fs->rename($parentZipFolderPath, "$themePath/{$this->themeName}");
  149. }
  150. // Run installer
  151. BackendExtensionsModel::installTheme($this->themeName);
  152. // Redirect with fireworks
  153. $this->redirect(
  154. BackendModel::createUrlForAction('Themes') . '&report=theme-installed&var=' . $this->themeName
  155. );
  156. }
  157. }
  158. /**
  159. * Two ideal situations possible: we have a zip with files including info.xml, or we have a zip with the theme-folder.
  160. *
  161. * @param ZipArchive $zip
  162. *
  163. * @return string|null
  164. */
  165. private function findInfoFileInZip(ZipArchive $zip): ?string
  166. {
  167. for ($i = 0; $i < $zip->numFiles; ++$i) {
  168. if (mb_stripos($zip->getNameIndex($i), self::INFO_FILE) !== false) {
  169. $infoFile = $zip->statIndex($i);
  170. // Check that the file is not found inside a directory to ignore.
  171. if ($this->checkIfPathContainsIgnoredWord($infoFile['name'])) {
  172. continue;
  173. }
  174. $this->infoFilePath = $infoFile['name'];
  175. $this->info = $zip->getFromName($this->infoFilePath);
  176. break;
  177. }
  178. }
  179. return $this->info;
  180. }
  181. /**
  182. * Create a list of files. These are the files that will actuall be unpacked to the Themes folder.
  183. * Either we have a zip that contains 1 parent directory with files inside (directory not necessarily named like
  184. * the theme) and we extract those files. Or we have a zip that directly contains the theme files and we should
  185. * prepend them with the theme folder.
  186. *
  187. * @param ZipArchive $zip
  188. *
  189. * @return string[]
  190. */
  191. private function getValidatedFilesList(ZipArchive $zip): array
  192. {
  193. $this->parentFolderName = $this->extractFolderNameBasedOnInfoFile($this->infoFilePath);
  194. // Check every file in the zip
  195. $files = [];
  196. for ($i = 0; $i < $zip->numFiles; ++$i) {
  197. // Get the file name
  198. $file = $zip->statIndex($i);
  199. $fileName = $file['name'];
  200. // We skip all the files that are outside of the theme folder or on the ignore list.
  201. if ($this->checkIfPathContainsIgnoredWord($fileName) ||
  202. (!empty($this->parentFolderName) && mb_stripos($fileName, $this->parentFolderName) !== 0)
  203. ) {
  204. continue;
  205. }
  206. $files[] = $fileName;
  207. }
  208. return $files;
  209. }
  210. /**
  211. * @param string $infoFilePath
  212. *
  213. * @return string|null
  214. */
  215. private function extractFolderNameBasedOnInfoFile(string $infoFilePath): ?string
  216. {
  217. $pathParts = explode('/', $infoFilePath);
  218. if (count($pathParts) > 1) {
  219. return $pathParts[0];
  220. }
  221. return null;
  222. }
  223. /**
  224. * @param string $path contains a to-be-ignored word.
  225. *
  226. * @return bool
  227. */
  228. private function checkIfPathContainsIgnoredWord(string $path): bool
  229. {
  230. foreach ($this->ignoreList as $ignoreItem) {
  231. if (mb_stripos($path, $ignoreItem) !== false) {
  232. return true;
  233. }
  234. }
  235. return false;
  236. }
  237. }