@fleetbase/registry-bridge-engine 0.0.16 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,16 @@
16
16
  </div>
17
17
  </button>
18
18
  <div class="space-y-2 mt-3">
19
+ {{#unless (eq extension.status "published")}}
20
+ <Button
21
+ @type="magic"
22
+ @size="sm"
23
+ @icon="rocket"
24
+ @text={{t "registry-bridge.component.extension-pending-publish-viewer.publish"}}
25
+ @onClick={{perform this.publishExtension extension}}
26
+ class="w-full"
27
+ />
28
+ {{/unless}}
19
29
  <Button
20
30
  @size="sm"
21
31
  @icon="clipboard-list"
@@ -16,7 +16,7 @@ export default class ExtensionPendingPublishViewerComponent extends Component {
16
16
  }
17
17
 
18
18
  @task *getExtensionsPendingPublish() {
19
- this.extensions = yield this.store.query('registry-extension', { status: 'approved' });
19
+ this.extensions = yield this.store.query('registry-extension', { status: 'approved', admin: 1 });
20
20
  }
21
21
 
22
22
  @task *downloadBundle(extension) {
@@ -27,6 +27,14 @@ export default class ExtensionPendingPublishViewerComponent extends Component {
27
27
  }
28
28
  }
29
29
 
30
+ @task *publishExtension(extension) {
31
+ try {
32
+ yield extension.publish();
33
+ } catch (error) {
34
+ this.notifications.error(error.message);
35
+ }
36
+ }
37
+
30
38
  @action focusExtension(extension) {
31
39
  this.focusedExtension = extension;
32
40
  }
@@ -18,7 +18,7 @@ export default class ExtensionReviewerControlComponent extends Component {
18
18
  }
19
19
 
20
20
  @task *getExtensionsPendingReview() {
21
- this.extensions = yield this.store.query('registry-extension', { status: 'awaiting_review' });
21
+ this.extensions = yield this.store.query('registry-extension', { status: 'awaiting_review', admin: 1 });
22
22
  }
23
23
 
24
24
  @task *downloadBundle(extension) {
@@ -134,6 +134,19 @@ export default class RegistryExtensionModel extends Model {
134
134
  return fetch.post('registry-extensions/approve', { id: this.id, ...params }, { namespace: '~registry/v1', normalizeToEmberData: true, modelType: 'registry-extension' });
135
135
  }
136
136
 
137
+ /**
138
+ * Submits the registry extension to be manually published.
139
+ *
140
+ * @return {Promise<RegistryExtensionModel>}
141
+ * @memberof RegistryExtensionModel
142
+ */
143
+ @action publish(params = {}) {
144
+ const owner = getOwner(this);
145
+ const fetch = owner.lookup('service:fetch');
146
+
147
+ return fetch.post('registry-extensions/publish', { id: this.id, ...params }, { namespace: '~registry/v1', normalizeToEmberData: true, modelType: 'registry-extension' });
148
+ }
149
+
137
150
  /**
138
151
  * Submits the registry extension for rejection.
139
152
  *
package/composer.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fleetbase/registry-bridge",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "Internal Bridge between Fleetbase API and Extensions Registry",
5
5
  "keywords": [
6
6
  "fleetbase-extension",
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "require": {
22
22
  "php": "^8.0",
23
- "fleetbase/core-api": "^1.5.10",
23
+ "fleetbase/core-api": "*",
24
24
  "laravel/cashier": "^15.2.1",
25
25
  "php-http/guzzle7-adapter": "^1.0",
26
26
  "psr/http-factory-implementation": "*",
package/extension.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "Registry Bridge",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "Internal Bridge between Fleetbase API and Extensions Registry",
5
5
  "repository": "https://github.com/fleetbase/registry-bridge",
6
6
  "license": "AGPL-3.0-or-later",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetbase/registry-bridge-engine",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "Internal Bridge between Fleetbase API and Extensions Registry",
5
5
  "fleetbase": {
6
6
  "route": "extensions"
@@ -5,7 +5,11 @@ namespace Fleetbase\RegistryBridge\Http\Controllers\Internal\v1;
5
5
  use Fleetbase\Http\Controllers\Controller;
6
6
  use Fleetbase\Http\Resources\Category as CategoryResource;
7
7
  use Fleetbase\Models\Category;
8
+ use Fleetbase\Models\File;
8
9
  use Fleetbase\RegistryBridge\Models\RegistryExtension;
10
+ use Fleetbase\RegistryBridge\Models\RegistryExtensionBundle;
11
+ use Fleetbase\RegistryBridge\Models\RegistryUser;
12
+ use Fleetbase\RegistryBridge\Support\Utils;
9
13
  use Illuminate\Http\Request;
10
14
 
11
15
  class RegistryController extends Controller
@@ -137,4 +141,154 @@ class RegistryController extends Controller
137
141
  'composer' => $composerJsonName,
138
142
  ]);
139
143
  }
144
+
145
+ /**
146
+ * Handles the upload of an extension bundle to the registry.
147
+ *
148
+ * This method performs the following operations:
149
+ * - Authenticates the user using a Bearer token from the Authorization header.
150
+ * - Validates the uploaded bundle file (ensuring it's a valid tar.gz file).
151
+ * - Extracts necessary files (`extension.json`, `package.json`, `composer.json`) from the bundle.
152
+ * - Associates the bundle with the correct extension based on package information.
153
+ * - Checks if the user is authorized to upload bundles for the extension.
154
+ * - Uploads the bundle to the storage system.
155
+ * - Creates a file record in the database.
156
+ * - Updates metadata and versioning information.
157
+ * - Creates a new extension bundle record.
158
+ *
159
+ * @param Request $request the HTTP request containing the bundle file and authentication token
160
+ *
161
+ * @return \Illuminate\Http\JsonResponse a JSON response indicating the success or failure of the upload process
162
+ */
163
+ public function bundleUpload(Request $request)
164
+ {
165
+ // Check for Authorization header
166
+ $authHeader = $request->header('Authorization');
167
+ if (!$authHeader) {
168
+ return response()->json(['error' => 'Unauthorized.'], 401);
169
+ }
170
+
171
+ // Extract the token from the 'Bearer ' prefix
172
+ $token = null;
173
+ if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
174
+ $token = $matches[1];
175
+ }
176
+
177
+ // Validate the token (implement your own token validation logic)
178
+ $registryUser = RegistryUser::findFromToken($token);
179
+ if (!$registryUser) {
180
+ return response()->json(['error' => 'Unauthorized.', 'token' => $token], 401);
181
+ }
182
+
183
+ // Check if file was uploaded
184
+ if (!$request->hasFile('bundle')) {
185
+ return response()->json(['error' => 'No bundle uploaded.'], 400);
186
+ }
187
+
188
+ $bundle = $request->file('bundle');
189
+
190
+ // Validate the file
191
+ if (!$bundle->isValid()) {
192
+ return response()->json(['error' => 'Invalid bundle file uploaded.'], 400);
193
+ }
194
+
195
+ // Ensure the file is a tar.gz
196
+ $mimeType = $bundle->getMimeType();
197
+ if ($mimeType !== 'application/gzip' && $mimeType !== 'application/x-gzip') {
198
+ return response()->json(['error' => 'Invalid bundle file type.'], 400);
199
+ }
200
+
201
+ // Get the extension assosciated to bundle by extension name
202
+ try {
203
+ $bundleData = RegistryExtensionBundle::extractUploadedBundleFile($bundle, ['extension.json', 'package.json', 'composer.json']);
204
+ } catch (\Throwable $e) {
205
+ return response()->json(['error' => $e->getMessage()], 400);
206
+ }
207
+
208
+ $bundlePackageData = Utils::getObjectKeyValue($bundleData, 'package.json') ?? Utils::getObjectKeyValue($bundleData, 'composer.json');
209
+ if ($bundlePackageData && data_get($bundlePackageData, 'name')) {
210
+ $extension = RegistryExtension::findByPackageName(data_get($bundlePackageData, 'name'));
211
+ if (!$extension) {
212
+ return response()->json(['error' => 'Unable to find extension for the uploaded bundle.'], 400);
213
+ }
214
+
215
+ if ($extension->company_uuid !== $registryUser->company_uuid) {
216
+ return response()->json(['error' => 'User is not authorized to upload bundles for this extension.'], 401);
217
+ }
218
+ } else {
219
+ return response()->json(['error' => 'Unable to parse uploaded bundle.'], 400);
220
+ }
221
+
222
+ // Prepare to upload the bundle
223
+ $size = $bundle->getSize();
224
+ $fileName = File::randomFileNameFromRequest($request, 'bundle');
225
+ $disk = config('filesystems.default');
226
+ $bucket = config('filesystems.disks.' . $disk . '.bucket', config('filesystems.disks.s3.bucket'));
227
+ $path = 'uploads/extensions/' . $extension->uuid . '/bundles';
228
+ $type = 'extension_bundle';
229
+
230
+ // Upload the bundle
231
+ try {
232
+ $path = $bundle->storeAs($path, $fileName, ['disk' => $disk]);
233
+ } catch (\Throwable $e) {
234
+ return response()->error($e->getMessage());
235
+ }
236
+
237
+ // If file upload failed
238
+ if ($path === false) {
239
+ return response()->error('File upload failed.');
240
+ }
241
+
242
+ // Create a file record
243
+ try {
244
+ $file = File::createFromUpload($request->file('bundle'), $path, $type, $size, $disk, $bucket);
245
+ } catch (\Throwable $e) {
246
+ return response()->error($e->getMessage());
247
+ }
248
+
249
+ // Set company and uploader
250
+ $file->update([
251
+ 'company_uuid' => $registryUser->company_uuid,
252
+ 'uploader_uuid' => $registryUser->user_uuid,
253
+ ]);
254
+
255
+ // Set file subject to extension
256
+ $file = $file->setSubject($extension);
257
+
258
+ // Get extension.json contents
259
+ $extensionJson = Utils::getObjectKeyValue($bundleData, 'extension.json');
260
+ if (!$extensionJson) {
261
+ return response()->error('Unable to find `extension.json` file required in bundle.');
262
+ }
263
+
264
+ // Set version in file meta
265
+ $file->updateMeta('version', data_get($extensionJson, 'version'));
266
+
267
+ // Check if version is set
268
+ if (!isset($extensionJson->version)) {
269
+ return response()->error('No `version` set in the `extension.json`');
270
+ }
271
+
272
+ // Check if either api or engine property is set
273
+ if (!isset($extensionJson->engine) && !isset($extensionJson->api)) {
274
+ return response()->error('No `api` or `engine` property set in the `extension.json`');
275
+ }
276
+
277
+ // Set bundle number to parsed JSON
278
+ $extensionJson->bundle_number = RegistryExtensionBundle::getNextBundleNumber($extension);
279
+
280
+ // Create the bundle
281
+ $extensionBundle = RegistryExtensionBundle::create([
282
+ 'company_uuid' => $registryUser->company_uuid,
283
+ 'created_by_uuid' => $registryUser->user_uuid,
284
+ 'extension_uuid' => $extension->uuid,
285
+ 'bundle_uuid' => $file->uuid,
286
+ 'status' => 'pending',
287
+ ]);
288
+
289
+ $extensionBundle->update(['bundle_number' => $extensionJson->bundle_number, 'version' => $extensionJson->version]);
290
+ $extensionBundle->updateMetaProperties((array) $bundleData);
291
+
292
+ return response()->json(['message' => 'Bundle uploaded successfully', 'filename' => $fileName, 'bundle' => $extensionBundle, 'extension' => $extension], 200);
293
+ }
140
294
  }
@@ -169,6 +169,32 @@ class RegistryExtensionController extends RegistryBridgeController
169
169
  return ['registryExtension' => new $this->resource($extension)];
170
170
  }
