1 | <?php |
||||
2 | |||||
3 | namespace Backend\Modules\Extensions\Actions; |
||||
4 | |||||
5 | use Backend\Core\Engine\Base\ActionAdd as BackendBaseActionAdd; |
||||
6 | use Backend\Core\Engine\Model as BackendModel; |
||||
7 | use Backend\Core\Engine\Form as BackendForm; |
||||
8 | use Backend\Core\Language\Language as BL; |
||||
9 | use Backend\Modules\Extensions\Engine\Model as BackendExtensionsModel; |
||||
10 | use Symfony\Component\Filesystem\Filesystem; |
||||
11 | |||||
12 | /** |
||||
13 | * This is the module upload-action. |
||||
14 | * It will install a module via a compressed zip file. |
||||
15 | */ |
||||
16 | class UploadModule extends BackendBaseActionAdd |
||||
17 | { |
||||
18 | 1 | public function execute(): void |
|||
19 | { |
||||
20 | // call parent, this will probably add some general CSS/JS or other required files |
||||
21 | 1 | parent::execute(); |
|||
22 | |||||
23 | // zip extension is required for module upload |
||||
24 | 1 | if (!extension_loaded('zlib')) { |
|||
25 | $this->template->assign('zlibIsMissing', true); |
||||
26 | } |
||||
27 | |||||
28 | 1 | if (!$this->isWritable()) { |
|||
29 | // we need write rights to upload files |
||||
30 | $this->template->assign('notWritable', true); |
||||
31 | } else { |
||||
32 | // everything allright, we can upload |
||||
33 | 1 | $this->buildForm(); |
|||
34 | 1 | $this->validateForm(); |
|||
35 | 1 | $this->parse(); |
|||
36 | } |
||||
37 | |||||
38 | // display the page |
||||
39 | 1 | $this->display(); |
|||
40 | 1 | } |
|||
41 | |||||
42 | /** |
||||
43 | * Process the zip-file & install the module |
||||
44 | * |
||||
45 | * @return string|null |
||||
46 | */ |
||||
47 | private function uploadModuleFromZip(): ?string |
||||
48 | { |
||||
49 | // list of validated files (these files will actually be unpacked) |
||||
50 | $files = []; |
||||
51 | |||||
52 | // shorten field variables |
||||
53 | /** @var $fileFile \SpoonFormFile */ |
||||
54 | $fileFile = $this->form->getField('file'); |
||||
55 | |||||
56 | // create \ziparchive instance |
||||
57 | $zip = new \ZipArchive(); |
||||
58 | |||||
59 | // try and open it |
||||
60 | if ($zip->open($fileFile->getTempFileName()) !== true) { |
||||
61 | $fileFile->addError(BL::getError('CorruptedFile')); |
||||
62 | } |
||||
63 | |||||
64 | // zip file needs to contain some files |
||||
65 | if ($zip->numFiles == 0) { |
||||
66 | $fileFile->addError(BL::getError('FileIsEmpty')); |
||||
67 | |||||
68 | return null; |
||||
69 | } |
||||
70 | |||||
71 | // directories we are allowed to upload to |
||||
72 | $allowedDirectories = [ |
||||
73 | 'src/Backend/Modules/', |
||||
74 | 'src/Frontend/Modules/', |
||||
75 | ]; |
||||
76 | |||||
77 | // name of the module we are trying to upload |
||||
78 | $moduleName = null; |
||||
79 | |||||
80 | // has the module zip one level of folders too much? |
||||
81 | $prefix = ''; |
||||
82 | |||||
83 | // check every file in the zip |
||||
84 | for ($i = 0; $i < $zip->numFiles; ++$i) { |
||||
85 | // get the file name |
||||
86 | $file = $zip->statIndex($i); |
||||
87 | $fileName = $file['name']; |
||||
88 | |||||
89 | if ($i === 0) { |
||||
90 | $prefix = $this->extractPrefix($file['name']); |
||||
91 | } |
||||
92 | |||||
93 | // check if the file is in one of the valid directories |
||||
94 | foreach ($allowedDirectories as $directory) { |
||||
95 | // yay, in a valid directory |
||||
96 | if (mb_stripos($fileName, $prefix . $directory) === 0) { |
||||
97 | // extract the module name from the url |
||||
98 | $tmpName = trim(str_ireplace($prefix . $directory, '', $fileName), '/'); |
||||
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||||
99 | if ($tmpName == '') { |
||||
100 | break; |
||||
101 | } |
||||
102 | $chunks = explode('/', $tmpName); |
||||
103 | $tmpName = $chunks[0]; |
||||
104 | |||||
105 | // ignore hidden files |
||||
106 | if (mb_substr(basename($fileName), 0, 1) == '.') { |
||||
107 | break; |
||||
108 | } elseif ($moduleName === null) { |
||||
109 | // first module we find, store the name |
||||
110 | $moduleName = $tmpName; |
||||
111 | } elseif ($moduleName !== $tmpName) { |
||||
112 | // the name does not match the previous module we found, skip the file |
||||
113 | break; |
||||
114 | } |
||||
115 | |||||
116 | // passed all our tests, store it for extraction |
||||
117 | $files[] = $fileName; |
||||
118 | |||||
119 | // go to next file |
||||
120 | break; |
||||
121 | } |
||||
122 | } |
||||
123 | } |
||||
124 | |||||
125 | // after filtering no files left (nothing useful found) |
||||
126 | if (count($files) == 0) { |
||||
127 | $fileFile->addError(BL::getError('FileContentsIsUseless')); |
||||
128 | |||||
129 | return null; |
||||
130 | } |
||||
131 | |||||
132 | // module already exists on the filesystem |
||||
133 | if (BackendExtensionsModel::existsModule($moduleName)) { |
||||
0 ignored issues
–
show
$moduleName of type null is incompatible with the type string expected by parameter $module of Backend\Modules\Extensio...e\Model::existsModule() .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||
134 | $fileFile->addError(sprintf(BL::getError('ModuleAlreadyExists'), $moduleName)); |
||||
135 | |||||
136 | return null; |
||||
137 | } |
||||
138 | |||||
139 | // installer in array? |
||||
140 | if (!in_array($prefix . 'src/Backend/Modules/' . $moduleName . '/Installer/Installer.php', $files)) { |
||||
141 | $fileFile->addError(sprintf(BL::getError('NoInstallerFile'), $moduleName)); |
||||
142 | |||||
143 | return null; |
||||
144 | } |
||||
145 | |||||
146 | // unpack module files |
||||
147 | $zip->extractTo($this->getContainer()->getParameter('site.path_www'), $files); |
||||
148 | |||||
149 | // place all the items in the prefixed folders in the right folders |
||||
150 | if (!empty($prefix)) { |
||||
151 | $filesystem = new Filesystem(); |
||||
152 | foreach ($files as &$file) { |
||||
153 | $fullPath = $this->getContainer()->getParameter('site.path_www') . '/' . $file; |
||||
154 | $newPath = str_replace( |
||||
155 | $this->getContainer()->getParameter('site.path_www') . '/' . $prefix, |
||||
156 | $this->getContainer()->getParameter('site.path_www') . '/', |
||||
157 | $fullPath |
||||
158 | ); |
||||
159 | |||||
160 | if ($filesystem->exists($fullPath) && is_dir($fullPath)) { |
||||
161 | $filesystem->mkdir($newPath); |
||||
162 | } elseif ($filesystem->exists($fullPath) && is_file($fullPath)) { |
||||
163 | $filesystem->copy( |
||||
164 | $fullPath, |
||||
165 | $newPath |
||||
166 | ); |
||||
167 | } |
||||
168 | } |
||||
169 | |||||
170 | $filesystem->remove($this->getContainer()->getParameter('site.path_www') . '/' . $prefix); |
||||
171 | } |
||||
172 | |||||
173 | // return the files |
||||
174 | return $moduleName; |
||||
175 | } |
||||
176 | |||||
177 | /** |
||||
178 | * Try to extract a prefix if a module has been zipped with unexpected |
||||
179 | * paths. |
||||
180 | * |
||||
181 | * @param string $file |
||||
182 | * |
||||
183 | * @return string |
||||
184 | */ |
||||
185 | private function extractPrefix(string $file): string |
||||
186 | { |
||||
187 | $name = explode(PATH_SEPARATOR, $file); |
||||
188 | $prefix = []; |
||||
189 | |||||
190 | foreach ($name as $element) { |
||||
191 | if ($element == 'src') { |
||||
192 | return implode(PATH_SEPARATOR, $prefix); |
||||
193 | } |
||||
194 | |||||
195 | $prefix[] = $element; |
||||
196 | } |
||||
197 | |||||
198 | // If the zip has a top-level single directory, eg |
||||
199 | // /myModuleName/, then we should just assume that is the prefix. |
||||
200 | return $file; |
||||
201 | } |
||||
202 | |||||
203 | /** |
||||
204 | * Do we have write rights to the modules folders? |
||||
205 | * |
||||
206 | * @return bool |
||||
207 | */ |
||||
208 | 1 | private function isWritable(): bool |
|||
209 | { |
||||
210 | 1 | if (!BackendExtensionsModel::isWritable(FRONTEND_MODULES_PATH)) { |
|||
211 | return false; |
||||
212 | } |
||||
213 | |||||
214 | 1 | return BackendExtensionsModel::isWritable(BACKEND_MODULES_PATH); |
|||
215 | } |
||||
216 | |||||
217 | 1 | private function buildForm(): void |
|||
218 | { |
||||
219 | // create form |
||||
220 | 1 | $this->form = new BackendForm('upload'); |
|||
221 | |||||
222 | // create and add elements |
||||
223 | 1 | $this->form->addFile('file'); |
|||
224 | 1 | } |
|||
225 | |||||
226 | 1 | private function validateForm(): void |
|||
227 | { |
||||
228 | 1 | if (!$this->form->isSubmitted()) { |
|||
229 | 1 | return; |
|||
230 | } |
||||
231 | |||||
232 | // shorten field variables |
||||
233 | $fileFile = $this->form->getField('file'); |
||||
234 | |||||
235 | // validate the file |
||||
236 | $fileFile->isFilled(BL::err('FieldIsRequired')); |
||||
237 | $fileFile->isAllowedExtension(['zip'], sprintf(BL::getError('ExtensionNotAllowed'), 'zip')); |
||||
238 | |||||
239 | // passed all validation |
||||
240 | if (!$this->form->isCorrect()) { |
||||
241 | return; |
||||
242 | } |
||||
243 | |||||
244 | $moduleName = $this->uploadModuleFromZip(); |
||||
245 | |||||
246 | // redirect to the install url, this is needed for doctrine modules because the container needs to |
||||
247 | // load this module as an allowed module to get the entities working |
||||
248 | $this->redirect(BackendModel::createUrlForAction('InstallModule') . '&module=' . $moduleName); |
||||
249 | } |
||||
250 | } |
||||
251 |