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

visavi / rotor / 28290024154

27 Jun 2026 01:03PM UTC coverage: 16.419% (-0.003%) from 16.422%
28290024154

push

github

visavi
Исправлен баг публикации ресурсов при обновлении выключенного модуля

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

983 of 5987 relevant lines covered (16.42%)

2.37 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 App\Providers\ModuleServiceProvider;
10
use Illuminate\Http\RedirectResponse;
11
use Illuminate\Http\Request;
12
use Illuminate\Support\Facades\Artisan;
13
use Illuminate\Support\Facades\Http;
14
use Illuminate\View\View;
15
use ZipArchive;
16

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

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

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

46
        $registryModules = ModuleRegistry::getAvailableModules();
×
47
        $failedModules = ModuleServiceProvider::$failed;
×
48

49
        return view('admin/modules/index', compact('moduleInstall', 'moduleNames', 'counts', 'registryModules', 'failedModules'));
×
50
    }
51

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

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

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

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

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

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

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

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

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

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

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

103
        foreach (['changelog.md', 'CHANGELOG.md'] as $changelog) {
×
104
            if (file_exists($modulePath . '/' . $changelog)) {
×
105
                $moduleConfig['changelog'] = file_get_contents($modulePath . '/' . $changelog);
×
106
                break;
×
107
            }
108
        }
109

110
        $registryInfo = ModuleRegistry::getAvailableModules()[$moduleName] ?? null;
×
111

112
        return view('admin/modules/module', compact('module', 'moduleConfig', 'moduleName', 'registryInfo'));
×
113
    }
114

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

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

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

131
        $moduleConfig = include $modulePath . '/module.php';
×
132

133
        $requires = $moduleConfig['requires'] ?? null;
×
134
        if ($requires && version_compare(ROTOR_VERSION, $requires, '<')) {
×
135
            setFlash('danger', __('admin.modules.requires') . ' ' . $requires . '!');
×
136

137
            return redirect('admin/modules/module?module=' . $moduleName);
×
138
        }
139

140
        // Файлы на диск кладём только для активного модуля: свежая установка,
141
        // включение или обновление уже активного. Обновление выключенного модуля
142
        // лишь повышает версию — его файлы не должны возвращаться на диск.
NEW
143
        if (! $module->exists || $enable || $module->active) {
×
NEW
144
            $module->createSymlink();
×
NEW
145
            $module->publish();
×
NEW
146
            $module->migrate();
×
147
        }
148

149
        Artisan::call('route:clear');
×
150
        $result = __('admin.modules.module_success_installed');
×
151

152
        if ($module->exists) {
×
153
            if ($update) {
×
154
                $module->update([
×
155
                    'version'    => $moduleConfig['version'],
×
156
                    'updated_at' => SITETIME,
×
157
                ]);
×
158
                $result = __('admin.modules.module_success_updated');
×
159
            }
160

161
            if ($enable) {
×
162
                $module->update([
×
163
                    'active'     => true,
×
164
                    'updated_at' => SITETIME,
×
165
                ]);
×
166
                $result = __('admin.modules.module_success_enabled');
×
167
            }
168
        } else {
169
            $module->fill([
×
170
                'version'    => $moduleConfig['version'],
×
171
                'updated_at' => SITETIME,
×
172
                'created_at' => SITETIME,
×
173
            ])->save();
×
174
        }
175

176
        // Полная синхронизация активных модулей: порядок установки перестаёт
177
        // иметь значение (напр. перевод модуля-языка подмешается в Форум,
178
        // даже если Форум поставили позже). Сама сбрасывает кэш модулей.
179
        Module::syncAll();
×
180

181
        setFlash('success', $result);
×
182

183
        return redirect('admin/modules/module?module=' . $moduleName);
×
184
    }
185

186
    /**
187
     * Каталог модулей из реестров
188
     */
189
    public function marketplace(Request $request): View
