Compare commits

..

4 Commits

Author SHA1 Message Date
Lance Pioch
ea978f3485 Reuse GuzzleClient with timeouts for presigned S3 requests 2026-02-07 23:17:12 -05:00
Lance Pioch
c8dcff2ee9 Fix phpstan 2026-02-06 18:14:01 -05:00
Lance Pioch
0579fd6df5 Use presigned URLs for all S3 operations (#1022)
Some S3-compatible providers (e.g., Hetzner) require presigned requests.
Previously only GetObject and UploadPart were presigned; the remaining
operations (CreateMultipartUpload, ListParts, CompleteMultipartUpload,
AbortMultipartUpload, DeleteObject) used header-based auth. This adds an
executeS3Command() method to S3Filesystem that sends all commands via
presigned URLs with manual XML response parsing, giving universal
compatibility with all S3-compatible providers.
2026-02-06 11:16:12 -05:00
Lance Pioch
789c4c7284 Localize email notifications (#2043) (#2178) 2026-02-06 10:37:15 -05:00
17 changed files with 171 additions and 331 deletions

View File

@@ -2,11 +2,18 @@
namespace App\Extensions\Filesystem;
use Aws\CommandInterface;
use Aws\Result;
use Aws\S3\S3ClientInterface;
use GuzzleHttp\Client as GuzzleClient;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use RuntimeException;
use SimpleXMLElement;
class S3Filesystem extends AwsS3V3Adapter
{
private ?GuzzleClient $guzzle = null;
/**
* @param array<mixed> $options
*/
@@ -26,6 +33,18 @@ class S3Filesystem extends AwsS3V3Adapter
);
}
private function getGuzzleClient(): GuzzleClient
{
if ($this->guzzle === null) {
$this->guzzle = new GuzzleClient([
'timeout' => 30,
'connect_timeout' => 10,
]);
}
return $this->guzzle;
}
public function getClient(): S3ClientInterface
{
return $this->client;
@@ -35,4 +54,78 @@ class S3Filesystem extends AwsS3V3Adapter
{
return $this->bucket;
}
/**
* Execute an S3 command using a presigned URL for maximum compatibility
* with S3-compatible providers.
*
* @return Result<array<string, mixed>>
*/
public function executeS3Command(CommandInterface $command): Result
{
$presignedRequest = $this->client->createPresignedRequest($command, '+60 minutes');
$response = $this->getGuzzleClient()->send($presignedRequest);
$body = (string) $response->getBody();
$commandName = $command->getName();
// S3's CompleteMultipartUpload can return HTTP 200 with an <Error> body
if ($body !== '' && str_contains($body, '<Error>')) {
throw new RuntimeException("S3 returned an error for $commandName: $body");
}
return new Result($this->parseS3Response($commandName, $body));
}
/**
* Parse the XML response body based on the S3 command type.
*
* @return array<string, mixed>
*/
private function parseS3Response(string $commandName, string $body): array
{
if ($body === '') {
return [];
}
$xml = @simplexml_load_string($body);
if ($xml === false) {
throw new RuntimeException("Failed to parse S3 XML response for $commandName: $body");
}
return match ($commandName) {
'CreateMultipartUpload' => $this->parseCreateMultipartUpload($xml),
'ListParts' => $this->parseListParts($xml),
'CompleteMultipartUpload' => [],
default => [],
};
}
/**
* @return array{UploadId: string}
*/
private function parseCreateMultipartUpload(SimpleXMLElement $xml): array
{
return [
'UploadId' => (string) $xml->UploadId,
];
}
/**
* @return array{Parts: array<int, array{ETag: string, PartNumber: int}>}
*/
private function parseListParts(SimpleXMLElement $xml): array
{
$parts = [];
foreach ($xml->Part as $part) {
$parts[] = [
'ETag' => (string) $part->ETag,
'PartNumber' => (int) $part->PartNumber,
];
}
return ['Parts' => $parts];
}
}

View File

@@ -91,7 +91,7 @@ class BackupRemoteUploadController extends Controller
}
// Execute the CreateMultipartUpload request
$result = $client->execute($client->getCommand('CreateMultipartUpload', $params));
$result = $adapter->executeS3Command($client->getCommand('CreateMultipartUpload', $params));
// Get the UploadId from the CreateMultipartUpload request, this is needed to create
// the other presigned urls.

View File

