• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dragomano / Plugin-Loader / 24596670125

18 Apr 2026 03:58AM UTC coverage: 54.444% (+8.4%) from 46.0%
24596670125

push

github

dragomano
Update tests.yml

147 of 270 relevant lines covered (54.44%)

0.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

56.83
/src/Sources/PluginLoader/Integration.php
1
<?php
2

3
/**
4
 * @package Plugin Loader
5
 * @link https://github.com/dragomano/Plugin-Loader
6
 * @author Bugo <bugo@dragomano.ru>
7
 * @copyright 2023-2026 Bugo
8
 * @license https://opensource.org/licenses/BSD-3-Clause The 3-Clause BSD License
9
 */
10

11
namespace Bugo\PluginLoader;
12

13
use Bugo\PluginLoader\Attributes\Hook;
14
use SimpleXMLElement;
15
use ZipArchive;
16

17
use function array_filter;
18
use function basename;
19
use function count;
20
use function explode;
21
use function file_get_contents;
22
use function file_put_contents;
23
use function gettype;
24
use function glob;
25
use function implode;
26
use function in_array;
27
use function ini_get;
28
use function is_array;
29
use function is_file;
30
use function libxml_clear_errors;
31
use function libxml_get_errors;
32
use function libxml_use_internal_errors;
33
use function parse_ini_file;
34
use function preg_replace;
35
use function simplexml_load_string;
36
use function sort;
37
use function sprintf;
38
use function strval;
39

40
if (! defined('SMF'))
1✔
41
        die('No direct access...');
×
42

43
/**
44
 * Generated by Simple Mod Maker
45
 */