×
190
    {
191
        $force = (bool) $request->input('refresh');
×
192
        $available = ModuleRegistry::getAvailableModules($force);
×
193

194
        $modules = Module::query()->get()->keyBy('name');
×
195
        $moduleNames = [];
×
196

197
        $modulesLoaded = glob(base_path('modules/*'), GLOB_ONLYDIR);
×
198
        foreach ($modulesLoaded as $module) {
×
199
            $moduleNames[] = basename($module);
×
200
        }
201

202
        $counts = ['all' => count($available), 'installed' => 0, 'disabled' => 0, 'not-installed' => 0];
×
203
        foreach ($available as $name => $info) {
×
204
            $localExists = in_array($name, $moduleNames, true);
×
205
            $installed = $modules->has($name) && $localExists;
×
206

207
            if ($installed) {
×
208
                $counts[$modules[$name]->active ? 'installed' : 'disabled']++;
×
209
            } else {
210
                $counts['not-installed']++;
×
211
            }
212
        }
213

214
        return view('admin/modules/marketplace', compact('available', 'modules', 'moduleNames', 'counts'));
×
215
    }
216

217
    /**
218
     * Форма загрузки модуля
219
     */
220
    public function upload(): View
×
221
    {
222
        return view('admin/modules/upload');
×
223
    }
224

225
    /**
226
     * Установка модуля из ZIP-файла
227
     */
228
    public function uploadZip(Request $request): RedirectResponse
×
229
    {
230
        if (! $request->hasFile('zip') || ! $request->file('zip')->isValid()) {
×
231
            setFlash('danger', __('admin.modules.upload_invalid_file'));
×
232

233
            return redirect()->route('admin.modules.upload');
×
234
        }
235

236
        try {
237
            $moduleName = $this->extractZip($request->file('zip')->getPathname());
×
238
        } catch (\Exception $e) {
×
239
            setFlash('danger', $e->getMessage());
×
240

241
            return redirect()->route('admin.modules.upload');
×
242
        }
243

244
        return redirect('/admin/modules/module?module=' . $moduleName)
×
245
            ->with('success', __('admin.modules.upload_success_extracted'));
×
246
    }
247

248
    /**
249
     * Установка модуля по URL
250
     */
251
    public function download(Request $request): RedirectResponse
×
252
    {
253
        $url = trim($request->input('url', ''));
×
254

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

258
            return redirect()->back();
×
259
        }
260

261
        $maxSize = (int) config('modules.download_max_size') * 1024 * 1024;
×
262

263
        $tempDir = storage_path('app/temp');
×
264
        if (! is_dir($tempDir)) {
×
265
            mkdir($tempDir, 0755, true);
×
266
        }
267
        $tempFile = $tempDir . '/rotor_module_' . uniqid() . '.zip';
×
268

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

273
            if (! $response->ok()) {
×
274
                setFlash('danger', __('admin.modules.download_failed'));
×
275

276
                return redirect()->back();
×
277
            }
278

279
            $stream = $response->toPsrResponse()->getBody();
×
280
            $handle = fopen($tempFile, 'wb');
×
281

282
            if ($handle === false) {
×
283
                setFlash('danger', __('admin.modules.download_failed'));
×
284

285
                return redirect()->back();
×
286
            }
287
            $written = 0;
×
288
            $tooLarge = false;
×
289

290
            while (! $stream->eof()) {
×
291
                $chunk = $stream->read(8192);
×
292
                $written += strlen($chunk);
×
293

294
                if ($written > $maxSize) {
×
295
                    $tooLarge = true;
×
296
                    break;
×
297
                }
298

299
                fwrite($handle, $chunk);
×
300
            }
301

302
            fclose($handle);
×
303

304
            if ($tooLarge) {
×
305
                @unlink($tempFile);
×
306
                setFlash('danger', __('admin.modules.download_too_large', ['size' => formatSize($maxSize)]));
×
307

308
                return redirect()->back();
×
309
            }
310

311
            if (file_get_contents($tempFile, false, null, 0, 4) !== "PK\x03\x04") {
×
312
                @unlink($tempFile);
×
313
                setFlash('danger', __('admin.modules.download_not_zip'));
×
314

315
                return redirect()->back();
×
316
            }
317

318
            try {
319
                $moduleName = $this->extractZip($tempFile);
×
320
            } finally {
321
                @unlink($tempFile);
×
322
            }
323
        } catch (\Exception $e) {
×
324
            @unlink($tempFile);
×
325
            setFlash('danger', $e->getMessage());
×
326

327
            return redirect()->back();
×
328
        }
329

330
        // Для уже установленного модуля распаковка — лишь первый шаг обновления:
331
        // подсказываем, что версия применится по кнопке «Применить обновление»
332
        $extracted = Module::query()->where('name', $moduleName)->exists()
×
333
            ? __('admin.modules.update_extracted')
×
334
            : __('admin.modules.upload_success_extracted');
×
335

336
        return redirect('/admin/modules/module?module=' . $moduleName)
×
337
            ->with('success', $extracted);
×
338
    }
339

340
    /**
341
     * Распаковка ZIP-архива модуля
342
     */
343
    private function extractZip(string $zipPath): string
×
344
    {
345
        $zip = new ZipArchive();
×
346

347
        if ($zip->open($zipPath) !== true) {
×
348
            throw new \RuntimeException(__('admin.modules.zip_open_failed'));
×
349
        }
350

351
        $topDirs = [];
×
352
        for ($i = 0; $i < $zip->numFiles; $i++) {
×
353
            $name = $zip->getNameIndex($i);
×
354

355
            if (str_contains($name, '..')) {
×
356
                $zip->close();
×
357
                throw new \RuntimeException(__('admin.modules.zip_invalid_path'));
×
358
            }
359

360
            $parts = explode('/', $name);
×
361
            if ($parts[0] !== '') {
×
362
                $topDirs[$parts[0]] = true;
×
363
            }
364
        }
365

366
        if (count($topDirs) !== 1) {
×
367
            $zip->close();
×
368
            throw new \RuntimeException(__('admin.modules.zip_invalid_structure'));
×
369
        }
370

371
        $moduleName = array_key_first($topDirs);
×
372

373
        if (! preg_match('/^[A-Z][A-Za-z0-9]+$/', $moduleName)) {
×
374
            $zip->close();
×
375
            throw new \RuntimeException(__('admin.modules.zip_invalid_name'));
×
376
        }
377

378
        $targetPath = base_path('modules/' . $moduleName);
×
379

380
        // Существующий модуль уводим в резервную копию, чтобы чистая распаковка
381
        // не оставила старых файлов и можно было откатиться при сбое
382
        $backupPath = null;
×
383
        if (is_dir($targetPath)) {
×
384
            $backupPath = base_path('modules/.backup_' . $moduleName . '_' . time());
×
385
            if (! rename($targetPath, $backupPath)) {
×
386
                $zip->close();
×
387
                throw new \RuntimeException(__('admin.modules.zip_backup_failed'));
×
388
            }
389
        }
390

391
        if (! $zip->extractTo(base_path('modules/'))) {
×
392
            $zip->close();
×
393
            $this->restoreBackup($targetPath, $backupPath);
×
394
            throw new \RuntimeException(__('admin.modules.zip_extract_failed'));
×
395
        }
396
        $zip->close();
×
397

398
        $this->chmodRecursive($targetPath);
×
399

400
        if (! file_exists($targetPath . '/module.php')) {
×
401
            $this->restoreBackup($targetPath, $backupPath);
×
402
            throw new \RuntimeException(__('admin.modules.zip_no_module_file'));
×
403
        }
404

405
        if ($backupPath) {
×
406
            $this->deleteDirectory($backupPath);
×
407
        }
408

409
        // Файлы перезаписаны на диске, но opcache (revalidate_freq) ещё держит
410
        // старый module.php — без сброса кнопка обновления и новый код модуля
411
        // подхватятся только со следующим запросом после ревалидации
412
        if (function_exists('opcache_reset')) {
×
413
            opcache_reset();
×
414
        }
415

416
        return $moduleName;
×
417
    }
