Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
name: PHP CI

# Release work lands on a dev-v* branch first and only reaches main via the release
# PR, so a main-only filter leaves every PR targeting a release branch with no CI at
# all — the release is then assembled from unverified commits.
on:
push:
branches: [ main ]
branches: [ main, 'dev-v*' ]
tags:
- 'v*'
pull_request:
branches: [ main ]
branches: [ main, 'dev-v*' ]

jobs:
build:
Expand Down
20 changes: 16 additions & 4 deletions .github/workflows/postman.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,34 @@ name: API Contract (Postman)
# collection against the live API. Delegates to the reusable workflow in
# fleetbase/fleetbase. Requires org secrets POSTMAN_API_KEY + _GITHUB_AUTH_TOKEN
# (inherited); no-ops until POSTMAN_API_KEY is set.
# TODO: change @dev-v0.7.53 to @main once that branch is merged.
#
# Deliberately unpinned. The reusable workflow defaults to booting fleetbase/fleetbase@main
# against fleetbase/fleetbase-api:latest, so every release is picked up automatically and
# there is no ref here to remember to bump. Each run records the image digest it actually
# resolved in its job summary, so a result stays traceable. To reproduce an older run:
#
# with:
# fleetbase-ref: v0.7.53
# api-image: fleetbase/fleetbase-api:v0.7.53

on:
push:
branches: [main]
branches: [main, 'dev-v*']
pull_request:
branches: [main]
branches: [main, 'dev-v*']
workflow_dispatch:

permissions:
contents: read

jobs:
contract:
uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@dev-v0.7.53
uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@main
with:
collections: "Fleetbase Core API"
build-from-source: false
# Without this the run tests the version of fleetbase/core-api baked into the
# published image, not the branch under review. The workflow checks this
# repository out at the commit under test and swaps it into the container.
overlay-package: fleetbase/core-api
secrets: inherit
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fleetbase/core-api",
"version": "1.6.55",
"version": "1.6.56",
"description": "Core Framework and Resources for Fleetbase API",
"keywords": [
"fleetbase",
Expand Down
119 changes: 119 additions & 0 deletions src/Console/Commands/DeleteUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

namespace Fleetbase\Console\Commands;

use Fleetbase\Services\UserDeletionService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;

/**
* @phpstan-import-type UserDeletionPlan from UserDeletionService
*/
class DeleteUser extends Command
{
protected $signature = 'fleetbase:user-delete
{--email= : Delete every user matching this email}
{--uuid=* : Delete one or more users by UUID}
{--execute : Execute the displayed deletion plan}
{--yes : Skip the interactive confirmation}';

protected $description = 'Safely preview and delete users and their identity-bound resources across Fleetbase schemas';

public function __construct(protected UserDeletionService $deletionService)
{
parent::__construct();
}

public function handle(): int
{
$emailOption = $this->option('email');
$email = is_string($emailOption) && $emailOption !== '' ? $emailOption : null;
$uuids = array_values(array_unique(array_filter((array) $this->option('uuid'), 'is_string')));

if (($email && $uuids !== []) || (!$email && $uuids === [])) {
$this->error('Provide exactly one selector: --email or --uuid.');

return self::FAILURE;
}

$invalidUuids = array_values(array_filter($uuids, fn ($uuid) => !Str::isUuid($uuid)));
if ($invalidUuids !== []) {
$this->error('Invalid UUIDs: ' . implode(', ', $invalidUuids));

return self::FAILURE;
}

$users = $this->deletionService->findUsers($email, $uuids);
if ($users->isEmpty()) {
$this->warn('No matching users were found.');

return self::SUCCESS;
}

$selectedUuids = [];
$userRows = [];
foreach ($users as $user) {
$selectedUuids[] = $user->uuid;
$userRows[] = [$user->uuid, $user->email, $user->name];
}
$this->table(['UUID', 'Email', 'Name'], $userRows);

$plan = $this->deletionService->plan($selectedUuids);
$this->displayPlan($plan);

if ($plan['blockers'] !== []) {
$this->error('Deletion is blocked by unresolved references: ' . implode(', ', $plan['blockers']));

return self::FAILURE;
}

if (!$this->option('execute')) {
$this->info('Dry run only. Re-run with --execute to apply this plan.');

return self::SUCCESS;
}

if (!$this->option('yes') && !$this->confirm('Permanently delete the displayed users and apply this cleanup plan?')) {
$this->warn('Deletion cancelled.');

return self::SUCCESS;
}

try {
$result = $this->deletionService->execute($selectedUuids);
} catch (\Throwable $error) {
$this->error('Deletion failed and was rolled back: ' . $error->getMessage());

return self::FAILURE;
}

$deleted = (int) ($result['users_deleted'] ?? 0);
$this->info("Deleted {$deleted} users successfully.");

return self::SUCCESS;
}

/**
* @param UserDeletionPlan $plan
*/
protected function displayPlan(array $plan): void
{
$rows = [];
foreach ($plan['actions'] as $action) {
if ($action['count'] === 0) {
continue;
}

$rows[] = [
$action['schema'],
$action['table'],
$action['column'],
strtoupper($action['action']),
$action['count'],
$action['reason'],
];
}

$this->table(['Schema', 'Table', 'Column', 'Action', 'Rows', 'Reason'], $rows);
}
}
48 changes: 44 additions & 4 deletions src/Exceptions/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,46 @@ public function render($request, \Throwable $exception)
return parent::render($request, $exception);
}

