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

visavi / rotor / 29200365228

12 Jul 2026 04:33PM UTC coverage: 32.915% (-0.02%) from 32.939%
29200365228

push

github

visavi
Поправил страницу просмотра changelog в модуле

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

1 existing line in 1 file now uncovered.

1864 of 5663 relevant lines covered (32.92%)

3.82 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\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
        $failedModules = ModuleServiceProvider::$failed;
×
47

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

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

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

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

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

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

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

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

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

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

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

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

102
        foreach (['changelog.md', 'CHANGELOG.md'] as $changelog) {
×
103
            if (file_exists($modulePath . '/' . $changelog)) {
×
104
                // Убираем шапку "# Changelog" — на странице уже есть свой заголовок
NEW
105
                $moduleConfig['changelog'] = trim((string) preg_replace(
×
NEW
106
                    '/^#\s+changelog\s*/ui',
×
NEW
107
                    '',
×
NEW
108
                    file_get_contents($modulePath . '/' . $changelog)
×
NEW
109
                ));
×
UNCOV
110
                break;
×
111
            }
112
        }
113

114
        $registryInfo = ModuleRegistry::getAvailableModules()[$moduleName] ?? null;
×
115

116
        return view('admin/modules/module', compact('module', 'moduleConfig', 'moduleName', 'registryInfo'));
×
117
    }
118

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

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

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

135
        $moduleConfig = include $modulePath . '/module.php';
×
136

137
        $requires = $moduleConfig['requires'] ?? null;
×
138
        if ($requires && version_compare(ROTOR_VERSION, $requires, '<')) {
×
139
            setFlash('danger', __('admin.modules.requires') . ' ' . $requires . '!');
×
140

141
            return redirect('admin/modules/module?module=' . $moduleName);
×
142
        }
143

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

153
        $result = __('admin.modules.module_success_installed');
×
154

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

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

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

180
        // После syncAll — пересборка увидит роуты нового модуля
181
        refreshCaches();
×
182

183
        setFlash('success', $result);
×
184

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

260
            return redirect()->back();
×
261
        }
262

263
        $maxSize = (int) config('modules.download_max_size') * 1024 * 1024;
×
264

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

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

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

278
                return redirect()->back();
×
279
            }
280

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

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

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

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

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

301
                fwrite($handle, $chunk);
×
302
            }
303

304
            fclose($handle);
×
305

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

310
                return redirect()->back();
×
311
            }
312

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

317
                return redirect()->back();
×
318
            }
319

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

329
            return redirect()->back();
×
330
        }
331

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

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

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

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

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

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

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

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

373
        $moduleName = array_key_first($topDirs);
×
374

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

380
        $targetPath = base_path('modules/' . $moduleName);
×
381

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

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

400
        $this->chmodRecursive($targetPath);
×
401

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

407
        if ($backupPath) {
×
408
            $this->deleteDirectory($backupPath);
×
409
        }
410

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

418
        return $moduleName;
×
419
    }
420

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

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

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

437
        $this->deleteDirectory($modulePath);
×
438

439
        refreshCaches();
×
440
        setFlash('success', __('admin.modules.module_files_deleted'));
×
441

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

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

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

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

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

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

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

486
            return;
×
487
        }
488

489
        if (! is_dir($path)) {
×
490
            return;
×
491
        }
492

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

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

502
        rmdir($path);
×
503
    }
504

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

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

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

523
        $module->deleteSymlink();
×
524
        $module->unpublish();
×
525

526
        if ($disable) {
×
527
            $module->update([
×
528
                'active' => false,
×
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

545
        // После смены статуса модуля — пересборка соберёт роуты без него
546
        refreshCaches();
×
547

548
        setFlash('success', $result);
×
549

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