418

419
    /**
420
     * Удаление файлов модуля с диска
421
     */
422
    public function deleteFiles(Request $request): RedirectResponse
×
423
    {
424
        $moduleName = $request->input('module');
×
425
        $modulePath = base_path('modules/' . $moduleName);
×
426

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

431
        if (Module::query()->where('name', $moduleName)->exists()) {
×
432
            abort(200, __('admin.modules.delete_files_not_uninstalled'));
×
433
        }
434

435
        $this->deleteDirectory($modulePath);
×
436

437
        Artisan::call('route:clear');
×
438
        setFlash('success', __('admin.modules.module_files_deleted'));
×
439

440
        return redirect()->route('admin.modules.index');
×
441
    }
442

443
    /**
444
     * Откат распаковки: удалить частично распакованное и вернуть резервную копию
445
     */
446
    private function restoreBackup(string $targetPath, ?string $backupPath): void
×
447
    {
448
        $this->deleteDirectory($targetPath);
×
449

450
        if ($backupPath && is_dir($backupPath)) {
×
451
            rename($backupPath, $targetPath);
×
452
        }
453
    }
454

455
    /**
456
     * Рекурсивно устанавливает права доступа (755 для директорий, 644 для файлов)
457
     */
458
    private function chmodRecursive(string $path): void
×
459
    {
460
        chmod($path, 0755);
×
461

462
        foreach (scandir($path) as $item) {
×
463
            if ($item === '.' || $item === '..') {
×
464
                continue;
×
465
            }
466

467
            $full = $path . '/' . $item;
×
468
            if (is_dir($full)) {
×
469
                $this->chmodRecursive($full);
×
470
            } else {
471
                chmod($full, 0644);
×
472
            }
473
        }
474
    }
475

476
    /**
477
     * Рекурсивно удаляет директорию, включая симлинки
478
     */
479
    private function deleteDirectory(string $path): void
×
480
    {
481
        if (is_link($path)) {
×
482
            unlink($path);
×
483

484
            return;
×
485
        }
486

487
        if (! is_dir($path)) {
×
488
            return;
×
489
        }
490

491
        foreach (scandir($path) as $item) {
×
492
            if ($item === '.' || $item === '..') {
×
493
                continue;
×
494
            }
495

496
            $full = $path . '/' . $item;
×
497
            is_dir($full) && ! is_link($full) ? $this->deleteDirectory($full) : unlink($full);
×
498
        }
499

500
        rmdir($path);
×
501
    }
502

503
    /**
504
     * Удаление/Выключение модуля
505
     */
506
    public function uninstall(Request $request): RedirectResponse
×
507
    {
508
        $moduleName = $request->input('module');
×
509
        $disable = int($request->input('disable'));
×
510
        $modulePath = base_path('modules/' . $moduleName);
×
511

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

516
        $module = Module::query()->where('name', $moduleName)->first();
×
517
        if (! $module) {
×
518
            abort(200, __('admin.modules.module_not_found'));
×
519
        }
520

521
        $module->deleteSymlink();
×
522
        $module->unpublish();
×
523
        Artisan::call('route:clear');
×
524

525
        if ($disable) {
×
526
            $module->update([
×
527
                'active'     => false,
×
528
                'updated_at' => SITETIME,
×
529
            ]);
×
530
            $result = __('admin.modules.module_success_disabled');
×
531
        } else {
532
            if (config('modules.safe_mode')) {
×
533
                setFlash('danger', __('admin.modules.safe_mode_enabled'));
×
534

535
                return redirect('admin/modules/module?module=' . $moduleName);
×
536
            }
537

538
            $module->rollback();
×
539
            $module->delete();
×
540
            $result = __('admin.modules.module_success_deleted');
×
541
        }
542

543
        clearCache(['modules', 'settings']);
×
544
        setFlash('success', $result);
×
545

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