46
final class Integration
47
{
48
        use HasInvoke;
49

50
        private string $sourcedir;
51

52
        private array $context;
53

54
        private array $smcFunc;
55

56
        private ?array $txt = null;
57

58
        private string $plugins;
59

60
        public function __construct()
61
        {
62
                $this->sourcedir = $GLOBALS['sourcedir'];
1✔
63

64
                foreach (['context', 'smcFunc', 'txt', 'plugins'] as $f) {
1✔
65
                        $this->{$f} = &$GLOBALS[$f];
1✔
66
                }
67
        }
1✔
68

69
        #[Hook('integrate_update_settings_file')]
70
        public function updateSettingsFile(array &$settings_defs): void
71
        {
72
                $settings_defs['plugins'] = [
1✔
73
                        'type'    => 'string',
1✔
74
                        'default' => '',
1✔
75
                        'text'    => implode("\n", [
1✔
76
                                '/**',
1✔
77
                                ' * Enabled plugins',
78
                                ' *',
79
                                ' * @var string',
80
                                ' */',
81
                        ]),
82
                ];
83
        }
1✔
84

85
        #[Hook('integrate_admin_areas')]
86
        public function adminAreas(array &$admin_areas): void
87
        {
88
                loadLanguage('PluginLoader/');
1✔
89

90
                $admin_areas['forum']['areas']['plugins'] = [
1✔
91
                        'label'       => $this->txt['pl_title'],
1✔
92
                        'function'    => $this->main(...),
1✔
93
                        'permission'  => ['admin_forum'],
94
                        'icon'        => 'modifications',
1✔
95
                        'subsections' => [
96
                                'browse' => [$this->txt['pl_browse']],
1✔
97
                                'upload' => [$this->txt['pl_upload']],
1✔
98
                        ],
99
                ];
100
        }
1✔
101

102
        public function main(): void
103
        {
104
                $subActions = [
×
105
                        'browse' => $this->browseList(...),
×
106
                        'upload' => $this->uploadArea(...),
×
107
                ];
108

109
                $this->context[$this->context['admin_menu_name']]['tab_data'] = [
×
110
                        'title' => $this->txt['pl_title'],
×
111
                        'tabs'  => [
112
                                'browse' => [
113
                                        'description' => $this->txt['pl_browse_desc'],
×
114
                                ],
115
                                'upload' => [
116
                                        'description' => $this->txt['pl_upload_desc'],
×
117
                                ]
118
                        ]
119
                ];
120

121
                require_once $this->sourcedir . '/ManageSettings.php';
×
122

123
                loadGeneralSettingParameters($subActions, 'browse');
×
124

125
                call_helper($subActions[$this->context['sub_action']]);
×
126
        }
×
127

128
        public function browseList(): void
129
        {
130
                $this->context['page_title'] = $this->txt['pl_title'];
×
131

132
                $this->preparePluginList();
×
133

134
                loadCSSFile('plugin_loader.css');
×
135

136
                loadJavaScriptFile('plugin_loader.js', ['minimize' => true]);
×
137

138
                loadTemplate('PluginLoader');
×
139

140
                $this->context['sub_template'] = 'main';
×
141

142
                $this->handleSave();
×
143
                $this->handleToggle();
×
144
                $this->handleRemove();
×
145
        }
×
146

147
        public function uploadArea(): void
148
        {
149
                loadLanguage('Packages');
×
150

151
                loadCSSFile('plugin_loader.css');
×
152

153
                loadTemplate('PluginLoader');
×
154

155
                $this->context['page_title'] = $this->txt['pl_title'] . ' - ' . $this->txt['pl_upload'];
×
156

157
                $this->context['max_file_size'] = memoryReturnBytes(ini_get('upload_max_filesize'));
×
158

159
                $this->context['upload_success'] = $this->extractPackage() ? $this->txt['download_success'] : false;
×
160

161
                $this->context['sub_template'] = 'upload';
×
162
        }
×
163

164
        private function preparePluginList(): void
165
        {
166
                $this->context['pl_plugins'] = [];
1✔
167

168
                $this->context['pl_enabled_plugins'] = empty($this->plugins) ? [] : explode(',', $this->plugins);
1✔
169

170
                $plugins = glob(PLUGINS_DIR . '/**/plugin-info.xml', GLOB_BRACE);
1✔
171

172
                foreach ($plugins as $plugin) {
1✔
173
                        if (is_file($plugin)) {
1✔
174
                                $id      = basename(dirname($plugin));
1✔
175
                                $content = file_get_contents($plugin);
1✔
176

177
                                if (empty($content)) {
1✔
178
                                        $this->context['pl_plugins'][$id] = false;
×
179

180
                                        continue;
×
181
                                }
182

183
                                $content           = preg_replace('~\s*<(!DOCTYPE|xsl)[^>]+?>\s*~i', '', $content);
1✔
184
                                $useInternalErrors = libxml_use_internal_errors(true);
1✔
185
                                $xmldata           = simplexml_load_string((string) $content);
1✔
186
                                $xmlErrors         = libxml_get_errors();
1✔
187

188
                                libxml_clear_errors();
1✔
189
                                libxml_use_internal_errors($useInternalErrors);
1✔
190

191
                                if ($xmldata === false || $xmlErrors !== []) {
1✔
192
                                        continue;
1✔
193
                                }
194

195
                                $this->context['pl_plugins'][$id] = $this->escapeArray($this->xmlToArray($xmldata));
1✔
196

197
                                $this->prepareSettings($id);
1✔
198
                        }
199
                }
200
        }
1✔
201

202
        private function handleSave(): void
203
        {
204
                if (! isset($_REQUEST['save']) || empty($_REQUEST['plugin_name']))
×
205
                        return;
×
206

207
                checkSession('request');
×
208

209
                $plugin_name    = $_REQUEST['plugin_name'];
×
210
                $plugin_options = [];
×
211

212
                foreach ($this->context['pl_plugins'][$plugin_name]['settings'] as $var => $data) {
×
213
                        if (isset($_REQUEST[$var])) {
×
214
                                if ($data['type'] === 'check') {
×
215
                                        $plugin_options[$var] = (bool) $_REQUEST[$var];
×
216
                                } elseif ($data['type'] ==='int') {
×
217
                                        $plugin_options[$var] = (int) $_REQUEST[$var];
×
218
                                } else {
219
                                        $plugin_options[$var] = (string) $_REQUEST[$var];
×
220
                                }
221
                        }
222
                }
223

224
                $this->saveSettings($plugin_name, $plugin_options);
×
225
        }
×
226

227
        private function handleToggle(): void
228
        {
229
                if (! isset($_REQUEST['toggle']))
1✔
230
                        return;
×
231

232
                checkSession('request');
1✔
233

234
                $input = file_get_contents('php://input');
1✔
235
                $data  = smf_json_decode($input, true) ?? [];
1✔
236

237
                if (empty($data) || empty($data['status']) || empty($data['plugin'])) {
1✔
238
                        redirectexit('action=admin;area=plugins');
1✔
239
                }
240

241
                if ($data['status'] === 'on') {
×
242
                        $this->context['pl_enabled_plugins'] = array_filter(
×
243
                                $this->context['pl_enabled_plugins'],
×
244
                                fn($item) => $item !== $data['plugin']
×
245
                        );
246
                } else {
247
                        if (! empty($this->context['pl_plugins'][$data['plugin']]['database'])) {
×
248
                                db_extend('packages');
×
249

250
                                require implode('', [
×
251
                                        PLUGINS_DIR,
×
252
                                        DIRECTORY_SEPARATOR,
×
253
                                        $data['plugin'],
×
254
                                        DIRECTORY_SEPARATOR,
×
255
                                        $this->context['pl_plugins'][$data['plugin']]['database']
×
256
                                ]);
257
                        }
258

259
                        $this->context['pl_enabled_plugins'][] = $data['plugin'];
×
260
                }
261

262
                sort($this->context['pl_enabled_plugins']);
×
263

264
                require_once $this->sourcedir . '/Subs-Admin.php';
×
265

266
                updateSettingsFile(['plugins' => implode(',', $this->context['pl_enabled_plugins'])]);
×
267
        }
×
268

269
        private function handleRemove(): void
270
        {
271
                if (! isset($_REQUEST['remove']))
1✔
272
                        return;
×
273

274
                checkSession('request');
1✔
275

276
                $input = file_get_contents('php://input');
1✔
277
                $data  = smf_json_decode($input, true) ?? [];
1✔
278

279
                if ($data === [] || empty($data['plugin'])) {
1✔
280
                        redirectexit('action=admin;area=plugins');
1✔
281
                }
282

283
                $enabledPlugins = isset($this->context['pl_enabled_plugins']) && is_array($this->context['pl_enabled_plugins'])
1✔
284
                        ? $this->context['pl_enabled_plugins']
×
285
                        : (empty($this->plugins) ? [] : explode(',', $this->plugins));
1✔
286

287
                if (in_array($data['plugin'], $enabledPlugins, true)) {
1✔
288
                        redirectexit('action=admin;area=plugins');
1✔
289
                }
290

291
                require_once $this->sourcedir . '/Subs-Package.php';
×
292

293
                deltree(PLUGINS_DIR . DIRECTORY_SEPARATOR . $data['plugin']);
×
294
        }
×
295

296
        private function prepareSettings(string $id): void
297
        {
298
                if (empty($this->context['pl_plugins'][$id]['settings']))
1✔
299
                        return;
1✔
300

301
                $settings  = $this->getSettings(PLUGINS_DIR . DIRECTORY_SEPARATOR . $id);
1✔
302
                $languages = $this->getLanguages($id);
1✔
303

304
                $options = [];
1✔
305
                if (isset($this->context['pl_plugins'][$id]['settings']['setting']['@attributes'])) {
1✔
306
                        $option = $this->context['pl_plugins'][$id]['settings']['setting']['@attributes'];
×
307

308
                        $options[$option['name']] = [
×
309
                                'name'  => $languages[$option['name']] ?? $this->txt['not_applicable'],
×
310
                                'type'  => $option['type'],
×
311
                                'value' => $settings[$option['name']] ?? $option['default'],
×
312
                        ];
313
                } else {
314
                        foreach ($this->context['pl_plugins'][$id]['settings']['setting'] as $setting) {
1✔
315
                                $option = $setting['@attributes'];
1✔
316

317
                                $options[$option['name']] = [
1✔
318
                                        'name'  => $languages[$option['name']] ?? $this->txt['not_applicable'],
1✔
319
                                        'type'  => $option['type'],
1✔
320
                                        'value' => $settings[$option['name']] ?? $option['default'],
1✔
321
                                ];
322
                        }
323
                }
324

325
                $this->context['pl_plugins'][$id]['settings'] = $options;
1✔
326
        }
1✔
327

328
        private function getSettings(string $path): array
329
        {
330
                return is_file($path . '/settings.ini') ? parse_ini_file($path . '/settings.ini') : [];
1✔
331
        }
332

333
        private function saveSettings(string $plugin, array $settings = []): void
334
        {
335
                if ($settings === [])
1✔
336
                        return;
×
337

338
                $iniString = '';
1✔
339
                foreach ($settings as $key => $value) {
1✔
340
                        if (in_array(gettype($value), ['boolean', 'integer'])) {
1✔
341
                                $iniString .= "$key = $value\n";
1✔
342
                        } else {
343
                                $iniString .= "$key = \"$value\"\n";
1✔
344
                        }
345
                }
346

347
                file_put_contents(PLUGINS_DIR . DIRECTORY_SEPARATOR . $plugin . '/settings.ini', $iniString);
1✔
348
        }
1✔
349

350
        private function getLanguages(string $id): array
351
        {
352
                $path = PLUGINS_DIR . DIRECTORY_SEPARATOR . $id . '/languages/';
1✔
353

354
                $langFile = is_file($path . $this->context['user']['language'] . '.php')
1✔
355
                        ? $path . $this->context['user']['language'] . '.php'
×
356
                        : $path . 'english.php';
1✔
357

358
                return require_once $langFile;
1✔
359
        }
360

361
        private function escapeArray(array $data): array
362
        {
363
                foreach ($data as $key => $value) {
1✔
364
                        $data[$key] = is_array($value)
1✔
365
                                ? $this->escapeArray($value)
1✔
366
                                : $this->smcFunc['htmlspecialchars']($value, ENT_QUOTES);
1✔
367
                }
368

369
                return $data;
1✔
370
        }
371

372
        private function xmlToArray(SimpleXMLElement $xml): array
373
        {
374
                $parser = function (SimpleXMLElement $xml, array $collection = []) use (&$parser) {
1✔
375
                        $nodes      = $xml->children();
1✔
376
                        $attributes = $xml->attributes();
1✔
377

378
                        if (0 !== count($attributes)) {
1✔
379
                                foreach ($attributes as $attrName => $attrValue) {
1✔
380
                                        $collection['@attributes'][$attrName] = strval($attrValue);
1✔
381
                                }
382
                        }
383

384
                        if (0 === $nodes->count()) {
1✔
385
                                if ($xml->attributes()) {
1✔
386
                                        $collection['value'] = strval($xml);
1✔
387
                                } else {
388
                                        $collection = strval($xml);
1✔
389
                                }
390

391
                                return $collection;
1✔
392
                        }
393

394
                        foreach ($nodes as $nodeName => $nodeValue) {
1✔
395
                                if (count($nodeValue->xpath('../' . $nodeName)) < 2) {
1✔
396
                                        $collection[$nodeName] = $parser($nodeValue);
1✔
397

398
                                        continue;
1✔
399
                                }
400

401
                                $collection[$nodeName][] = $parser($nodeValue);
1✔
402
                        }
403

404
                        return $collection;
1✔
405
                };
406

407
                return $parser($xml);
1✔
408
        }
409

410
        private function extractPackage(): bool
411
        {
412
                if (! isset($_REQUEST['get'])) {
1✔
413
                        return false;
×
414
                }
415

416
                $package = $_FILES['package'];
1✔
417

418
                if ($package['error'] !== UPLOAD_ERR_OK) {
1✔
419
                        $errorMessages = [
×
420
                                UPLOAD_ERR_PARTIAL    => sprintf($this->txt['pl_upload_error_partial'], $package['name']),
×
421
                                UPLOAD_ERR_INI_SIZE   => sprintf($this->txt['pl_upload_error_ini_size'], $package['name']),
×
422
                                UPLOAD_ERR_CANT_WRITE => sprintf($this->txt['pl_upload_error_cant_write'], $package['name']),
×
423
                                UPLOAD_ERR_FORM_SIZE  => sprintf(
×
424
                                        $this->txt['pl_upload_error_size'], $this->context['max_file_size'] / 1024 / 1024
×
425
                                ),
426
                                UPLOAD_ERR_NO_FILE    => $this->txt['pl_upload_error_upload_no_file'],
×
427
                                UPLOAD_ERR_EXTENSION  => $this->txt['pl_upload_error_upload_extension'],
×
428
                                UPLOAD_ERR_NO_TMP_DIR => $this->txt['pl_upload_error_upload_no_tmp_dir'],
×
429
                        ];
430

431
                        $this->context['upload_error'] = $errorMessages[$package['error']] ?? $this->txt['pl_upload_error_unknown'];
×
432

433
                        return false;
×
434
                }
435

436
                switch ($package['type']) {
1✔
437
                        case 'application/zip':
1✔
438
                        case 'application/x-zip':
×
439
                        case 'application/x-zip-compressed':
×
440
                                break;
1✔
441

442
                        default:
443
                                $this->context['upload_error'] = $this->txt['pl_upload_wrong_file'];
×
444
                                return false;
×
445
                }
446

447
                $zip    = new ZipArchive();
1✔
448
                $result = $zip->open($package['tmp_name']);
1✔
449

450
                if ($result === true) {
1✔
451
                        $plugin = pathinfo((string) $package['name'], PATHINFO_FILENAME);
1✔
452

453
                        if (! $this->hasSafeZipEntryPaths($zip)) {
1✔
454
                                $this->context['upload_error'] = $this->txt['pl_upload_wrong_file'];
1✔
455

456
                                return false;
1✔
457
                        }
458

459
                        if ($zip->locateName($plugin . '/plugin-info.xml') !== false) {
1✔
460
                                return $zip->extractTo(PLUGINS_DIR);
1✔
461
                        } elseif ($zip->locateName('plugin-info.xml') !== false) {
×
462
                                return $zip->extractTo(PLUGINS_DIR . DIRECTORY_SEPARATOR . $plugin);
×
463
                        }
464

465
                        $this->context['upload_error'] = $this->txt['pl_upload_wrong_file'];
×
466
                } else {
467
                        $this->context['upload_error'] = sprintf($this->txt['pl_upload_failed'], $result);
×
468
                }
469

470
                return false;
×
471
        }
472

473
        private function hasSafeZipEntryPaths(ZipArchive $zip): bool
474
        {
475
                for ($index = 0; $index < $zip->numFiles; $index++) {
1✔
476
                        $entry = $zip->getNameIndex($index);
1✔
477

478
                        if ($entry === false || ! $this->isSafeZipEntryPath($entry)) {
1✔
479
                                return false;
1✔
480
                        }
481
                }
482

483
                return true;
1✔
484
        }
485

486
        private function isSafeZipEntryPath(string $entry): bool
487
        {
488
                $entry = str_replace('\\', '/', $entry);
1✔
489

490
                if ($entry === '' || str_starts_with($entry, '/') || preg_match('/^[A-Za-z]:\//', $entry) === 1) {
1✔
491
                        return false;
×
492
                }
493

494
                if (in_array('..', explode('/', $entry), true)) {
1✔
495
                        return false;
1✔
496
                }
497

498
                return true;
1✔
499
        }
500
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc