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

visavi / rotor / 27918035804

21 Jun 2026 09:28PM UTC coverage: 16.223% (-0.03%) from 16.253%
27918035804

push

github

visavi
Синхронизация симлинков и публикаций в модулях + проверка пути публикации статики

0 of 17 new or added lines in 3 files covered. (0.0%)

3 existing lines in 3 files now uncovered.

970 of 5979 relevant lines covered (16.22%)

2.37 hits per line

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

12.41
/app/Models/Module.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace App\Models;
6

7
use Exception;
8
use Illuminate\Database\Eloquent\Model;
9
use Illuminate\Support\Facades\Artisan;
10
use Illuminate\Support\Facades\Cache;
11
use Illuminate\Support\Facades\DB;
12
use Illuminate\Support\Facades\File;
13
use Illuminate\Support\Str;
14

15
/**
16
 * Class Module
17
 *
18
 * @property int    $id
19
 * @property string $name
20
 * @property string $version
21
 * @property bool   $active
22
 * @property int    $updated_at
23
 * @property int    $created_at
24
 */
25
class Module extends Model
26
{
27
    /**
28
     * Assets modules path
29
     */
30
    protected const ASSETS_PATH = '/assets/modules/';
31

32
    /**
33
     * Indicates if the model should be timestamped.
34
     */
35
    public $timestamps = false;
36

37
    /**
38
     * The attributes that aren't mass assignable.
39
     */
40
    protected $guarded = [];
41

42
    /**
43
     * Get the attributes that should be cast.
44
     */
45
    protected function casts(): array
109✔
46
    {
47
        return [
109✔
48
            'active' => 'bool',
109✔
49
        ];
109✔
50
    }
51

52
    /**
53
     * Выполняет применение миграции
54
     */
55
    public function migrate(): void
×
56
    {
57
        $migrationPath = base_path('modules/' . $this->name . '/database/migrations');
×
58

59
        if (file_exists($migrationPath)) {
×
60
            Artisan::call('migrate', [
×
61
                '--force'    => true,
×
62
                '--realpath' => true,
×
63
                '--path'     => $migrationPath,
×
64
            ]);
×
65
        }
66
    }
67

68
    /**
69
     * Выполняет откат миграций
70
     */
71
    public function rollback(): void
×
72
    {
73
        $migrationPath = base_path('modules/' . $this->name . '/database/migrations');
×
74

75
        if (file_exists($migrationPath)) {
×
76
            $migrator = app('migrator');
×
77
            $nextBatchNumber = $migrator->getRepository()->getNextBatchNumber();
×
78
            $migrationNames = array_keys($migrator->getMigrationFiles($migrationPath));
×
79

80
            DB::table(config('database.migrations.table'))
×
81
                ->whereIn('migration', $migrationNames)
×
82
                ->update(['batch' => $nextBatchNumber]);
×
83

84
            Artisan::call('migrate:rollback', [
×
85
                '--force'    => true,
×
86
                '--realpath' => true,
×
87
                '--path'     => $migrationPath,
×
88
            ]);
×
89
        }
90
    }
91

92
    /**
93
     * Создает симлинки модулей
94
     */
95
    public function createSymlink(): void
×
96
    {
97
        $originPath = public_path($this->getLinkName());
×
98
        if (file_exists($originPath)) {
×
99
            return;
×
100
        }
101

102
        $assetsPath = base_path('modules/' . $this->name . '/resources/assets');
×
103
        if (! file_exists($assetsPath)) {
×
104
            return;
×
105
        }
106

107
        File::link($assetsPath, $originPath);
×
108
    }
109

110
    /**
111
     * Удаляет симлинки модулей
112
     */
113
    public function deleteSymlink(): void
×
114
    {
115
        $originPath = public_path($this->getLinkName());
×
116
        if (! file_exists($originPath)) {
×
117
            return;
×
118
        }
119

120
        File::delete($originPath);
×
121
    }
122

123
    /**
124
     * Синхронизирует все активные модули с ядром: симлинки и публикация файлов.
125
     *
126
     * Каждый модуль обёрнут в try/catch — битый модуль (ошибка в module.php,
127
     * проблема с правами на симлинк и т.п.) не роняет синхронизацию остальных.
128
     * Полная синхронизация делает публикацию независимой от порядка установки:
129
     * напр. перевод модуля-языка подмешается в Форум, даже если Форум поставили позже.
130
     *
131
     * @return array<string, string> [имя модуля => текст ошибки]
132
     */
NEW
133
    public static function syncAll(): array
×
134
    {
NEW
135
        $failed = [];
×
136

NEW
137
        foreach (self::query()->where('active', true)->get() as $module) {
×
138
            try {
NEW
139
                $module->createSymlink();
×
NEW
140
                $module->publish();
×
NEW
141
            } catch (\Throwable $e) {
×
NEW
142
                $failed[$module->name] = $e->getMessage();
×
NEW
143
                report($e);
×
144
            }
145
        }
146

NEW
147
        clearCache(['modules', 'settings']);
×
148

NEW
149
        return $failed;
×
150
    }
151

152
    /**
153
     * Копирует файлы модуля в директории движка
154
     */
155
    public function publish(): void
×
156
    {
157
        foreach ($this->getPublishMap() as $from => $to) {
×
158
            if (! file_exists($from)) {
×
159
                continue;
×
160
            }
161

162
            if (is_dir($from)) {
×
163
                File::copyDirectory($from, $to);
×
164
            } else {
165
                File::ensureDirectoryExists(dirname($to));
×
166
                File::copy($from, $to);
×
167
            }
168
        }
169
    }
170

171
    /**
172
     * Удаляет ранее скопированные файлы модуля
173
     */
174
    public function unpublish(): void
×
175
    {
176
        foreach ($this->getPublishMap() as $from => $to) {
×
177
            if (is_dir($from)) {
×
178
                File::deleteDirectory($to);
×
179
            } elseif (is_file($from)) {
×
180
                File::delete($to);
×
181
            }
182
        }
183
    }
184

185
    /**
186
     * Карта публикации файлов модуля [источник => назначение]
187
     */
188
    private function getPublishMap(): array
×
189
    {
190
        $configFile = base_path('modules/' . $this->name . '/module.php');
×
191
        if (! file_exists($configFile)) {
×
192
            return [];
×
193
        }
194

195
        $config = include $configFile;
×
196

197
        $map = [];
×
198
        foreach ($config['publish'] ?? [] as $from => $to) {
×
199
            // Защита от выхода за пределы директорий
200
            if (str_contains($from, '..') || str_contains($to, '..')) {
×
201
                continue;
×
202
            }
203

204
            // Публикация в другой модуль только если он есть на диске
205
            // (напр. модуль-язык подмешивает перевод в modules/Forum/...)
NEW
206
            if (preg_match('#^modules/([^/]+)/#', $to, $match)
×
NEW
207
                && ! is_dir(base_path('modules/' . $match[1]))) {
×
NEW
208
                continue;
×
209
            }
210

UNCOV
211
            $map[base_path('modules/' . $this->name . '/' . $from)] = base_path($to);
×
212
        }
213

214
        return $map;
×
215
    }
216

217
    /**
218
     * Получает название директории для симлинка
219
     */
220
    public function getLinkName(): string
×
221
    {
222
        return self::ASSETS_PATH . Str::plural(strtolower($this->name));
×
223
    }
224

225
    /**
226
     * Получает название директории для симлинка из пути
227
     */
228
    public static function getLinkNameByPath(string $modulePath): string
×
229
    {
230
        return self::ASSETS_PATH . Str::plural(strtolower(basename($modulePath)));
×
231
    }
232

233
    /**
234
     * Количество установленных модулей с доступным обновлением
235
     */
236
    public static function updatesCount(): int
×
237
    {
238
        $installed = self::query()->pluck('version', 'name')->all();
×
239
        if (! $installed) {
×
240
            return 0;
×
241
        }
242

243
        $registry = ModuleRegistry::getAvailableModules();
×
244

245
        $count = 0;
×
246
        foreach ($installed as $name => $version) {
×
247
            $configFile = base_path('modules/' . $name . '/module.php');
×
248
            if (! file_exists($configFile)) {
×
249
                continue;
×
250
            }
251

252
            $config = include $configFile;
×
253
            $localVersion = $config['version'] ?? null;
×
254
            $registryVersion = $registry[$name]['version'] ?? null;
×
255

256
            $hasUpdate = ($localVersion && version_compare($localVersion, $version, '>'))
×
257
                || ($registryVersion && version_compare($registryVersion, $version, '>'));
×
258

259
            if ($hasUpdate) {
×
260
                $count++;
×
261
            }
262
        }
263

264
        return $count;
×
265
    }
266

267
    /**
268
     * Get enabled modules
269
     *
270
     * Кеш привязан к версии движка: после обновления ядра структура данных
271
     * могла измениться, поэтому устаревший кеш пересобирается
272
     */
273
    public static function getEnabledModules(): array
109✔
274
    {
275
        $cached = Cache::get('modules', []);
109✔
276

277
        if (($cached['version'] ?? null) === ROTOR_VERSION && ! empty($cached['modules'])) {
109✔
278
            return $cached['modules'];
×
279
        }
280

281
        $modules = self::loadEnabledModules();
109✔
282

283
        if ($modules !== []) {
109✔
284
            Cache::forever('modules', [
×
285
                'version' => ROTOR_VERSION,
×
286
                'modules' => $modules,
×
287
            ]);
×
288
        }
289

290
        return $modules;
109✔
291
    }
292

293
    /**
294
     * Загружает активные модули с их файловой структурой
295
     */
296
    private static function loadEnabledModules(): array
109✔
297
    {
298
        try {
299
            $names = self::query()
109✔
300
                ->where('active', true)
109✔
301
                ->pluck('name')
109✔
302
                ->all();
109✔
303
        } catch (Exception) {
×
304
            return [];
×
305
        }
306

307
        $result = [];
109✔
308
        foreach ($names as $name) {
109✔
309
            $result[$name] = [
×
310
                'files'  => self::scanModuleFiles($name),
×
311
                'config' => self::loadModuleConfig($name),
×
312
            ];
×
313
        }
314

315
        return $result;
109✔
316
    }
317

318
    /**
319
     * Загружает config модуля
320
     */
321
    private static function loadModuleConfig(string $name): ?array
×
322
    {
323
        $configFile = base_path('modules/' . $name . '/config.php');
×
324
        if (! file_exists($configFile)) {
×
325
            return null;
×
326
        }
327

328
        $config = include $configFile;
×
329

330
        return is_array($config) ? $config : null;
×
331
    }
332

333
    /**
334
     * Сканирует наличие файлов модуля
335
     */
336
    private static function scanModuleFiles(string $name): array
×
337
    {
338
        $base = base_path('modules/' . $name . '/');
×
339

340
        return [
×
341
            'views'      => is_dir($base . 'resources/views'),
×
342
            'lang'       => is_dir($base . 'resources/lang'),
×
343
            'helpers'    => file_exists($base . 'helpers.php'),
×
344
            'hooks'      => file_exists($base . 'hooks.php'),
×
345
            'routes'     => file_exists($base . 'routes.php'),
×
346
            'middleware' => file_exists($base . 'middleware.php'),
×
347
            'module'     => file_exists($base . 'module.php'),
×
348
            'commands'   => array_map(
×
349
                static fn ($file) => 'Modules\\' . $name . '\\Console\\' . basename($file, '.php'),
×
350
                glob($base . 'Console/*.php') ?: []
×
351
            ),
×
352
        ];
×
353
    }
354
}
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