@@ -138,7 +138,7 @@ class BackupStatusController extends Controller
$client = $adapter->getClient();
if (!$successful) {
$client->execute($client->getCommand('AbortMultipartUpload', $params));
$adapter->executeS3Command($client->getCommand('AbortMultipartUpload', $params));
return;
}
@@ -149,7 +149,7 @@ class BackupStatusController extends Controller
];
if (is_null($parts)) {
$params['MultipartUpload']['Parts'] = $client->execute($client->getCommand('ListParts', $params))['Parts'];
$params['MultipartUpload']['Parts'] = $adapter->executeS3Command($client->getCommand('ListParts', $params))['Parts'];
} else {
foreach ($parts as $part) {
$params['MultipartUpload']['Parts'][] = [
@@ -159,6 +159,6 @@ class BackupStatusController extends Controller
}
}
$client->execute($client->getCommand('CompleteMultipartUpload', $params));
$adapter->executeS3Command($client->getCommand('CompleteMultipartUpload', $params));
}
}

View File

@@ -1,72 +0,0 @@
<?php
namespace App\Listeners;
use App\Facades\Activity;
use Filament\Facades\Filament;
use Filament\Resources\Events\RecordCreated;
use Filament\Resources\Events\RecordUpdated;
use Illuminate\Support\Str;
class AdminActivityListener
{
protected const REDACTED_FIELDS = [
'password',
'password_confirmation',
'current_password',
'token',
'secret',
'api_key',
'daemon_token',
'_token',
];
public function handle(RecordCreated|RecordUpdated $event): void
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return;
}
$record = $event->getRecord();
$page = $event->getPage();
$data = $event->getData();
$resourceClass = $page::getResource();
$modelClass = $resourceClass::getModel();
$slug = Str::kebab(class_basename($modelClass));
$action = $event instanceof RecordCreated ? 'create' : 'update';
$properties = $this->redactSensitiveFields($data);
Activity::event("admin:$slug.$action")
->subject($record)
->property($properties)
->log();
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function redactSensitiveFields(array $data): array
{
$redacted = [];
foreach ($data as $key => $value) {
if (in_array($key, self::REDACTED_FIELDS, true)) {
$redacted[$key] = '[REDACTED]';
continue;
}
if (is_array($value)) {
$redacted[$key] = $this->redactSensitiveFields($value);
} else {
$redacted[$key] = $value;
}
}
return $redacted;
}
}

View File

@@ -23,14 +23,16 @@ class AccountCreated extends Notification implements ShouldQueue
public function toMail(User $notifiable): MailMessage
{
$locale = $notifiable->language ?? 'en';
$message = (new MailMessage())
->greeting('Hello ' . $notifiable->username . '!')
->line('You are receiving this email because an account has been created for you on ' . config('app.name') . '.')
->line('Username: ' . $notifiable->username)
->line('Email: ' . $notifiable->email);
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
->line(trans('mail.account_created.body', ['app' => config('app.name')], $locale))
->line(trans('mail.account_created.username', ['username' => $notifiable->username], $locale))
->line(trans('mail.account_created.email', ['email' => $notifiable->email], $locale));
if (!is_null($this->token)) {
return $message->action('Setup Your Account', Filament::getPanel('app')->getResetPasswordUrl($this->token, $notifiable));
return $message->action(trans('mail.account_created.action', locale: $locale), Filament::getPanel('app')->getResetPasswordUrl($this->token, $notifiable));
}
return $message;

View File

@@ -24,10 +24,12 @@ class AddedToServer extends Notification implements ShouldQueue
public function toMail(User $notifiable): MailMessage
{
$locale = $notifiable->language ?? 'en';
return (new MailMessage())
->greeting('Hello ' . $notifiable->username . '!')
->line('You have been added as a subuser for the following server, allowing you certain control over the server.')
->line('Server Name: ' . $this->server->name)
->action('Visit Server', Console::getUrl(panel: 'server', tenant: $this->server));
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
->line(trans('mail.added_to_server.body', locale: $locale))
->line(trans('mail.added_to_server.server_name', ['name' => $this->server->name], $locale))
->action(trans('mail.added_to_server.action', locale: $locale), Console::getUrl(panel: 'server', tenant: $this->server));
}
}

View File