171
171
 
172
+ /**
173
+ * Mannually publishes a specific extension by its ID.
174
+ *
175
+ * This function locates a `RegistryExtension` using the provided ID and sets its status to 'published'.
176
+ * If the extension is successfully found and updated, it returns the extension resource. If the extension
177
+ * cannot be found, it returns an error response indicating the inability to locate the extension.
178
+ *
179
+ * @param RegistryExtensionActionRequest $request the validated request object
180
+ *
181
+ * @return \Illuminate\Http\Response|array returns an array containing the extension resource if successful,
182
+ * or an error response if the extension cannot be found
183
+ */
184
+ public function manualPublish(RegistryExtensionActionRequest $request)
185
+ {
186
+ $id = $request->input('id');
187
+ $extension = RegistryExtension::find($id);
188
+ if ($extension) {
189
+ $extension->update(['status' => 'published', 'current_bundle_uuid' => $extension->next_bundle_uuid]);
190
+ $extension->nextBundle()->update(['status' => 'published']);
191
+ } else {
192
+ return response()->error('Unable to find extension to publish.');
193
+ }
194
+
195
+ return ['registryExtension' => new $this->resource($extension)];
196
+ }
197
+
172
198
  /**
173
199
  * Rejects a specific extension by its ID.
174
200
  *
@@ -10,7 +10,7 @@ class RegistryExtensionFilter extends Filter
10
10
  {
11
11
  public function queryForInternal()
12
12
  {
13
- if ($this->request->boolean('explore')) {
13
+ if ($this->request->boolean('explore') || $this->request->boolean('admin')) {
14
14
  return;
15
15
  }
16
16
  $this->builder->where('company_uuid', $this->session->get('company'));
@@ -21,6 +21,14 @@ class RegistryExtensionFilter extends Filter
21
21
  $this->builder->where('company_uuid', $this->session->get('company'));
22
22
  }
23
23
 
24
+ public function admin()
25
+ {
26
+ $user = $this->request->user();
27
+ if ($user && $user->isNotAdmin()) {
28
+ $this->builder->where('company_uuid', $this->session->get('company'));
29
+ }
30
+ }
31
+
24
32
  public function query(?string $searchQuery)
25
33
  {
26
34
  $this->builder->search($searchQuery);
@@ -15,6 +15,8 @@ use Fleetbase\Traits\HasMetaAttributes;
15
15
  use Fleetbase\Traits\HasPublicId;
16
16
  use Fleetbase\Traits\HasUuid;
17
17
  use Fleetbase\Traits\Searchable;
18
+ use Illuminate\Database\Eloquent\Relations\BelongsTo;
19
+ use Illuminate\Http\UploadedFile;
18
20
  use Illuminate\Support\Facades\Storage;
19
21
  use Illuminate\Support\Str;
20
22
  use stdClass;
@@ -111,44 +113,30 @@ class RegistryExtensionBundle extends Model
111
113
  });
112
114
  }
113
115
 
114
- /**
115
- * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
116
- */
117
- public function company()
116
+ public function company(): BelongsTo
118
117
  {
119
118
  return $this->belongsTo(Company::class, 'company_uuid', 'uuid');
120
119
  }
