Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.62% covered (warning)
70.62%
113 / 160
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExtensionService
70.62% covered (warning)
70.62%
113 / 160
36.36% covered (danger)
36.36%
4 / 11
86.71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExtensionFiles
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
4
 transformReleases
63.46% covered (warning)
63.46%
33 / 52
0.00% covered (danger)
0.00%
0 / 1
21.24
 transformArtifacts
61.22% covered (warning)
61.22%
30 / 49
0.00% covered (danger)
0.00%
0 / 1
22.85
 sortProduction
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 sortStaging
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getCacheKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 invalidateCache
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getCacheStats
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 cleanupExpiredArtifacts
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 invalidateCachedFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace App\Http\Services;
4
5use App\Http\Repositories\GitHubRepository;
6use Carbon\Carbon;
7use Exception;
8use Illuminate\Support\Facades\Cache;
9use Illuminate\Support\Facades\Log;
10
11/**
12 * Service for managing FlyMSG extension files
13 *
14 * Fetches, transforms, and caches extension files from GitHub releases and artifacts.
15 * Integrates with ExtensionCacheService to provide S3-cached download URLs.
16 */
17class ExtensionService
18{
19    public function __construct(
20        private GitHubRepository $githubRepository,
21        private ExtensionCacheService $extensionCacheService
22    ) {}
23
24    /**
25     * Get available extension files from GitHub
26     *
27     * Fetches production releases and staging artifacts, transforms them,
28     * applies filtering and pagination, and caches the results.
29     *
30     * @param  bool  $includeExpired  Whether to include expired artifacts
31     * @param  int  $page  Page number (1-indexed)
32     * @param  int  $perPage  Items per page
33     * @return array{production: array, staging: array, meta: array} Structured extension files data
34     *
35     * @throws Exception When GitHub API fails or configuration is invalid
36     */
37    public function getExtensionFiles(bool $includeExpired = false, int $page = 1, int $perPage = 30): array
38    {
39        $cacheKey = $this->getCacheKey($includeExpired, $page, $perPage);
40        $cacheTtl = config('github.cache_ttl', 300);
41
42        return Cache::remember($cacheKey, $cacheTtl, function () use ($includeExpired, $page, $perPage) {
43            try {
44                $owner = config('github.extension_repo.owner');
45                $repo = config('github.extension_repo.name');
46
47                if (empty($owner) || empty($repo)) {
48                    throw new Exception('GitHub repository configuration is missing. Please check your .env file.');
49                }
50
51                // Fetch all releases and artifacts (GitHub handles pagination internally)
52                $releases = $this->githubRepository->getRepositoryReleases($owner, $repo, 100);
53                $artifacts = $this->githubRepository->getRepositoryArtifacts($owner, $repo, 100);
54
55                // Transform releases into production files
56                $production = $this->transformReleases($releases);
57
58                // Transform artifacts into staging files
59                $staging = $this->transformArtifacts($artifacts, $includeExpired);
60
61                // Sort results
62                $production = $this->sortProduction($production);
63                $staging = $this->sortStaging($staging);
64
65                // Calculate totals before pagination
66                $totalProduction = count($production);
67                $totalStaging = count($staging);
68                $total = $totalProduction + $totalStaging;
69
70                // Apply pagination
71                $offset = ($page - 1) * $perPage;
72                $production = array_slice($production, 0, $perPage);
73                $staging = array_slice($staging, 0, $perPage);
74
75                return [
76                    'production' => array_values($production),
77                    'staging' => array_values($staging),
78                    'meta' => [
79                        'current_page' => $page,
80                        'per_page' => $perPage,
81                        'total' => $total,
82                        'total_production' => $totalProduction,
83                        'total_staging' => $totalStaging,
84                    ],
85                ];
86            } catch (Exception $e) {
87                Log::error('Failed to fetch extension files from GitHub', [
88                    'error' => $e->getMessage(),
89                    'trace' => $e->getTraceAsString(),
90                ]);
91
92                throw $e;
93            }
94        });
95    }
96
97    /**
98     * Transform GitHub releases into production file data
99     *
100     * Parses release tags matching pattern: v{X.X.X}-build.{N}
101     * Extracts both Chrome and Edge extension assets from each release.
102     * Optionally caches assets in S3 for direct download access.
103     *
104     * @param  array<int, array<string, mixed>>  $releases  GitHub release objects
105     * @param  bool  $enableCaching  Whether to cache assets in S3
106     * @return array<int, array<string, mixed>> Transformed production files
107     */
108    protected function transformReleases(array $releases, bool $enableCaching = true): array
109    {
110        $production = [];
111        $shouldCache = $enableCaching && config('github.extension_cache.auto_cache_releases', true);
112
113        foreach ($releases as $release) {
114            try {
115                $tagName = $release['tag_name'] ?? '';
116
117                // Parse tag: v{X.X.X}-build.{N}
118                if (! preg_match('/^v(\d+\.\d+\.\d+)-build\.(\d+)$/', $tagName, $matches)) {
119                    continue;
120                }
121
122                $version = $matches[1];
123                $build = (int) $matches[2];
124                $publishedAt = isset($release['published_at'])
125                    ? Carbon::parse($release['published_at'])->timestamp
126                    : time();
127
128                $assets = $release['assets'] ?? [];
129
130                foreach ($assets as $asset) {
131                    $fileName = $asset['name'] ?? '';
132
133                    // Detect browser from filename
134                    $browser = null;
135                    if (str_contains(strtolower($fileName), 'chrome')) {
136                        $browser = 'chrome';
137                    } elseif (str_contains(strtolower($fileName), 'edge')) {
138                        $browser = 'edge';
139                    }
140
141                    if (! $browser) {
142                        continue;
143                    }
144
145                    $downloadUrl = $asset['browser_download_url'] ?? '';
146                    $cached = false;
147
148                    // Try to cache the asset in S3
149                    if ($shouldCache && ! empty($downloadUrl)) {
150                        $cachedFile = $this->extensionCacheService->getOrCacheReleaseAsset(
151                            $asset,
152                            $version,
153                            $build,
154                            $browser,
155                            $publishedAt
156                        );
157
158                        if ($cachedFile) {
159                            $downloadUrl = $cachedFile->s3_url;
160                            $cached = true;
161                        }
162                    }
163
164                    $production[] = [
165                        'version' => $version,
166                        'build' => $build,
167                        'browser' => $browser,
168                        'file_name' => $fileName,
169                        'download_url' => $downloadUrl,
170                        'size_bytes' => $asset['size'] ?? 0,
171                        'published_at' => $publishedAt,
172                        'source' => 'release',
173                        'cached' => $cached,
174                    ];
175                }
176            } catch (Exception $e) {
177                Log::warning('Failed to parse release', [
178                    'release' => $release,
179                    'error' => $e->getMessage(),
180                ]);
181
182                continue;
183            }
184        }
185
186        return $production;
187    }
188
189    /**
190     * Transform GitHub artifacts into staging file data
191     *
192     * Parses artifact names matching pattern: {chrome|edge}-staging-{version}
193     * Filters out expired artifacts unless explicitly included.
194     * Optionally caches artifacts in S3 for direct download access.
195     *
196     * @param  array<int, array<string, mixed>>  $artifacts  GitHub artifact objects
197     * @param  bool  $includeExpired  Whether to include expired artifacts
198     * @param  bool  $enableCaching  Whether to cache artifacts in S3
199     * @return array<int, array<string, mixed>> Transformed staging files
200     */
201    protected function transformArtifacts(array $artifacts, bool $includeExpired, bool $enableCaching = true): array
202    {
203        $staging = [];
204        $shouldCache = $enableCaching && config('github.extension_cache.auto_cache_artifacts', true);
205
206        foreach ($artifacts as $artifact) {
207            try {
208                $artifactName = $artifact['name'] ?? '';
209
210                // Parse artifact name: {chrome|edge}-staging-{version}
211                if (! preg_match('/^(chrome|edge)-staging-(.+)$/', $artifactName, $matches)) {
212                    continue;
213                }
214
215                $browser = $matches[1];
216                $version = $matches[2].'-staging';
217
218                $expired = $artifact['expired'] ?? false;
219
220                // Filter expired artifacts unless explicitly included
221                if ($expired && ! $includeExpired) {
222                    continue;
223                }
224
225                $createdAt = isset($artifact['created_at'])
226                    ? Carbon::parse($artifact['created_at'])->timestamp
227                    : time();
228
229                $expiresAt = isset($artifact['expires_at'])
230                    ? Carbon::parse($artifact['expires_at'])->timestamp
231                    : null;
232
233                $downloadUrl = $artifact['archive_download_url'] ?? '';
234                $cached = false;
235
236                // Try to cache the artifact in S3 (only if not expired)
237                if ($shouldCache && ! $expired && ! empty($downloadUrl)) {
238                    $cachedFile = $this->extensionCacheService->getOrCacheArtifact(
239                        $artifact,
240                        $version,
241                        $browser,
242                        $createdAt,
243                        $expiresAt
244                    );
245
246                    if ($cachedFile) {
247                        $downloadUrl = $cachedFile->s3_url;
248                        $cached = true;
249                    }
250                }
251
252                $staging[] = [
253                    'version' => $version,
254                    'browser' => $browser,
255                    'artifact_name' => $artifactName,
256                    'download_url' => $downloadUrl,
257                    'size_bytes' => $artifact['size_in_bytes'] ?? 0,
258                    'created_at' => $createdAt,
259                    'expires_at' => $expiresAt,
260                    'expired' => $expired,
261                    'source' => 'artifact',
262                    'cached' => $cached,
263                ];
264            } catch (Exception $e) {
265                Log::warning('Failed to parse artifact', [
266                    'artifact' => $artifact,
267                    'error' => $e->getMessage(),
268                ]);
269
270                continue;
271            }
272        }
273
274        return $staging;
275    }
276
277    /**
278     * Sort production files by version (descending) and build (descending)
279     *
280     * @param  array<int, array<string, mixed>>  $production
281     * @return array<int, array<string, mixed>>
282     */
283    protected function sortProduction(array $production): array
284    {
285        usort($production, function ($a, $b) {
286            // Compare versions
287            $versionCompare = version_compare($b['version'], $a['version']);
288            if ($versionCompare !== 0) {
289                return $versionCompare;
290            }
291
292            // If versions are equal, compare builds
293            return $b['build'] <=> $a['build'];
294        });
295
296        return $production;
297    }
298
299    /**
300     * Sort staging files by created_at timestamp (descending)
301     *
302     * @param  array<int, array<string, mixed>>  $staging
303     * @return array<int, array<string, mixed>>
304     */
305    protected function sortStaging(array $staging): array
306    {
307        usort($staging, function ($a, $b) {
308            return $b['created_at'] <=> $a['created_at'];
309        });
310
311        return $staging;
312    }
313
314    /**
315     * Generate cache key for extension files
316     */
317    protected function getCacheKey(bool $includeExpired, int $page, int $perPage): string
318    {
319        $suffix = $includeExpired ? ':with_expired' : '';
320
321        return "github:extension_files:{$page}:{$perPage}{$suffix}";
322    }
323
324    /**
325     * Invalidate cached extension files
326     *
327     * Clears all cached variants (with and without expired artifacts).
328     */
329    public function invalidateCache(): void
330    {
331        // Clear common pagination combinations
332        for ($page = 1; $page <= 10; $page++) {
333            foreach ([30, 50, 100] as $perPage) {
334                Cache::forget($this->getCacheKey(false, $page, $perPage));
335                Cache::forget($this->getCacheKey(true, $page, $perPage));
336            }
337        }
338
339        Log::info('Extension files cache invalidated');
340    }
341
342    /**
343     * Get S3 cache statistics
344     *
345     * Returns statistics about cached extension files in S3.
346     *
347     * @return array{total: int, releases: int, artifacts: int, expired: int, total_size_bytes: int}
348     */
349    public function getCacheStats(): array
350    {
351        return $this->extensionCacheService->getCacheStats();
352    }
353
354    /**
355     * Cleanup expired artifacts from S3 cache
356     *
357     * Removes expired artifact files from S3 and database.
358     *
359     * @return int Number of artifacts cleaned up
360     */
361    public function cleanupExpiredArtifacts(): int
362    {
363        return $this->extensionCacheService->cleanupExpiredArtifacts();
364    }
365
366    /**
367     * Invalidate a specific cached file
368     *
369     * Removes a specific file from S3 cache and database.
370     *
371     * @param  string  $githubId  The GitHub asset/artifact ID
372     * @param  string  $source  'release' or 'artifact'
373     * @return bool True if successfully invalidated
374     */
375    public function invalidateCachedFile(string $githubId, string $source): bool
376    {
377        return $this->extensionCacheService->invalidateCache($githubId, $source);
378    }
379}