@@ -20,9 +20,11 @@ class MailTested extends Notification
public function toMail(): MailMessage
{
$locale = $this->user->language ?? 'en';
return (new MailMessage())
->subject('Panel Test Message')
->greeting('Hello ' . $this->user->username . '!')
->line('This is a test of the Panel mail system. You\'re good to go!');
->subject(trans('mail.mail_tested.subject', locale: $locale))
->greeting(trans('mail.greeting', ['name' => $this->user->username], $locale))
->line(trans('mail.mail_tested.body', locale: $locale));
}
}

View File

@@ -23,11 +23,13 @@ class RemovedFromServer extends Notification implements ShouldQueue
public function toMail(User $notifiable): MailMessage
{
$locale = $notifiable->language ?? 'en';
return (new MailMessage())
->error()
->greeting('Hello ' . $notifiable->username . '.')
->line('You have been removed as a subuser for the following server.')
->line('Server Name: ' . $this->server->name)
->action('Visit Panel', url(''));
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
->line(trans('mail.removed_from_server.body', locale: $locale))
->line(trans('mail.removed_from_server.server_name', ['name' => $this->server->name], $locale))
->action(trans('mail.removed_from_server.action', locale: $locale), url(''));
}
}

View File

@@ -24,10 +24,12 @@ class ServerInstalled extends Notification implements ShouldQueue
public function toMail(User $notifiable): MailMessage
{
$locale = $notifiable->language ?? 'en';
return (new MailMessage())
->greeting('Hello ' . $notifiable->username . '.')
->line('Your server has finished installing and is now ready for you to use.')
->line('Server Name: ' . $this->server->name)
->action('Login and Begin Using', Console::getUrl(panel: 'server', tenant: $this->server));
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
->line(trans('mail.server_installed.body', locale: $locale))
->line(trans('mail.server_installed.server_name', ['name' => $this->server->name], $locale))
->action(trans('mail.server_installed.action', locale: $locale), Console::getUrl(panel: 'server', tenant: $this->server));
}
}

View File

@@ -2,10 +2,7 @@
namespace App\Providers;
use App\Listeners\AdminActivityListener;
use App\Listeners\DispatchWebhooks;
use Filament\Resources\Events\RecordCreated;
use Filament\Resources\Events\RecordUpdated;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
@@ -18,7 +15,5 @@ class EventServiceProvider extends ServiceProvider
'eloquent.created*' => [DispatchWebhooks::class],
'eloquent.deleted*' => [DispatchWebhooks::class],
'eloquent.updated*' => [DispatchWebhooks::class],
RecordCreated::class => [AdminActivityListener::class],
RecordUpdated::class => [AdminActivityListener::class],
];
}

View File

@@ -4,14 +4,12 @@ namespace App\Providers\Filament;
use App\Enums\CustomizationKey;
use App\Enums\TablerIcon;
use App\Facades\Activity;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Field;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater;
@@ -31,10 +29,8 @@ use Filament\Support\View\SupportIconAlias;
use Filament\Tables\View\TablesIconAlias;
use Filament\View\PanelsIconAlias;
use Filament\View\PanelsRenderHook;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Livewire\Component;
use Livewire\Livewire;
@@ -136,18 +132,6 @@ class FilamentServiceProvider extends ServiceProvider
$action->iconButton();
$action->iconSize(IconSize::ExtraLarge);
}
$action->before(function (Model $record) {
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return;
}
$slug = Str::kebab(class_basename($record));
Activity::event("admin:$slug.delete")
->subject($record)
->log();
});
});
CreateAction::configureUsing(function (CreateAction $action) {

View File

@@ -10,7 +10,6 @@ use Filament\Facades\Filament;
use Illuminate\Contracts\Auth\Factory as AuthFactory;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Request;
use Throwable;
@@ -71,7 +70,7 @@ class ActivityLogService
*/
public function subject(...$subjects): self
{
foreach (Arr::wrap($subjects) as $subject) {
foreach ($subjects as $subject) {
if (is_null($subject)) {
continue;
}

View File

@@ -7,7 +7,6 @@ use App\Extensions\Backups\BackupManager;
use App\Extensions\Filesystem\S3Filesystem;
use App\Models\Backup;
use App\Repositories\Daemon\DaemonBackupRepository;
use Aws\S3\S3Client;
use Exception;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Http\Response;
@@ -72,14 +71,12 @@ class DeleteBackupService
/** @var S3Filesystem $adapter */
$adapter = $this->manager->adapter(Backup::ADAPTER_AWS_S3);
/** @var S3Client $client */
$client = $adapter->getClient();
$client->deleteObject([
$adapter->executeS3Command($client->getCommand('DeleteObject', [
'Bucket' => $adapter->getBucket(),
'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid),
]);
]));
});
}
}

View File

@@ -122,51 +122,4 @@ return [
],
'crashed' => 'Server crashed',
],
'admin' => [
'user' => [
'create' => 'Created user <b>:username</b>',
'update' => 'Updated user <b>:username</b>',
'delete' => 'Deleted user <b>:username</b>',
],
'server' => [
'create' => 'Created server <b>:name</b>',
'update' => 'Updated server <b>:name</b>',
'delete' => 'Deleted server <b>:name</b>',
],
'node' => [
'create' => 'Created node <b>:name</b>',
'update' => 'Updated node <b>:name</b>',
'delete' => 'Deleted node <b>:name</b>',
],
'egg' => [
'create' => 'Created egg <b>:name</b>',
'update' => 'Updated egg <b>:name</b>',
'delete' => 'Deleted egg <b>:name</b>',
],
'role' => [
'create' => 'Created role <b>:name</b>',
'update' => 'Updated role <b>:name</b>',
'delete' => 'Deleted role <b>:name</b>',
],
'database-host' => [
'create' => 'Created database host <b>:name</b>',
'update' => 'Updated database host <b>:name</b>',
'delete' => 'Deleted database host <b>:name</b>',
],
'mount' => [
'create' => 'Created mount <b>:name</b>',
'update' => 'Updated mount <b>:name</b>',
'delete' => 'Deleted mount <b>:name</b>',
],
'webhook-configuration' => [
'create' => 'Created webhook <b>:description</b>',
'update' => 'Updated webhook <b>:description</b>',
'delete' => 'Deleted webhook <b>:description</b>',
],
'api-key' => [
'create' => 'Created API key',
'update' => 'Updated API key',
'delete' => 'Deleted API key',
],
],
];