121
120
 
122
- /**
123
- * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
124
- */
125
- public function createdBy()
121
+ public function createdBy(): BelongsTo
126
122
  {
127
123
  return $this->belongsTo(User::class, 'created_by_uuid', 'uuid');
128
124
  }
129
125
 
130
- /**
131
- * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
132
- */
133
- public function extension()
126
+ public function extension(): BelongsTo
134
127
  {
135
128
  return $this->belongsTo(RegistryExtension::class, 'extension_uuid', 'uuid');
136
129
  }
137
130
 
138
- /**
139
- * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
140
- */
141
- public function bundle()
131
+ public function bundle(): BelongsTo
142
132
  {
143
133
  return $this->belongsTo(File::class, 'bundle_uuid', 'uuid');
144
134
  }
145
135
 
146
136
  /**
147
137
  * Get the bundle original filename.
148
- *
149
- * @return string
150
138
  */
151
- public function getBundleFilenameAttribute()
139
+ public function getBundleFilenameAttribute(): ?string
152
140
  {
153
141
  if ($this->bundle instanceof File) {
154
142
  return $this->bundle->original_filename;
@@ -168,7 +156,7 @@ class RegistryExtensionBundle extends Model
168
156
  *
169
157
  * @return string a unique, uppercase bundle ID
170
158
  */
171
- public static function generateUniqueBundleId()
159
+ public static function generateUniqueBundleId(): string
172
160
  {
173
161
  do {
174
162
  $prefix = 'EXTBNDL';
@@ -204,7 +192,7 @@ class RegistryExtensionBundle extends Model
204
192
  protected static function extractBundleFile(File $bundle, $filenames = 'extension.json', $options = []): ?\stdClass
205
193
  {
206
194
  $shouldParseJson = data_get($options, 'parse_json', true);
207
- $tempDir = sys_get_temp_dir() . '/' . str_replace(['.', ','], '_', uniqid('fleetbase_zip_', true));
195
+ $tempDir = sys_get_temp_dir() . '/' . str_replace(['.', ','], '_', uniqid('fleetbase_archive_', true));
208
196
  mkdir($tempDir);
209
197
 
210
198
  // Download the file to a local temporary directory
@@ -213,7 +201,7 @@ class RegistryExtensionBundle extends Model
213
201
  file_put_contents($tempFilePath, $contents);
214
202
 
215
203
  // Extract file paths
216
- $extractedFilePaths = static::_extractAndFindFile($tempFilePath, $tempDir, $filenames);
204
+ $extractedFilePaths = static::_extractAndFindFile($tempFilePath, $tempDir, $filenames, $bundle->getExtension());
217
205
  $result = new \stdClass();
218
206
  foreach ($extractedFilePaths as $filename => $path) {
219
207
  if (file_exists($path)) {
@@ -238,46 +226,177 @@ class RegistryExtensionBundle extends Model
238
226
  }
239
227
 
240
228
  /**
241
- * Extracts and finds the path(s) of specified file(s) within a zipped archive.
229
+ * Extracts specified files from an uploaded bundle file and optionally parses their contents.
242
230
  *
243
- * Opens the specified ZIP archive and extracts it to a temporary directory. It then
244
- * searches for the given file(s) in both the root and the first valid subdirectory of
245
- * the unzipped content. It returns an associative array of file paths, indexed by filenames.
246
- * Invalid directories such as '__MACOSX', '.', '..', etc., are excluded from the search.
231
+ * This method handles the extraction of files from an uploaded archive (ZIP or TAR.GZ) and returns their contents.
247
232
  *
248
- * @param string $zipFilePath path to the ZIP archive file
249
- * @param string $tempDir temporary directory where the ZIP file is extracted
250
- * @param string|array $targetFiles a single filename or an array of filenames to search for within the archive
233
+ * @param UploadedFile $bundle the uploaded bundle file to extract
234
+ * @param string|array $filenames The filename or array of filenames to extract from the bundle (default: 'extension.json').
235
+ * @param array $options additional options:
236
+ * - 'parse_json' (bool): Whether to parse the file contents as JSON (default: true)
251
237
  *
252
- * @return array associative array of paths for the requested files within the archive
238
+ * @return \stdClass|null an object containing the extracted file contents, or null if extraction fails
239
+ *
240
+ * @throws \Exception if extraction of the bundle fails
253
241
  */
254
- private static function _extractAndFindFile($zipFilePath, $tempDir, $targetFiles)
242
+ public static function extractUploadedBundleFile(UploadedFile $bundle, $filenames = 'extension.json', $options = []): ?\stdClass
243
+ {
244
+ $shouldParseJson = data_get($options, 'parse_json', true);
245
+ $tempDir = sys_get_temp_dir() . '/' . str_replace(['.', ','], '_', uniqid('fleetbase_archive_', true));
246
+ mkdir($tempDir);
247
+
248
+ // Download the file to a local temporary directory
249
+ $tempFilePath = $bundle->getPathname();
250
+
251
+ // Get the archive extension
252
+ $extension = $bundle->guessExtension();
253
+ if ($extension === 'gz') {
254
+ $extension = 'tar.gz';
255
+ }
256
+
257
+ // Extract file paths
258
+ $extractedFilePaths = static::_extractAndFindFile($tempFilePath, $tempDir, $filenames, $extension);
259
+ $result = new \stdClass();
260
+ foreach ($extractedFilePaths as $filename => $path) {
261
+ if (file_exists($path)) {
262
+ $fileContents = file_get_contents($path);
263
+ if ($shouldParseJson) {
264
+ $result->$filename = json_decode($fileContents);
265
+ } else {
266
+ $result->$filename = $fileContents;
267
+ }
268
+ }
269
+ }
270
+
271
+ // Cleanup: Delete the temporary directory
272
+ try {
273
+ array_map('unlink', glob("$tempDir/*.*"));
274
+ } catch (\Throwable $e) {
275
+ // Probably a directory ...
276
+ }
277
+ Utils::deleteDirectory($tempDir);
278
+
279
+ return $result;
280
+ }
281
+
282
+ /**
283
+ * Extracts target files from an archive and finds their paths.
284
+ *
285
+ * This method handles both ZIP and TAR.GZ archives. It extracts the archive to a temporary directory
286
+ * and searches for the specified target files within the extracted contents.
287
+ *
288
+ * @param string $archiveFilePath the full path to the archive file to extract
289
+ * @param string $tempDir the temporary directory where the archive will be extracted
290
+ * @param string|array $targetFiles the filename or array of filenames to search for within the extracted contents
291
+ * @param string $extension the expected extension of the archive file (default: 'zip')
292
+ *
293
+ * @return array an associative array where keys are target filenames and values are their full paths within the extracted contents
294
+ *
295
+ * @throws \Exception if the archive format is unsupported or extraction fails
296
+ */
297
+ private static function _extractAndFindFile($archiveFilePath, $tempDir, $targetFiles, $extension = 'zip'): array
255
298
  {
256
299
  $paths = [];
257
- $zip = new \ZipArchive();
258
300
 
259
- if ($zip->open($zipFilePath) === true) {
301
+ // Ensure $tempDir exists
302
+ if (!is_dir($tempDir)) {
303
+ mkdir($tempDir, 0777, true);
304
+ }
305
+
306
+ // Get the base name of the archive file with extension
307
+ $archiveFileName = basename($archiveFilePath);
308
+
309
+ // Determine the extension of the archive file
310
+ $archiveExtension = pathinfo($archiveFileName, PATHINFO_EXTENSION);
311
+ if (empty($archiveExtension)) {
312
+ // Try to determine the extension from the MIME type
313
+ $finfo = new \finfo(FILEINFO_MIME_TYPE);
314
+ $mimeType = $finfo->file($archiveFilePath);
315
+
316
+ if ($mimeType === 'application/zip') {
317
+ $archiveExtension = 'zip';
318
+ } elseif ($mimeType === 'application/gzip' || $mimeType === 'application/x-gzip') {
319
+ $archiveExtension = 'gz';
320
+ } else {
321
+ $archiveExtension = $extension; // Default to 'zip' if not detected
322
+ }
323
+ }
324
+
325
+ // Create a temporary file in $tempDir with the correct extension
326
+ $tempArchivePath = $tempDir . '/' . uniqid('archive_', false) . '.' . $archiveExtension;
327
+
328
+ // Copy the archive file to the temporary file
329
+ copy($archiveFilePath, $tempArchivePath);
330
+
331
+ $zip = new \ZipArchive();
332
+
333
+ if ($zip->open($tempArchivePath) === true) {
334
+ // If it's a ZIP file, extract it
260
335
  $zip->extractTo($tempDir);
261
336
  $zip->close();
337
+ // Remove the temp archive file
338
+ unlink($tempArchivePath);
339
+ } else {
340
+ // Attempt to handle as tar.gz or tar using system 'tar' command
341
+ try {
342
+ // Determine the appropriate flags for tar extraction
343
+ $flags = '';
344
+ if (in_array($archiveExtension, ['gz', 'tgz', 'tar.gz'])) {
345
+ // For .tar.gz or .tgz files
346
+ $flags = '-xzf';
347
+ } elseif ($archiveExtension === 'tar') {
348
+ // For .tar files
349
+ $flags = '-xf';
350
+ } else {
351
+ // Unsupported archive format
352
+ unlink($tempArchivePath);
353
+ throw new \Exception('Unsupported archive format.');
354
+ }
262
355
 
263
- foreach ((array) $targetFiles as $targetFile) {
264
- // Direct check in the tempDir
265
- $directPath = $tempDir . '/' . $targetFile;
266
- if (file_exists($directPath)) {
267
- $paths[$targetFile] = $directPath;
268
- continue;
356
+ // Build the command safely using escapeshellarg
357
+ $command = sprintf('tar %s %s -C %s', $flags, escapeshellarg($tempArchivePath), escapeshellarg($tempDir));
358
+
359
+ // Execute the command
360
+ $output = [];
361
+ $returnVar = 0;
362
+ exec($command, $output, $returnVar);
363
+
364
+ // Check if the command was successful
365
+ if ($returnVar !== 0) {
366
+ throw new \Exception('Extraction failed with code ' . $returnVar . ': ' . implode("\n", $output));
269
367
  }
270
368
 
271
- // Check in the first subdirectory
272
- $files = scandir($tempDir);
273
- foreach ($files as $file) {
274
- $invalidDirectories = ['__MACOSX', '.', '..', 'DS_Store', '.DS_Store', '.idea', '.vscode'];
275
- if (!Str::startsWith($file, ['.', '_']) && !in_array($file, $invalidDirectories) && is_dir($tempDir . '/' . $file)) {
276
- $nestedPath = $tempDir . '/' . $file . '/' . $targetFile;
277
- if (file_exists($nestedPath)) {
278
- $paths[$targetFile] = $nestedPath;
279
- break;
280
- }
369
+ // Clean up temporary archive file
370
+ unlink($tempArchivePath);
371
+ } catch (\Exception $e) {
372
+ // Handle extraction errors
373
+ // Clean up temporary files
374
+ if (file_exists($tempArchivePath)) {
375
+ unlink($tempArchivePath);
376
+ }
377
+ // Throw the exception
378
+ throw new \Exception('Failed to extract archive: ' . $e->getMessage());
379
+ }
380
+ }
381
+
382
+ // Now search for the target files in the extracted contents
383
+ foreach ((array) $targetFiles as $targetFile) {
384
+ // Direct check in the tempDir
385
+ $directPath = $tempDir . '/' . $targetFile;
386
+ if (file_exists($directPath)) {
387
+ $paths[$targetFile] = $directPath;
388
+ continue;
389
+ }
390
+
391
+ // Check in the first subdirectory
392
+ $files = scandir($tempDir);
393
+ foreach ($files as $file) {
394
+ $invalidDirectories = ['__MACOSX', '.', '..', 'DS_Store', '.DS_Store', '.idea', '.vscode'];
395
+ if (!in_array($file, $invalidDirectories) && is_dir($tempDir . '/' . $file)) {
396
+ $nestedPath = $tempDir . '/' . $file . '/' . $targetFile;
397
+ if (file_exists($nestedPath)) {
398
+ $paths[$targetFile] = $nestedPath;
399
+ break;
281
400
  }
282
401
  }
283
402
  }
@@ -673,6 +792,12 @@ class RegistryExtensionBundle extends Model
673
792
  }
674
793
  }
675
794
 
795
+ /**
796
+ * Simulates the installation progress of an extension by publishing progress updates.
797
+ *
798
+ * This method publishes progress updates to a SocketCluster channel for each step in the installation process.
799
+ * It can be used to provide real-time feedback to clients about the installation status.
800
+ */
676
801
  public function runInstallerProgress(): void
677
802
  {
678
803
  $channel = implode('.', ['install', $this->company_uuid, $this->extension_uuid]);
@@ -693,6 +818,12 @@ class RegistryExtensionBundle extends Model
693
818
  }
694
819
  }
695
820
 
821
+ /**
822
+ * Simulates the uninstallation progress of an extension by publishing progress updates.
823
+ *
824
+ * This method publishes progress updates to a SocketCluster channel for each step in the uninstallation process.
825
+ * It can be used to provide real-time feedback to clients about the uninstallation status.
826
+ */
696
827
  public function runUninstallerProgress(): void
697
828
  {
698
829
  $channel = implode('.', ['uninstall', $this->company_uuid, $this->extension_uuid]);
@@ -1012,4 +1143,32 @@ class RegistryExtensionBundle extends Model
1012
1143
  // Default progress if no known phrases are matched
1013
1144
  return 0;
1014
1145
  }
1146
+
1147
+ /**
1148
+ * Calculates the next bundle number for a given extension.
1149
+ *
1150
+ * This method counts the existing bundles associated with the extension and returns the next sequential number.
1151
+ *
1152
+ * @param string|RegistryExtension $extension the UUID of the extension or a RegistryExtension instance
1153
+ *
1154
+ * @return int the next bundle number for the extension
1155
+ */
1156
+ public static function getNextBundleNumber(string|RegistryExtension $extension): int
1157
+ {
1158
+ $numberOfBundles = RegistryExtensionBundle::whereHas('extension', function ($query) use ($extension) {
1159
+ $query->where('uuid', Str::isUuid($extension) ? $extension : $extension->uuid);
1160
+ })->count();
1161
+
1162
+ return ($numberOfBundles ?? 0) + 1;
1163
+ }
1164
+
1165
+ /**
1166
+ * Retrieves the next bundle number for the current extension instance.
1167
+ *
1168
+ * @return int the next bundle number
1169
+ */
1170
+ public function nextBundleNumber()
1171
+ {
1172
+ return static::getNextBundleNumber($this->extension_uuid);
1173
+ }
1015
1174
  }
@@ -212,6 +212,11 @@ class RegistryUser extends Model
212
212
  ->first();
213
213
  }
214
214
 
215
+ public static function findFromToken(string $token): ?RegistryUser
216
+ {
217
+ return static::where('registry_token', $token)->orWhere('token', $token)->first();
218
+ }
219
+
215
220
  /**
216
221
  * Determine if the registry user can access a specific package.
217
222
  *
@@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Route;
14
14
  */
15
15
  // Lookup package endpoint
16
16
  Route::get(config('internals.api.routing.prefix', '~registry') . '/v1/lookup', 'Fleetbase\RegistryBridge\Http\Controllers\Internal\v1\RegistryController@lookupPackage');
17
+ Route::post(config('internals.api.routing.prefix', '~registry') . '/v1/bundle-upload', 'Fleetbase\RegistryBridge\Http\Controllers\Internal\v1\RegistryController@bundleUpload');
17
18
  Route::prefix(config('internals.api.routing.prefix', '~registry'))->middleware(['fleetbase.registry'])->namespace('Fleetbase\RegistryBridge\Http\Controllers')->group(
18
19
  function ($router) {
19
20
  /*
@@ -57,6 +58,7 @@ Route::prefix(config('internals.api.routing.prefix', '~registry'))->middleware([
57
58
  $router->post('{id}/submit', $controller('submit'));
58
59
  $router->post('approve', $controller('approve'));
59
60
  $router->post('reject', $controller('reject'));
61
+ $router->post('publish', $controller('manualPublish'));
60
62
  $router->get('download-bundle', $controller('downloadBundle'));
61
63
  $router->get('analytics', $controller('analytics'));
62
64
  $router->get('installed', $controller('installed'))->middleware([Spatie\ResponseCache\Middlewares\DoNotCacheResponse::class]);
@@ -6,6 +6,7 @@ registry-bridge:
6
6
  approve: Approve
7
7
  reject: Reject
8
8
  details: Details
9
+ publish: Publish
9
10
  about: About
10
11
  about-extension: About {extensionName}
11
12
  component:
@@ -26,6 +27,7 @@ registry-bridge:
26
27
  {extensionName} Details
27
28
  download-bundle: Download Bundle
28
29
  view-details: View Details
30
+ publish: Publish
29
31
  no-extensions-awaiting-publish: No extensions awaiting publish
30
32
  extension-reviewer-control:
31
33
  content-panel-title: Extensions Awaiting Review