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
30 changes: 16 additions & 14 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
# v1.6.68 — Resource transformers apply to every resource
# v1.6.69 — Authenticated realtime channels, private media, database backups and hashed codes

## Added

- **Agnostic resource transformers.** Any extension can decorate the serialized output of any API resource without modifying the resource or its model. Register a transformer against an HTTP resource class, an Eloquent model class, an interface, or `'*'` (subclasses match), and `FleetbaseResource::resolve()` applies it to JSON responses, nested resources, collection items, webhook payloads and broadcast payloads. Transformers chain in ascending `priority` and can be scoped by `contexts` (`http`, `webhook`, `broadcast`) and `only` (`internal`, `public`). (#285)
- `Fleetbase\Contracts\ResourceTransformer`, `Fleetbase\Contracts\PreparesResourceTransformation` (a once-per-collection `prepare()` hook for batch loading, so transformers never add N+1 queries), `Fleetbase\Support\ResourceTransformerContext`, and the `Fleetbase\Http\Transformers\Transformer` base class.
- Closure transformers via `ResourceTransformerRegistry::register(fn (...) => ..., ['target' => ...])`.
- `CoreServiceProvider::$transformers`, `registerTransformers()` and `registerTransformersFrom(__DIR__ . '/../Http/Transformers')` for declarative and directory-based registration from extensions, mirroring expansions.
- **Authenticated realtime channels.** Socket tokens, a channel authorizer with per-resource resolvers, and signed HTTP publish. `POST int/v1/socket/token` mints a user token, and `POST int/v1/socket/authorize` is called only by the socket server, with signed requests. Extensions register channel resolvers for their own resources. It is **off by default** and stays off until `SOCKETCLUSTER_AUTH_ENABLED=true`, even with a key set, so existing socket clients (mobile apps, the console, integrations) keep working. (#290)
- **Database backups.** Settings-driven backups (`db:backup`) on a configurable schedule, with environment defaults in `config/database-backups.php` (`DB_BACKUP_*`) and an admin override. Failures email the configured addresses and are logged. (#288)
- **Hashed one-time codes.** `VerificationCode::issue()` stores an HMAC of the code and returns the plain code once. `check()` counts attempts and reports `valid`, `invalid`, `expired` or `locked`. The existing generators are unchanged. (#289)

## Changed

- `FleetbaseResourceCollection` resolves items (instead of calling `toArray()`), sharing one `prepare()` pass per collection. A hand-built collection with a manually set `preserveKeys` now filters item arrays with the item's flag.
- `ResourceLifecycleEvent` payloads, chat participant broadcasts, `Utils::serializeJsonResource()` and the cached internal user payload serialize through `resolve()`, so transformers reach them and conditional `MissingValue`s are no longer emitted as `{}`.
- `Find::httpResourceForModel()` caches internal and public resolutions separately, consulting the request only when a model has a dedicated `Internal` resource.
- **Private media buckets.** Stored file URLs that point into the configured `s3` bucket are signed again on read, so the bucket can be fully private. (#287)
- **Faster lookups.** The country lookup is cached, and `files.subject_uuid` is indexed. (#291)
- The admin SocketCluster test always publishes to `test.{current user uuid}` and returns the channel it used. (#290)

## Removed

- Legacy duck-typed transformers (`$target` property + static `output($model, $data)`), `ResourceTransformerRegistry::transform(Model, array)`, `resolveByTarget()`, `fixClassName()` and the static `$transformers` array. The `User` resource no longer calls the registry directly.

## Dependencies

- `fleetbase/laravel-mysql-spatial` `^1.0.3`. The spatial `MysqlConnection` no longer connects to MySQL when the connection object is built, so resolving `DB::connection()` during boot (for example `artisan package:discover` during `composer install`) no longer requires a reachable database.
- `MysqlS3Backup`, `S3BackupTrimmer` and `config/laravel-mysql-s3-backup.php`, replaced by the new database backups. (#288)

## Upgrade Steps

- Extensions that registered a legacy transformer must implement `Fleetbase\Contracts\ResourceTransformer` (or extend `Fleetbase\Http\Transformers\Transformer`) and register it through `$transformers` or `registerTransformersFrom()`. See the README section "Resource transformers". The only known legacy consumer, aws-marketplace, is deprecated and is not updated.
- Run migrations: `database_backups` table (#288) and the `files.subject_uuid` index (#291).
- Socket auth (#290) adds `SOCKETCLUSTER_AUTH_ENABLED` (default `false`), `SOCKETCLUSTER_AUTH_KEY`, `SOCKETCLUSTER_PUBLISH_URL` (default `http://{SOCKETCLUSTER_HOST}:8001`) and `SOCKETCLUSTER_TOKEN_TTL` (default 900) to `broadcasting.connections.socketcluster`. Nothing changes for socket clients until `SOCKETCLUSTER_AUTH_ENABLED=true`. Roll out in this order:
1. Ship clients that fall back to connecting without a token when the token route answers 404.
2. Set `SOCKETCLUSTER_AUTH_ENABLED=true` on the API and the socket server, with the socket server in `log` mode.
3. Switch the socket server to `enforce`.
- To make the media bucket private, remove any public `s3:GetObject` statement from the bucket policy and turn on Block Public Access (#287).
- Database backups replace the old S3 backup settings; configure them with `DB_BACKUP_*` or in the admin settings (#288).
- fleetbase/storefront v0.4.25 and fleetbase/fleetops#358 require this release (`fleetbase/core-api ^1.6.69`).
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.68",
"version": "1.6.69",
"description": "Core Framework and Resources for Fleetbase API",
"keywords": [
"fleetbase",
Expand Down
81 changes: 81 additions & 0 deletions config/database-backups.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

/*
* Environment defaults for database backups.
*
* A system administrator overrides any of these from the console (Admin → Database Backups);
* the override is stored as the `system.database-backups` setting. See
* Fleetbase\Support\DatabaseBackupSettings.
*/
return [
/*
* Whether scheduled backups run at all. Off by default so a fresh install does not
* start dumping its database somewhere before anyone has chosen where.
*/
'enabled' => env('DB_BACKUP_ENABLED', false),

/*
* hourly, every_six_hours, every_twelve_hours, daily or weekly. Times are UTC.
*/
'frequency' => env('DB_BACKUP_FREQUENCY', 'daily'),
'time' => env('DB_BACKUP_TIME', '00:00'),
'day_of_week' => (int) env('DB_BACKUP_DAY_OF_WEEK', 0),

/*
* Where dumps go: a disk from config/filesystems.php, an optional bucket override for
* s3 disks, and a key prefix.
*/
'disk' => env('DB_BACKUP_DISK', 's3'),
'bucket' => env('DB_BACKUP_BUCKET', 'fleetbase-db-backups'),
'path' => env('DB_BACKUP_PATH', ''),

/*
* The database connections to dump, by name.
*/
'connections' => array_values(array_filter(array_map('trim', explode(',', (string) env('DB_BACKUP_CONNECTIONS', 'mysql,sandbox'))))),

/*
* Retention, applied after each fully successful run. Null disables that limit.
*/
'retention_days' => env('DB_BACKUP_RETENTION_DAYS', 30),
'retention_count' => env('DB_BACKUP_RETENTION_COUNT'),

/*
* A compressed dump smaller than this many bytes fails the run. A gzip of nothing is
* 20 bytes; even an empty schema dump compresses to several hundred.
*/
'min_size_bytes' => (int) env('DB_BACKUP_MIN_SIZE_BYTES', 1024),

/*
* Who hears about a failed run.
*/
'notify_on_failure' => env('DB_BACKUP_NOTIFY_ON_FAILURE', false),
'notify_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('DB_BACKUP_NOTIFY_EMAILS', ''))))),

/*
* The dump client and the arguments it always gets. --single-transaction gives a
* consistent InnoDB snapshot without locking; --no-tablespaces avoids needing the
* PROCESS privilege, which managed databases such as RDS do not grant.
*/
'dump_binary' => env('DB_BACKUP_DUMP_BINARY', 'mysqldump'),
'dump_args' => [
'--single-transaction',
'--quick',
'--routines',
'--triggers',
'--hex-blob',
'--no-tablespaces',
'--default-character-set=utf8mb4',
],
'extra_dump_args' => array_values(array_filter(explode(' ', (string) env('DB_BACKUP_EXTRA_DUMP_ARGS', '')))),

/*
* Seconds a single database's dump may take.
*/
'timeout' => (int) env('DB_BACKUP_TIMEOUT', 7200),

/*
* Where dumps are written before upload.
*/
'tmp_dir' => env('DB_BACKUP_TMP_DIR', sys_get_temp_dir()),
];
63 changes: 0 additions & 63 deletions config/laravel-mysql-s3-backup.php

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

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

return new class extends Migration {
/**
* Files are looked up by subject (store media, product images, proofs of
* delivery). Without an index each lookup scanned the whole files table.
*/
public function up(): void
{
if ($this->indexExists('files', 'files_subject_uuid_index')) {
return;
}

Schema::table('files', function (Blueprint $table) {
$table->index('subject_uuid');
});
}

public function down(): void
{
if (!$this->indexExists('files', 'files_subject_uuid_index')) {
return;
}

Schema::table('files', function (Blueprint $table) {
$table->dropIndex(['subject_uuid']);
});
}

protected function indexExists(string $table, string $index): bool
{
try {
$indexes = Schema::getConnection()
->getDoctrineSchemaManager()
->listTableIndexes($table);

return isset($indexes[$index]);
} catch (Throwable $e) {
return false;
}
}
};
44 changes: 44 additions & 0 deletions migrations/2026_10_06_000000_create_database_backups_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

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

return new class extends Migration {
/**
* Run the migrations.
*
* One row per database per backup run, so an administrator can see what ran, how big it
* was, how long it took and why it failed — the old command wrote nothing anywhere and
* reported success while uploading empty dumps.
*/
public function up(): void
{
Schema::create('database_backups', function (Blueprint $table) {
$table->uuid('uuid')->primary();
$table->string('connection_name', 64);
$table->string('database', 128);
$table->string('status', 16)->index();
$table->string('trigger', 16);
$table->string('disk', 64);
$table->string('path', 512)->nullable();
$table->unsignedBigInteger('size_bytes')->nullable();
$table->unsignedBigInteger('duration_ms')->nullable();
$table->text('error')->nullable();
$table->timestamp('started_at')->index();
$table->timestamp('completed_at')->nullable();
$table->timestamp('pruned_at')->nullable();
$table->timestamps();

$table->index(['disk', 'path']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('database_backups');
}
};
65 changes: 65 additions & 0 deletions src/Console/Commands/BackupDatabase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace Fleetbase\Console\Commands;

use Fleetbase\Models\DatabaseBackup;
use Fleetbase\Services\DatabaseBackup\DatabaseBackupException;
use Fleetbase\Services\DatabaseBackup\DatabaseBackupService;
use Fleetbase\Support\DatabaseBackupSettings;
use Illuminate\Console\Command;

/**
* Dump the configured databases and upload them to the backup disk.
*
* Exits non-zero when any database fails, so the scheduler reports FAIL instead of DONE.
*/
class BackupDatabase extends Command
{
protected $signature = 'db:backup
{--connection=* : Back up only these connections (default: those in the backup settings)}
{--trigger=console : Recorded as what started the run (scheduled, manual or console)}
{--force : Run even when backups are disabled in the settings}';

protected $description = 'Dump the MySQL databases, upload them to the backup disk and apply retention';

public function handle(DatabaseBackupService $service): int
{
$settings = DatabaseBackupSettings::settings();

if (!$settings['enabled'] && !$this->option('force')) {
$this->info('Database backups are disabled. Enable them under Admin → Database Backups, or pass --force.');

return self::SUCCESS;
}

$trigger = in_array($this->option('trigger'), [DatabaseBackup::TRIGGER_SCHEDULED, DatabaseBackup::TRIGGER_MANUAL, DatabaseBackup::TRIGGER_CONSOLE], true)
? $this->option('trigger')
: DatabaseBackup::TRIGGER_CONSOLE;

try {
$records = $service->run($trigger, $this->option('connection') ?: null, $settings);
} catch (DatabaseBackupException $e) {
$this->error($e->getMessage());

return self::FAILURE;
}

if (!$records) {
$this->error('No database connections are configured for backup.');

return self::FAILURE;
}

$failed = 0;
foreach ($records as $record) {
if ($record->status === DatabaseBackup::STATUS_COMPLETED) {
$this->info(sprintf('Backed up %s to %s:%s (%d bytes, %d ms)', $record->database, $record->disk, $record->path, $record->size_bytes, $record->duration_ms));
} else {
$failed++;
$this->error(sprintf('Backup of %s failed: %s', $record->database, $record->error));
}
}

return $failed ? self::FAILURE : self::SUCCESS;
}
}
Loading
Loading