Merge branch 'next' into feat/copy-resource-logs-with-sanitization

This commit is contained in:
Duane Adam authored and GitHub committed 2025-12-16 12:03:53 +08:00
commit b0b3098abe
85 files changed
+8842 -1086

No files matched your search

+3 -9
View File
@@ -30,7 +30,6 @@ class UpdateCoolify
if (! $this->server) {
return;
}
CleanupDocker::dispatch($this->server, false, false);
// Fetch fresh version from CDN instead of using cache
try {
@@ -117,17 +116,12 @@ class UpdateCoolify
private function update()
{
$helperImage = config('constants.coolify.helper_image');
$latest_version = getHelperVersion();
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
instant_remote_process(["docker pull -q $image"], $this->server, false);
$latestHelperImageVersion = getHelperVersion();
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
remote_process([
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
"bash /data/coolify/source/upgrade.sh $this->latestVersion",
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
], $this->server);
}
}
+3 -1
View File
@@ -309,7 +309,9 @@ class Github extends Controller
if (! $id || ! $branch) {
return response('Nothing to do. No id or branch found.');
}
$applications = Application::where('repository_project_id', $id)->whereRelation('source', 'is_public', false);
$applications = Application::where('repository_project_id', $id)
->where('source_id', $github_app->id)
->whereRelation('source', 'is_public', false);
if ($x_github_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get();
if ($applications->isEmpty()) {
+2 -8
View File
@@ -2,10 +2,8 @@
namespace App\Livewire;
use App\Models\InstanceSettings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class NavbarDeleteTeam extends Component
@@ -19,12 +17,8 @@ class NavbarDeleteTeam extends Component
public function delete($password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$currentTeam = currentTeam();
@@ -20,6 +20,8 @@ class Show extends Component
public bool $is_debug_enabled = false;
public bool $fullscreen = false;
private bool $deploymentFinishedDispatched = false;
public function getListeners()
+2 -9
View File
@@ -2,12 +2,9 @@
namespace App\Livewire\Project\Database;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -154,12 +151,8 @@ class BackupEdit extends Component
{
$this->authorize('manageBackups', $this->backup->database);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
try {
@@ -2,11 +2,9 @@
namespace App\Livewire\Project\Database;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class BackupExecutions extends Component
@@ -69,12 +67,8 @@ class BackupExecutions extends Component
public function deleteBackup($executionId, $password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$execution = $this->backup->executions()->where('id', $executionId)->first();
@@ -128,6 +128,7 @@ class GithubPrivateRepository extends Component
$this->loadBranchByPage();
}
}
$this->branches = sortBranchesByPriority($this->branches);
$this->selected_branch_name = data_get($this->branches, '0.name', 'main');
}
+2 -9
View File
@@ -4,12 +4,9 @@ namespace App\Livewire\Project\Service;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Models\InstanceSettings;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Database extends Component
@@ -96,12 +93,8 @@ class Database extends Component
try {
$this->authorize('delete', $this->database);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->database->delete();
+5 -11
View File
@@ -3,7 +3,6 @@
namespace App\Livewire\Project\Service;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@@ -16,8 +15,6 @@ use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -62,7 +59,7 @@ class FileStorage extends Component
$this->fs_path = $this->fileStorage->fs_path;
}
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
$this->syncData();
}
@@ -104,7 +101,8 @@ class FileStorage extends Component
public function loadStorageOnServer()
{
try {
$this->authorize('update', $this->resource);
// Loading content is a read operation, so we use 'view' permission
$this->authorize('view', $this->resource);
$this->fileStorage->loadStorageOnServer();
$this->syncData();
@@ -140,12 +138,8 @@ class FileStorage extends Component
{
$this->authorize('update', $this->resource);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
try {
@@ -2,12 +2,9 @@
namespace App\Livewire\Project\Service;
use App\Models\InstanceSettings;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
@@ -128,12 +125,8 @@ class ServiceApplicationView extends Component
try {
$this->authorize('delete', $this->application);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->application->delete();
+1 -1
View File
@@ -67,7 +67,7 @@ class Storage extends Component
public function refreshStorages()
{
$this->fileStorage = $this->resource->fileStorages()->get();
$this->resource->refresh();
$this->resource->load('persistentStorages.resource');
}
public function getFilesProperty()
+2 -9
View File
@@ -3,13 +3,10 @@
namespace App\Livewire\Project\Shared;
use App\Jobs\DeleteResourceJob;
use App\Models\InstanceSettings;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -93,12 +90,8 @@ class Danger extends Component
public function delete($password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
if (! $this->resource) {
+2 -9
View File
@@ -5,12 +5,9 @@ namespace App\Livewire\Project\Shared;
use App\Actions\Application\StopApplicationOneServer;
use App\Actions\Docker\GetContainersStatus;
use App\Events\ApplicationStatusChanged;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -140,12 +137,8 @@ class Destination extends Component
public function removeServer(int $network_id, int $server_id, $password)
{
try {
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) {
+3 -10
View File
@@ -2,11 +2,8 @@
namespace App\Livewire\Project\Shared\Storages;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Show extends Component
@@ -67,7 +64,7 @@ class Show extends Component
public function mount()
{
$this->syncData(false);
$this->isReadOnly = $this->storage->isReadOnlyVolume();
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
}
public function submit()
@@ -84,12 +81,8 @@ class Show extends Component
{
$this->authorize('update', $this->resource);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->storage->delete();
+2 -9
View File
@@ -3,11 +3,8 @@
namespace App\Livewire\Server;
use App\Actions\Server\DeleteServer;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Delete extends Component
@@ -29,12 +26,8 @@ class Delete extends Component
public function delete($password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
try {
$this->authorize('delete', $this->server);
@@ -2,11 +2,8 @@
namespace App\Livewire\Server\Security;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -44,13 +41,9 @@ class TerminalAccess extends Component
throw new \Exception('Only team administrators and owners can modify terminal access.');
}
// Verify password unless two-step confirmation is disabled
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
// Verify password
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
// Toggle the terminal setting
+109
View File
@@ -5,9 +5,12 @@ namespace App\Livewire\Server;
use App\Actions\Server\StartSentinel;
use App\Actions\Server\StopSentinel;
use App\Events\ServerReachabilityChanged;
use App\Models\CloudProviderToken;
use App\Models\Server;
use App\Services\HetznerService;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -73,6 +76,17 @@ class Show extends Component
public bool $isValidating = false;
// Hetzner linking properties
public Collection $availableHetznerTokens;
public ?int $selectedHetznerTokenId = null;
public ?array $matchedHetznerServer = null;
public ?string $hetznerSearchError = null;
public bool $hetznerNoMatchFound = false;
public function getListeners()
{
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
@@ -150,6 +164,9 @@ class Show extends Component
$this->hetznerServerStatus = $this->server->hetzner_server_status;
$this->isValidating = $this->server->is_validating ?? false;
// Load Hetzner tokens for linking
$this->loadHetznerTokens();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -465,6 +482,98 @@ class Show extends Component
}
}
public function loadHetznerTokens(): void
{
$this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->get();
}
public function searchHetznerServer(): void
{
$this->hetznerSearchError = null;
$this->hetznerNoMatchFound = false;
$this->matchedHetznerServer = null;
if (! $this->selectedHetznerTokenId) {
$this->hetznerSearchError = 'Please select a Hetzner token.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
if (! $token) {
$this->hetznerSearchError = 'Invalid token selected.';
return;
}
$hetznerService = new HetznerService($token->token);
$matched = $hetznerService->findServerByIp($this->server->ip);
if ($matched) {
$this->matchedHetznerServer = $matched;
} else {
$this->hetznerNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->hetznerSearchError = 'Failed to search Hetzner servers: '.$e->getMessage();
}
}
public function linkToHetzner()
{
if (! $this->matchedHetznerServer) {
$this->dispatch('error', 'No Hetzner server selected.');
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
if (! $token) {
$this->dispatch('error', 'Invalid token selected.');
return;
}
// Verify the server exists and is accessible with the token
$hetznerService = new HetznerService($token->token);
$serverData = $hetznerService->getServer($this->matchedHetznerServer['id']);
if (empty($serverData)) {
$this->dispatch('error', 'Could not find Hetzner server with ID: '.$this->matchedHetznerServer['id']);
return;
}
// Update the server with Hetzner details
$this->server->update([
'cloud_provider_token_id' => $this->selectedHetznerTokenId,
'hetzner_server_id' => $this->matchedHetznerServer['id'],
'hetzner_server_status' => $serverData['status'] ?? null,
]);
$this->hetznerServerStatus = $serverData['status'] ?? null;
// Clear the linking state
$this->matchedHetznerServer = null;
$this->selectedHetznerTokenId = null;
$this->hetznerNoMatchFound = false;
$this->hetznerSearchError = null;
$this->dispatch('success', 'Server successfully linked to Hetzner Cloud!');
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.show');
+1 -5
View File
@@ -5,8 +5,6 @@ namespace App\Livewire\Settings;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Rules\ValidIpOrCidr;
use Auth;
use Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -157,9 +155,7 @@ class Advanced extends Component
public function toggleTwoStepConfirmation($password): bool
{
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
if (! verifyPasswordConfirmation($password, $this)) {
return false;
}
+2 -9
View File
@@ -2,10 +2,7 @@
namespace App\Livewire\Team;
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class AdminView extends Component
@@ -58,12 +55,8 @@ class AdminView extends Component
return redirect()->route('dashboard');
}
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
if (! auth()->user()->isInstanceAdmin()) {
+76
View File
@@ -4,6 +4,7 @@ namespace App\Livewire;
use App\Actions\Server\UpdateCoolify;
use App\Models\InstanceSettings;
use App\Models\Server;
use Livewire\Component;
class Upgrade extends Component
@@ -14,12 +15,20 @@ class Upgrade extends Component
public string $latestVersion = '';
public string $currentVersion = '';
protected $listeners = ['updateAvailable' => 'checkUpdate'];
public function mount()
{
$this->currentVersion = config('constants.coolify.version');
}
public function checkUpdate()
{
try {
$this->latestVersion = get_latest_version_of_coolify();
$this->currentVersion = config('constants.coolify.version');
$this->isUpgradeAvailable = data_get(InstanceSettings::get(), 'new_version_available', false);
if (isDev()) {
$this->isUpgradeAvailable = true;
@@ -41,4 +50,71 @@ class Upgrade extends Component
return handleError($e, $this);
}
}
public function getUpgradeStatus(): array
{
// Only root team members can view upgrade status
if (auth()->user()?->currentTeam()?->id !== 0) {
return ['status' => 'none'];
}
$server = Server::find(0);
if (! $server) {
return ['status' => 'none'];
}
$statusFile = '/data/coolify/source/.upgrade-status';
try {
$content = instant_remote_process(
["cat {$statusFile} 2>/dev/null || echo ''"],
$server,
false
);
$content = trim($content ?? '');
} catch (\Throwable $e) {
return ['status' => 'none'];
}
if (empty($content)) {
return ['status' => 'none'];
}
$parts = explode('|', $content);
if (count($parts) < 3) {
return ['status' => 'none'];
}
[$step, $message, $timestamp] = $parts;
// Check if status is stale (older than 10 minutes)
try {
$statusTime = new \DateTime($timestamp);
$now = new \DateTime;
$diffMinutes = ($now->getTimestamp() - $statusTime->getTimestamp()) / 60;
if ($diffMinutes > 10) {
return ['status' => 'none'];
}
} catch (\Throwable $e) {
return ['status' => 'none'];
}
if ($step === 'error') {
return [
'status' => 'error',
'step' => 0,
'message' => $message,
];
}
$stepInt = (int) $step;
$status = $stepInt >= 6 ? 'complete' : 'in_progress';
return [
'status' => $status,
'step' => $stepInt,
'message' => $message,
];
}
}
+39 -4
View File
@@ -209,6 +209,23 @@ class LocalFileVolume extends BaseModel
return $query->get()->where('plain_mount_path', $path);
}
// Check if this volume belongs to a service resource
public function isServiceResource(): bool
{
return in_array($this->resource_type, [
'App\Models\ServiceApplication',
'App\Models\ServiceDatabase',
]);
}
// Determine if this volume should be read-only in the UI
// File/directory mounts can be edited even for services
public function shouldBeReadOnlyInUI(): bool
{
// Check for explicit :ro flag in compose (existing logic)
return $this->isReadOnlyVolume();
}
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
@@ -239,22 +256,40 @@ class LocalFileVolume extends BaseModel
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
// Note: We match on mount_path (container path) only, since fs_path gets transformed
// from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
$parts = explode(':', $volume);
// Check if this volume matches our fs_path and mount_path
// Check if this volume matches our mount_path
if (count($parts) >= 2) {
$hostPath = $parts[0];
$containerPath = $parts[1];
$options = $parts[2] ?? null;
// Match based on fs_path and mount_path
if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) {
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str($this->mount_path)->ltrim('/')->toString();
$containerPathClean = str($containerPath)->ltrim('/')->toString();
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $options === 'ro';
}
}
} elseif (is_array($volume)) {
// Long-form syntax: { type: bind, source: ..., target: ..., read_only: true }
$containerPath = data_get($volume, 'target');
$readOnly = data_get($volume, 'read_only', false);
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str($this->mount_path)->ltrim('/')->toString();
$containerPathClean = str($containerPath)->ltrim('/')->toString();
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $readOnly === true;
}
}
}
+67
View File
@@ -10,6 +10,11 @@ class LocalPersistentVolume extends Model
{
protected $guarded = [];
public function resource()
{
return $this->morphTo('resource');
}
public function application()
{
return $this->morphTo('resource');
@@ -50,6 +55,54 @@ class LocalPersistentVolume extends Model
);
}
// Check if this volume belongs to a service resource
public function isServiceResource(): bool
{
return in_array($this->resource_type, [
'App\Models\ServiceApplication',
'App\Models\ServiceDatabase',
]);
}
// Check if this volume belongs to a dockercompose application
public function isDockerComposeResource(): bool
{
if ($this->resource_type !== 'App\Models\Application') {
return false;
}
// Only access relationship if already eager loaded to avoid N+1
if (! $this->relationLoaded('resource')) {
return false;
}
$application = $this->resource;
if (! $application) {
return false;
}
return data_get($application, 'build_pack') === 'dockercompose';
}
// Determine if this volume should be read-only in the UI
// Service volumes and dockercompose application volumes are read-only
// (users should edit compose file directly)
public function shouldBeReadOnlyInUI(): bool
{
// All service volumes should be read-only in UI
if ($this->isServiceResource()) {
return true;
}
// All dockercompose application volumes should be read-only in UI
if ($this->isDockerComposeResource()) {
return true;
}
// Check for explicit :ro flag in compose (existing logic)
return $this->isReadOnlyVolume();
}
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
@@ -85,6 +138,7 @@ class LocalPersistentVolume extends Model
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
// Note: We match on mount_path (container path) only, since host paths get transformed
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
@@ -104,6 +158,19 @@ class LocalPersistentVolume extends Model
return $options === 'ro';
}
}
} elseif (is_array($volume)) {
// Long-form syntax: { type: bind/volume, source: ..., target: ..., read_only: true }
$containerPath = data_get($volume, 'target');
$readOnly = data_get($volume, 'read_only', false);
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str($this->mount_path)->ltrim('/')->toString();
$containerPathClean = str($containerPath)->ltrim('/')->toString();
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $readOnly === true;
}
}
}
+52
View File
@@ -20,6 +20,28 @@ class S3Storage extends BaseModel
'secret' => 'encrypted',
];
/**
* Boot the model and register event listeners.
*/
protected static function boot(): void
{
parent::boot();
// Trim whitespace from credentials before saving to prevent
// "Malformed Access Key Id" errors from accidental whitespace in pasted values.
// Note: We use the saving event instead of Attribute mutators because key/secret
// use Laravel's 'encrypted' cast. Attribute mutators fire before casts, which
// would cause issues with the encryption/decryption cycle.
static::saving(function (S3Storage $storage) {
if ($storage->key !== null) {
$storage->key = trim($storage->key);
}
if ($storage->secret !== null) {
$storage->secret = trim($storage->secret);
}
});
}
public static function ownedByCurrentTeam(array $select = ['*'])
{
$selectArray = collect($select)->concat(['id']);
@@ -55,6 +77,36 @@ class S3Storage extends BaseModel
);
}
/**
* Trim whitespace from endpoint to prevent malformed URLs.
*/
protected function endpoint(): Attribute
{
return Attribute::make(
set: fn (?string $value) => $value ? trim($value) : null,
);
}
/**
* Trim whitespace from bucket name to prevent connection errors.
*/
protected function bucket(): Attribute
{
return Attribute::make(
set: fn (?string $value) => $value ? trim($value) : null,
);
}
/**
* Trim whitespace from region to prevent connection errors.
*/
protected function region(): Attribute
{
return Attribute::make(
set: fn (?string $value) => $value ? trim($value) : null,
);
}
public function testConnection(bool $shouldSave = false)
{
try {
+9
View File
@@ -443,4 +443,13 @@ class User extends Authenticatable implements SendsEmail
&& $this->email_change_code_expires_at
&& Carbon::now()->lessThan($this->email_change_code_expires_at);
}
/**
* Check if the user has a password set.
* OAuth users are created without passwords.
*/
public function hasPassword(): bool
{
return ! empty($this->password);
}
}
+20 -15
View File
@@ -43,21 +43,26 @@ class EmailChannel
throw new Exception('No email recipients found');
}
foreach ($recipients as $recipient) {
// Check if the recipient is part of the team
if (! $members->contains('email', $recipient)) {
$emailSettings = $notifiable->emailNotificationSettings;
data_set($emailSettings, 'smtp_password', '********');
data_set($emailSettings, 'resend_api_key', '********');
send_internal_notification(sprintf(
"Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s",
$recipient,
$team,
get_class($notification),
get_class($notifiable),
json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
));
throw new Exception('Recipient is not part of the team');
// Skip team membership validation for test notifications
$isTestNotification = data_get($notification, 'isTestNotification', false);
if (! $isTestNotification) {
foreach ($recipients as $recipient) {
// Check if the recipient is part of the team
if (! $members->contains('email', $recipient)) {
$emailSettings = $notifiable->emailNotificationSettings;
data_set($emailSettings, 'smtp_password', '********');
data_set($emailSettings, 'resend_api_key', '********');
send_internal_notification(sprintf(
"Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s",
$recipient,
$team,
get_class($notification),
get_class($notifiable),
json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
));
throw new Exception('Recipient is not part of the team');
}
}
}
+2
View File
@@ -23,6 +23,8 @@ class Test extends Notification implements ShouldQueue
public $tries = 5;
public bool $isTestNotification = true;
public function __construct(public ?string $emails = null, public ?string $channel = null, public ?bool $ping = false)
{
$this->onQueue('high');
@@ -8,6 +8,8 @@ use Illuminate\Notifications\Messages\MailMessage;
class Test extends CustomEmailNotification
{
public bool $isTestNotification = true;
public function __construct(public string $emails, public bool $isTransactionalEmail = true)
{
$this->onQueue('high');
+26
View File
@@ -161,4 +161,30 @@ class HetznerService
{
$this->request('delete', "/servers/{$serverId}");
}
public function getServers(): array
{
return $this->requestPaginated('get', '/servers', 'servers');
}
public function findServerByIp(string $ip): ?array
{
$servers = $this->getServers();
foreach ($servers as $server) {
// Check IPv4
$ipv4 = data_get($server, 'public_net.ipv4.ip');
if ($ipv4 === $ip) {
return $server;
}
// Check IPv6 (Hetzner returns the full /64 block)
$ipv6 = data_get($server, 'public_net.ipv6.ip');
if ($ipv6 && str_starts_with($ip, rtrim($ipv6, '/'))) {
return $server;
}
}
return null;
}
}