/**
* Determine if the exception should be rendered as JSON.
*
* Fleetbase is a JSON-only HTTP surface, but clients do not reliably send an
* `Accept: application/json` header. Laravel's default would then render an HTML
* error page for any exception not covered by shouldManuallyHandleException(),
* leaking file paths and stack frames to API consumers. Outside of local debugging
* always answer with JSON; when debugging is on the HTML page is kept, since it is
* the more useful development affordance.
*
* @param \Illuminate\Http\Request $request
*/
protected function shouldReturnJson($request, \Throwable $e): bool
{
if (!config('app.debug')) {
return true;
}

return parent::shouldReturnJson($request, $e);
}

/**
* Convert the given exception to an array.
*
* Matches the `{"errors": [...]}` envelope used by response()->error() so error
* payloads are consistent across the API, and withholds internals when debugging
* is off. HTTP exception messages are preserved because they describe the request
* the caller already made; anything else collapses to a generic message.
*/
protected function convertExceptionToArray(\Throwable $e): array
{
if (config('app.debug')) {
return parent::convertExceptionToArray($e);
}

$message = $this->isHttpException($e) && $e->getMessage() !== '' ? $e->getMessage() : 'Server Error';

return ['errors' => [$message]];
}

/**
* Retrieves a loggable message from an exception for CloudWatch.
*
Expand Down Expand Up @@ -162,16 +202,16 @@ private function manuallyHandleException(\Throwable $exception): ?\Illuminate\Ht

switch ($type) {
case 'TokenMismatchException':
return response()->error('Invalid XSRF token sent with request.');
return response()->error('Invalid XSRF token sent with request.', 419);

case 'ThrottleRequestsException':
return response()->error('Too many requests.');
return response()->error('Too many requests.', 429);

case 'AuthenticationException':
return response()->error('Unauthenticated.');
return response()->error('Unauthenticated.', 401);

case 'NotFoundHttpException':
return response()->error('There is nothing to see here.');
return response()->error('There is nothing to see here.', 404);

case 'ModelNotFoundException':
return response()->error($this->modelNotFoundMessage($exception), 404);
Expand Down
2 changes: 1 addition & 1 deletion src/Http/Controllers/Api/v1/FileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace Fleetbase\Http\Controllers\Api\v1;

use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Http\Requests\Internal\DownloadFileRequest;
use Fleetbase\Http\Requests\DownloadFileRequest;
use Fleetbase\Http\Requests\Internal\UploadBase64FileRequest;
use Fleetbase\Http\Requests\Internal\UploadFileRequest;
use Fleetbase\Http\Resources\DeletedResource;
Expand Down
19 changes: 17 additions & 2 deletions src/Http/Controllers/Internal/v1/SettingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -906,26 +906,41 @@ protected function setTemporarySmsProviderConfig(string $provider, array $provid
*/
public function testSentryConfig(AdminRequest $request)
{
$dsn = $request->input('dsn');
$dsn = $request->input('dsn');
$clientDsn = $dsn;

// Set config from request
config(['sentry.dsn' => $dsn]);

if (is_string($dsn) && $dsn !== '') {
try {
$clientDsn = \Sentry\Dsn::createFromString($dsn);
} catch (\InvalidArgumentException) {
return response()->json([
'status' => 'error',
'message' => 'The provided Sentry DSN is invalid.',
]);
}
}

$message = 'Sentry configuration is successful, test Exception sent.';
$status = 'success';
$clientBuilder = null;

try {
$clientBuilder = \Sentry\ClientBuilder::create([
'dsn' => $dsn,
'dsn' => $clientDsn,
'release' => env('SENTRY_RELEASE'),
'environment' => app()->environment(),
'traces_sample_rate' => 1.0,
]);
// @codeCoverageIgnoreStart
// Sentry client construction errors depend on SDK versions that throw instead of normalizing invalid options.
} catch (\Exception $e) {
$message = $e->getMessage();
$status = 'error';
}
// @codeCoverageIgnoreEnd

if ($clientBuilder) {
// Set the Laravel SDK identifier and version
Expand Down
78 changes: 78 additions & 0 deletions src/Http/Requests/DownloadFileRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace Fleetbase\Http\Requests;

/**
* Download validation for the PUBLIC API.
*
* The internal counterpart requires the identifier to be a uuid, because the console
* works in uuids. The public API does not: every other public endpoint addresses a
* resource by its public_id and explicitly rejects uuids, and an upload returns
* `file_xxxxxxxx`. Validating the public route with the internal rules meant a consumer
* could not download the file it had just uploaded — it got
* "The file identifier must be a valid UUID."
*
* Existence is deliberately not asserted here. FileController::download already resolves
* through File::findRecordOrFail() and answers 404 for an unknown file, which is the
* correct status for a missing resource; an `exists` rule would report 422 instead.
*/
class DownloadFileRequest extends FleetbaseRequest
{
/**
* Determine if the user is authorized to make this request.
*
* The route sits behind the `fleetbase.api` middleware group, which authenticates the
* API credential before this runs. The internal request checks for a session user,
* which is the wrong notion of identity for a key-authenticated request.
*
* @return bool
*/
public function authorize()
{
return true;
}

/**
* Prepare the data for validation.
*
* Ensures route parameters are available for validation rules.
*/
protected function prepareForValidation(): void
{
if ($this->route('id')) {
$this->merge([
'id' => $this->route('id'),
]);
}
}

/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'file' => ['required_without:id', 'string'],
'id' => ['required_without:file', 'string'],
'disk' => ['sometimes', 'string'],
];
}

/**
* Get the validation rules error messages.
*
* @return array
*/
public function messages()
{
return [
'id.required_without' => 'Please provide a file identifier.',
'file.required_without' => 'Please provide a file identifier.',
'id.string' => 'The file identifier must be a string.',
'file.string' => 'The file identifier must be a string.',
'disk.string' => 'The storage disk must be a valid string.',
];
}
}
1 change: 1 addition & 0 deletions src/Providers/CoreServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class CoreServiceProvider extends ServiceProvider
\Fleetbase\Console\Commands\PurgeActivityLogs::class,
\Fleetbase\Console\Commands\PurgeScheduledTaskLogs::class,
\Fleetbase\Console\Commands\PurgeOrphanedModelRecords::class,
\Fleetbase\Console\Commands\DeleteUser::class,
\Fleetbase\Console\Commands\BackupDatabase\MysqlS3Backup::class,
\Fleetbase\Console\Commands\TelemetryPing::class,
];
Expand Down
Loading
Loading