35
lang/en/mail.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
return [
'greeting' => 'Hello :name!',
'account_created' => [
'body' => 'You are receiving this email because an account has been created for you on :app.',
'username' => 'Username: :username',
'email' => 'Email: :email',
'action' => 'Setup Your Account',
],
'added_to_server' => [
'body' => 'You have been added as a subuser for the following server, allowing you certain control over the server.',
'server_name' => 'Server Name: :name',
'action' => 'Visit Server',
],
'removed_from_server' => [
'body' => 'You have been removed as a subuser for the following server.',
'server_name' => 'Server Name: :name',
'action' => 'Visit Panel',
],
'server_installed' => [
'body' => 'Your server has finished installing and is now ready for you to use.',
'server_name' => 'Server Name: :name',
'action' => 'Login and Begin Using',
],
'mail_tested' => [
'subject' => 'Panel Test Message',
'body' => 'This is a test of the Panel mail system. You\'re good to go!',
],
];

View File

@@ -1,158 +0,0 @@
<?php
use App\Events\ActivityLogged;
use App\Filament\Admin\Resources\Eggs\Pages\CreateEgg;
use App\Filament\Admin\Resources\Eggs\Pages\EditEgg;
use App\Filament\Admin\Resources\Nodes\Pages\CreateNode;
use App\Filament\Admin\Resources\Nodes\Pages\EditNode;
use App\Listeners\AdminActivityListener;
use App\Models\Egg;
use App\Models\Node;
use App\Models\Role;
use Filament\Facades\Filament;
use Filament\Resources\Events\RecordCreated;
use Filament\Resources\Events\RecordUpdated;
use Illuminate\Support\Facades\Event;
function pageInstance(string $class): object
{
return (new ReflectionClass($class))->newInstanceWithoutConstructor();
}
function createEvent(object $record, array $data, object $page): RecordCreated
{
return new RecordCreated($record, $data, $page);
}
function updateEvent(object $record, array $data, object $page): RecordUpdated
{
return new RecordUpdated($record, $data, $page);
}
beforeEach(function () {
[$this->admin] = generateTestAccount([]);
$this->admin = $this->admin->syncRoles(Role::getRootAdmin());
$this->actingAs($this->admin);
Filament::setCurrentPanel('admin');
});
it('logs create activity for an egg', function () {
$egg = Egg::first();
$listener = new AdminActivityListener();
$listener->handle(createEvent($egg, ['name' => 'Test Egg'], pageInstance(CreateEgg::class)));
$this->assertActivityLogged('admin:egg.create');
});
it('logs update activity for an egg', function () {
$egg = Egg::first();
$listener = new AdminActivityListener();
$listener->handle(updateEvent($egg, ['name' => 'Updated Egg'], pageInstance(EditEgg::class)));
$this->assertActivityLogged('admin:egg.update');
});
it('logs create activity for a node', function () {
$node = Node::first();
$listener = new AdminActivityListener();
$listener->handle(createEvent($node, ['name' => 'Test Node'], pageInstance(CreateNode::class)));
$this->assertActivityLogged('admin:node.create');
});
it('logs update activity for a node', function () {
$node = Node::first();
$listener = new AdminActivityListener();
$listener->handle(updateEvent($node, ['name' => 'Updated Node'], pageInstance(EditNode::class)));
$this->assertActivityLogged('admin:node.update');
});
it('does not log activity for non-admin panels', function () {
Filament::setCurrentPanel('app');
$egg = Egg::first();
$listener = new AdminActivityListener();
$listener->handle(createEvent($egg, ['name' => 'Test'], pageInstance(CreateEgg::class)));
Event::assertNotDispatched(ActivityLogged::class);
});
it('sets the record as the activity subject', function () {
$egg = Egg::first();
$listener = new AdminActivityListener();
$listener->handle(createEvent($egg, ['name' => 'Test'], pageInstance(CreateEgg::class)));
$this->assertActivityFor('admin:egg.create', $this->admin, $egg);
});
it('redacts sensitive fields from activity properties', function () {
$egg = Egg::first();
$data = [
'name' => 'Visible',
'password' => 'should-be-redacted',
'password_confirmation' => 'should-be-redacted',
'token' => 'should-be-redacted',
'secret' => 'should-be-redacted',
'api_key' => 'should-be-redacted',
];
$listener = new AdminActivityListener();
$listener->handle(updateEvent($egg, $data, pageInstance(EditEgg::class)));
Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $event) {
$properties = $event->model->properties;
expect($properties)->toHaveKey('name', 'Visible')
->toHaveKey('password', '[REDACTED]')
->toHaveKey('password_confirmation', '[REDACTED]')
->toHaveKey('token', '[REDACTED]')
->toHaveKey('secret', '[REDACTED]')
->toHaveKey('api_key', '[REDACTED]');
return true;
});
});
it('redacts sensitive fields in nested arrays', function () {
$egg = Egg::first();
$data = [
'name' => 'Visible',
'nested' => [
'safe' => 'value',
'password' => 'should-be-redacted',
'token' => 'should-be-redacted',
],
];
$listener = new AdminActivityListener();
$listener->handle(updateEvent($egg, $data, pageInstance(EditEgg::class)));
Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $event) {
$properties = $event->model->properties;
expect($properties['nested'])->toHaveKey('safe', 'value')
->toHaveKey('password', '[REDACTED]')
->toHaveKey('token', '[REDACTED]');
return true;
});
});
it('generates kebab-case event names from model class names', function () {
$node = Node::first();
$listener = new AdminActivityListener();
$listener->handle(createEvent($node, ['name' => 'Test'], pageInstance(CreateNode::class)));
$this->assertActivityLogged('admin:node.create');
});

View File

@@ -9,8 +9,10 @@ use App\Models\Backup;
use App\Repositories\Daemon\DaemonBackupRepository;
use App\Services\Backups\DeleteBackupService;
use App\Tests\Integration\IntegrationTestCase;
use Aws\CommandInterface;
use GuzzleHttp\Psr7\Response;
use Illuminate\Http\Client\ConnectionException;
use Mockery;
class DeleteBackupServiceTest extends IntegrationTestCase
{
@@ -92,10 +94,12 @@ class DeleteBackupServiceTest extends IntegrationTestCase
$manager->expects('adapter')->with(Backup::ADAPTER_AWS_S3)->andReturn($adapter);
$adapter->expects('getBucket')->andReturn('foobar');
$adapter->expects('getClient->deleteObject')->with([
$mockCommand = Mockery::mock(CommandInterface::class);
$adapter->expects('getClient->getCommand')->with('DeleteObject', [
'Bucket' => 'foobar',
'Key' => sprintf('%s/%s.tar.gz', $server->uuid, $backup->uuid),
]);
])->andReturn($mockCommand);
$adapter->expects('executeS3Command')->with($mockCommand);
$this->app->make(DeleteBackupService::class)->handle($backup);