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

visavi / rotor / 27909067548

21 Jun 2026 03:33PM UTC coverage: 16.253% (-0.09%) from 16.344%
27909067548

push

github

visavi
Добавлено более понятное обновления модулей, кнопки заменены на скачать обновление и применить обновление, на странице обновления в админке есть возможность выбрать апгрейд или скачать полную версиию, добавлена кнопка переустановки ядра, добавлен сброс opcache чтобы кнопка обновления появлялась сразу после скачивания, испавлено экранирование в md

0 of 40 new or added lines in 4 files covered. (0.0%)

3 existing lines in 3 files now uncovered.

970 of 5968 relevant lines covered (16.25%)

2.37 hits per line

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

0.0
/app/Services/UpgradeService.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace App\Services;
6

7
use FilesystemIterator;
8
use Illuminate\Support\Facades\Http;
9
use RecursiveDirectoryIterator;
10
use RecursiveIteratorIterator;
11
use RuntimeException;
12
use ZipArchive;
13

14
class UpgradeService
15
{
16
    private array $excluded = [
17
        '.env',
18
        'storage',
19
        'public/uploads',
20
        'modules',
21
        'app/hooks.php',
22
    ];
23

24
    private array $writableDirs = [
25
        'app',
26
        'bootstrap',
27
        'database',
28
        'public/assets',
29
        'resources',
30
        'routes',
31
        'vendor',
32
    ];
33

34
    public function getNewReleases(GithubService $github): array
×
35
    {
36
        return array_values(array_filter($github->getLatestReleases(), function (array $release) {
×
37
            $version = ltrim($release['tag_name'] ?? '', 'v');
×
38
            $version = str_replace(['-alpha', '-beta', '-rc'], '', $version);
×
39

40
            return version_compare(ROTOR_VERSION, $version, '<') && ! empty($release['assets']);
×
41
        }));
×
42
    }
43

44
    /**
45
     * Находит asset релиза по тегу (источник — кешированный список GitHub)
46
     *
47
     * Upgrade-архив (без vendor) берём только для патч-релиза — когда мажор и минор
48
     * совпадают (14.0.0 → 14.0.1), и только если он реально приложен к релизу.
49
     * Смена минора/мажора или отсутствие upgrade — полный архив с vendor: минор и
50
     * мажор могут тянуть новые зависимости, vendor несовместим.
51
     */
NEW
52
    public function findAsset(GithubService $github, string $tag, bool $forceFull = false): ?array
×
53
    {
54
        foreach ($github->getLatestReleases() as $release) {
×
55
            if (($release['tag_name'] ?? null) === $tag) {
×
NEW
56
                return $this->selectAsset($release['assets'] ?? [], $tag, $forceFull);
×
57
            }
58
        }
59

60
        return null;
×
61
    }
62

63
    /**
64
     * Делит assets релиза на полный (с vendor) и upgrade-архив (без vendor)
65
     */
NEW
66
    public function splitAssets(array $assets): array
×
67
    {
68
        $full = null;
×
69
        $upgrade = null;
×
70

71
        foreach ($assets as $asset) {
×
72
            $name = $asset['name'] ?? '';
×
73

74
            if (! str_ends_with($name, '.zip')) {
×
75
                continue;
×
76
            }
77

78
            if (str_ends_with($name, '_upgrade.zip')) {
×
79
                $upgrade = $asset;
×
80
            } else {
81
                $full = $asset;
×
82
            }
83
        }
84

NEW
85
        return ['full' => $full, 'upgrade' => $upgrade];
×
86
    }
87

88
    /**
89
     * Выбирает архив релиза: upgrade (без vendor) для патча в той же линии
90
     * мажор.минор, иначе полный. $forceFull принудительно берёт полный архив
91
     * (запасной вариант, когда патч сменил composer-зависимости).
92
     */
NEW
93
    public function selectAsset(array $assets, string $tag, bool $forceFull = false): ?array
×
94
    {
NEW
95
        ['full' => $full, 'upgrade' => $upgrade] = $this->splitAssets($assets);
×
96

NEW
97
        if ($forceFull) {
×
NEW
98
            return $full ?? $upgrade;
×
99
        }
100

UNCOV
101
        $samePatchLine = $this->branch(ROTOR_VERSION) === $this->branch(ltrim($tag, 'v'));
×
102

103
        return ($samePatchLine && $upgrade) ? $upgrade : $full;
×
104
    }
105

106
    /**
107
     * Линия патчей версии — мажор.минор (14.0.3 → "14.0")
108
     */
109
    private function branch(string $version): string
×
110
    {
111
        $parts = explode('.', $version);
×
112

113
        return $parts[0] . '.' . ($parts[1] ?? '0');
×
114
    }
115

116
    public function checkPermissions(): array
×
117
    {
118
        $failed = [];
×
119

120
        foreach ($this->writableDirs as $dir) {
×
121
            $path = base_path($dir);
×
122

123
            if (! file_exists($path)) {
×
124
                continue;
×
125
            }
126

127
            $iterator = new RecursiveIteratorIterator(
×
128
                new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
×
129
                RecursiveIteratorIterator::SELF_FIRST
×
130
            );
×
131

132
            foreach ($iterator as $item) {
×
133
                if ($item->isDir() && ! $item->isWritable()) {
×
134
                    $relative = str_replace('\\', '/', substr($item->getPathname(), strlen(base_path()) + 1));
×
135
                    $failed[] = $relative;
×
136

137
                    if (count($failed) >= 10) {
×
138
                        return $failed;
×
139
                    }
140
                }
141
            }
142
        }
143

144
        return $failed;
×
145
    }
146

147
    public function downloadRelease(string $tag, string $url): void
×
148
    {
149
        $tempDir = storage_path('app/temp');
×
150

151
        if (! is_dir($tempDir)) {
×
152
            mkdir($tempDir, 0755, true);
×
153
        }
154

155
        $zipPath = $this->zipPath($tag);
×
156

157
        $response = Http::withOptions(['sink' => $zipPath])
×
158
            ->timeout(300)
×
159
            ->get($url);
×
160

161
        if ($response->failed()) {
×
162
            @unlink($zipPath);
×
163
            throw new RuntimeException('Download failed: HTTP ' . $response->status());
×
164
        }
165

166
        $this->extractRelease($zipPath, $tag);
×
167

168
        @unlink($zipPath);
×
169
    }
170

171
    public function applyUpdate(string $tag): array
×
172
    {
173
        $sourcePath = $this->sourcePath($tag);
×
174

175
        if (! is_dir($sourcePath)) {
×
176
            throw new RuntimeException('Update not downloaded');
×
177
        }
178

179
        $errors = [];
×
180
        $this->copyDirectory($sourcePath, base_path(), $errors);
×
181

182
        // Сироты чистим только в vendor: пользовательский код там не живёт,
183
        // а старые файлы пакетов остаются досягаемыми для автозагрузчика.
184
        // Если в архиве vendor нет (кривой релиз) — не трогаем.
185
        if (is_dir($sourcePath . '/vendor')) {
×
186
            $this->deleteVendorOrphans($sourcePath . '/vendor');
×
187
        }
188

189
        return $errors;
×
190
    }
191

192
    public function cleanup(string $tag): void
×
193
    {
194
        $zip = $this->zipPath($tag);
×
195
        $dir = storage_path('app/temp/update-' . $tag);
×
196

197
        if (file_exists($zip)) {
×
198
            unlink($zip);
×
199
        }
200

201
        if (is_dir($dir)) {
×
202
            $this->deleteDirectory($dir);
×
203
        }
204
    }
205

206
    public function sourcePath(string $tag): string
×
207
    {
208
        $base = storage_path('app/temp/update-' . $tag);
×
209

210
        if (! is_dir($base)) {
×
211
            return $base;
×
212
        }
213

214
        // If ZIP had single root dir (e.g. rotor-14.0.0/), use that
215
        $dirs = array_filter((array) glob($base . '/*'), 'is_dir');
×
216
        $files = array_filter((array) glob($base . '/*'), 'is_file');
×
217

218
        if (count($dirs) === 1 && empty($files)) {
×
219
            return array_values($dirs)[0];
×
220
        }
221

222
        return $base;
×
223
    }
224

225
    private function zipPath(string $tag): string
×
226
    {
227
        return storage_path('app/temp/update-' . $tag . '.zip');
×
228
    }
229

230
    private function extractRelease(string $zipPath, string $tag): void
×
231
    {
232
        $extractPath = storage_path('app/temp/update-' . $tag);
×
233

234
        if (is_dir($extractPath)) {
×
235
            $this->deleteDirectory($extractPath);
×
236
        }
237

238
        mkdir($extractPath, 0755, true);
×
239

240
        $zip = new ZipArchive();
×
241

242
        if ($zip->open($zipPath) !== true) {
×
243
            throw new RuntimeException('Failed to open ZIP archive');
×
244
        }
245

246
        $zip->extractTo($extractPath);
×
247
        $zip->close();
×
248
    }
249

250
    private function copyDirectory(string $src, string $dst, array &$errors): void
×
251
    {
252
        $iterator = new RecursiveIteratorIterator(
×
253
            new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
×
254
            RecursiveIteratorIterator::SELF_FIRST
×
255
        );
×
256

257
        foreach ($iterator as $item) {
×
258
            $relative = substr($item->getPathname(), strlen($src) + 1);
×
259
            $relative = str_replace('\\', '/', $relative);
×
260

261
            if ($this->isExcluded($relative)) {
×
262
                continue;
×
263
            }
264

265
            $dest = $dst . DIRECTORY_SEPARATOR . $relative;
×
266

267
            if ($item->isDir()) {
×
268
                if (! is_dir($dest)) {
×
269
                    mkdir($dest, 0755, true);
×
270
                }
271
            } elseif (! $this->replaceFile($item->getPathname(), $dest)) {
×
272
                $errors[] = $relative;
×
273
            }
274
        }
275
    }
276

277
    /**
278
     * Атомарно заменяет файл: копия рядом + rename.
279
     * Процессы, читающие старый файл (включая текущий запрос,
280
     * лениво подгружающий классы из vendor), сохраняют свой inode.
281
     */
282
    private function replaceFile(string $src, string $dest): bool
×
283
    {
284
        $tmp = $dest . '.tmp' . getmypid();
×
285

286
        if (! @copy($src, $tmp)) {
×
287
            @unlink($tmp);
×
288

289
            return false;
×
290
        }
291

292
        if (! @rename($tmp, $dest)) {
×
293
            @unlink($tmp);
×
294

295
            return false;
×
296
        }
297

298
        return true;
×
299
    }
300

301
    /**
302
     * Удаляет из локального vendor файлы, которых нет в vendor архива
303
     */
304
    private function deleteVendorOrphans(string $archiveVendor): void
×
305
    {
306
        $archiveSet = [];
×
307
        $iterator = new RecursiveIteratorIterator(
×
308
            new RecursiveDirectoryIterator($archiveVendor, FilesystemIterator::SKIP_DOTS)
×
309
        );
×
310

311
        foreach ($iterator as $item) {
×
312
            if ($item->isFile()) {
×
313
                $relative = str_replace('\\', '/', substr($item->getPathname(), strlen($archiveVendor) + 1));
×
314
                $archiveSet[$relative] = true;
×
315
            }
316
        }
317

318
        $vendorPath = base_path('vendor');
×
319
        $iterator = new RecursiveIteratorIterator(
×
320
            new RecursiveDirectoryIterator($vendorPath, FilesystemIterator::SKIP_DOTS)
×
321
        );
×
322

323
        foreach ($iterator as $item) {
×
324
            if ($item->isFile()) {
×
325
                $relative = str_replace('\\', '/', substr($item->getPathname(), strlen($vendorPath) + 1));
×
326

327
                if (! isset($archiveSet[$relative])) {
×
328
                    @unlink($item->getPathname());
×
329
                }
330
            }
331
        }
332
    }
333

334
    private function isExcluded(string $relative): bool
×
335
    {
336
        foreach ($this->excluded as $exclude) {
×
337
            if ($relative === $exclude || str_starts_with($relative, $exclude . '/')) {
×
338
                return true;
×
339
            }
340
        }
341

342
        return false;
×
343
    }
344

345
    private function deleteDirectory(string $path): void
×
346
    {
347
        $items = new RecursiveIteratorIterator(
×
348
            new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
×
349
            RecursiveIteratorIterator::CHILD_FIRST
×
350
        );
×
351

352
        foreach ($items as $item) {
×
353
            $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
×
354
        }
355

356
        rmdir($path);
×
357
    }
358
}
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