Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.62% covered (warning)
79.62%
125 / 157
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExtensionCacheService
79.62% covered (warning)
79.62%
125 / 157
37.50% covered (danger)
37.50%
3 / 8
39.14
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
 getOrCacheReleaseAsset
77.78% covered (warning)
77.78%
28 / 36
0.00% covered (danger)
0.00%
0 / 1
6.40
 getOrCacheArtifact
74.42% covered (warning)
74.42%
32 / 43
0.00% covered (danger)
0.00%
0 / 1
13.03
 downloadAndCache
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
3
 generateS3Path
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 cleanupExpiredArtifacts
77.27% covered (warning)
77.27%
17 / 22
0.00% covered (danger)
0.00%
0 / 1
4.19
 getCacheStats
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 invalidateCache
61.11% covered (warning)
61.11%
11 / 18
0.00% covered (danger)
0.00%
0 / 1
4.94
1<?php
2
3namespace App\Http\Services;
4
5use App\Http\Models\CachedExtensionFile;
6use App\Http\Repositories\GitHubRepository;
7use Carbon\Carbon;
8use Exception;
9use Illuminate\Support\Facades\Log;
10use Illuminate\Support\Facades\Storage;
11
12/**
13 * Service for caching GitHub extension files in S3
14 *
15 * Handles downloading extension files from GitHub and uploading them to S3,
16 * providing public URLs that don't require GitHub authentication.
17 */
18class ExtensionCacheService
19{
20    public function __construct(
21        private GitHubRepository $githubRepository
22    ) {}
23
24    /**
25     * Get or cache a release asset
26     *
27     * Checks if the asset is already cached, downloads and caches if not.
28     *
29     * @param  array<string, mixed>  $assetData  Asset data from GitHub API
30     * @param  string  $version  Version string (e.g., '1.0.0')
31     * @param  int  $build  Build number
32     * @param  string  $browser  Browser type ('chrome' or 'edge')
33     * @param  int  $publishedAt  Unix timestamp of publication
34     * @return CachedExtensionFile|null The cached file record, or null if caching failed
35     */
36    public function getOrCacheReleaseAsset(
37        array $assetData,
38        string $version,
39        int $build,
40        string $browser,
41        int $publishedAt
42    ): ?CachedExtensionFile {
43        $githubId = (string) ($assetData['id'] ?? '');
44        $fileName = $assetData['name'] ?? '';
45        $downloadUrl = $assetData['browser_download_url'] ?? '';
46        $sizeBytes = $assetData['size'] ?? 0;
47
48        if (empty($githubId) || empty($downloadUrl)) {
49            Log::warning('Invalid release asset data', ['asset' => $assetData]);
50
51            return null;
52        }
53
54        // Check if already cached
55        $cached = CachedExtensionFile::findByGithubId($githubId, CachedExtensionFile::SOURCE_RELEASE);
56        if ($cached && ! $cached->is_expired) {
57            return $cached;
58        }
59
60        // Download and cache
61        try {
62            $s3Path = $this->generateS3Path($browser, CachedExtensionFile::SOURCE_RELEASE, $version, $fileName);
63
64            return $this->downloadAndCache(
65                downloadUrl: $downloadUrl,
66                s3Path: $s3Path,
67                metadata: [
68                    'github_id' => $githubId,
69                    'source' => CachedExtensionFile::SOURCE_RELEASE,
70                    'browser' => $browser,
71                    'version' => $version,
72                    'build' => $build,
73                    'file_name' => $fileName,
74                    'github_url' => $downloadUrl,
75                    'size_bytes' => $sizeBytes,
76                    'published_at' => Carbon::createFromTimestamp($publishedAt),
77                    'expires_at' => null, // Releases don't expire
78                ],
79                requiresAuth: false // Release assets have public URLs
80            );
81        } catch (Exception $e) {
82            Log::error('Failed to cache release asset', [
83                'github_id' => $githubId,
84                'version' => $version,
85                'browser' => $browser,
86                'error' => $e->getMessage(),
87            ]);
88
89            return null;
90        }
91    }
92
93    /**
94     * Get or cache an artifact
95     *
96     * Checks if the artifact is already cached, downloads and caches if not.
97     * Artifacts require authentication to download.
98     *
99     * @param  array<string, mixed>  $artifactData  Artifact data from GitHub API
100     * @param  string  $version  Version string (e.g., '1.0.0-staging')
101     * @param  string  $browser  Browser type ('chrome' or 'edge')
102     * @param  int  $createdAt  Unix timestamp of creation
103     * @param  int|null  $expiresAt  Unix timestamp of expiration
104     * @return CachedExtensionFile|null The cached file record, or null if caching failed
105     */
106    public function getOrCacheArtifact(
107        array $artifactData,
108        string $version,
109        string $browser,
110        int $createdAt,
111        ?int $expiresAt
112    ): ?CachedExtensionFile {
113        $githubId = (string) ($artifactData['id'] ?? '');
114        $artifactName = $artifactData['name'] ?? '';
115        $downloadUrl = $artifactData['archive_download_url'] ?? '';
116        $sizeBytes = $artifactData['size_in_bytes'] ?? 0;
117        $expired = $artifactData['expired'] ?? false;
118
119        if (empty($githubId) || empty($downloadUrl)) {
120            Log::warning('Invalid artifact data', ['artifact' => $artifactData]);
121
122            return null;
123        }
124
125        // Don't cache expired artifacts
126        if ($expired) {
127            Log::info('Skipping expired artifact', ['github_id' => $githubId, 'name' => $artifactName]);
128
129            return null;
130        }
131
132        // Check if already cached and not expired
133        $cached = CachedExtensionFile::findByGithubId($githubId, CachedExtensionFile::SOURCE_ARTIFACT);
134        if ($cached && ! $cached->is_expired && ! $cached->hasExpired()) {
135            return $cached;
136        }
137
138        // If cached but expired, mark it and re-cache
139        if ($cached && $cached->hasExpired()) {
140            $cached->markAsExpired();
141        }
142
143        // Download and cache
144        try {
145            $fileName = $artifactName.'.zip';
146            $s3Path = $this->generateS3Path($browser, CachedExtensionFile::SOURCE_ARTIFACT, $version, $fileName);
147
148            return $this->downloadAndCache(
149                downloadUrl: $downloadUrl,
150                s3Path: $s3Path,
151                metadata: [
152                    'github_id' => $githubId,
153                    'source' => CachedExtensionFile::SOURCE_ARTIFACT,
154                    'browser' => $browser,
155                    'version' => $version,
156                    'build' => null,
157                    'file_name' => $fileName,
158                    'github_url' => $downloadUrl,
159                    'size_bytes' => $sizeBytes,
160                    'published_at' => Carbon::createFromTimestamp($createdAt),
161                    'expires_at' => $expiresAt ? Carbon::createFromTimestamp($expiresAt) : null,
162                ],
163                requiresAuth: true // Artifacts require authentication
164            );
165        } catch (Exception $e) {
166            Log::error('Failed to cache artifact', [
167                'github_id' => $githubId,
168                'version' => $version,
169                'browser' => $browser,
170                'error' => $e->getMessage(),
171            ]);
172
173            return null;
174        }
175    }
176
177    /**
178     * Download file from GitHub and upload to S3
179     *
180     * @param  string  $downloadUrl  The URL to download from
181     * @param  string  $s3Path  The path in S3 to store the file
182     * @param  array<string, mixed>  $metadata  Metadata to store in the database
183     * @param  bool  $requiresAuth  Whether the download requires GitHub authentication
184     * @return CachedExtensionFile The created cache record
185     *
186     * @throws Exception When download or upload fails
187     */
188    protected function downloadAndCache(
189        string $downloadUrl,
190        string $s3Path,
191        array $metadata,
192        bool $requiresAuth
193    ): CachedExtensionFile {
194        // Download file from GitHub
195        $content = $requiresAuth
196            ? $this->githubRepository->downloadFile($downloadUrl)
197            : $this->githubRepository->downloadPublicFile($downloadUrl);
198
199        // Calculate content hash
200        $contentHash = hash('sha256', $content);
201
202        // Upload to S3
203        $uploaded = Storage::disk('s3')->put($s3Path, $content, 'public');
204
205        if (! $uploaded) {
206            throw new Exception("Failed to upload file to S3: {$s3Path}");
207        }
208
209        // Get S3 URL
210        $s3Url = Storage::disk('s3')->url($s3Path);
211
212        // Create or update cache record
213        $cached = CachedExtensionFile::updateOrCreate(
214            [
215                'github_id' => $metadata['github_id'],
216                'source' => $metadata['source'],
217            ],
218            array_merge($metadata, [
219                's3_path' => $s3Path,
220                's3_url' => $s3Url,
221                'content_hash' => $contentHash,
222                'is_expired' => false,
223                'cached_at' => now(),
224            ])
225        );
226
227        Log::info('Cached extension file', [
228            'github_id' => $metadata['github_id'],
229            'source' => $metadata['source'],
230            's3_path' => $s3Path,
231        ]);
232
233        return $cached;
234    }
235
236    /**
237     * Generate S3 path for an extension file
238     *
239     * Path structure: {prefix}/{browser}/{source}/{version}/{filename}
240     *
241     * @param  string  $browser  Browser type ('chrome' or 'edge')
242     * @param  string  $source  Source type ('release' or 'artifact')
243     * @param  string  $version  Version string
244     * @param  string  $fileName  Original file name
245     * @return string The S3 path
246     */
247    protected function generateS3Path(string $browser, string $source, string $version, string $fileName): string
248    {
249        $prefix = config('github.extension_cache.s3_path_prefix', 'romeo/extensions');
250
251        // Sanitize version for path safety
252        $safeVersion = preg_replace('/[^a-zA-Z0-9._-]/', '_', $version);
253
254        return "{$prefix}/{$browser}/{$source}/{$safeVersion}/{$fileName}";
255    }
256
257    /**
258     * Clean up expired artifacts from S3
259     *
260     * Finds all expired cached artifacts, deletes them from S3,
261     * and removes the database records.
262     *
263     * @return int Number of artifacts cleaned up
264     */
265    public function cleanupExpiredArtifacts(): int
266    {
267        $count = 0;
268
269        // Find expired artifacts
270        $expiredArtifacts = CachedExtensionFile::artifacts()
271            ->where(function ($query) {
272                $query->where('is_expired', true)
273                    ->orWhere('expires_at', '<', now());
274            })
275            ->get();
276
277        foreach ($expiredArtifacts as $artifact) {
278            try {
279                // Delete from S3
280                if (Storage::disk('s3')->exists($artifact->s3_path)) {
281                    Storage::disk('s3')->delete($artifact->s3_path);
282                }
283
284                // Delete database record
285                $artifact->delete();
286
287                $count++;
288
289                Log::info('Cleaned up expired artifact', [
290                    'github_id' => $artifact->github_id,
291                    's3_path' => $artifact->s3_path,
292                ]);
293            } catch (Exception $e) {
294                Log::error('Failed to cleanup expired artifact', [
295                    'github_id' => $artifact->github_id,
296                    'error' => $e->getMessage(),
297                ]);
298            }
299        }
300
301        return $count;
302    }
303
304    /**
305     * Get cache statistics
306     *
307     * @return array{total: int, releases: int, artifacts: int, expired: int, total_size_bytes: int}
308     */
309    public function getCacheStats(): array
310    {
311        return [
312            'total' => CachedExtensionFile::count(),
313            'releases' => CachedExtensionFile::releases()->count(),
314            'artifacts' => CachedExtensionFile::artifacts()->count(),
315            'expired' => CachedExtensionFile::expired()->count(),
316            'total_size_bytes' => (int) CachedExtensionFile::sum('size_bytes'),
317        ];
318    }
319
320    /**
321     * Invalidate cache for a specific file
322     *
323     * @param  string  $githubId  The GitHub asset/artifact ID
324     * @param  string  $source  'release' or 'artifact'
325     * @return bool True if successfully invalidated
326     */
327    public function invalidateCache(string $githubId, string $source): bool
328    {
329        $cached = CachedExtensionFile::findByGithubId($githubId, $source);
330
331        if (! $cached) {
332            return false;
333        }
334
335        try {
336            // Delete from S3
337            if (Storage::disk('s3')->exists($cached->s3_path)) {
338                Storage::disk('s3')->delete($cached->s3_path);
339            }
340
341            // Delete database record
342            $cached->delete();
343
344            Log::info('Invalidated cached extension file', [
345                'github_id' => $githubId,
346                'source' => $source,
347            ]);
348
349            return true;
350        } catch (Exception $e) {
351            Log::error('Failed to invalidate cache', [
352                'github_id' => $githubId,
353                'source' => $source,
354                'error' => $e->getMessage(),
355            ]);
356
357            return false;
358        }
359    }
360}