Skip to content
Merged
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 RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
# v1.6.74 — Webhook endpoints for API keys, signed per endpoint, and safer upload names
# v1.6.74 — Webhook endpoints for API keys, signed per endpoint, safer upload names, and App Review access

## Added

- **Webhook endpoints can be managed with an API key** (#319). `/v1/webhook-endpoints` supports create, list (optionally filtered by `url`), show, update, `POST {id}/rotate-secret` and delete. Endpoints are scoped to the key's company and environment (`live`/`test`). The URL must pass `PublicWebhookUrl`, and events are checked against `config('api.events')` when it is set. Integrations such as WooCommerce and Shopify connectors can now register their own endpoints instead of merchants copying URLs into the console.
- **Per-endpoint signing secrets** (#319). Each endpoint has its own secret (`whsec_…`), stored encrypted, hidden from serialisation and kept out of the activity log. Endpoints that already exist get one the first time it is needed. Deliveries now also carry `X-Fleetbase-Signature: t=<unix>,v1=<hmac_sha256("<t>.<body>", secret)>`; `EndpointSignature::verify()` documents the receiver side. The legacy `Signature` header is unchanged. The console can reveal (`GET int/v1/webhook-endpoints/{id}/secret`, view permission) and rotate (`POST int/v1/webhook-endpoints/{id}/rotate-secret`, create permission) the secret.
- **App Review access** (#323). Apple and Google reviewers can't receive a texted sign-in code. Extensions register what their app signs reviewers in to (`Fleetbase\Support\AppReview::register()`, e.g. a Storefront store or an organization's Navigator app) with a review number from the 555-0100–0199 range reserved for fiction. Owners turn access on in the Console (`GET|PUT int/v1/review-access/{type}/{id}`) and give the app store that number and a six-digit code, stored hashed, shown once, and accepted only for that subject. Works on Fleetbase Cloud and self-hosted. `AppReview::configuredAccountMatches()` is the shared env fallback for self-hosted review accounts.

## Security

- **Numbers reserved for fiction are never texted** (#323). `SmsService::send()` answers as sent without calling a provider, and SMS verification codes for those numbers are saved but not sent.

- **Uploaded files are stored under an extension that matches their contents** (#320). The stored name used to keep the client's extension, so a file that passed content-type validation could be stored as `.php`. The client's extension is now kept only when it matches the detected type (or is a loosely detected data format such as `csv`, `json` or `xlsx`); otherwise the extension comes from the detected type. Script extensions (`php`, `phtml`, `phar`, `shtml`, `htaccess`, `cgi`, …) are never used and become `bin`. `original_filename` is unchanged.

## Upgrade Steps

- Run migrations: `php artisan migrate` adds the nullable `webhook_endpoints.secret` column.
- Run migrations: `php artisan migrate` adds the nullable `webhook_endpoints.secret` column and the `review_accesses` table.
48 changes: 48 additions & 0 deletions migrations/2026_10_24_000000_create_review_accesses_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations.
*
* App Review access: one row per thing an app signs reviewers in to (a Storefront store
* or network, an organization's Navigator app, ...). Apple and Google reviewers can't
* receive a texted code, so the owner turns access on and gives the app store a fixed
* fictional number and a code. `subject_type` is the type an extension registered with
* Fleetbase\Support\AppReview. The code is only ever stored hashed.
*/
public function up(): void
{
if (Schema::hasTable('review_accesses')) {
return;
}

Schema::create('review_accesses', function (Blueprint $table) {
$table->bigIncrements('id');
$table->uuid('uuid')->unique();
$table->uuid('company_uuid')->nullable()->index();
$table->string('subject_type', 64);
$table->uuid('subject_uuid');
$table->boolean('enabled')->default(false);
$table->string('code')->nullable();
$table->timestamp('code_generated_at')->nullable();
$table->timestamp('enabled_at')->nullable();
$table->uuid('updated_by_uuid')->nullable();
$table->timestamps();
$table->softDeletes();

$table->unique(['subject_type', 'subject_uuid']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('review_accesses');
}
};
104 changes: 104 additions & 0 deletions src/Http/Controllers/Internal/v1/ReviewAccessController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

namespace Fleetbase\Http\Controllers\Internal\v1;

use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Models\Permission;
use Fleetbase\Support\AppReview;
use Fleetbase\Support\Auth;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

/**
* The Console's App Review access endpoints, for every type an extension registered with
* AppReview (a Storefront store or network, an organization's Navigator app, ...):
*
* GET {type}/{id} the status, never the code
* PUT {type}/{id} {enabled?: bool, regenerate_code?: bool}; the response carries the plain
* `code` only when this request generated one
*
* `{id}` is the subject's uuid or public id, found in the signed-in company only.
*/
class ReviewAccessController extends Controller
{
public function show(string $type, string $id, Request $request): JsonResponse
{
$subject = $this->subject($type, $id, $request);
if (!$subject) {
return response()->error('Not found.', 404);
}

if (!$this->allowed($type, 'view_permission', $request)) {
return response()->authorizationError();
}

return response()->json(['review_access' => AppReview::status($subject)]);
}

public function update(string $type, string $id, Request $request): JsonResponse
{
$subject = $this->subject($type, $id, $request);
if (!$subject) {
return response()->error('Not found.', 404);
}

if (!$this->allowed($type, 'permission', $request)) {
return response()->authorizationError();
}

$request->validate([
'enabled' => 'sometimes|boolean',
'regenerate_code' => 'sometimes|boolean',
]);

$enabled = $request->has('enabled') ? $request->boolean('enabled') : null;

[, $code] = AppReview::update($subject, $enabled, $request->boolean('regenerate_code'), $request->session()->get('user'));

$status = AppReview::status($subject);
if ($code !== null) {
$status['code'] = $code;
}

return response()->json(['review_access' => $status]);
}

protected function subject(string $type, string $id, Request $request): ?Model
{
return AppReview::resolveSubject($type, $id, $request->session()->get('company'));
}

/**
* Administrators always may; anyone else needs the permission the type registered, when
* it registered one.
*/
protected function allowed(string $type, string $key, Request $request): bool
{
$permission = AppReview::definition($type)[$key] ?? null;
if (!$permission) {
return true;
}

$user = Auth::getUserFromSession($request);
if (!$user) {
return false;
}

if (!$user->isNotAdmin()) {
return true;
}

// The permission or a wildcard over it, the way AuthorizationGuard reads them.
$names = [$permission];
$parts = explode(' ', $permission);
if (count($parts) === 3) {
$names[] = implode(' ', [$parts[0], '*', $parts[2]]);
$names[] = implode(' ', [$parts[0], '*']);
}

$permissions = Permission::findByNames($names);

return $permissions->isNotEmpty() && !$user->doesntHavePermissions($permissions);
}
}
66 changes: 66 additions & 0 deletions src/Models/ReviewAccess.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace Fleetbase\Models;

use Fleetbase\Traits\HasUuid;
use Illuminate\Support\Facades\Hash;

/**
* App Review access for one subject (a Storefront store, an organization's Navigator app,
* ...): whether app store reviewers may sign in to it with its type's fixed review number
* and the code its owner generated. See Fleetbase\Support\AppReview. The code is only ever
* stored hashed.
*
* @property string $uuid
* @property string|null $company_uuid
* @property string $subject_type the type registered with AppReview, e.g. `storefront-store`
* @property string $subject_uuid
* @property bool $enabled
* @property string|null $code
* @property \Illuminate\Support\Carbon|null $code_generated_at
* @property \Illuminate\Support\Carbon|null $enabled_at
* @property string|null $updated_by_uuid
*/
class ReviewAccess extends Model
{
use HasUuid;

protected $table = 'review_accesses';

protected $fillable = ['company_uuid', 'subject_type', 'subject_uuid', 'enabled', 'code', 'code_generated_at', 'enabled_at', 'updated_by_uuid'];

protected $hidden = ['code'];

protected $casts = [
'enabled' => 'boolean',
'code_generated_at' => 'datetime',
'enabled_at' => 'datetime',
];

/**
* Turned on and holding a code: the only state in which a reviewer can sign in.
*/
public function isUsable(): bool
{
return $this->enabled && $this->hasCode();
}

public function hasCode(): bool
{
return filled($this->code);
}

/**
* Replace the code with the given one, stored hashed.
*/
public function setPlainCode(string $code): void
{
$this->code = Hash::make($code);
$this->code_generated_at = now();
}

public function checkCode(string $code): bool
{
return $this->hasCode() && $code !== '' && Hash::check($code, $this->code);
}
}
10 changes: 8 additions & 2 deletions src/Models/VerificationCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Fleetbase\Casts\Json;
use Fleetbase\Mail\VerificationMail;
use Fleetbase\Services\SmsService;
use Fleetbase\Support\AppReview;
use Fleetbase\Support\Sms\SmsContext;
use Fleetbase\Support\Sms\SmsGuard;
use Fleetbase\Support\Sms\SmsLedger;
Expand Down Expand Up @@ -396,10 +397,15 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi
$destination = data_get($options, 'phone') ?: data_get($subject, 'phone');
$companyUuid = data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid');

// A number reserved for fiction (an App Review number) is never texted: the code is
// saved as usual, but nothing is reserved or sent. App reviewers sign in with the code
// their app's owner set up instead (Fleetbase\Support\AppReview).
$fictional = $destination && AppReview::isFictionalPhone((string) $destination);

// Ask the guard BEFORE the code row exists: a refused send must leave nothing
// behind, and the budgets must be counted even when the provider later fails.
$reservation = null;
if ($destination) {
if ($destination && !$fictional) {
$reservation = SmsGuard::make()->reserve(SmsContext::resolve((string) $destination, [
'purpose' => $for,
'company_uuid' => $companyUuid,
Expand Down Expand Up @@ -476,7 +482,7 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi
$provider = data_get($options, 'provider');

// Send SMS using SmsService with automatic provider routing
if ($destination) {
if ($destination && !$fictional) {
try {
$smsService = new SmsService();
$smsService->send($destination, $message, $smsOptions, $provider);
Expand Down
9 changes: 9 additions & 0 deletions src/Services/SmsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Fleetbase\Services;

use Fleetbase\Support\AppReview;
use Fleetbase\Support\Sms\SmsContext;
use Fleetbase\Support\Sms\SmsGuard;
use Fleetbase\Support\Sms\SmsReservation;
Expand Down Expand Up @@ -70,6 +71,14 @@ public function send(string $to, string $text, array $options = [], ?string $pro
// Normalize phone number
$normalizedPhone = $this->normalizePhoneNumber($to);

// A number reserved for fiction (the App Review numbers among them) can never reach
// anyone: answer as sent without calling a provider or counting it.
if (AppReview::isFictionalPhone($normalizedPhone)) {
Log::info('SMS to a fictional number skipped', ['to' => $to]);

return ['success' => true, 'skipped' => true, 'provider' => null, 'message_id' => null];
}

// Every send passes the guard before a provider is called. A caller that already
// reserved the send (VerificationCode) hands its reservation over so it counts once.
$reservation = $options['sms_reservation'] ?? null;
Expand Down
Loading
Loading