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

visavi / rotor / 27376486937

11 Jun 2026 08:48PM UTC coverage: 13.926% (-0.02%) from 13.943%
27376486937

push

github

visavi
Установка-удаление модулей через post с проверкой csrf

0 of 30 new or added lines in 1 file covered. (0.0%)

2 existing lines in 1 file now uncovered.

807 of 5795 relevant lines covered (13.93%)

1.4 hits per line

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

0.0
/app/Http/Controllers/Admin/ModuleController.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace App\Http\Controllers\Admin;
6

7
use App\Models\Module;
8
use App\Models\ModuleRegistry;
9
use Illuminate\Http\RedirectResponse;
10
use Illuminate\Http\Request;
11
use Illuminate\Support\Facades\Artisan;
12
use Illuminate\Support\Facades\Http;
13
use Illuminate\View\View;
14
use ZipArchive;
15

16
class ModuleController extends AdminController
17
{
18
    /**
19
     * Главная страница
20
     */
21
    public function index(): View
×
22
    {
23
        $modules = Module::query()->get();
×
24
        $moduleInstall = [];
×
25
        foreach ($modules as $module) {
×
26
            $moduleInstall[$module->name] = $module;
×
27
        }
28

29
        $moduleNames = [];
×
30
        $modulesLoaded = glob(base_path('modules/*'), GLOB_ONLYDIR);
×
31
        foreach ($modulesLoaded as $module) {
×
32
            if (file_exists($module . '/module.php')) {
×
33
                $moduleNames[basename($module)] = include $module . '/module.php';
×
34
            }
35
        }
36

37
        $installed = array_intersect_key($moduleInstall, $moduleNames);
×
38
        $counts = [
×
39
            'all'           => count($moduleNames),
×
40
            'installed'     => count(array_filter($installed, fn ($m) => $m->active)),
×
41
            'disabled'      => count(array_filter($installed, fn ($m) => ! $m->active)),
×
42
            'not-installed' => count($moduleNames) - count($installed),
×
43
        ];
×
44

45
        $registryModules = ModuleRegistry::getAvailableModules();
×
46

47
        return view('admin/modules/index', compact('moduleInstall', 'moduleNames', 'counts', 'registryModules'));
×
48
    }
49

50
    /**
51
     * Просмотр модуля
52
     */
53
    public function module(Request $request): View
×
54
    {
55
        $moduleName = (string) $request->input('module');
×
56
        $modulePath = base_path('modules/' . $moduleName);
×
57

58
        if (! preg_match('|^[A-Z][\w\-]+$|', $moduleName) || ! file_exists($modulePath)) {
×
59
            abort(200, __('admin.modules.module_not_found'));
×
60
        }
61

62
        $moduleConfig = include $modulePath . '/module.php';
×
63
        $module = Module::query()->where('name', $moduleName)->first();
×
64

65
        if (file_exists($modulePath . '/screenshots')) {
×
66
            $moduleConfig['screenshots'] = glob($modulePath . '/screenshots/*.{gif,png,jpg,jpeg,webp}', GLOB_BRACE);
×
67
        }
68

69
        if (file_exists($modulePath . '/database/migrations')) {
×
70
            $migrations = [];
×
71
            foreach (glob($modulePath . '/database/migrations/*.php') as $migration) {
×
72
                $migrations[basename($migration)] = file_get_contents($migration);
×
73
            }
74
            $moduleConfig['migrations'] = $migrations;
×
75
        }
76

77
        if (file_exists($modulePath . '/resources/assets')) {
×
78
            $moduleConfig['symlink'] = Module::getLinkNameByPath($modulePath);
×
79
        }
80

81
        if (file_exists($modulePath . '/config.php')) {
×
82
            $moduleConfig['config'] = file_get_contents($modulePath . '/config.php');
×
83
        }
84

85
        if ($module && $module->settings) {
×
86
            $moduleConfig['settings'] = var_export($module->settings, true);
×
87
        }
88

89
        if (file_exists($modulePath . '/routes.php')) {
×
90
            $moduleConfig['routes'] = file_get_contents($modulePath . '/routes.php');
×
91
        }
92

93
        if (file_exists($modulePath . '/hooks.php')) {
×
94
            $moduleConfig['hooks'] = file_get_contents($modulePath . '/hooks.php');
×
95
        }
96

97
        if (file_exists($modulePath . '/helpers.php')) {
×
98
            $moduleConfig['helpers'] = file_get_contents($modulePath . '/helpers.php');
×
99
        }
100

101
        if (file_exists($modulePath . '/middleware.php')) {
×
102
            $moduleConfig['middleware'] = file_get_contents($modulePath . '/middleware.php');
×
103
        }
104

105
        $registryInfo = ModuleRegistry::getAvailableModules()[$moduleName] ?? null;
×
106

107
        return view('admin/modules/module', compact('module', 'moduleConfig', 'moduleName', 'registryInfo'));
×
108
    }
109

110
    /**
111
     * Установка модуля
112
     */
113
    public function install(Request $request): RedirectResponse
×
114
    {
115
        $moduleName = $request->input('module');
×
116
        $enable = int($request->input('enable'));
×
117
        $update = int($request->input('update'));
×
118
        $modulePath = base_path('modules/' . $moduleName);
×
119

120
        if (! preg_match('|^[A-Z][\w\-]+$|', $moduleName) || ! file_exists($modulePath)) {
×
121
            abort(200, __('admin.modules.module_not_found'));
×
122
        }
123

124
        $module = Module::query()->firstOrNew(['name' => $moduleName]);
×
125

126
        $moduleConfig = include $modulePath . '/module.php';
×
127
        $module->createSymlink();
×
128
        $module->migrate();
×
129

130
        Artisan::call('route:clear');
×
131
        $result = __('admin.modules.module_success_installed');
×
132

133
        if ($module->exists) {
×
134
            if ($update) {
×
135
                $module->update([
×
136
                    'version'    => $moduleConfig['version'],
×
137
                    'updated_at' => SITETIME,
×
138
                ]);
×
139
                $result = __('admin.modules.module_success_updated');
×
140
            }
141

142
            if ($enable) {
×
143
                $module->update([
×
144
                    'active'     => true,
×
145
                    'updated_at' => SITETIME,
×
146
                ]);
×
147
                $result = __('admin.modules.module_success_enabled');
×
148
            }
149
        } else {
150
            $module->fill([
×
151
                'version'    => $moduleConfig['version'],
×
152
                'updated_at' => SITETIME,
×
153
                'created_at' => SITETIME,
×
154
            ])->save();
×
155
        }
156

157
        clearCache(['modules', 'settings']);
×
158
        setFlash('success', $result);
×
159

160
        return redirect('admin/modules/module?module=' . $moduleName);
×
161
    }
162

163
    /**
164
     * Каталог модулей из реестров
165
     */
166
    public function marketplace(Request $request): View
×
167
    {
168
        $force = (bool) $request->input('refresh');
×
169
        $available = ModuleRegistry::getAvailableModules($force);
×
170

171
        $modules = Module::query()->get()->keyBy('name');
×
172
        $moduleNames = [];
×
173

174
        $modulesLoaded = glob(base_path('modules/*'), GLOB_ONLYDIR);
×
175
        foreach ($modulesLoaded as $module) {
×
176
            $moduleNames[] = basename($module);
×
177
        }
178

179
        $counts = ['all' => count($available), 'installed' => 0, 'disabled' => 0, 'not-installed' => 0];
×
180
        foreach ($available as $name => $info) {
×
181
            $localExists = in_array($name, $moduleNames, true);
×
182
            $installed = $modules->has($name) && $localExists;
×
183

184
            if ($installed) {
×
185
                $counts[$modules[$name]->active ? 'installed' : 'disabled']++;
×
186
            } else {
187
                $counts['not-installed']++;
×
188
            }
189
        }
190

191
        return view('admin/modules/marketplace', compact('available', 'modules', 'moduleNames', 'counts'));
×
192
    }
193

194
    /**
195
     * Форма загрузки модуля
196
     */
197
    public function upload(): View
×
198
    {
199
        return view('admin/modules/upload');
×
200
    }
201

202
    /**
203
     * Установка модуля из ZIP-файла
204
     */
205
    public function uploadZip(Request $request): RedirectResponse
×
206
    {
207
        if (! $request->hasFile('zip') || ! $request->file('zip')->isValid()) {
×
208
            setFlash('danger', __('admin.modules.upload_invalid_file'));
×
209

210
            return redirect()->route('admin.modules.upload');
×
211
        }
212

213
        try {
214
            $moduleName = $this->extractZip($request->file('zip')->getPathname());
×
215
        } catch (\Exception $e) {
×
216
            setFlash('danger', $e->getMessage());
×
217

218
            return redirect()->route('admin.modules.upload');
×
219
        }
220

NEW
221
        return redirect('/admin/modules/module?module=' . $moduleName)
×
222
            ->with('success', __('admin.modules.upload_success_extracted'));
×
223
    }
224

225
    /**
226
     * Установка модуля по URL
227
     */
228
    public function download(Request $request): RedirectResponse
×
229
    {
230
        $url = trim($request->input('url', ''));
×
231

NEW
232
        if (! filter_var($url, FILTER_VALIDATE_URL) || ! in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) {
×
233
            setFlash('danger', __('admin.modules.download_invalid_url'));
×
234

235
            return redirect()->back();
×
236
        }
237

238
        $maxSize = (int) setting('filesize') * 1024;
×
239

NEW
240
        $tempDir = storage_path('app/temp');
×
NEW
241
        if (! is_dir($tempDir)) {
×
NEW
242
            mkdir($tempDir, 0755, true);
×
243
        }
NEW
244
        $tempFile = $tempDir . '/rotor_module_' . uniqid() . '.zip';
×
245

246
        try {
247
            // Потоковая запись: лимит проверяется по мере чтения, тело не держим в памяти
NEW
248
            $response = Http::timeout(30)->withOptions(['stream' => true])->get($url);
×
249

250
            if (! $response->ok()) {
×
251
                setFlash('danger', __('admin.modules.download_failed'));
×
252

253
                return redirect()->back();
×
254
            }
255

NEW
256
            $stream = $response->toPsrResponse()->getBody();
×
NEW
257
            $handle = fopen($tempFile, 'wb');
×
258

NEW
259
            if ($handle === false) {
×
NEW
260
                setFlash('danger', __('admin.modules.download_failed'));
×
261

262
                return redirect()->back();
×
263
            }
NEW
264
            $written = 0;
×
NEW
265
            $tooLarge = false;
×
266

NEW
267
            while (! $stream->eof()) {
×
NEW
268
                $chunk = $stream->read(8192);
×
NEW
269
                $written += strlen($chunk);
×
270

NEW
271
                if ($written > $maxSize) {
×
NEW
272
                    $tooLarge = true;
×
NEW
273
                    break;
×
274
                }
275

NEW
276
                fwrite($handle, $chunk);
×
277
            }
278

NEW
279
            fclose($handle);
×
280

NEW
281
            if ($tooLarge) {
×
NEW
282
                @unlink($tempFile);
×
UNCOV
283
                setFlash('danger', __('admin.modules.download_too_large', ['size' => formatSize($maxSize)]));
×
284

285
                return redirect()->back();
×
286
            }
287

NEW
288
            if (file_get_contents($tempFile, false, null, 0, 4) !== "PK\x03\x04") {
×
NEW
289
                @unlink($tempFile);
×
NEW
290
                setFlash('danger', __('admin.modules.download_not_zip'));
×
291

NEW
292
                return redirect()->back();
×
293
            }
294

295
            try {
296
                $moduleName = $this->extractZip($tempFile);
×
297
            } finally {
298
                @unlink($tempFile);
×
299
            }
300
        } catch (\Exception $e) {
×
NEW
301
            @unlink($tempFile);
×
UNCOV
302
            setFlash('danger', $e->getMessage());
×
303

304
            return redirect()->back();
×
305
        }
306

NEW
307
        return redirect('/admin/modules/module?module=' . $moduleName)
×
308
            ->with('success', __('admin.modules.upload_success_extracted'));
×
309
    }
310

311
    /**
312
     * Распаковка ZIP-архива модуля
313
     */
314
    private function extractZip(string $zipPath): string
×
315
    {
316
        $zip = new ZipArchive();
×
317

318
        if ($zip->open($zipPath) !== true) {
×
319
            throw new \RuntimeException(__('admin.modules.zip_open_failed'));
×
320
        }
321

322
        $topDirs = [];
×
323
        for ($i = 0; $i < $zip->numFiles; $i++) {
×
324
            $name = $zip->getNameIndex($i);
×
325

326
            if (str_contains($name, '..')) {
×
327
                $zip->close();
×
328
                throw new \RuntimeException(__('admin.modules.zip_invalid_path'));
×
329
            }
330

331
            $parts = explode('/', $name);
×
332
            if ($parts[0] !== '') {
×
333
                $topDirs[$parts[0]] = true;
×
334
            }
335
        }
336

337
        if (count($topDirs) !== 1) {
×
338
            $zip->close();
×
339
            throw new \RuntimeException(__('admin.modules.zip_invalid_structure'));
×
340
        }
341

342
        $moduleName = array_key_first($topDirs);
×
343

344
        if (! preg_match('/^[A-Z][A-Za-z0-9]+$/', $moduleName)) {
×
345
            $zip->close();
×
346
            throw new \RuntimeException(__('admin.modules.zip_invalid_name'));
×
347
        }
348

349
        $targetPath = base_path('modules/' . $moduleName);
×
350

351
        // Существующий модуль уводим в резервную копию, чтобы чистая распаковка
352
        // не оставила старых файлов и можно было откатиться при сбое
353
        $backupPath = null;
×
354
        if (is_dir($targetPath)) {
×
355
            $backupPath = base_path('modules/.backup_' . $moduleName . '_' . time());
×
356
            if (! rename($targetPath, $backupPath)) {
×
357
                $zip->close();
×
358
                throw new \RuntimeException(__('admin.modules.zip_backup_failed'));
×
359
            }
360
        }
361

362
        if (! $zip->extractTo(base_path('modules/'))) {
×
363
            $zip->close();
×
364
            $this->restoreBackup($targetPath, $backupPath);
×
365
            throw new \RuntimeException(__('admin.modules.zip_extract_failed'));
×
366
        }
367
        $zip->close();
×
368

369
        $this->chmodRecursive($targetPath);
×
370

371
        if (! file_exists($targetPath . '/module.php')) {
×
372
            $this->restoreBackup($targetPath, $backupPath);
×
373
            throw new \RuntimeException(__('admin.modules.zip_no_module_file'));
×
374
        }
375

376
        if ($backupPath) {
×
377
            $this->deleteDirectory($backupPath);
×
378
        }
379

380
        return $moduleName;
×
381
    }
382

383
    /**
384
     * Удаление файлов модуля с диска
385
     */
386
    public function deleteFiles(Request $request): RedirectResponse
×
387
    {
388
        $moduleName = $request->input('module');
×
389
        $modulePath = base_path('modules/' . $moduleName);
×
390

391
        if (! preg_match('|^[A-Z][\w\-]+$|', $moduleName) || ! file_exists($modulePath)) {
×
392
            abort(200, __('admin.modules.module_not_found'));
×
393
        }
394

395
        if (Module::query()->where('name', $moduleName)->exists()) {
×
396
            abort(200, __('admin.modules.delete_files_not_uninstalled'));
×
397
        }
398

399
        $this->deleteDirectory($modulePath);
×
400

401
        Artisan::call('route:clear');
×
402
        setFlash('success', __('admin.modules.module_files_deleted'));
×
403

404
        return redirect()->route('admin.modules.index');
×
405
    }
406

407
    /**
408
     * Откат распаковки: удалить частично распакованное и вернуть резервную копию
409
     */
410
    private function restoreBackup(string $targetPath, ?string $backupPath): void
×
411
    {
412
        $this->deleteDirectory($targetPath);
×
413

414
        if ($backupPath && is_dir($backupPath)) {
×
415
            rename($backupPath, $targetPath);
×
416
        }
417
    }
418

419
    /**
420
     * Рекурсивно устанавливает права доступа (755 для директорий, 644 для файлов)
421
     */
422
    private function chmodRecursive(string $path): void
×
423
    {
424
        chmod($path, 0755);
×
425

426
        foreach (scandir($path) as $item) {
×
427
            if ($item === '.' || $item === '..') {
×
428
                continue;
×
429
            }
430

431
            $full = $path . '/' . $item;
×
432
            if (is_dir($full)) {
×
433
                $this->chmodRecursive($full);
×
434
            } else {
435
                chmod($full, 0644);
×
436
            }
437
        }
438
    }
439

440
    /**
441
     * Рекурсивно удаляет директорию, включая симлинки
442
     */
443
    private function deleteDirectory(string $path): void
×
444
    {
445
        if (is_link($path)) {
×
446
            unlink($path);
×
447

448
            return;
×
449
        }
450

451
        if (! is_dir($path)) {
×
452
            return;
×
453
        }
454

455
        foreach (scandir($path) as $item) {
×
456
            if ($item === '.' || $item === '..') {
×
457
                continue;
×
458
            }
459

460
            $full = $path . '/' . $item;
×
461
            is_dir($full) && ! is_link($full) ? $this->deleteDirectory($full) : unlink($full);
×
462
        }
463

464
        rmdir($path);
×
465
    }
466

467
    /**
468
     * Удаление/Выключение модуля
469
     */
470
    public function uninstall(Request $request): RedirectResponse
×
471
    {
472
        $moduleName = $request->input('module');
×
473
        $disable = int($request->input('disable'));
×
474
        $modulePath = base_path('modules/' . $moduleName);
×
475

476
        if (! preg_match('|^[A-Z][\w\-]+$|', $moduleName) || ! file_exists($modulePath)) {
×
477
            abort(200, __('admin.modules.module_not_found'));
×
478
        }
479

480
        $module = Module::query()->where('name', $moduleName)->first();
×
481
        if (! $module) {
×
482
            abort(200, __('admin.modules.module_not_found'));
×
483
        }
484

485
        $module->deleteSymlink();
×
486
        Artisan::call('route:clear');
×
487

488
        if ($disable) {
×
489
            $module->update([
×
490
                'active'     => false,
×
491
                'updated_at' => SITETIME,
×
492
            ]);
×
493
            $result = __('admin.modules.module_success_disabled');
×
494
        } else {
NEW
495
            if (config('modules.safe_mode')) {
×
496
                setFlash('danger', __('admin.modules.safe_mode_enabled'));
×
497

498
                return redirect('admin/modules/module?module=' . $moduleName);
×
499
            }
500

501
            $module->rollback();
×
502
            $module->delete();
×
503
            $result = __('admin.modules.module_success_deleted');
×
504
        }
505

506
        clearCache(['modules', 'settings']);
×
507
        setFlash('success', $result);
×
508

509
        return redirect('admin/modules/module?module=' . $moduleName);
×
510
    }
511
}
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