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
47 changes: 47 additions & 0 deletions server/src/Exceptions/PublicRelationNotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace Fleetbase\FleetOps\Exceptions;

/**
* Thrown when a public relationship input — `vendor`, `parent_fleet`, `zone`,
* and friends — names a resource that does not exist inside the authenticated
* company.
*
* A cross-company identifier is deliberately indistinguishable from a missing
* one: both raise this, so the response cannot be used to probe whether some
* other organization holds a given public id.
*/
class PublicRelationNotFoundException extends \Exception
{
/**
* The request key that failed to resolve, e.g. `parent_fleet`.
*/
private string $relation;

/**
* The public identifier that was supplied for that key.
*/
private ?string $identifier;

public function __construct(string $relation, ?string $identifier = null, ?\Throwable $previous = null)
{
$this->relation = $relation;
$this->identifier = $identifier;

parent::__construct(
sprintf('No %s resource found for the identifier provided.', str_replace('_', ' ', $relation)),
0,
$previous
);
}

public function getRelation(): string
{
return $this->relation;
}

public function getIdentifier(): ?string
{
return $this->identifier;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns;

use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException;
use Fleetbase\FleetOps\Models\Contact;
use Fleetbase\FleetOps\Models\Device;
use Fleetbase\FleetOps\Models\Driver;
Expand All @@ -23,16 +24,20 @@

trait ResolvesFleetOpsApiResources
{
protected function resolveUuid(string $modelClass, ?string $id): ?string
protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
{
if (empty($id)) {
return null;
}

return $this->resolveModel($modelClass, $id)->uuid;
return $this->resolveModel($modelClass, $id, $companyUuid)->uuid;
}

protected function resolveModel(string $modelClass, string $id): Model
/**
* @param string|null $companyUuid the company to scope the lookup to; defaults
* to the session company
*/
protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): Model
{
$instance = new $modelClass();
$query = $modelClass::query()->where(function ($query) use ($id, $instance) {
Expand All @@ -47,8 +52,10 @@ protected function resolveModel(string $modelClass, string $id): Model
}
});

if (session('company') && $this->modelHasColumn($instance, 'company_uuid')) {
$query->where($instance->qualifyColumn('company_uuid'), session('company'));
$companyUuid = $companyUuid ?? session('company');

if ($companyUuid && $this->modelHasColumn($instance, 'company_uuid')) {
$query->where($instance->qualifyColumn('company_uuid'), $companyUuid);
}

$model = $query->first();
Expand Down Expand Up @@ -114,17 +121,45 @@ protected function isUuidIdentifierKey(string $key): bool
return preg_match('/(^uuid$|_uuid$|Uuid$|UUID$)/', $key) === 1;
}

protected function applyPublicIdRelation(array &$input, string $requestKey, string $column, string $modelClass, $request): void
protected function applyPublicIdRelation(array &$input, string $requestKey, string $column, string $modelClass, $request, ?string $companyUuid = null): void
{
if (!$request->exists($requestKey)) {
return;
}

$input[$column] = filled($request->input($requestKey))
? $this->resolveUuid($modelClass, $request->input($requestKey))
? $this->resolveUuid($modelClass, $request->input($requestKey), $companyUuid)
: null;
}

/**
* Apply a set of public-ID relationship inputs in one pass.
*
* `$map` is keyed by the public request key and holds `[column, modelClass]`,
* e.g. `['parent_fleet' => ['parent_fleet_uuid', Fleet::class]]`. A key that is
* absent from the request is left untouched; a key sent empty clears the column.
*
* Resolution failures are rethrown as a PublicRelationNotFoundException so the
* caller can say which input was at fault rather than answering with a bare
* "not found" that names no field.
*
* @param array<string, array{0: string, 1: class-string}> $map
*
* @throws PublicRelationNotFoundException
*/
protected function applyPublicIdRelations(array &$input, array $map, $request, ?string $companyUuid = null): void
{
foreach ($map as $requestKey => [$column, $modelClass]) {
try {
$this->applyPublicIdRelation($input, $requestKey, $column, $modelClass, $request, $companyUuid);
} catch (ModelNotFoundException $exception) {
$identifier = $request->input($requestKey);

throw new PublicRelationNotFoundException($requestKey, is_scalar($identifier) ? (string) $identifier : null, $exception);
}
}
}

protected function allowedMorphTypes(): array
{
return [
Expand Down
119 changes: 62 additions & 57 deletions server/src/Http/Controllers/Api/v1/DriverController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
use Fleetbase\FleetOps\Events\GeofenceEntered;
use Fleetbase\FleetOps\Events\GeofenceExited;
use Fleetbase\FleetOps\Events\VehicleLocationChanged;
use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException;
use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources;
use Fleetbase\FleetOps\Http\Requests\CreateDriverRequest;
use Fleetbase\FleetOps\Http\Requests\DriverSimulationRequest;
use Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest;
Expand All @@ -16,6 +18,7 @@
use Fleetbase\FleetOps\Models\Driver;
use Fleetbase\FleetOps\Models\Order;
use Fleetbase\FleetOps\Models\Vehicle;
use Fleetbase\FleetOps\Models\Vendor;
use Fleetbase\FleetOps\Support\GeofenceIntersectionService;
use Fleetbase\FleetOps\Support\OSRM;
use Fleetbase\FleetOps\Support\Utils;
Expand All @@ -40,6 +43,7 @@
class DriverController extends Controller
{
use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass;
use ResolvesFleetOpsApiResources;

/**
* Creates a new Fleetbase Driver resource.
Expand All @@ -48,12 +52,6 @@ class DriverController extends Controller
*/
public function create(CreateDriverRequest $request)
{
// get request input
$input = $request->except(['name', 'password', 'email', 'phone', 'location', 'altitude', 'heading', 'speed', 'meta']);

// Add default status
$input['status'] = $request->input('status', 'available');

// get user details for driver
$userDetails = $request->only(['name', 'password', 'email', 'phone', 'timezone']);

Expand All @@ -65,6 +63,18 @@ public function create(CreateDriverRequest $request)
return $this->apiError('Company not found.');
}

// get request input. Relationship inputs resolve against the company the
// driver is being created in, which is not always the session company —
// a request may name one explicitly.
try {
$input = $this->driverInputFromRequest($request, $company->uuid);
} catch (PublicRelationNotFoundException $exception) {
return $this->jsonResponse(['error' => $exception->getMessage()], 404);
}

// Add default status
$input['status'] = $request->input('status', 'available');

// Apply user infos
$userDetails = $this->applyUserInfoFromRequest($request, $userDetails);

Expand All @@ -87,30 +97,6 @@ public function create(CreateDriverRequest $request)
$input['user_uuid'] = $user->uuid;
$input['company_uuid'] = $company->uuid; // Ensure correct company_uuid is set

// vehicle assignment public_id -> uuid
if ($request->has('vehicle')) {
$input['vehicle_uuid'] = $this->getUuid('vehicles', [
'public_id' => $request->input('vehicle'),
'company_uuid' => $company->uuid, // Use $company->uuid instead of session
]);
}

// vendor assignment public_id -> uuid
if ($request->has('vendor')) {
$input['vendor_uuid'] = $this->getUuid('vendors', [
'public_id' => $request->input('vendor'),
'company_uuid' => $company->uuid, // Use $company->uuid instead of session
]);
}

// order|alias:job assignment public_id -> uuid
if ($request->has('job')) {
$input['current_job_uuid'] = $this->getUuid('orders', [
'public_id' => $request->input('job'),
'company_uuid' => $company->uuid, // Use $company->uuid instead of session
]);
}

// set default online
if (!isset($input['online'])) {
$input['online'] = 0;
Expand All @@ -130,7 +116,11 @@ public function create(CreateDriverRequest $request)
$file = $this->resolveFile($request->input('photo'), $path);

if ($file) {
$user->update(['photo_uuid' => $file->uuid]);
// `photo_uuid` is not a column on users — the avatar lives in
// `avatar_uuid`, and User guards mass assignment by fillable, so
// the old key was dropped without a word and every driver photo
// uploaded through the public API was discarded.
$user->update(['avatar_uuid' => $file->uuid]);
}
}

Expand Down Expand Up @@ -164,7 +154,11 @@ public function update($id, UpdateDriverRequest $request)
}

// get request input
$input = $request->except(['name', 'password', 'email', 'phone', 'location', 'altitude', 'heading', 'speed', 'meta']);
try {
$input = $this->driverInputFromRequest($request);
} catch (PublicRelationNotFoundException $exception) {
return $this->jsonResponse(['error' => $exception->getMessage()], 404);
}

/*
* Deliberately no `password` here. Setting one through a general update
Expand All @@ -181,30 +175,6 @@ public function update($id, UpdateDriverRequest $request)
$driverUser->update($userDetails);
}

// vehicle assignment public_id -> uuid
if ($request->has('vehicle')) {
$input['vehicle_uuid'] = $this->getUuid('vehicles', [
'public_id' => $request->input('vehicle'),
'company_uuid' => $this->sessionCompany(),
]);
}

// vendor assignment public_id -> uuid
if ($request->has('vendor')) {
$input['vendor_uuid'] = $this->getUuid('vendors', [
'public_id' => $request->input('vendor'),
'company_uuid' => $this->sessionCompany(),
]);
}

// order|alias:job assignment public_id -> uuid
if ($request->has('job')) {
$input['current_job_uuid'] = $this->getUuid('orders', [
'public_id' => $request->input('job'),
'company_uuid' => $this->sessionCompany(),
]);
}

// latitude / longitude
if ($request->has(['latitude', 'longitude'])) {
$input['location'] = $this->pointFromCoordinates($request->only(['latitude', 'longitude']));
Expand All @@ -220,7 +190,7 @@ public function update($id, UpdateDriverRequest $request)
$file = $this->resolveFile($request->input('photo'), $path);

if ($file) {
$driver->user->update(['photo_uuid' => $file->uuid]);
$driver->user->update(['avatar_uuid' => $file->uuid]);
}
}

Expand Down Expand Up @@ -919,6 +889,41 @@ public function simulateDrivingForOrder(Driver $driver, Order $order)
return response()->json($route);
}

/**
* The explicit public input allowlist for a driver.
*
* Replaces an `except()` blocklist: anything not named here — `user_uuid`,
* `company_uuid`, `auth_token`, `signup_token_used`, `public_id`, `uuid`,
* `_key`, the generated `slug`, and the raw `*_uuid` relation columns — used
* to reach `Driver::create()` intact simply because nobody had thought to
* exclude it. `location`, `heading`, `altitude`, `speed` and `meta` were on
* that blocklist and so were dropped on every write, which is why a driver's
* metadata never persisted through the public API.
*
* @throws PublicRelationNotFoundException
*/
protected function driverInputFromRequest(Request $request, ?string $companyUuid = null): array
{
$input = $request->only([
// Identity
'internal_id', 'drivers_license_number', 'license_expiry',
// Operational
'country', 'currency', 'city', 'online', 'current_status', 'status',
'location', 'heading', 'bearing', 'altitude', 'speed',
// Structured / orchestrator
'meta', 'skills', 'max_travel_time', 'max_distance',
'time_window_start', 'time_window_end',
]);

$this->applyPublicIdRelations($input, [
'vehicle' => ['vehicle_uuid', Vehicle::class],
'vendor' => ['vendor_uuid', Vendor::class],
'job' => ['current_job_uuid', Order::class],
], $request, $companyUuid);

return $input;
}

protected function companyFromRequest(Request $request): ?Company
{
return Auth::getCompanyFromRequest($request);
Expand Down
Loading
Loading