Merge pull request #6898 from coollabsio/next

v4.0.0-beta.436
This commit is contained in:
Andras Bacsai 2025-10-17 15:48:19 +02:00 committed by GitHub
commit 0fee43f086
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
126 changed files with 8755 additions and 2030 deletions

View file

@ -0,0 +1,24 @@
name: Cleanup Untagged GHCR Images
on:
workflow_dispatch: # Allow manual trigger
schedule:
- cron: '0 */6 * * *' # Run every 6 hours to handle large volume (16k+ images)
env:
GITHUB_REGISTRY: ghcr.io
jobs:
cleanup-testing-host:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Delete untagged coolify-testing-host images
uses: actions/delete-package-versions@v5
with:
package-name: 'coolify-testing-host'
package-type: 'container'
min-versions-to-keep: 0
delete-only-untagged-versions: 'true'

View file

@ -30,7 +30,7 @@ public function getSubscriptionsPreview(): Collection
$subscriptions = collect();
// Get all teams the user belongs to
$teams = $this->user->teams;
$teams = $this->user->teams()->get();
foreach ($teams as $team) {
// Only include subscriptions from teams where user is owner
@ -49,6 +49,64 @@ public function getSubscriptionsPreview(): Collection
return $subscriptions;
}
/**
* Verify subscriptions exist and are active in Stripe API
*
* @return array ['verified' => Collection, 'not_found' => Collection, 'errors' => array]
*/
public function verifySubscriptionsInStripe(): array
{
if (! isCloud()) {
return [
'verified' => collect(),
'not_found' => collect(),
'errors' => [],
];
}
$stripe = new StripeClient(config('subscription.stripe_api_key'));
$subscriptions = $this->getSubscriptionsPreview();
$verified = collect();
$notFound = collect();
$errors = [];
foreach ($subscriptions as $subscription) {
try {
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
// Check if subscription is actually active in Stripe
if (in_array($stripeSubscription->status, ['active', 'trialing', 'past_due'])) {
$verified->push([
'subscription' => $subscription,
'stripe_status' => $stripeSubscription->status,
'current_period_end' => $stripeSubscription->current_period_end,
]);
} else {
$notFound->push([
'subscription' => $subscription,
'reason' => "Status in Stripe: {$stripeSubscription->status}",
]);
}
} catch (\Stripe\Exception\InvalidRequestException $e) {
// Subscription doesn't exist in Stripe
$notFound->push([
'subscription' => $subscription,
'reason' => 'Not found in Stripe',
]);
} catch (\Exception $e) {
$errors[] = "Error verifying subscription {$subscription->stripe_subscription_id}: ".$e->getMessage();
\Log::error("Error verifying subscription {$subscription->stripe_subscription_id}: ".$e->getMessage());
}
}
return [
'verified' => $verified,
'not_found' => $notFound,
'errors' => $errors,
];
}
public function execute(): array
{
if ($this->isDryRun) {

View file

@ -24,23 +24,46 @@ public function getResourcesPreview(): array
$services = collect();
// Get all teams the user belongs to
$teams = $this->user->teams;
$teams = $this->user->teams()->get();
foreach ($teams as $team) {
// Only delete resources from teams that will be FULLY DELETED
// This means: user is the ONLY member of the team
//
// DO NOT delete resources if:
// - User is just a member (not owner)
// - Team has other members (ownership will be transferred or user just removed)
$userRole = $team->pivot->role;
$memberCount = $team->members->count();
// Skip if user is not owner
if ($userRole !== 'owner') {
continue;
}
// Skip if team has other members (will be transferred/user removed, not deleted)
if ($memberCount > 1) {
continue;
}
// Only delete resources from teams where user is the ONLY member
// These teams will be fully deleted
// Get all servers for this team
$servers = $team->servers;
$servers = $team->servers()->get();
foreach ($servers as $server) {
// Get applications
$serverApplications = $server->applications;
// Get applications (custom method returns Collection)
$serverApplications = $server->applications();
$applications = $applications->merge($serverApplications);
// Get databases
$serverDatabases = $this->getAllDatabasesForServer($server);
// Get databases (custom method returns Collection)
$serverDatabases = $server->databases();
$databases = $databases->merge($serverDatabases);
// Get services
$serverServices = $server->services;
// Get services (relationship needs ->get())
$serverServices = $server->services()->get();
$services = $services->merge($serverServices);
}
}
@ -105,21 +128,4 @@ public function execute(): array
return $deletedCounts;
}
private function getAllDatabasesForServer($server): Collection
{
$databases = collect();
// Get all standalone database types
$databases = $databases->merge($server->postgresqls);
$databases = $databases->merge($server->mysqls);
$databases = $databases->merge($server->mariadbs);
$databases = $databases->merge($server->mongodbs);
$databases = $databases->merge($server->redis);
$databases = $databases->merge($server->keydbs);
$databases = $databases->merge($server->dragonflies);
$databases = $databases->merge($server->clickhouses);
return $databases;
}
}

View file

@ -23,13 +23,13 @@ public function getServersPreview(): Collection
$servers = collect();
// Get all teams the user belongs to
$teams = $this->user->teams;
$teams = $this->user->teams()->get();
foreach ($teams as $team) {
// Only include servers from teams where user is owner or admin
$userRole = $team->pivot->role;
if ($userRole === 'owner' || $userRole === 'admin') {
$teamServers = $team->servers;
$teamServers = $team->servers()->get();
$servers = $servers->merge($teamServers);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,56 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
class AdminRemoveUser extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:remove-user {email}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Remove User from database';
/**
* Execute the console command.
*/
public function handle()
{
try {
$email = $this->argument('email');
$confirm = $this->confirm('Are you sure you want to remove user with email: '.$email.'?');
if (! $confirm) {
$this->info('User removal cancelled.');
return;
}
$this->info("Removing user with email: $email");
$user = User::whereEmail($email)->firstOrFail();
$teams = $user->teams;
foreach ($teams as $team) {
if ($team->members->count() > 1) {
$this->error('User is a member of a team with more than one member. Please remove user from team first.');
return;
}
$team->delete();
}
$user->delete();
} catch (\Exception $e) {
$this->error('Failed to remove user.');
$this->error($e->getMessage());
return;
}
}
}

View file

@ -1,744 +0,0 @@
<?php
namespace App\Console\Commands\Cloud;
use App\Actions\Stripe\CancelSubscription;
use App\Actions\User\DeleteUserResources;
use App\Actions\User\DeleteUserServers;
use App\Actions\User\DeleteUserTeams;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class CloudDeleteUser extends Command
{
protected $signature = 'cloud:delete-user {email}
{--dry-run : Preview what will be deleted without actually deleting}
{--skip-stripe : Skip Stripe subscription cancellation}
{--skip-resources : Skip resource deletion}';
protected $description = 'Delete a user from the cloud instance with phase-by-phase confirmation';
private bool $isDryRun = false;
private bool $skipStripe = false;
private bool $skipResources = false;
private User $user;
public function handle()
{
if (! isCloud()) {
$this->error('This command is only available on cloud instances.');
return 1;
}
$email = $this->argument('email');
$this->isDryRun = $this->option('dry-run');
$this->skipStripe = $this->option('skip-stripe');
$this->skipResources = $this->option('skip-resources');
if ($this->isDryRun) {
$this->info('🔍 DRY RUN MODE - No data will be deleted');
$this->newLine();
}
try {
$this->user = User::whereEmail($email)->firstOrFail();
} catch (\Exception $e) {
$this->error("User with email '{$email}' not found.");
return 1;
}
// Implement file lock to prevent concurrent deletions of the same user
$lockKey = "user_deletion_{$this->user->id}";
$lock = Cache::lock($lockKey, 600); // 10 minute lock
if (! $lock->get()) {
$this->error('Another deletion process is already running for this user. Please try again later.');
$this->logAction("Deletion blocked for user {$email}: Another process is already running");
return 1;
}
try {
$this->logAction("Starting user deletion process for: {$email}");
// Phase 1: Show User Overview (outside transaction)
if (! $this->showUserOverview()) {
$this->info('User deletion cancelled.');
$lock->release();
return 0;
}
// If not dry run, wrap everything in a transaction
if (! $this->isDryRun) {
try {
DB::beginTransaction();
// Phase 2: Delete Resources
if (! $this->skipResources) {
if (! $this->deleteResources()) {
DB::rollBack();
$this->error('User deletion failed at resource deletion phase. All changes rolled back.');
return 1;
}
}
// Phase 3: Delete Servers
if (! $this->deleteServers()) {
DB::rollBack();
$this->error('User deletion failed at server deletion phase. All changes rolled back.');
return 1;
}
// Phase 4: Handle Teams
if (! $this->handleTeams()) {
DB::rollBack();
$this->error('User deletion failed at team handling phase. All changes rolled back.');
return 1;
}
// Phase 5: Cancel Stripe Subscriptions
if (! $this->skipStripe && isCloud()) {
if (! $this->cancelStripeSubscriptions()) {
DB::rollBack();
$this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.');
return 1;
}
}
// Phase 6: Delete User Profile
if (! $this->deleteUserProfile()) {
DB::rollBack();
$this->error('User deletion failed at final phase. All changes rolled back.');
return 1;
}
// Commit the transaction
DB::commit();
$this->newLine();
$this->info('✅ User deletion completed successfully!');
$this->logAction("User deletion completed for: {$email}");
} catch (\Exception $e) {
DB::rollBack();
$this->error('An error occurred during user deletion: '.$e->getMessage());
$this->logAction("User deletion failed for {$email}: ".$e->getMessage());
return 1;
}
} else {
// Dry run mode - just run through the phases without transaction
// Phase 2: Delete Resources
if (! $this->skipResources) {
if (! $this->deleteResources()) {
$this->info('User deletion would be cancelled at resource deletion phase.');
return 0;
}
}
// Phase 3: Delete Servers
if (! $this->deleteServers()) {
$this->info('User deletion would be cancelled at server deletion phase.');
return 0;
}
// Phase 4: Handle Teams
if (! $this->handleTeams()) {
$this->info('User deletion would be cancelled at team handling phase.');
return 0;
}
// Phase 5: Cancel Stripe Subscriptions
if (! $this->skipStripe && isCloud()) {
if (! $this->cancelStripeSubscriptions()) {
$this->info('User deletion would be cancelled at Stripe cancellation phase.');
return 0;
}
}
// Phase 6: Delete User Profile
if (! $this->deleteUserProfile()) {
$this->info('User deletion would be cancelled at final phase.');
return 0;
}
$this->newLine();
$this->info('✅ DRY RUN completed successfully! No data was deleted.');
}
return 0;
} finally {
// Ensure lock is always released
$lock->release();
}
}
private function showUserOverview(): bool
{
$this->info('═══════════════════════════════════════');
$this->info('PHASE 1: USER OVERVIEW');
$this->info('═══════════════════════════════════════');
$this->newLine();
$teams = $this->user->teams;
$ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner');
$memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner');
// Collect all servers from all teams
$allServers = collect();
$allApplications = collect();
$allDatabases = collect();
$allServices = collect();
$activeSubscriptions = collect();
foreach ($teams as $team) {
$servers = $team->servers;
$allServers = $allServers->merge($servers);
foreach ($servers as $server) {
$resources = $server->definedResources();
foreach ($resources as $resource) {
if ($resource instanceof \App\Models\Application) {
$allApplications->push($resource);
} elseif ($resource instanceof \App\Models\Service) {
$allServices->push($resource);
} else {
$allDatabases->push($resource);
}
}
}
if ($team->subscription && $team->subscription->stripe_subscription_id) {
$activeSubscriptions->push($team->subscription);
}
}
$this->table(
['Property', 'Value'],
[
['User', $this->user->email],
['User ID', $this->user->id],
['Created', $this->user->created_at->format('Y-m-d H:i:s')],
['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')],
['Teams (Total)', $teams->count()],
['Teams (Owner)', $ownedTeams->count()],
['Teams (Member)', $memberTeams->count()],
['Servers', $allServers->unique('id')->count()],
['Applications', $allApplications->count()],
['Databases', $allDatabases->count()],
['Services', $allServices->count()],
['Active Stripe Subscriptions', $activeSubscriptions->count()],
]
);
$this->newLine();
$this->warn('⚠️ WARNING: This will permanently delete the user and all associated data!');
$this->newLine();
if (! $this->confirm('Do you want to continue with the deletion process?', false)) {
return false;
}
return true;
}
private function deleteResources(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 2: DELETE RESOURCES');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new DeleteUserResources($this->user, $this->isDryRun);
$resources = $action->getResourcesPreview();
if ($resources['applications']->isEmpty() &&
$resources['databases']->isEmpty() &&
$resources['services']->isEmpty()) {
$this->info('No resources to delete.');
return true;
}
$this->info('Resources to be deleted:');
$this->newLine();
if ($resources['applications']->isNotEmpty()) {
$this->warn("Applications to be deleted ({$resources['applications']->count()}):");
$this->table(
['Name', 'UUID', 'Server', 'Status'],
$resources['applications']->map(function ($app) {
return [
$app->name,
$app->uuid,
$app->destination->server->name,
$app->status ?? 'unknown',
];
})->toArray()
);
$this->newLine();
}
if ($resources['databases']->isNotEmpty()) {
$this->warn("Databases to be deleted ({$resources['databases']->count()}):");
$this->table(
['Name', 'Type', 'UUID', 'Server'],
$resources['databases']->map(function ($db) {
return [
$db->name,
class_basename($db),
$db->uuid,
$db->destination->server->name,
];
})->toArray()
);
$this->newLine();
}
if ($resources['services']->isNotEmpty()) {
$this->warn("Services to be deleted ({$resources['services']->count()}):");
$this->table(
['Name', 'UUID', 'Server'],
$resources['services']->map(function ($service) {
return [
$service->name,
$service->uuid,
$service->server->name,
];
})->toArray()
);
$this->newLine();
}
$this->error('⚠️ THIS ACTION CANNOT BE UNDONE!');
if (! $this->confirm('Are you sure you want to delete all these resources?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Deleting resources...');
$result = $action->execute();
$this->info("Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services");
$this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services");
}
return true;
}
private function deleteServers(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 3: DELETE SERVERS');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new DeleteUserServers($this->user, $this->isDryRun);
$servers = $action->getServersPreview();
if ($servers->isEmpty()) {
$this->info('No servers to delete.');
return true;
}
$this->warn("Servers to be deleted ({$servers->count()}):");
$this->table(
['ID', 'Name', 'IP', 'Description', 'Resources Count'],
$servers->map(function ($server) {
$resourceCount = $server->definedResources()->count();
return [
$server->id,
$server->name,
$server->ip,
$server->description ?? '-',
$resourceCount,
];
})->toArray()
);
$this->newLine();
$this->error('⚠️ WARNING: Deleting servers will remove all server configurations!');
if (! $this->confirm('Are you sure you want to delete all these servers?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Deleting servers...');
$result = $action->execute();
$this->info("Deleted {$result['servers']} servers");
$this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}");
}
return true;
}
private function handleTeams(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 4: HANDLE TEAMS');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new DeleteUserTeams($this->user, $this->isDryRun);
$preview = $action->getTeamsPreview();
// Check for edge cases first - EXIT IMMEDIATELY if found
if ($preview['edge_cases']->isNotEmpty()) {
$this->error('═══════════════════════════════════════');
$this->error('⚠️ EDGE CASES DETECTED - CANNOT PROCEED');
$this->error('═══════════════════════════════════════');
$this->newLine();
foreach ($preview['edge_cases'] as $edgeCase) {
$team = $edgeCase['team'];
$reason = $edgeCase['reason'];
$this->error("Team: {$team->name} (ID: {$team->id})");
$this->error("Issue: {$reason}");
// Show team members for context
$this->info('Current members:');
foreach ($team->members as $member) {
$role = $member->pivot->role;
$this->line(" - {$member->name} ({$member->email}) - Role: {$role}");
}
// Check for active resources
$resourceCount = 0;
foreach ($team->servers as $server) {
$resources = $server->definedResources();
$resourceCount += $resources->count();
}
if ($resourceCount > 0) {
$this->warn(" ⚠️ This team has {$resourceCount} active resources!");
}
// Show subscription details if relevant
if ($team->subscription && $team->subscription->stripe_subscription_id) {
$this->warn(' ⚠️ Active Stripe subscription details:');
$this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}");
$this->warn(" Customer ID: {$team->subscription->stripe_customer_id}");
// Show other owners who could potentially take over
$otherOwners = $team->members
->where('id', '!=', $this->user->id)
->filter(function ($member) {
return $member->pivot->role === 'owner';
});
if ($otherOwners->isNotEmpty()) {
$this->info(' Other owners who could take over billing:');
foreach ($otherOwners as $owner) {
$this->line(" - {$owner->name} ({$owner->email})");
}
}
}
$this->newLine();
}
$this->error('Please resolve these issues manually before retrying:');
// Check if any edge case involves subscription payment issues
$hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) {
return str_contains($edgeCase['reason'], 'Stripe subscription');
});
if ($hasSubscriptionIssue) {
$this->info('For teams with subscription payment issues:');
$this->info('1. Cancel the subscription through Stripe dashboard, OR');
$this->info('2. Transfer the subscription to another owner\'s payment method, OR');
$this->info('3. Have the other owner create a new subscription after cancelling this one');
$this->newLine();
}
$hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) {
return str_contains($edgeCase['reason'], 'No suitable owner replacement');
});
if ($hasNoOwnerReplacement) {
$this->info('For teams with no suitable owner replacement:');
$this->info('1. Assign an admin role to a trusted member, OR');
$this->info('2. Transfer team resources to another team, OR');
$this->info('3. Delete the team manually if no longer needed');
$this->newLine();
}
$this->error('USER DELETION ABORTED DUE TO EDGE CASES');
$this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling");
// Exit immediately - don't proceed with deletion
if (! $this->isDryRun) {
DB::rollBack();
}
exit(1);
}
if ($preview['to_delete']->isEmpty() &&
$preview['to_transfer']->isEmpty() &&
$preview['to_leave']->isEmpty()) {
$this->info('No team changes needed.');
return true;
}
if ($preview['to_delete']->isNotEmpty()) {
$this->warn('Teams to be DELETED (user is the only member):');
$this->table(
['ID', 'Name', 'Resources', 'Subscription'],
$preview['to_delete']->map(function ($team) {
$resourceCount = 0;
foreach ($team->servers as $server) {
$resourceCount += $server->definedResources()->count();
}
$hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id
? '⚠️ YES - '.$team->subscription->stripe_subscription_id
: 'No';
return [
$team->id,
$team->name,
$resourceCount,
$hasSubscription,
];
})->toArray()
);
$this->newLine();
}
if ($preview['to_transfer']->isNotEmpty()) {
$this->warn('Teams where ownership will be TRANSFERRED:');
$this->table(
['Team ID', 'Team Name', 'New Owner', 'New Owner Email'],
$preview['to_transfer']->map(function ($item) {
return [
$item['team']->id,
$item['team']->name,
$item['new_owner']->name,
$item['new_owner']->email,
];
})->toArray()
);
$this->newLine();
}
if ($preview['to_leave']->isNotEmpty()) {
$this->warn('Teams where user will be REMOVED (other owners/admins exist):');
$userId = $this->user->id;
$this->table(
['ID', 'Name', 'User Role', 'Other Members'],
$preview['to_leave']->map(function ($team) use ($userId) {
$userRole = $team->members->where('id', $userId)->first()->pivot->role;
$otherMembers = $team->members->count() - 1;
return [
$team->id,
$team->name,
$userRole,
$otherMembers,
];
})->toArray()
);
$this->newLine();
}
$this->error('⚠️ WARNING: Team changes affect access control and ownership!');
if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Processing team changes...');
$result = $action->execute();
$this->info("Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}");
$this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}");
}
return true;
}
private function cancelStripeSubscriptions(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 5: CANCEL STRIPE SUBSCRIPTIONS');
$this->info('═══════════════════════════════════════');
$this->newLine();
$action = new CancelSubscription($this->user, $this->isDryRun);
$subscriptions = $action->getSubscriptionsPreview();
if ($subscriptions->isEmpty()) {
$this->info('No Stripe subscriptions to cancel.');
return true;
}
$this->info('Stripe subscriptions to cancel:');
$this->newLine();
$totalMonthlyValue = 0;
foreach ($subscriptions as $subscription) {
$team = $subscription->team;
$planId = $subscription->stripe_plan_id;
// Try to get the price from config
$monthlyValue = $this->getSubscriptionMonthlyValue($planId);
$totalMonthlyValue += $monthlyValue;
$this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})");
if ($monthlyValue > 0) {
$this->line(" Monthly value: \${$monthlyValue}");
}
if ($subscription->stripe_cancel_at_period_end) {
$this->line(' ⚠️ Already set to cancel at period end');
}
}
if ($totalMonthlyValue > 0) {
$this->newLine();
$this->warn("Total monthly value: \${$totalMonthlyValue}");
}
$this->newLine();
$this->error('⚠️ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!');
if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) {
return false;
}
if (! $this->isDryRun) {
$this->info('Cancelling subscriptions...');
$result = $action->execute();
$this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed");
if ($result['failed'] > 0 && ! empty($result['errors'])) {
$this->error('Failed subscriptions:');
foreach ($result['errors'] as $error) {
$this->error(" - {$error}");
}
}
$this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}");
}
return true;
}
private function deleteUserProfile(): bool
{
$this->newLine();
$this->info('═══════════════════════════════════════');
$this->info('PHASE 6: DELETE USER PROFILE');
$this->info('═══════════════════════════════════════');
$this->newLine();
$this->warn('⚠️ FINAL STEP - This action is IRREVERSIBLE!');
$this->newLine();
$this->info('User profile to be deleted:');
$this->table(
['Property', 'Value'],
[
['Email', $this->user->email],
['Name', $this->user->name],
['User ID', $this->user->id],
['Created', $this->user->created_at->format('Y-m-d H:i:s')],
['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'],
['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'],
]
);
$this->newLine();
$this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:");
$confirmation = $this->ask('Confirmation');
if ($confirmation !== "DELETE {$this->user->email}") {
$this->error('Confirmation text does not match. Deletion cancelled.');
return false;
}
if (! $this->isDryRun) {
$this->info('Deleting user profile...');
try {
$this->user->delete();
$this->info('User profile deleted successfully.');
$this->logAction("User profile deleted: {$this->user->email}");
} catch (\Exception $e) {
$this->error('Failed to delete user profile: '.$e->getMessage());
$this->logAction("Failed to delete user profile {$this->user->email}: ".$e->getMessage());
return false;
}
}
return true;
}
private function getSubscriptionMonthlyValue(string $planId): int
{
// Try to get pricing from subscription metadata or config
// Since we're using dynamic pricing, return 0 for now
// This could be enhanced by fetching the actual price from Stripe API
// Check if this is a dynamic pricing plan
$dynamicMonthlyPlanId = config('subscription.stripe_price_id_dynamic_monthly');
$dynamicYearlyPlanId = config('subscription.stripe_price_id_dynamic_yearly');
if ($planId === $dynamicMonthlyPlanId || $planId === $dynamicYearlyPlanId) {
// For dynamic pricing, we can't determine the exact amount without calling Stripe API
// Return 0 to indicate dynamic/usage-based pricing
return 0;
}
// For any other plans, return 0 as we don't have hardcoded prices
return 0;
}
private function logAction(string $message): void
{
$logMessage = "[CloudDeleteUser] {$message}";
if ($this->isDryRun) {
$logMessage = "[DRY RUN] {$logMessage}";
}
Log::channel('single')->info($logMessage);
// Also log to a dedicated user deletion log file
$logFile = storage_path('logs/user-deletions.log');
// Ensure the logs directory exists
$logDir = dirname($logFile);
if (! is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$timestamp = now()->format('Y-m-d H:i:s');
file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX);
}
}

View file

@ -597,6 +597,224 @@ public function update_by_uuid(Request $request)
]);
}
#[OA\Post(
summary: 'Create Backup',
description: 'Create a new scheduled backup configuration for a database',
path: '/databases/{uuid}/backups',
operationId: 'create-database-backup',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
requestBody: new OA\RequestBody(
description: 'Backup configuration data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['frequency'],
properties: [
'frequency' => ['type' => 'string', 'description' => 'Backup frequency (cron expression or: every_minute, hourly, daily, weekly, monthly, yearly)'],
'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled', 'default' => true],
'save_s3' => ['type' => 'boolean', 'description' => 'Whether to save backups to S3', 'default' => false],
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID (required if save_s3 is true)'],
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
'dump_all' => ['type' => 'boolean', 'description' => 'Whether to dump all databases', 'default' => false],
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'],
],
),
)
),
responses: [
new OA\Response(
response: 201,
description: 'Backup configuration created successfully',
content: new OA\JsonContent(
type: 'object',
properties: [
'uuid' => ['type' => 'string', 'format' => 'uuid', 'example' => '550e8400-e29b-41d4-a716-446655440000'],
'message' => ['type' => 'string', 'example' => 'Backup configuration created successfully.'],
]
)
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_backup(Request $request)
{
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// Validate incoming request is valid JSON
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'frequency' => 'required|string',
'enabled' => 'boolean',
'save_s3' => 'boolean',
'dump_all' => 'boolean',
'backup_now' => 'boolean|nullable',
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
'databases_to_backup' => 'string|nullable',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'integer|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'integer|min:0',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
if (! $request->uuid) {
return response()->json(['message' => 'UUID is required.'], 404);
}
$uuid = $request->uuid;
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('manageBackups', $database);
// Validate frequency is a valid cron expression
$isValid = validate_cron_expression($request->frequency);
if (! $isValid) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
], 422);
}
// Validate S3 storage if save_s3 is true
if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']],
], 422);
}
if ($request->filled('s3_storage_uuid')) {
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
], 422);
}
}
// Check for extra fields
$extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']);
if (! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$backupData = $request->only($backupConfigFields);
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
], 422);
}
unset($backupData['s3_storage_uuid']);
}
// Set default databases_to_backup based on database type if not provided
if (! isset($backupData['databases_to_backup']) || empty($backupData['databases_to_backup'])) {
if ($database->type() === 'standalone-postgresql') {
$backupData['databases_to_backup'] = $database->postgres_db;
} elseif ($database->type() === 'standalone-mysql') {
$backupData['databases_to_backup'] = $database->mysql_database;
} elseif ($database->type() === 'standalone-mariadb') {
$backupData['databases_to_backup'] = $database->mariadb_database;
}
}
// Add required fields
$backupData['database_id'] = $database->id;
$backupData['database_type'] = $database->getMorphClass();
$backupData['team_id'] = $teamId;
// Set defaults
if (! isset($backupData['enabled'])) {
$backupData['enabled'] = true;
}
$backupConfig = ScheduledDatabaseBackup::create($backupData);
// Trigger immediate backup if requested
if ($request->backup_now) {
dispatch(new DatabaseBackupJob($backupConfig));
}
return response()->json([
'uuid' => $backupConfig->uuid,
'message' => 'Backup configuration created successfully.',
], 201);
}
#[OA\Patch(
summary: 'Update',
description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID',

View file

@ -131,6 +131,161 @@ public function deployment_by_uuid(Request $request)
return response()->json($this->removeSensitiveData($deployment));
}
#[OA\Post(
summary: 'Cancel',
description: 'Cancel a deployment by UUID.',
path: '/deployments/{uuid}/cancel',
operationId: 'cancel-deployment-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Deployments'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Deployment UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Deployment cancelled successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Deployment cancelled successfully.'],
'deployment_uuid' => ['type' => 'string', 'example' => 'cm37r6cqj000008jm0veg5tkm'],
'status' => ['type' => 'string', 'example' => 'cancelled-by-user'],
]
)
),
]),
new OA\Response(
response: 400,
description: 'Deployment cannot be cancelled (already finished/failed/cancelled).',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Deployment cannot be cancelled. Current status: finished'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 403,
description: 'User doesn\'t have permission to cancel this deployment.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'You do not have permission to cancel this deployment.'],
]
)
),
]),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function cancel_deployment(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
// Find the deployment by UUID
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', $uuid)->first();
if (! $deployment) {
return response()->json(['message' => 'Deployment not found.'], 404);
}
// Check if the deployment belongs to the user's team
$servers = Server::whereTeamId($teamId)->pluck('id');
if (! $servers->contains($deployment->server_id)) {
return response()->json(['message' => 'You do not have permission to cancel this deployment.'], 403);
}
// Check if deployment can be cancelled (must be queued or in_progress)
$cancellableStatuses = [
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
];
if (! in_array($deployment->status, $cancellableStatuses)) {
return response()->json([
'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}",
], 400);
}
// Perform the cancellation
try {
$deployment_uuid = $deployment->deployment_uuid;
$kill_command = "docker rm -f {$deployment_uuid}";
$build_server_id = $deployment->build_server_id ?? $deployment->server_id;
// Mark deployment as cancelled
$deployment->update([
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
// Get the server
$server = Server::find($build_server_id);
if ($server) {
// Add cancellation log entry
$deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr');
// Check if container exists and kill it
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
$containerExists = instant_remote_process([$checkCommand], $server);
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
instant_remote_process([$kill_command], $server);
$deployment->addLogEntry('Deployment container stopped.');
} else {
$deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.');
}
// Kill running process if process ID exists
if ($deployment->current_process_id) {
try {
$processKillCommand = "kill -9 {$deployment->current_process_id}";
instant_remote_process([$processKillCommand], $server);
} catch (\Throwable $e) {
// Process might already be gone
}
}
}
return response()->json([
'message' => 'Deployment cancelled successfully.',
'deployment_uuid' => $deployment->deployment_uuid,
'status' => $deployment->status,
]);
} catch (\Throwable $e) {
return response()->json([
'message' => 'Failed to cancel deployment: '.$e->getMessage(),
], 500);
}
}
#[OA\Get(
summary: 'Deploy',
description: 'Deploy by tag or uuid. `Post` request also accepted with `uuid` and `tag` json body.',

View file

@ -12,6 +12,88 @@
class GithubController extends Controller
{
private function removeSensitiveData($githubApp)
{
$githubApp->makeHidden([
'client_secret',
'webhook_secret',
]);
return serializeApiResponse($githubApp);
}
#[OA\Get(
summary: 'List',
description: 'List all GitHub apps.',
path: '/github-apps',
operationId: 'list-github-apps',
security: [
['bearerAuth' => []],
],
tags: ['GitHub Apps'],
responses: [
new OA\Response(
response: 200,
description: 'List of GitHub apps.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'],
'organization' => ['type' => 'string', 'nullable' => true],
'api_url' => ['type' => 'string'],
'html_url' => ['type' => 'string'],
'custom_user' => ['type' => 'string'],
'custom_port' => ['type' => 'integer'],
'app_id' => ['type' => 'integer'],
'installation_id' => ['type' => 'integer'],
'client_id' => ['type' => 'string'],
'private_key_id' => ['type' => 'integer'],
'is_system_wide' => ['type' => 'boolean'],
'is_public' => ['type' => 'boolean'],
'team_id' => ['type' => 'integer'],
'type' => ['type' => 'string'],
]
)
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function list_github_apps(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$githubApps = GithubApp::where(function ($query) use ($teamId) {
$query->where('team_id', $teamId)
->orWhere('is_system_wide', true);
})->get();
$githubApps = $githubApps->map(function ($app) {
return $this->removeSensitiveData($app);
});
return response()->json($githubApps);
}
#[OA\Post(
summary: 'Create GitHub App',
description: 'Create a new GitHub app.',

View file

@ -328,9 +328,23 @@ public function create_service(Request $request)
});
}
if ($oneClickService) {
$service_payload = [
$dockerComposeRaw = base64_decode($oneClickService);
// Validate for command injection BEFORE creating service
try {
validateDockerComposeForInjection($dockerComposeRaw);
} catch (\Exception $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => $e->getMessage(),
],
], 422);
}
$servicePayload = [
'name' => "$oneClickServiceName-".str()->random(10),
'docker_compose_raw' => base64_decode($oneClickService),
'docker_compose_raw' => $dockerComposeRaw,
'environment_id' => $environment->id,
'service_type' => $oneClickServiceName,
'server_id' => $server->id,
@ -338,9 +352,9 @@ public function create_service(Request $request)
'destination_type' => $destination->getMorphClass(),
];
if ($oneClickServiceName === 'cloudflared') {
data_set($service_payload, 'connect_to_docker_network', true);
data_set($servicePayload, 'connect_to_docker_network', true);
}
$service = Service::create($service_payload);
$service = Service::create($servicePayload);
$service->name = "$oneClickServiceName-".$service->uuid;
$service->save();
if ($oneClickDotEnvs?->count() > 0) {
@ -462,6 +476,18 @@ public function create_service(Request $request)
$dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
// Validate for command injection BEFORE saving to database
try {
validateDockerComposeForInjection($dockerComposeRaw);
} catch (\Exception $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => $e->getMessage(),
],
], 422);
}
$connectToDockerNetwork = $request->connect_to_docker_network ?? false;
$instantDeploy = $request->instant_deploy ?? false;
@ -777,6 +803,19 @@ public function update_by_uuid(Request $request)
}
$dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
// Validate for command injection BEFORE saving to database
try {
validateDockerComposeForInjection($dockerComposeRaw);
} catch (\Exception $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => $e->getMessage(),
],
], 422);
}
$service->docker_compose_raw = $dockerComposeRaw;
}

View file

@ -14,7 +14,7 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,

View file

@ -2,7 +2,10 @@
namespace App\Http\Middleware;
use App\Models\InstanceSettings;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
use Illuminate\Support\Facades\Cache;
use Spatie\Url\Url;
class TrustHosts extends Middleware
{
@ -13,8 +16,37 @@ class TrustHosts extends Middleware
*/
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
$trustedHosts = [];
// Trust the configured FQDN from InstanceSettings (cached to avoid DB query on every request)
// Use empty string as sentinel value instead of null so negative results are cached
$fqdnHost = Cache::remember('instance_settings_fqdn_host', 300, function () {
try {
$settings = InstanceSettings::get();
if ($settings && $settings->fqdn) {
$url = Url::fromString($settings->fqdn);
$host = $url->getHost();
return $host ?: '';
}
} catch (\Exception $e) {
// If instance settings table doesn't exist yet (during installation),
// return empty string (sentinel) so this result is cached
}
return '';
});
// Convert sentinel value back to null for consumption
$fqdnHost = $fqdnHost !== '' ? $fqdnHost : null;
if ($fqdnHost) {
$trustedHosts[] = $fqdnHost;
}
// Trust all subdomains of APP_URL as fallback
$trustedHosts[] = $this->allSubdomainsOfApplicationUrl();
return array_filter($trustedHosts);
}
}

View file

@ -1319,12 +1319,18 @@ private function save_runtime_environment_variables()
private function generate_buildtime_environment_variables()
{
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
$this->application_deployment_queue->addLogEntry('[DEBUG] Generating build-time environment variables');
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
}
$envs = collect([]);
$coolify_envs = $this->generate_coolify_env_variables();
// Add COOLIFY variables
$coolify_envs->each(function ($item, $key) use ($envs) {
$envs->push($key.'='.$item);
$envs->push($key.'='.escapeBashEnvValue($item));
});
// Add SERVICE_NAME variables for Docker Compose builds
@ -1338,7 +1344,7 @@ private function generate_buildtime_environment_variables()
}
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName);
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.escapeBashEnvValue($serviceName));
}
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
@ -1351,8 +1357,8 @@ private function generate_buildtime_environment_variables()
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
}
}
} else {
@ -1360,7 +1366,7 @@ private function generate_buildtime_environment_variables()
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) {
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)));
}
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
@ -1373,8 +1379,8 @@ private function generate_buildtime_environment_variables()
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
}
}
}
@ -1396,7 +1402,32 @@ private function generate_buildtime_environment_variables()
}
foreach ($sorted_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
// Strip outer quotes from real_value and apply proper bash escaping
$value = trim($env->real_value, "'");
$escapedValue = escapeBashEnvValue($value);
$envs->push($env->key.'='.$escapedValue);
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
$this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
} else {
// For normal vars, use double quotes to allow $VAR expansion
$escapedValue = escapeBashDoubleQuoted($env->real_value);
$envs->push($env->key.'='.$escapedValue);
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
$this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
}
}
} else {
$sorted_environment_variables = $this->application->environment_variables_preview()
@ -1413,11 +1444,42 @@ private function generate_buildtime_environment_variables()
}
foreach ($sorted_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
// Strip outer quotes from real_value and apply proper bash escaping
$value = trim($env->real_value, "'");
$escapedValue = escapeBashEnvValue($value);
$envs->push($env->key.'='.$escapedValue);
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
$this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
} else {
// For normal vars, use double quotes to allow $VAR expansion
$escapedValue = escapeBashDoubleQuoted($env->real_value);
$envs->push($env->key.'='.$escapedValue);
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
$this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
}
}
}
// Return the generated environment variables
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
$this->application_deployment_queue->addLogEntry("[DEBUG] Total build-time env variables: {$envs->count()}");
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
}
return $envs;
}

View file

@ -35,6 +35,9 @@ public function handle()
if ($this->application->is_public_repository()) {
return;
}
$serviceName = $this->application->name;
if ($this->status === ProcessStatus::CLOSED) {
$this->delete_comment();
@ -42,12 +45,12 @@ public function handle()
}
match ($this->status) {
ProcessStatus::QUEUED => $this->body = "The preview deployment is queued. ⏳\n\n",
ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment is in progress. 🟡\n\n",
ProcessStatus::FINISHED => $this->body = "The preview deployment is ready. 🟢\n\n".($this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : ''),
ProcessStatus::ERROR => $this->body = "The preview deployment failed. 🔴\n\n",
ProcessStatus::KILLED => $this->body = "The preview deployment was killed. ⚫\n\n",
ProcessStatus::CANCELLED => $this->body = "The preview deployment was cancelled. 🚫\n\n",
ProcessStatus::QUEUED => $this->body = "The preview deployment for **{$serviceName}** is queued. ⏳\n\n",
ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment for **{$serviceName}** is in progress. 🟡\n\n",
ProcessStatus::FINISHED => $this->body = "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".($this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : ''),
ProcessStatus::ERROR => $this->body = "The preview deployment for **{$serviceName}** failed. 🔴\n\n",
ProcessStatus::KILLED => $this->body = "The preview deployment for **{$serviceName}** was killed. ⚫\n\n",
ProcessStatus::CANCELLED => $this->body = "The preview deployment for **{$serviceName}** was cancelled. 🚫\n\n",
ProcessStatus::CLOSED => '', // Already handled above, but included for completeness
};
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";

View file

@ -0,0 +1,35 @@
<?php
namespace App\Livewire\Concerns;
trait SynchronizesModelData
{
/**
* Define the mapping between component properties and model keys.
*
* @return array<string, string> Array mapping property names to model keys (e.g., ['content' => 'fileStorage.content'])
*/
abstract protected function getModelBindings(): array;
/**
* Synchronize component properties TO the model.
* Copies values from component properties to the model.
*/
protected function syncToModel(): void
{
foreach ($this->getModelBindings() as $property => $modelKey) {
data_set($this, $modelKey, $this->{$property});
}
}
/**
* Synchronize component properties FROM the model.
* Copies values from the model to component properties.
*/
protected function syncFromModel(): void
{
foreach ($this->getModelBindings() as $property => $modelKey) {
$this->{$property} = data_get($this, $modelKey);
}
}
}

View file

@ -25,6 +25,7 @@ public function __construct(
public bool $readonly,
public bool $allowTab,
public bool $spellcheck,
public bool $autofocus,
public ?string $helper,
public bool $realtimeValidation,
public bool $allowToPeak,

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Project\Application;
use App\Actions\Application\GenerateConfig;
use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\Application;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -14,6 +15,7 @@
class General extends Component
{
use AuthorizesRequests;
use SynchronizesModelData;
public string $applicationId;
@ -23,6 +25,8 @@ class General extends Component
public string $name;
public ?string $description = null;
public ?string $fqdn = null;
public string $git_repository;
@ -31,14 +35,82 @@ class General extends Component
public ?string $git_commit_sha = null;
public ?string $install_command = null;
public ?string $build_command = null;
public ?string $start_command = null;
public string $build_pack;
public string $static_image;
public string $base_directory;
public ?string $publish_directory = null;
public ?string $ports_exposes = null;
public ?string $ports_mappings = null;
public ?string $custom_network_aliases = null;
public ?string $dockerfile = null;
public ?string $dockerfile_location = null;
public ?string $dockerfile_target_build = null;
public ?string $docker_registry_image_name = null;
public ?string $docker_registry_image_tag = null;
public ?string $docker_compose_location = null;
public ?string $docker_compose = null;
public ?string $docker_compose_raw = null;
public ?string $docker_compose_custom_start_command = null;
public ?string $docker_compose_custom_build_command = null;
public ?string $custom_labels = null;
public ?string $custom_docker_run_options = null;
public ?string $pre_deployment_command = null;
public ?string $pre_deployment_command_container = null;
public ?string $post_deployment_command = null;
public ?string $post_deployment_command_container = null;
public ?string $custom_nginx_configuration = null;
public bool $is_static = false;
public bool $is_spa = false;
public bool $is_build_server_enabled = false;
public bool $is_preserve_repository_enabled = false;
public bool $is_container_label_escape_enabled = true;
public bool $is_container_label_readonly_enabled = false;
public bool $is_http_basic_auth_enabled = false;
public ?string $http_basic_auth_username = null;
public ?string $http_basic_auth_password = null;
public ?string $watch_paths = null;
public string $redirect;
public $customLabels;
public bool $labelsChanged = false;
@ -66,50 +138,50 @@ class General extends Component
protected function rules(): array
{
return [
'application.name' => ValidationPatterns::nameRules(),
'application.description' => ValidationPatterns::descriptionRules(),
'application.fqdn' => 'nullable',
'application.git_repository' => 'required',
'application.git_branch' => 'required',
'application.git_commit_sha' => 'nullable',
'application.install_command' => 'nullable',
'application.build_command' => 'nullable',
'application.start_command' => 'nullable',
'application.build_pack' => 'required',
'application.static_image' => 'required',
'application.base_directory' => 'required',
'application.publish_directory' => 'nullable',
'application.ports_exposes' => 'required',
'application.ports_mappings' => 'nullable',
'application.custom_network_aliases' => 'nullable',
'application.dockerfile' => 'nullable',
'application.docker_registry_image_name' => 'nullable',
'application.docker_registry_image_tag' => 'nullable',
'application.dockerfile_location' => 'nullable',
'application.docker_compose_location' => 'nullable',
'application.docker_compose' => 'nullable',
'application.docker_compose_raw' => 'nullable',
'application.dockerfile_target_build' => 'nullable',
'application.docker_compose_custom_start_command' => 'nullable',
'application.docker_compose_custom_build_command' => 'nullable',
'application.custom_labels' => 'nullable',
'application.custom_docker_run_options' => 'nullable',
'application.pre_deployment_command' => 'nullable',
'application.pre_deployment_command_container' => 'nullable',
'application.post_deployment_command' => 'nullable',
'application.post_deployment_command_container' => 'nullable',
'application.custom_nginx_configuration' => 'nullable',
'application.settings.is_static' => 'boolean|required',
'application.settings.is_spa' => 'boolean|required',
'application.settings.is_build_server_enabled' => 'boolean|required',
'application.settings.is_container_label_escape_enabled' => 'boolean|required',
'application.settings.is_container_label_readonly_enabled' => 'boolean|required',
'application.settings.is_preserve_repository_enabled' => 'boolean|required',
'application.is_http_basic_auth_enabled' => 'boolean|required',
'application.http_basic_auth_username' => 'string|nullable',
'application.http_basic_auth_password' => 'string|nullable',
'application.watch_paths' => 'nullable',
'application.redirect' => 'string|required',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable',
'git_repository' => 'required',
'git_branch' => 'required',
'git_commit_sha' => 'nullable',
'install_command' => 'nullable',
'build_command' => 'nullable',
'start_command' => 'nullable',
'build_pack' => 'required',
'static_image' => 'required',
'base_directory' => 'required',
'publish_directory' => 'nullable',
'ports_exposes' => 'required',
'ports_mappings' => 'nullable',
'custom_network_aliases' => 'nullable',
'dockerfile' => 'nullable',
'docker_registry_image_name' => 'nullable',
'docker_registry_image_tag' => 'nullable',
'dockerfile_location' => 'nullable',
'docker_compose_location' => 'nullable',
'docker_compose' => 'nullable',
'docker_compose_raw' => 'nullable',
'dockerfile_target_build' => 'nullable',
'docker_compose_custom_start_command' => 'nullable',
'docker_compose_custom_build_command' => 'nullable',
'custom_labels' => 'nullable',
'custom_docker_run_options' => 'nullable',
'pre_deployment_command' => 'nullable',
'pre_deployment_command_container' => 'nullable',
'post_deployment_command' => 'nullable',
'post_deployment_command_container' => 'nullable',
'custom_nginx_configuration' => 'nullable',
'is_static' => 'boolean|required',
'is_spa' => 'boolean|required',
'is_build_server_enabled' => 'boolean|required',
'is_container_label_escape_enabled' => 'boolean|required',
'is_container_label_readonly_enabled' => 'boolean|required',
'is_preserve_repository_enabled' => 'boolean|required',
'is_http_basic_auth_enabled' => 'boolean|required',
'http_basic_auth_username' => 'string|nullable',
'http_basic_auth_password' => 'string|nullable',
'watch_paths' => 'nullable',
'redirect' => 'string|required',
];
}
@ -118,31 +190,31 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'application.name.required' => 'The Name field is required.',
'application.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'application.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'application.git_repository.required' => 'The Git Repository field is required.',
'application.git_branch.required' => 'The Git Branch field is required.',
'application.build_pack.required' => 'The Build Pack field is required.',
'application.static_image.required' => 'The Static Image field is required.',
'application.base_directory.required' => 'The Base Directory field is required.',
'application.ports_exposes.required' => 'The Exposed Ports field is required.',
'application.settings.is_static.required' => 'The Static setting is required.',
'application.settings.is_static.boolean' => 'The Static setting must be true or false.',
'application.settings.is_spa.required' => 'The SPA setting is required.',
'application.settings.is_spa.boolean' => 'The SPA setting must be true or false.',
'application.settings.is_build_server_enabled.required' => 'The Build Server setting is required.',
'application.settings.is_build_server_enabled.boolean' => 'The Build Server setting must be true or false.',
'application.settings.is_container_label_escape_enabled.required' => 'The Container Label Escape setting is required.',
'application.settings.is_container_label_escape_enabled.boolean' => 'The Container Label Escape setting must be true or false.',
'application.settings.is_container_label_readonly_enabled.required' => 'The Container Label Readonly setting is required.',
'application.settings.is_container_label_readonly_enabled.boolean' => 'The Container Label Readonly setting must be true or false.',
'application.settings.is_preserve_repository_enabled.required' => 'The Preserve Repository setting is required.',
'application.settings.is_preserve_repository_enabled.boolean' => 'The Preserve Repository setting must be true or false.',
'application.is_http_basic_auth_enabled.required' => 'The HTTP Basic Auth setting is required.',
'application.is_http_basic_auth_enabled.boolean' => 'The HTTP Basic Auth setting must be true or false.',
'application.redirect.required' => 'The Redirect setting is required.',
'application.redirect.string' => 'The Redirect setting must be a string.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'git_repository.required' => 'The Git Repository field is required.',
'git_branch.required' => 'The Git Branch field is required.',
'build_pack.required' => 'The Build Pack field is required.',
'static_image.required' => 'The Static Image field is required.',
'base_directory.required' => 'The Base Directory field is required.',
'ports_exposes.required' => 'The Exposed Ports field is required.',
'is_static.required' => 'The Static setting is required.',
'is_static.boolean' => 'The Static setting must be true or false.',
'is_spa.required' => 'The SPA setting is required.',
'is_spa.boolean' => 'The SPA setting must be true or false.',
'is_build_server_enabled.required' => 'The Build Server setting is required.',
'is_build_server_enabled.boolean' => 'The Build Server setting must be true or false.',
'is_container_label_escape_enabled.required' => 'The Container Label Escape setting is required.',
'is_container_label_escape_enabled.boolean' => 'The Container Label Escape setting must be true or false.',
'is_container_label_readonly_enabled.required' => 'The Container Label Readonly setting is required.',
'is_container_label_readonly_enabled.boolean' => 'The Container Label Readonly setting must be true or false.',
'is_preserve_repository_enabled.required' => 'The Preserve Repository setting is required.',
'is_preserve_repository_enabled.boolean' => 'The Preserve Repository setting must be true or false.',
'is_http_basic_auth_enabled.required' => 'The HTTP Basic Auth setting is required.',
'is_http_basic_auth_enabled.boolean' => 'The HTTP Basic Auth setting must be true or false.',
'redirect.required' => 'The Redirect setting is required.',
'redirect.string' => 'The Redirect setting must be a string.',
]
);
}
@ -193,11 +265,15 @@ public function mount()
$this->parsedServices = $this->application->parse();
if (is_null($this->parsedServices) || empty($this->parsedServices)) {
$this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.');
// Still sync data even if parse fails, so form fields are populated
$this->syncFromModel();
return;
}
} catch (\Throwable $e) {
$this->dispatch('error', $e->getMessage());
// Still sync data even on error, so form fields are populated
$this->syncFromModel();
}
if ($this->application->build_pack === 'dockercompose') {
// Only update if user has permission
@ -218,9 +294,6 @@ public function mount()
}
$this->parsedServiceDomains = $sanitizedDomains;
$this->ports_exposes = $this->application->ports_exposes;
$this->is_preserve_repository_enabled = $this->application->settings->is_preserve_repository_enabled;
$this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled;
$this->customLabels = $this->application->parseContainerLabels();
if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && $this->application->settings->is_container_label_readonly_enabled === true) {
// Only update custom labels if user has permission
@ -249,6 +322,60 @@ public function mount()
if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) {
$this->dispatch('configurationChanged');
}
// Sync data from model to properties at the END, after all business logic
// This ensures any modifications to $this->application during mount() are reflected in properties
$this->syncFromModel();
}
protected function getModelBindings(): array
{
return [
'name' => 'application.name',
'description' => 'application.description',
'fqdn' => 'application.fqdn',
'git_repository' => 'application.git_repository',
'git_branch' => 'application.git_branch',
'git_commit_sha' => 'application.git_commit_sha',
'install_command' => 'application.install_command',
'build_command' => 'application.build_command',
'start_command' => 'application.start_command',
'build_pack' => 'application.build_pack',
'static_image' => 'application.static_image',
'base_directory' => 'application.base_directory',
'publish_directory' => 'application.publish_directory',
'ports_exposes' => 'application.ports_exposes',
'ports_mappings' => 'application.ports_mappings',
'custom_network_aliases' => 'application.custom_network_aliases',
'dockerfile' => 'application.dockerfile',
'dockerfile_location' => 'application.dockerfile_location',
'dockerfile_target_build' => 'application.dockerfile_target_build',
'docker_registry_image_name' => 'application.docker_registry_image_name',
'docker_registry_image_tag' => 'application.docker_registry_image_tag',
'docker_compose_location' => 'application.docker_compose_location',
'docker_compose' => 'application.docker_compose',
'docker_compose_raw' => 'application.docker_compose_raw',
'docker_compose_custom_start_command' => 'application.docker_compose_custom_start_command',
'docker_compose_custom_build_command' => 'application.docker_compose_custom_build_command',
'custom_labels' => 'application.custom_labels',
'custom_docker_run_options' => 'application.custom_docker_run_options',
'pre_deployment_command' => 'application.pre_deployment_command',
'pre_deployment_command_container' => 'application.pre_deployment_command_container',
'post_deployment_command' => 'application.post_deployment_command',
'post_deployment_command_container' => 'application.post_deployment_command_container',
'custom_nginx_configuration' => 'application.custom_nginx_configuration',
'is_static' => 'application.settings.is_static',
'is_spa' => 'application.settings.is_spa',
'is_build_server_enabled' => 'application.settings.is_build_server_enabled',
'is_preserve_repository_enabled' => 'application.settings.is_preserve_repository_enabled',
'is_container_label_escape_enabled' => 'application.settings.is_container_label_escape_enabled',
'is_container_label_readonly_enabled' => 'application.settings.is_container_label_readonly_enabled',
'is_http_basic_auth_enabled' => 'application.is_http_basic_auth_enabled',
'http_basic_auth_username' => 'application.http_basic_auth_username',
'http_basic_auth_password' => 'application.http_basic_auth_password',
'watch_paths' => 'application.watch_paths',
'redirect' => 'application.redirect',
];
}
public function instantSave()
@ -256,6 +383,12 @@ public function instantSave()
try {
$this->authorize('update', $this->application);
$oldPortsExposes = $this->application->ports_exposes;
$oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
$oldIsPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
$this->syncToModel();
if ($this->application->settings->isDirty('is_spa')) {
$this->generateNginxConfiguration($this->application->settings->is_spa ? 'spa' : 'static');
}
@ -265,20 +398,21 @@ public function instantSave()
$this->application->settings->save();
$this->dispatch('success', 'Settings saved.');
$this->application->refresh();
$this->syncFromModel();
// If port_exposes changed, reset default labels
if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) {
if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) {
$this->resetDefaultLabels(false);
}
if ($this->is_preserve_repository_enabled !== $this->application->settings->is_preserve_repository_enabled) {
if ($this->application->settings->is_preserve_repository_enabled === false) {
if ($oldIsPreserveRepositoryEnabled !== $this->is_preserve_repository_enabled) {
if ($this->is_preserve_repository_enabled === false) {
$this->application->fileStorages->each(function ($storage) {
$storage->is_based_on_git = $this->application->settings->is_preserve_repository_enabled;
$storage->is_based_on_git = $this->is_preserve_repository_enabled;
$storage->save();
});
}
}
if ($this->application->settings->is_container_label_readonly_enabled) {
if ($this->is_container_label_readonly_enabled) {
$this->resetDefaultLabels(false);
}
} catch (\Throwable $e) {
@ -366,21 +500,21 @@ public function generateDomain(string $serviceName)
}
}
public function updatedApplicationBaseDirectory()
public function updatedBaseDirectory()
{
if ($this->application->build_pack === 'dockercompose') {
if ($this->build_pack === 'dockercompose') {
$this->loadComposeFile();
}
}
public function updatedApplicationSettingsIsStatic($value)
public function updatedIsStatic($value)
{
if ($value) {
$this->generateNginxConfiguration();
}
}
public function updatedApplicationBuildPack()
public function updatedBuildPack()
{
// Check if user has permission to update
try {
@ -388,21 +522,28 @@ public function updatedApplicationBuildPack()
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User doesn't have permission, revert the change and return
$this->application->refresh();
$this->syncFromModel();
return;
}
if ($this->application->build_pack !== 'nixpacks') {
// Sync property to model before checking/modifying
$this->syncToModel();
if ($this->build_pack !== 'nixpacks') {
$this->is_static = false;
$this->application->settings->is_static = false;
$this->application->settings->save();
} else {
$this->application->ports_exposes = $this->ports_exposes = 3000;
$this->ports_exposes = 3000;
$this->application->ports_exposes = 3000;
$this->resetDefaultLabels(false);
}
if ($this->application->build_pack === 'dockercompose') {
if ($this->build_pack === 'dockercompose') {
// Only update if user has permission
try {
$this->authorize('update', $this->application);
$this->fqdn = null;
$this->application->fqdn = null;
$this->application->settings->save();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
@ -421,8 +562,9 @@ public function updatedApplicationBuildPack()
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
}
}
if ($this->application->build_pack === 'static') {
$this->application->ports_exposes = $this->ports_exposes = 80;
if ($this->build_pack === 'static') {
$this->ports_exposes = 80;
$this->application->ports_exposes = 80;
$this->resetDefaultLabels(false);
$this->generateNginxConfiguration();
}
@ -438,8 +580,11 @@ public function getWildcardDomain()
$server = data_get($this->application, 'destination.server');
if ($server) {
$fqdn = generateUrl(server: $server, random: $this->application->uuid);
$this->application->fqdn = $fqdn;
$this->fqdn = $fqdn;
$this->syncToModel();
$this->application->save();
$this->application->refresh();
$this->syncFromModel();
$this->resetDefaultLabels();
$this->dispatch('success', 'Wildcard domain generated.');
}
@ -453,8 +598,11 @@ public function generateNginxConfiguration($type = 'static')
try {
$this->authorize('update', $this->application);
$this->application->custom_nginx_configuration = defaultNginxConfiguration($type);
$this->custom_nginx_configuration = defaultNginxConfiguration($type);
$this->syncToModel();
$this->application->save();
$this->application->refresh();
$this->syncFromModel();
$this->dispatch('success', 'Nginx configuration generated.');
} catch (\Throwable $e) {
return handleError($e, $this);
@ -464,15 +612,16 @@ public function generateNginxConfiguration($type = 'static')
public function resetDefaultLabels($manualReset = false)
{
try {
if (! $this->application->settings->is_container_label_readonly_enabled && ! $manualReset) {
if (! $this->is_container_label_readonly_enabled && ! $manualReset) {
return;
}
$this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
$this->ports_exposes = $this->application->ports_exposes;
$this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled;
$this->application->custom_labels = base64_encode($this->customLabels);
$this->custom_labels = base64_encode($this->customLabels);
$this->syncToModel();
$this->application->save();
if ($this->application->build_pack === 'dockercompose') {
$this->application->refresh();
$this->syncFromModel();
if ($this->build_pack === 'dockercompose') {
$this->loadComposeFile(showToast: false);
}
$this->dispatch('configurationChanged');
@ -483,8 +632,8 @@ public function resetDefaultLabels($manualReset = false)
public function checkFqdns($showToaster = true)
{
if (data_get($this->application, 'fqdn')) {
$domains = str($this->application->fqdn)->trim()->explode(',');
if ($this->fqdn) {
$domains = str($this->fqdn)->trim()->explode(',');
if ($this->application->additional_servers->count() === 0) {
foreach ($domains as $domain) {
if (! validateDNSEntry($domain, $this->application->destination->server)) {
@ -507,7 +656,8 @@ public function checkFqdns($showToaster = true)
$this->forceSaveDomains = false;
}
$this->application->fqdn = $domains->implode(',');
$this->fqdn = $domains->implode(',');
$this->application->fqdn = $this->fqdn;
$this->resetDefaultLabels(false);
}
@ -547,21 +697,27 @@ public function submit($showToaster = true)
$this->validate();
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
$oldPortsExposes = $this->application->ports_exposes;
$oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
$oldDockerComposeLocation = $this->initialDockerComposeLocation;
// Process FQDN with intermediate variable to avoid Collection/string confusion
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
$warning = sslipDomainWarning($this->application->fqdn);
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
// $this->resetDefaultLabels();
$this->syncToModel();
if ($this->application->isDirty('redirect')) {
$this->setRedirect();
@ -581,38 +737,42 @@ public function submit($showToaster = true)
$this->application->save();
}
if ($this->application->build_pack === 'dockercompose' && $this->initialDockerComposeLocation !== $this->application->docker_compose_location) {
if ($this->build_pack === 'dockercompose' && $oldDockerComposeLocation !== $this->docker_compose_location) {
$compose_return = $this->loadComposeFile(showToast: false);
if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) {
return;
}
}
if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) {
if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) {
$this->resetDefaultLabels();
}
if (data_get($this->application, 'build_pack') === 'dockerimage') {
if ($this->build_pack === 'dockerimage') {
$this->validate([
'application.docker_registry_image_name' => 'required',
'docker_registry_image_name' => 'required',
]);
}
if (data_get($this->application, 'custom_docker_run_options')) {
$this->application->custom_docker_run_options = str($this->application->custom_docker_run_options)->trim();
if ($this->custom_docker_run_options) {
$this->custom_docker_run_options = str($this->custom_docker_run_options)->trim()->toString();
$this->application->custom_docker_run_options = $this->custom_docker_run_options;
}
if (data_get($this->application, 'dockerfile')) {
$port = get_port_from_dockerfile($this->application->dockerfile);
if ($port && ! $this->application->ports_exposes) {
if ($this->dockerfile) {
$port = get_port_from_dockerfile($this->dockerfile);
if ($port && ! $this->ports_exposes) {
$this->ports_exposes = $port;
$this->application->ports_exposes = $port;
}
}
if ($this->application->base_directory && $this->application->base_directory !== '/') {
$this->application->base_directory = rtrim($this->application->base_directory, '/');
if ($this->base_directory && $this->base_directory !== '/') {
$this->base_directory = rtrim($this->base_directory, '/');
$this->application->base_directory = $this->base_directory;
}
if ($this->application->publish_directory && $this->application->publish_directory !== '/') {
$this->application->publish_directory = rtrim($this->application->publish_directory, '/');
if ($this->publish_directory && $this->publish_directory !== '/') {
$this->publish_directory = rtrim($this->publish_directory, '/');
$this->application->publish_directory = $this->publish_directory;
}
if ($this->application->build_pack === 'dockercompose') {
if ($this->build_pack === 'dockercompose') {
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) {
@ -643,12 +803,12 @@ public function submit($showToaster = true)
}
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
$this->application->refresh();
$this->syncFromModel();
$showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!');
} catch (\Throwable $e) {
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
}
$this->application->refresh();
$this->syncFromModel();
return handleError($e, $this);
} finally {

View file

@ -33,14 +33,34 @@ class Previews extends Component
public $pendingPreviewId = null;
public array $previewFqdns = [];
protected $rules = [
'application.previews.*.fqdn' => 'string|nullable',
'previewFqdns.*' => 'string|nullable',
];
public function mount()
{
$this->pull_requests = collect();
$this->parameters = get_route_parameters();
$this->syncData(false);
}
private function syncData(bool $toModel = false): void
{
if ($toModel) {
foreach ($this->previewFqdns as $key => $fqdn) {
$preview = $this->application->previews->get($key);
if ($preview) {
$preview->fqdn = $fqdn;
}
}
} else {
$this->previewFqdns = [];
foreach ($this->application->previews as $key => $preview) {
$this->previewFqdns[$key] = $preview->fqdn;
}
}
}
public function load_prs()
@ -73,35 +93,52 @@ public function save_preview($preview_id)
$this->authorize('update', $this->application);
$success = true;
$preview = $this->application->previews->find($preview_id);
if (data_get_str($preview, 'fqdn')->isNotEmpty()) {
$preview->fqdn = str($preview->fqdn)->replaceEnd(',', '')->trim();
$preview->fqdn = str($preview->fqdn)->replaceStart(',', '')->trim();
$preview->fqdn = str($preview->fqdn)->trim()->lower();
if (! validateDNSEntry($preview->fqdn, $this->application->destination->server)) {
$this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$preview->fqdn->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$success = false;
}
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application, domain: $preview->fqdn);
if ($result['hasConflicts']) {
$this->domainConflicts = $result['conflicts'];
$this->showDomainConflictModal = true;
$this->pendingPreviewId = $preview_id;
return;
}
} else {
// Reset the force flag after using it
$this->forceSaveDomains = false;
}
}
if (! $preview) {
throw new \Exception('Preview not found');
}
$success && $preview->save();
$success && $this->dispatch('success', 'Preview saved.<br><br>Do not forget to redeploy the preview to apply the changes.');
// Find the key for this preview in the collection
$previewKey = $this->application->previews->search(function ($item) use ($preview_id) {
return $item->id == $preview_id;
});
if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) {
$fqdn = $this->previewFqdns[$previewKey];
if (! empty($fqdn)) {
$fqdn = str($fqdn)->replaceEnd(',', '')->trim();
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
$fqdn = str($fqdn)->trim()->lower();
$this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
$this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$fqdn->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$success = false;
}
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application, domain: $fqdn);
if ($result['hasConflicts']) {
$this->domainConflicts = $result['conflicts'];
$this->showDomainConflictModal = true;
$this->pendingPreviewId = $preview_id;
return;
}
} else {
// Reset the force flag after using it
$this->forceSaveDomains = false;
}
}
}
if ($success) {
$this->syncData(true);
$preview->save();
$this->dispatch('success', 'Preview saved.<br><br>Do not forget to redeploy the preview to apply the changes.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -121,6 +158,7 @@ public function generate_preview($preview_id)
if ($this->application->build_pack === 'dockercompose') {
$preview->generate_preview_fqdn_compose();
$this->application->refresh();
$this->syncData(false);
$this->dispatch('success', 'Domain generated.');
return;
@ -128,6 +166,7 @@ public function generate_preview($preview_id)
$preview->generate_preview_fqdn();
$this->application->refresh();
$this->syncData(false);
$this->dispatch('update_links');
$this->dispatch('success', 'Domain generated.');
} catch (\Throwable $e) {
@ -152,6 +191,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null)
}
$found->generate_preview_fqdn_compose();
$this->application->refresh();
$this->syncData(false);
} else {
$this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
@ -164,6 +204,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null)
}
$found->generate_preview_fqdn();
$this->application->refresh();
$this->syncData(false);
$this->dispatch('update_links');
$this->dispatch('success', 'Preview added.');
}

View file

@ -18,6 +18,13 @@ class PreviewsCompose extends Component
public ApplicationPreview $preview;
public ?string $domain = null;
public function mount()
{
$this->domain = data_get($this->service, 'domain');
}
public function render()
{
return view('livewire.project.application.previews-compose');
@ -28,10 +35,10 @@ public function save()
try {
$this->authorize('update', $this->preview->application);
$domain = data_get($this->service, 'domain');
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true);
$docker_compose_domains[$this->serviceName]['domain'] = $domain;
$docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
$docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? [];
$docker_compose_domains[$this->serviceName]['domain'] = $this->domain;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();
$this->dispatch('update_links');
@ -46,7 +53,7 @@ public function generate()
try {
$this->authorize('update', $this->preview->application);
$domains = collect(json_decode($this->preview->application->docker_compose_domains)) ?? collect();
$domains = collect(json_decode($this->preview->application->docker_compose_domains, true) ?: []);
$domain = $domains->first(function ($_, $key) {
return $key === $this->serviceName;
});
@ -68,24 +75,40 @@ public function generate()
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
} else {
// Use the existing domain from the main application
$url = Url::fromString($domain_string);
// Handle multiple domains separated by commas
$domain_list = explode(',', $domain_string);
$preview_fqdns = [];
$template = $this->preview->application->preview_url_template;
$host = $url->getHost();
$schema = $url->getScheme();
$portInt = $url->getPort();
$port = $portInt !== null ? ':'.$portInt : '';
$random = new Cuid2;
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn);
$preview_fqdn = "$schema://$preview_fqdn";
foreach ($domain_list as $single_domain) {
$single_domain = trim($single_domain);
if (empty($single_domain)) {
continue;
}
$url = Url::fromString($single_domain);
$host = $url->getHost();
$schema = $url->getScheme();
$portInt = $url->getPort();
$port = $portInt !== null ? ':'.$portInt : '';
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn);
$preview_fqdns[] = "$schema://$preview_fqdn";
}
$preview_fqdn = implode(',', $preview_fqdns);
}
// Save the generated domain
$this->domain = $preview_fqdn;
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true);
$docker_compose_domains[$this->serviceName]['domain'] = $this->service->domain = $preview_fqdn;
$docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
$docker_compose_domains[$this->serviceName] = $docker_compose_domains[$this->serviceName] ?? [];
$docker_compose_domains[$this->serviceName]['domain'] = $this->domain;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();

View file

@ -9,6 +9,7 @@
class Configuration extends Component
{
use AuthorizesRequests;
public $currentRoute;
public $database;

View file

@ -37,6 +37,10 @@ public function submit()
'dockerComposeRaw' => 'required',
]);
$this->dockerComposeRaw = Yaml::dump(Yaml::parse($this->dockerComposeRaw), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->dockerComposeRaw);
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();

View file

@ -24,16 +24,30 @@ class Database extends Component
public $parameters;
public ?string $humanName = null;
public ?string $description = null;
public ?string $image = null;
public bool $excludeFromStatus = false;
public ?int $publicPort = null;
public bool $isPublic = false;
public bool $isLogDrainEnabled = false;
protected $listeners = ['refreshFileStorages'];
protected $rules = [
'database.human_name' => 'nullable',
'database.description' => 'nullable',
'database.image' => 'required',
'database.exclude_from_status' => 'required|boolean',
'database.public_port' => 'nullable|integer',
'database.is_public' => 'required|boolean',
'database.is_log_drain_enabled' => 'required|boolean',
'humanName' => 'nullable',
'description' => 'nullable',
'image' => 'required',
'excludeFromStatus' => 'required|boolean',
'publicPort' => 'nullable|integer',
'isPublic' => 'required|boolean',
'isLogDrainEnabled' => 'required|boolean',
];
public function render()
@ -50,11 +64,33 @@ public function mount()
$this->db_url_public = $this->database->getServiceDatabaseUrl();
}
$this->refreshFileStorages();
$this->syncData(false);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->database->human_name = $this->humanName;
$this->database->description = $this->description;
$this->database->image = $this->image;
$this->database->exclude_from_status = $this->excludeFromStatus;
$this->database->public_port = $this->publicPort;
$this->database->is_public = $this->isPublic;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
} else {
$this->humanName = $this->database->human_name;
$this->description = $this->database->description;
$this->image = $this->database->image;
$this->excludeFromStatus = $this->database->exclude_from_status ?? false;
$this->publicPort = $this->database->public_port;
$this->isPublic = $this->database->is_public ?? false;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled ?? false;
}
}
public function delete($password)
{
try {
@ -92,7 +128,7 @@ public function instantSaveLogDrain()
try {
$this->authorize('update', $this->database);
if (! $this->database->service->destination->server->isLogDrainEnabled()) {
$this->database->is_log_drain_enabled = false;
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
@ -145,15 +181,17 @@ public function instantSave()
{
try {
$this->authorize('update', $this->database);
if ($this->database->is_public && ! $this->database->public_port) {
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
$this->syncData(true);
if ($this->database->is_public) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->isPublic = false;
$this->database->is_public = false;
return;
@ -182,7 +220,10 @@ public function submit()
try {
$this->authorize('update', $this->database);
$this->validate();
$this->syncData(true);
$this->database->save();
$this->database->refresh();
$this->syncData(false);
updateCompose($this->database);
$this->dispatch('success', 'Database saved.');
} catch (\Throwable $e) {

View file

@ -11,6 +11,12 @@ class EditCompose extends Component
public $serviceId;
public ?string $dockerComposeRaw = null;
public ?string $dockerCompose = null;
public bool $isContainerLabelEscapeEnabled = false;
protected $listeners = [
'refreshEnvs',
'envsUpdated',
@ -18,30 +24,45 @@ class EditCompose extends Component
];
protected $rules = [
'service.docker_compose_raw' => 'required',
'service.docker_compose' => 'required',
'service.is_container_label_escape_enabled' => 'required',
'dockerComposeRaw' => 'required',
'dockerCompose' => 'required',
'isContainerLabelEscapeEnabled' => 'required',
];
public function envsUpdated()
{
$this->dispatch('saveCompose', $this->service->docker_compose_raw);
$this->dispatch('saveCompose', $this->dockerComposeRaw);
$this->refreshEnvs();
}
public function refreshEnvs()
{
$this->service = Service::ownedByCurrentTeam()->find($this->serviceId);
$this->syncData(false);
}
public function mount()
{
$this->service = Service::ownedByCurrentTeam()->find($this->serviceId);
$this->syncData(false);
}
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->service->docker_compose_raw = $this->dockerComposeRaw;
$this->service->docker_compose = $this->dockerCompose;
$this->service->is_container_label_escape_enabled = $this->isContainerLabelEscapeEnabled;
} else {
$this->dockerComposeRaw = $this->service->docker_compose_raw;
$this->dockerCompose = $this->service->docker_compose;
$this->isContainerLabelEscapeEnabled = $this->service->is_container_label_escape_enabled ?? false;
}
}
public function validateCompose()
{
$isValid = validateComposeFile($this->service->docker_compose_raw, $this->service->server_id);
$isValid = validateComposeFile($this->dockerComposeRaw, $this->service->server_id);
if ($isValid !== 'OK') {
$this->dispatch('error', "Invalid docker-compose file.\n$isValid");
} else {
@ -52,16 +73,17 @@ public function validateCompose()
public function saveEditedCompose()
{
$this->dispatch('info', 'Saving new docker compose...');
$this->dispatch('saveCompose', $this->service->docker_compose_raw);
$this->dispatch('saveCompose', $this->dockerComposeRaw);
$this->dispatch('refreshStorages');
}
public function instantSave()
{
$this->validate([
'service.is_container_label_escape_enabled' => 'required',
'isContainerLabelEscapeEnabled' => 'required',
]);
$this->service->save(['is_container_label_escape_enabled' => $this->service->is_container_label_escape_enabled]);
$this->syncData(true);
$this->service->save(['is_container_label_escape_enabled' => $this->isContainerLabelEscapeEnabled]);
$this->dispatch('success', 'Service updated successfully');
}

View file

@ -2,12 +2,14 @@
namespace App\Livewire\Project\Service;
use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\ServiceApplication;
use Livewire\Component;
use Spatie\Url\Url;
class EditDomain extends Component
{
use SynchronizesModelData;
public $applicationId;
public ServiceApplication $application;
@ -18,14 +20,24 @@ class EditDomain extends Component
public $forceSaveDomains = false;
public ?string $fqdn = null;
protected $rules = [
'application.fqdn' => 'nullable',
'application.required_fqdn' => 'required|boolean',
'fqdn' => 'nullable',
];
public function mount()
{
$this->application = ServiceApplication::find($this->applicationId);
$this->application = ServiceApplication::query()->findOrFail($this->applicationId);
$this->authorize('view', $this->application);
$this->syncFromModel();
}
protected function getModelBindings(): array
{
return [
'fqdn' => 'application.fqdn',
];
}
public function confirmDomainUsage()
@ -38,19 +50,22 @@ public function confirmDomainUsage()
public function submit()
{
try {
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
$this->authorize('update', $this->application);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
$warning = sslipDomainWarning($this->application->fqdn);
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
// Sync to model for domain conflict check
$this->syncToModel();
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
@ -67,6 +82,8 @@ public function submit()
$this->validate();
$this->application->save();
$this->application->refresh();
$this->syncData(false);
updateCompose($this->application);
if (str($this->application->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
@ -79,6 +96,7 @@ public function submit()
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
$this->syncData(false);
}
return handleError($e, $this);

View file

@ -2,6 +2,7 @@
namespace App\Livewire\Project\Service;
use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
@ -22,7 +23,7 @@
class FileStorage extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, SynchronizesModelData;
public LocalFileVolume $fileStorage;
@ -36,12 +37,16 @@ class FileStorage extends Component
public bool $isReadOnly = false;
public ?string $content = null;
public bool $isBasedOnGit = false;
protected $rules = [
'fileStorage.is_directory' => 'required',
'fileStorage.fs_path' => 'required',
'fileStorage.mount_path' => 'required',
'fileStorage.content' => 'nullable',
'fileStorage.is_based_on_git' => 'required|boolean',
'content' => 'nullable',
'isBasedOnGit' => 'required|boolean',
];
public function mount()
@ -56,6 +61,15 @@ public function mount()
}
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
$this->syncFromModel();
}
protected function getModelBindings(): array
{
return [
'content' => 'fileStorage.content',
'isBasedOnGit' => 'fileStorage.is_based_on_git',
];
}
public function convertToDirectory()
@ -82,6 +96,7 @@ public function loadStorageOnServer()
$this->authorize('update', $this->resource);
$this->fileStorage->loadStorageOnServer();
$this->syncFromModel();
$this->dispatch('success', 'File storage loaded from server.');
} catch (\Throwable $e) {
return handleError($e, $this);
@ -148,14 +163,16 @@ public function submit()
try {
$this->validate();
if ($this->fileStorage->is_directory) {
$this->fileStorage->content = null;
$this->content = null;
}
$this->syncToModel();
$this->fileStorage->save();
$this->fileStorage->saveStorageOnServer();
$this->dispatch('success', 'File updated.');
} catch (\Throwable $e) {
$this->fileStorage->setRawAttributes($original);
$this->fileStorage->save();
$this->syncFromModel();
return handleError($e, $this);
}

View file

@ -2,6 +2,7 @@
namespace App\Livewire\Project\Service;
use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\InstanceSettings;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -14,6 +15,7 @@
class ServiceApplicationView extends Component
{
use AuthorizesRequests;
use SynchronizesModelData;
public ServiceApplication $application;
@ -29,16 +31,32 @@ class ServiceApplicationView extends Component
public $forceSaveDomains = false;
public ?string $humanName = null;
public ?string $description = null;
public ?string $fqdn = null;
public ?string $image = null;
public bool $excludeFromStatus = false;
public bool $isLogDrainEnabled = false;
public bool $isGzipEnabled = false;
public bool $isStripprefixEnabled = false;
protected $rules = [
'application.human_name' => 'nullable',
'application.description' => 'nullable',
'application.fqdn' => 'nullable',
'application.image' => 'string|nullable',
'application.exclude_from_status' => 'required|boolean',
'humanName' => 'nullable',
'description' => 'nullable',
'fqdn' => 'nullable',
'image' => 'string|nullable',
'excludeFromStatus' => 'required|boolean',
'application.required_fqdn' => 'required|boolean',
'application.is_log_drain_enabled' => 'nullable|boolean',
'application.is_gzip_enabled' => 'nullable|boolean',
'application.is_stripprefix_enabled' => 'nullable|boolean',
'isLogDrainEnabled' => 'nullable|boolean',
'isGzipEnabled' => 'nullable|boolean',
'isStripprefixEnabled' => 'nullable|boolean',
];
public function instantSave()
@ -56,11 +74,12 @@ public function instantSaveAdvanced()
try {
$this->authorize('update', $this->application);
if (! $this->application->service->destination->server->isLogDrainEnabled()) {
$this->application->is_log_drain_enabled = false;
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->syncToModel();
$this->application->save();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
@ -95,11 +114,26 @@ public function mount()
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->application);
$this->syncFromModel();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
protected function getModelBindings(): array
{
return [
'humanName' => 'application.human_name',
'description' => 'application.description',
'fqdn' => 'application.fqdn',
'image' => 'application.image',
'excludeFromStatus' => 'application.exclude_from_status',
'isLogDrainEnabled' => 'application.is_log_drain_enabled',
'isGzipEnabled' => 'application.is_gzip_enabled',
'isStripprefixEnabled' => 'application.is_stripprefix_enabled',
];
}
public function convertToDatabase()
{
try {
@ -146,19 +180,21 @@ public function submit()
{
try {
$this->authorize('update', $this->application);
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
$warning = sslipDomainWarning($this->application->fqdn);
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
// Sync to model for domain conflict check
$this->syncToModel();
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
@ -175,6 +211,8 @@ public function submit()
$this->validate();
$this->application->save();
$this->application->refresh();
$this->syncFromModel();
updateCompose($this->application);
if (str($this->application->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
@ -186,6 +224,7 @@ public function submit()
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
$this->syncFromModel();
}
return handleError($e, $this);

View file

@ -15,14 +15,25 @@ class StackForm extends Component
protected $listeners = ['saveCompose'];
// Explicit properties
public string $name;
public ?string $description = null;
public string $dockerComposeRaw;
public string $dockerCompose;
public ?bool $connectToDockerNetwork = null;
protected function rules(): array
{
$baseRules = [
'service.docker_compose_raw' => 'required',
'service.docker_compose' => 'required',
'service.name' => ValidationPatterns::nameRules(),
'service.description' => ValidationPatterns::descriptionRules(),
'service.connect_to_docker_network' => 'nullable',
'dockerComposeRaw' => 'required',
'dockerCompose' => 'required',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'connectToDockerNetwork' => 'nullable',
];
// Add dynamic field rules
@ -39,19 +50,44 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'service.name.required' => 'The Name field is required.',
'service.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'service.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'service.docker_compose_raw.required' => 'The Docker Compose Raw field is required.',
'service.docker_compose.required' => 'The Docker Compose field is required.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'dockerComposeRaw.required' => 'The Docker Compose Raw field is required.',
'dockerCompose.required' => 'The Docker Compose field is required.',
]
);
}
public $validationAttributes = [];
/**
* Sync data between component properties and model
*
* @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
*/
private function syncData(bool $toModel = false): void
{
if ($toModel) {
// Sync TO model (before save)
$this->service->name = $this->name;
$this->service->description = $this->description;
$this->service->docker_compose_raw = $this->dockerComposeRaw;
$this->service->docker_compose = $this->dockerCompose;
$this->service->connect_to_docker_network = $this->connectToDockerNetwork;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->service->name;
$this->description = $this->service->description;
$this->dockerComposeRaw = $this->service->docker_compose_raw;
$this->dockerCompose = $this->service->docker_compose;
$this->connectToDockerNetwork = $this->service->connect_to_docker_network;
}
}
public function mount()
{
$this->syncData(false);
$this->fields = collect([]);
$extraFields = $this->service->extraFields();
foreach ($extraFields as $serviceName => $fields) {
@ -87,12 +123,13 @@ public function mount()
public function saveCompose($raw)
{
$this->service->docker_compose_raw = $raw;
$this->dockerComposeRaw = $raw;
$this->submit(notify: true);
}
public function instantSave()
{
$this->syncData(true);
$this->service->save();
$this->dispatch('success', 'Service settings saved.');
}
@ -101,6 +138,11 @@ public function submit($notify = true)
{
try {
$this->validate();
$this->syncData(true);
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->service->docker_compose_raw);
$this->service->save();
$this->service->saveExtraFields($this->fields);
$this->service->parse();

View file

@ -2,35 +2,90 @@
namespace App\Livewire\Project\Shared;
use App\Livewire\Concerns\SynchronizesModelData;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class HealthChecks extends Component
{
use AuthorizesRequests;
use SynchronizesModelData;
public $resource;
protected $rules = [
'resource.health_check_enabled' => 'boolean',
'resource.health_check_path' => 'string',
'resource.health_check_port' => 'nullable|string',
'resource.health_check_host' => 'string',
'resource.health_check_method' => 'string',
'resource.health_check_return_code' => 'integer',
'resource.health_check_scheme' => 'string',
'resource.health_check_response_text' => 'nullable|string',
'resource.health_check_interval' => 'integer|min:1',
'resource.health_check_timeout' => 'integer|min:1',
'resource.health_check_retries' => 'integer|min:1',
'resource.health_check_start_period' => 'integer',
'resource.custom_healthcheck_found' => 'boolean',
// Explicit properties
public bool $healthCheckEnabled = false;
public string $healthCheckMethod;
public string $healthCheckScheme;
public string $healthCheckHost;
public ?string $healthCheckPort = null;
public string $healthCheckPath;
public int $healthCheckReturnCode;
public ?string $healthCheckResponseText = null;
public int $healthCheckInterval;
public int $healthCheckTimeout;
public int $healthCheckRetries;
public int $healthCheckStartPeriod;
public bool $customHealthcheckFound = false;
protected $rules = [
'healthCheckEnabled' => 'boolean',
'healthCheckPath' => 'string',
'healthCheckPort' => 'nullable|string',
'healthCheckHost' => 'string',
'healthCheckMethod' => 'string',
'healthCheckReturnCode' => 'integer',
'healthCheckScheme' => 'string',
'healthCheckResponseText' => 'nullable|string',
'healthCheckInterval' => 'integer|min:1',
'healthCheckTimeout' => 'integer|min:1',
'healthCheckRetries' => 'integer|min:1',
'healthCheckStartPeriod' => 'integer',
'customHealthcheckFound' => 'boolean',
];
protected function getModelBindings(): array
{
return [
'healthCheckEnabled' => 'resource.health_check_enabled',
'healthCheckMethod' => 'resource.health_check_method',
'healthCheckScheme' => 'resource.health_check_scheme',
'healthCheckHost' => 'resource.health_check_host',
'healthCheckPort' => 'resource.health_check_port',
'healthCheckPath' => 'resource.health_check_path',
'healthCheckReturnCode' => 'resource.health_check_return_code',
'healthCheckResponseText' => 'resource.health_check_response_text',
'healthCheckInterval' => 'resource.health_check_interval',
'healthCheckTimeout' => 'resource.health_check_timeout',
'healthCheckRetries' => 'resource.health_check_retries',
'healthCheckStartPeriod' => 'resource.health_check_start_period',
'customHealthcheckFound' => 'resource.custom_healthcheck_found',
];
}
public function mount()
{
$this->authorize('view', $this->resource);
$this->syncFromModel();
}
public function instantSave()
{
$this->authorize('update', $this->resource);
$this->syncToModel();
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
}
@ -40,6 +95,8 @@ public function submit()
try {
$this->authorize('update', $this->resource);
$this->validate();
$this->syncToModel();
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
} catch (\Throwable $e) {
@ -51,14 +108,16 @@ public function toggleHealthcheck()
{
try {
$this->authorize('update', $this->resource);
$wasEnabled = $this->resource->health_check_enabled;
$this->resource->health_check_enabled = ! $this->resource->health_check_enabled;
$wasEnabled = $this->healthCheckEnabled;
$this->healthCheckEnabled = ! $this->healthCheckEnabled;
$this->syncToModel();
$this->resource->save();
if ($this->resource->health_check_enabled && ! $wasEnabled && $this->resource->isRunning()) {
if ($this->healthCheckEnabled && ! $wasEnabled && $this->resource->isRunning()) {
$this->dispatch('info', 'Health check has been enabled. A restart is required to apply the new settings.');
} else {
$this->dispatch('success', 'Health check '.($this->resource->health_check_enabled ? 'enabled' : 'disabled').'.');
$this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'.');
}
} catch (\Throwable $e) {
return handleError($e, $this);

View file

@ -11,52 +11,105 @@ class ResourceLimits extends Component
public $resource;
// Explicit properties
public ?string $limitsCpus = null;
public ?string $limitsCpuset = null;
public ?int $limitsCpuShares = null;
public string $limitsMemory;
public string $limitsMemorySwap;
public int $limitsMemorySwappiness;
public string $limitsMemoryReservation;
protected $rules = [
'resource.limits_memory' => 'required|string',
'resource.limits_memory_swap' => 'required|string',
'resource.limits_memory_swappiness' => 'required|integer|min:0|max:100',
'resource.limits_memory_reservation' => 'required|string',
'resource.limits_cpus' => 'nullable',
'resource.limits_cpuset' => 'nullable',
'resource.limits_cpu_shares' => 'nullable',
'limitsMemory' => 'required|string',
'limitsMemorySwap' => 'required|string',
'limitsMemorySwappiness' => 'required|integer|min:0|max:100',
'limitsMemoryReservation' => 'required|string',
'limitsCpus' => 'nullable',
'limitsCpuset' => 'nullable',
'limitsCpuShares' => 'nullable',
];
protected $validationAttributes = [
'resource.limits_memory' => 'memory',
'resource.limits_memory_swap' => 'swap',
'resource.limits_memory_swappiness' => 'swappiness',
'resource.limits_memory_reservation' => 'reservation',
'resource.limits_cpus' => 'cpus',
'resource.limits_cpuset' => 'cpuset',
'resource.limits_cpu_shares' => 'cpu shares',
'limitsMemory' => 'memory',
'limitsMemorySwap' => 'swap',
'limitsMemorySwappiness' => 'swappiness',
'limitsMemoryReservation' => 'reservation',
'limitsCpus' => 'cpus',
'limitsCpuset' => 'cpuset',
'limitsCpuShares' => 'cpu shares',
];
/**
* Sync data between component properties and model
*
* @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
*/
private function syncData(bool $toModel = false): void
{
if ($toModel) {
// Sync TO model (before save)
$this->resource->limits_cpus = $this->limitsCpus;
$this->resource->limits_cpuset = $this->limitsCpuset;
$this->resource->limits_cpu_shares = $this->limitsCpuShares;
$this->resource->limits_memory = $this->limitsMemory;
$this->resource->limits_memory_swap = $this->limitsMemorySwap;
$this->resource->limits_memory_swappiness = $this->limitsMemorySwappiness;
$this->resource->limits_memory_reservation = $this->limitsMemoryReservation;
} else {
// Sync FROM model (on load/refresh)
$this->limitsCpus = $this->resource->limits_cpus;
$this->limitsCpuset = $this->resource->limits_cpuset;
$this->limitsCpuShares = $this->resource->limits_cpu_shares;
$this->limitsMemory = $this->resource->limits_memory;
$this->limitsMemorySwap = $this->resource->limits_memory_swap;
$this->limitsMemorySwappiness = $this->resource->limits_memory_swappiness;
$this->limitsMemoryReservation = $this->resource->limits_memory_reservation;
}
}
public function mount()
{
$this->syncData(false);
}
public function submit()
{
try {
$this->authorize('update', $this->resource);
if (! $this->resource->limits_memory) {
$this->resource->limits_memory = '0';
// Apply default values to properties
if (! $this->limitsMemory) {
$this->limitsMemory = '0';
}
if (! $this->resource->limits_memory_swap) {
$this->resource->limits_memory_swap = '0';
if (! $this->limitsMemorySwap) {
$this->limitsMemorySwap = '0';
}
if (is_null($this->resource->limits_memory_swappiness)) {
$this->resource->limits_memory_swappiness = '60';
if (is_null($this->limitsMemorySwappiness)) {
$this->limitsMemorySwappiness = 60;
}
if (! $this->resource->limits_memory_reservation) {
$this->resource->limits_memory_reservation = '0';
if (! $this->limitsMemoryReservation) {
$this->limitsMemoryReservation = '0';
}
if (! $this->resource->limits_cpus) {
$this->resource->limits_cpus = '0';
if (! $this->limitsCpus) {
$this->limitsCpus = '0';
}
if ($this->resource->limits_cpuset === '') {
$this->resource->limits_cpuset = null;
if ($this->limitsCpuset === '') {
$this->limitsCpuset = null;
}
if (is_null($this->resource->limits_cpu_shares)) {
$this->resource->limits_cpu_shares = 1024;
if (is_null($this->limitsCpuShares)) {
$this->limitsCpuShares = 1024;
}
$this->validate();
$this->syncData(true);
$this->resource->save();
$this->dispatch('success', 'Resource limits updated.');
} catch (\Throwable $e) {

View file

@ -25,20 +25,48 @@ class Show extends Component
public ?string $startedAt = null;
// Explicit properties
public string $name;
public string $mountPath;
public ?string $hostPath = null;
protected $rules = [
'storage.name' => 'required|string',
'storage.mount_path' => 'required|string',
'storage.host_path' => 'string|nullable',
'name' => 'required|string',
'mountPath' => 'required|string',
'hostPath' => 'string|nullable',
];
protected $validationAttributes = [
'name' => 'name',
'mount_path' => 'mount',
'host_path' => 'host',
'mountPath' => 'mount',
'hostPath' => 'host',
];
/**
* Sync data between component properties and model
*
* @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
*/
private function syncData(bool $toModel = false): void
{
if ($toModel) {
// Sync TO model (before save)
$this->storage->name = $this->name;
$this->storage->mount_path = $this->mountPath;
$this->storage->host_path = $this->hostPath;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->storage->name;
$this->mountPath = $this->storage->mount_path;
$this->hostPath = $this->storage->host_path;
}
}
public function mount()
{
$this->syncData(false);
$this->isReadOnly = $this->storage->isReadOnlyVolume();
}
@ -47,6 +75,7 @@ public function submit()
$this->authorize('update', $this->resource);
$this->validate();
$this->syncData(true);
$this->storage->save();
$this->dispatch('success', 'Storage updated successfully');
}

View file

@ -13,15 +13,24 @@ class Show extends Component
public PrivateKey $private_key;
// Explicit properties
public string $name;
public ?string $description = null;
public string $privateKeyValue;
public bool $isGitRelated = false;
public $public_key = 'Loading...';
protected function rules(): array
{
return [
'private_key.name' => ValidationPatterns::nameRules(),
'private_key.description' => ValidationPatterns::descriptionRules(),
'private_key.private_key' => 'required|string',
'private_key.is_git_related' => 'nullable|boolean',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'privateKeyValue' => 'required|string',
'isGitRelated' => 'nullable|boolean',
];
}
@ -30,25 +39,48 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'private_key.name.required' => 'The Name field is required.',
'private_key.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'private_key.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'private_key.private_key.required' => 'The Private Key field is required.',
'private_key.private_key.string' => 'The Private Key must be a valid string.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'privateKeyValue.required' => 'The Private Key field is required.',
'privateKeyValue.string' => 'The Private Key must be a valid string.',
]
);
}
protected $validationAttributes = [
'private_key.name' => 'name',
'private_key.description' => 'description',
'private_key.private_key' => 'private key',
'name' => 'name',
'description' => 'description',
'privateKeyValue' => 'private key',
];
/**
* Sync data between component properties and model
*
* @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
*/
private function syncData(bool $toModel = false): void
{
if ($toModel) {
// Sync TO model (before save)
$this->private_key->name = $this->name;
$this->private_key->description = $this->description;
$this->private_key->private_key = $this->privateKeyValue;
$this->private_key->is_git_related = $this->isGitRelated;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->private_key->name;
$this->description = $this->private_key->description;
$this->privateKeyValue = $this->private_key->private_key;
$this->isGitRelated = $this->private_key->is_git_related;
}
}
public function mount()
{
try {
$this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related'])->whereUuid(request()->private_key_uuid)->firstOrFail();
$this->syncData(false);
} catch (\Throwable) {
abort(404);
}
@ -81,6 +113,10 @@ public function changePrivateKey()
{
try {
$this->authorize('update', $this->private_key);
$this->validate();
$this->syncData(true);
$this->private_key->updatePrivateKey([
'private_key' => formatPrivateKey($this->private_key->private_key),
]);

View file

@ -290,7 +290,7 @@ private function loadHetznerData(string $token)
}
}
private function getCpuVendorInfo(array $serverType): string|null
private function getCpuVendorInfo(array $serverType): ?string
{
$name = strtolower($serverType['name'] ?? '');

View file

@ -22,6 +22,8 @@ class Proxy extends Component
public ?string $redirectUrl = null;
public bool $generateExactLabels = false;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
@ -33,7 +35,7 @@ public function getListeners()
}
protected $rules = [
'server.settings.generate_exact_labels' => 'required|boolean',
'generateExactLabels' => 'required|boolean',
];
public function mount()
@ -41,6 +43,16 @@ public function mount()
$this->selectedProxy = $this->server->proxyType();
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
$this->syncData(false);
}
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->server->settings->generate_exact_labels = $this->generateExactLabels;
} else {
$this->generateExactLabels = $this->server->settings->generate_exact_labels ?? false;
}
}
public function getConfigurationFilePathProperty()
@ -75,6 +87,7 @@ public function instantSave()
try {
$this->authorize('update', $this->server);
$this->validate();
$this->syncData(true);
$this->server->settings->save();
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {

View file

@ -34,32 +34,60 @@ class Change extends Component
public ?GithubApp $github_app = null;
// Explicit properties
public string $name;
public bool $is_system_wide;
public ?string $organization = null;
public string $apiUrl;
public string $htmlUrl;
public string $customUser;
public int $customPort;
public int $appId;
public int $installationId;
public string $clientId;
public string $clientSecret;
public string $webhookSecret;
public bool $isSystemWide;
public int $privateKeyId;
public ?string $contents = null;
public ?string $metadata = null;
public ?string $pullRequests = null;
public $applications;
public $privateKeys;
protected $rules = [
'github_app.name' => 'required|string',
'github_app.organization' => 'nullable|string',
'github_app.api_url' => 'required|string',
'github_app.html_url' => 'required|string',
'github_app.custom_user' => 'required|string',
'github_app.custom_port' => 'required|int',
'github_app.app_id' => 'required|int',
'github_app.installation_id' => 'required|int',
'github_app.client_id' => 'required|string',
'github_app.client_secret' => 'required|string',
'github_app.webhook_secret' => 'required|string',
'github_app.is_system_wide' => 'required|bool',
'github_app.contents' => 'nullable|string',
'github_app.metadata' => 'nullable|string',
'github_app.pull_requests' => 'nullable|string',
'github_app.administration' => 'nullable|string',
'github_app.private_key_id' => 'required|int',
'name' => 'required|string',
'organization' => 'nullable|string',
'apiUrl' => 'required|string',
'htmlUrl' => 'required|string',
'customUser' => 'required|string',
'customPort' => 'required|int',
'appId' => 'required|int',
'installationId' => 'required|int',
'clientId' => 'required|string',
'clientSecret' => 'required|string',
'webhookSecret' => 'required|string',
'isSystemWide' => 'required|bool',
'contents' => 'nullable|string',
'metadata' => 'nullable|string',
'pullRequests' => 'nullable|string',
'privateKeyId' => 'required|int',
];
public function boot()
@ -69,6 +97,52 @@ public function boot()
}
}
/**
* Sync data between component properties and model
*
* @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
*/
private function syncData(bool $toModel = false): void
{
if ($toModel) {
// Sync TO model (before save)
$this->github_app->name = $this->name;
$this->github_app->organization = $this->organization;
$this->github_app->api_url = $this->apiUrl;
$this->github_app->html_url = $this->htmlUrl;
$this->github_app->custom_user = $this->customUser;
$this->github_app->custom_port = $this->customPort;
$this->github_app->app_id = $this->appId;
$this->github_app->installation_id = $this->installationId;
$this->github_app->client_id = $this->clientId;
$this->github_app->client_secret = $this->clientSecret;
$this->github_app->webhook_secret = $this->webhookSecret;
$this->github_app->is_system_wide = $this->isSystemWide;
$this->github_app->private_key_id = $this->privateKeyId;
$this->github_app->contents = $this->contents;
$this->github_app->metadata = $this->metadata;
$this->github_app->pull_requests = $this->pullRequests;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->github_app->name;
$this->organization = $this->github_app->organization;
$this->apiUrl = $this->github_app->api_url;
$this->htmlUrl = $this->github_app->html_url;
$this->customUser = $this->github_app->custom_user;
$this->customPort = $this->github_app->custom_port;
$this->appId = $this->github_app->app_id;
$this->installationId = $this->github_app->installation_id;
$this->clientId = $this->github_app->client_id;
$this->clientSecret = $this->github_app->client_secret;
$this->webhookSecret = $this->github_app->webhook_secret;
$this->isSystemWide = $this->github_app->is_system_wide;
$this->privateKeyId = $this->github_app->private_key_id;
$this->contents = $this->github_app->contents;
$this->metadata = $this->github_app->metadata;
$this->pullRequests = $this->github_app->pull_requests;
}
}
public function checkPermissions()
{
try {
@ -126,6 +200,10 @@ public function mount()
$this->applications = $this->github_app->applications;
$settings = instanceSettings();
// Sync data from model to properties
$this->syncData(false);
// Override name with kebab case for display
$this->name = str($this->github_app->name)->kebab();
$this->fqdn = $settings->fqdn;
@ -247,21 +325,9 @@ public function submit()
$this->authorize('update', $this->github_app);
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->validate([
'github_app.name' => 'required|string',
'github_app.organization' => 'nullable|string',
'github_app.api_url' => 'required|string',
'github_app.html_url' => 'required|string',
'github_app.custom_user' => 'required|string',
'github_app.custom_port' => 'required|int',
'github_app.app_id' => 'required|int',
'github_app.installation_id' => 'required|int',
'github_app.client_id' => 'required|string',
'github_app.client_secret' => 'required|string',
'github_app.webhook_secret' => 'required|string',
'github_app.is_system_wide' => 'required|bool',
'github_app.private_key_id' => 'required|int',
]);
$this->validate();
$this->syncData(true);
$this->github_app->save();
$this->dispatch('success', 'Github App updated.');
} catch (\Throwable $e) {
@ -286,6 +352,8 @@ public function instantSave()
$this->authorize('update', $this->github_app);
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->syncData(true);
$this->github_app->save();
$this->dispatch('success', 'Github App updated.');
} catch (\Throwable $e) {

View file

@ -14,17 +14,34 @@ class Form extends Component
public S3Storage $storage;
// Explicit properties
public ?string $name = null;
public ?string $description = null;
public string $endpoint;
public string $bucket;
public string $region;
public string $key;
public string $secret;
public ?bool $isUsable = null;
protected function rules(): array
{
return [
'storage.is_usable' => 'nullable|boolean',
'storage.name' => ValidationPatterns::nameRules(required: false),
'storage.description' => ValidationPatterns::descriptionRules(),
'storage.region' => 'required|max:255',
'storage.key' => 'required|max:255',
'storage.secret' => 'required|max:255',
'storage.bucket' => 'required|max:255',
'storage.endpoint' => 'required|url|max:255',
'isUsable' => 'nullable|boolean',
'name' => ValidationPatterns::nameRules(required: false),
'description' => ValidationPatterns::descriptionRules(),
'region' => 'required|max:255',
'key' => 'required|max:255',
'secret' => 'required|max:255',
'bucket' => 'required|max:255',
'endpoint' => 'required|url|max:255',
];
}
@ -33,34 +50,69 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'storage.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'storage.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'storage.region.required' => 'The Region field is required.',
'storage.region.max' => 'The Region may not be greater than 255 characters.',
'storage.key.required' => 'The Access Key field is required.',
'storage.key.max' => 'The Access Key may not be greater than 255 characters.',
'storage.secret.required' => 'The Secret Key field is required.',
'storage.secret.max' => 'The Secret Key may not be greater than 255 characters.',
'storage.bucket.required' => 'The Bucket field is required.',
'storage.bucket.max' => 'The Bucket may not be greater than 255 characters.',
'storage.endpoint.required' => 'The Endpoint field is required.',
'storage.endpoint.url' => 'The Endpoint must be a valid URL.',
'storage.endpoint.max' => 'The Endpoint may not be greater than 255 characters.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'region.required' => 'The Region field is required.',
'region.max' => 'The Region may not be greater than 255 characters.',
'key.required' => 'The Access Key field is required.',
'key.max' => 'The Access Key may not be greater than 255 characters.',
'secret.required' => 'The Secret Key field is required.',
'secret.max' => 'The Secret Key may not be greater than 255 characters.',
'bucket.required' => 'The Bucket field is required.',
'bucket.max' => 'The Bucket may not be greater than 255 characters.',
'endpoint.required' => 'The Endpoint field is required.',
'endpoint.url' => 'The Endpoint must be a valid URL.',
'endpoint.max' => 'The Endpoint may not be greater than 255 characters.',
]
);
}
protected $validationAttributes = [
'storage.is_usable' => 'Is Usable',
'storage.name' => 'Name',
'storage.description' => 'Description',
'storage.region' => 'Region',
'storage.key' => 'Key',
'storage.secret' => 'Secret',
'storage.bucket' => 'Bucket',
'storage.endpoint' => 'Endpoint',
'isUsable' => 'Is Usable',
'name' => 'Name',
'description' => 'Description',
'region' => 'Region',
'key' => 'Key',
'secret' => 'Secret',
'bucket' => 'Bucket',
'endpoint' => 'Endpoint',
];
/**
* Sync data between component properties and model
*
* @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
*/
private function syncData(bool $toModel = false): void
{
if ($toModel) {
// Sync TO model (before save)
$this->storage->name = $this->name;
$this->storage->description = $this->description;
$this->storage->endpoint = $this->endpoint;
$this->storage->bucket = $this->bucket;
$this->storage->region = $this->region;
$this->storage->key = $this->key;
$this->storage->secret = $this->secret;
$this->storage->is_usable = $this->isUsable;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->storage->name;
$this->description = $this->storage->description;
$this->endpoint = $this->storage->endpoint;
$this->bucket = $this->storage->bucket;
$this->region = $this->storage->region;
$this->key = $this->storage->key;
$this->secret = $this->storage->secret;
$this->isUsable = $this->storage->is_usable;
}
}
public function mount()
{
$this->syncData(false);
}
public function testConnection()
{
try {
@ -94,6 +146,9 @@ public function submit()
DB::transaction(function () {
$this->validate();
// Sync properties to model before saving
$this->syncData(true);
$this->storage->save();
// Test connection with new values - if this fails, transaction will rollback
@ -103,12 +158,16 @@ public function submit()
$this->storage->is_usable = true;
$this->storage->unusable_email_sent = false;
$this->storage->save();
// Update local property to reflect success
$this->isUsable = true;
});
$this->dispatch('success', 'Storage settings updated and connection verified.');
} catch (\Throwable $e) {
// Refresh the model to revert UI to database values after rollback
$this->storage->refresh();
$this->syncData(false);
return handleError($e, $this);
}

View file

@ -18,11 +18,16 @@ class Index extends Component
public Team $team;
// Explicit properties
public string $name;
public ?string $description = null;
protected function rules(): array
{
return [
'team.name' => ValidationPatterns::nameRules(),
'team.description' => ValidationPatterns::descriptionRules(),
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
];
}
@ -31,21 +36,40 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'team.name.required' => 'The Name field is required.',
'team.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'team.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
]
);
}
protected $validationAttributes = [
'team.name' => 'name',
'team.description' => 'description',
'name' => 'name',
'description' => 'description',
];
/**
* Sync data between component properties and model
*
* @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
*/
private function syncData(bool $toModel = false): void
{
if ($toModel) {
// Sync TO model (before save)
$this->team->name = $this->name;
$this->team->description = $this->description;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->team->name;
$this->description = $this->team->description;
}
}
public function mount()
{
$this->team = currentTeam();
$this->syncData(false);
if (auth()->user()->isAdminFromSession()) {
$this->invitations = TeamInvitation::whereTeamId(currentTeam()->id)->get();
@ -62,6 +86,7 @@ public function submit()
$this->validate();
try {
$this->authorize('update', $this->team);
$this->syncData(true);
$this->team->save();
refreshSession();
$this->dispatch('success', 'Team updated.');

View file

@ -45,9 +45,16 @@ private function generateInviteLink(bool $sendEmail = false)
try {
$this->authorize('manageInvitations', currentTeam());
$this->validate();
if (auth()->user()->role() === 'admin' && $this->role === 'owner') {
// Prevent privilege escalation: users cannot invite someone with higher privileges
$userRole = auth()->user()->role();
if ($userRole === 'member' && in_array($this->role, ['admin', 'owner'])) {
throw new \Exception('Members cannot invite admins or owners.');
}
if ($userRole === 'admin' && $this->role === 'owner') {
throw new \Exception('Admins cannot invite owners.');
}
$this->email = strtolower($this->email);
$member_emails = currentTeam()->members()->get()->pluck('email');

View file

@ -1064,18 +1064,24 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
$source_html_url_scheme = $url['scheme'];
if ($this->source->getMorphClass() == 'App\Models\GithubApp') {
$escapedCustomRepository = escapeshellarg($customRepository);
if ($this->source->is_public) {
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
$fullRepoUrl = "{$this->source->html_url}/{$customRepository}";
$base_command = "{$base_command} {$this->source->html_url}/{$customRepository}";
$base_command = "{$base_command} {$escapedRepoUrl}";
} else {
$github_access_token = generateGithubInstallationToken($this->source);
if ($exec_in_docker) {
$base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
$escapedRepoUrl = escapeshellarg($repoUrl);
$base_command = "{$base_command} {$escapedRepoUrl}";
$fullRepoUrl = $repoUrl;
} else {
$base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
$escapedRepoUrl = escapeshellarg($repoUrl);
$base_command = "{$base_command} {$escapedRepoUrl}";
$fullRepoUrl = $repoUrl;
}
}
@ -1100,7 +1106,10 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
throw new RuntimeException('Private key not found. Please add a private key to the application and try again.');
}
$private_key = base64_encode($private_key);
$base_comamnd = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} {$customRepository}";
// When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context
// Replace ' with '\'' to safely escape within single-quoted bash strings
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'";
if ($exec_in_docker) {
$commands = collect([
@ -1117,9 +1126,9 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_comamnd));
$commands->push(executeInDocker($deployment_uuid, $base_command));
} else {
$commands->push($base_comamnd);
$commands->push($base_command);
}
return [

View file

@ -35,13 +35,18 @@ class InstanceSettings extends Model
protected static function booted(): void
{
static::updated(function ($settings) {
if ($settings->isDirty('helper_version')) {
if ($settings->wasChanged('helper_version')) {
Server::chunkById(100, function ($servers) {
foreach ($servers as $server) {
PullHelperImageJob::dispatch($server);
}
});
}
// Clear trusted hosts cache when FQDN changes
if ($settings->wasChanged('fqdn')) {
\Cache::forget('instance_settings_fqdn_host');
}
});
}

View file

@ -79,11 +79,11 @@ protected static function booted()
});
static::updated(function ($settings) {
if (
$settings->isDirty('sentinel_token') ||
$settings->isDirty('sentinel_custom_url') ||
$settings->isDirty('sentinel_metrics_refresh_rate_seconds') ||
$settings->isDirty('sentinel_metrics_history_days') ||
$settings->isDirty('sentinel_push_interval_seconds')
$settings->wasChanged('sentinel_token') ||
$settings->wasChanged('sentinel_custom_url') ||
$settings->wasChanged('sentinel_metrics_refresh_rate_seconds') ||
$settings->wasChanged('sentinel_metrics_history_days') ||
$settings->wasChanged('sentinel_push_interval_seconds')
) {
$settings->server->restartSentinel();
}

View file

@ -42,8 +42,7 @@ public function update(User $user, Team $team): bool
return false;
}
// return $user->isAdmin() || $user->isOwner();
return true;
return $user->isAdmin() || $user->isOwner();
}
/**
@ -56,8 +55,7 @@ public function delete(User $user, Team $team): bool
return false;
}
// return $user->isAdmin() || $user->isOwner();
return true;
return $user->isAdmin() || $user->isOwner();
}
/**
@ -70,8 +68,7 @@ public function manageMembers(User $user, Team $team): bool
return false;
}
// return $user->isAdmin() || $user->isOwner();
return true;
return $user->isAdmin() || $user->isOwner();
}
/**
@ -84,8 +81,7 @@ public function viewAdmin(User $user, Team $team): bool
return false;
}
// return $user->isAdmin() || $user->isOwner();
return true;
return $user->isAdmin() || $user->isOwner();
}
/**
@ -98,7 +94,6 @@ public function manageInvitations(User $user, Team $team): bool
return false;
}
// return $user->isAdmin() || $user->isOwner();
return true;
return $user->isAdmin() || $user->isOwner();
}
}

View file

@ -26,7 +26,7 @@ protected static function bootDeletesUserSessions()
{
static::updated(function ($user) {
// Check if password was changed
if ($user->isDirty('password')) {
if ($user->wasChanged('password')) {
$user->deleteAllSessions();
}
});

View file

@ -6,9 +6,14 @@
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
use Visus\Cuid2\Cuid2;
class Checkbox extends Component
{
public ?string $modelBinding = null;
public ?string $htmlId = null;
/**
* Create a new component instance.
*/
@ -47,6 +52,18 @@ public function __construct(
*/
public function render(): View|Closure|string
{
// Store original ID for wire:model binding (property name)
$this->modelBinding = $this->id;
// Generate unique HTML ID by adding random suffix
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->id) {
$uniqueSuffix = new Cuid2;
$this->htmlId = $this->id.'-'.$uniqueSuffix;
} else {
$this->htmlId = $this->id;
}
return view('components.forms.checkbox');
}
}

View file

@ -10,6 +10,10 @@
class Datalist extends Component
{
public ?string $modelBinding = null;
public ?string $htmlId = null;
/**
* Create a new component instance.
*/
@ -47,11 +51,27 @@ public function __construct(
*/
public function render(): View|Closure|string
{
// Store original ID for wire:model binding (property name)
$this->modelBinding = $this->id;
if (is_null($this->id)) {
$this->id = new Cuid2;
// Don't create wire:model binding for auto-generated IDs
$this->modelBinding = 'null';
}
// Generate unique HTML ID by adding random suffix
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->modelBinding && $this->modelBinding !== 'null') {
// Use original ID with random suffix for uniqueness
$uniqueSuffix = new Cuid2;
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
} else {
$this->htmlId = (string) $this->id;
}
if (is_null($this->name)) {
$this->name = $this->id;
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
return view('components.forms.datalist');

View file

@ -10,6 +10,10 @@
class Input extends Component
{
public ?string $modelBinding = null;
public ?string $htmlId = null;
public function __construct(
public ?string $id = null,
public ?string $name = null,
@ -43,11 +47,26 @@ public function __construct(
public function render(): View|Closure|string
{
// Store original ID for wire:model binding (property name)
$this->modelBinding = $this->id;
if (is_null($this->id)) {
$this->id = new Cuid2;
// Don't create wire:model binding for auto-generated IDs
$this->modelBinding = 'null';
}
// Generate unique HTML ID by adding random suffix
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->modelBinding && $this->modelBinding !== 'null') {
// Use original ID with random suffix for uniqueness
$uniqueSuffix = new Cuid2;
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
} else {
$this->htmlId = (string) $this->id;
}
if (is_null($this->name)) {
$this->name = $this->id;
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
if ($this->type === 'password') {
$this->defaultClass = $this->defaultClass.' pr-[2.8rem]';

View file

@ -10,6 +10,10 @@
class Select extends Component
{
public ?string $modelBinding = null;
public ?string $htmlId = null;
/**
* Create a new component instance.
*/
@ -40,11 +44,27 @@ public function __construct(
*/
public function render(): View|Closure|string
{
// Store original ID for wire:model binding (property name)
$this->modelBinding = $this->id;
if (is_null($this->id)) {
$this->id = new Cuid2;
// Don't create wire:model binding for auto-generated IDs
$this->modelBinding = 'null';
}
// Generate unique HTML ID by adding random suffix
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->modelBinding && $this->modelBinding !== 'null') {
// Use original ID with random suffix for uniqueness
$uniqueSuffix = new Cuid2;
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
} else {
$this->htmlId = (string) $this->id;
}
if (is_null($this->name)) {
$this->name = $this->id;
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
return view('components.forms.select');

View file

@ -10,6 +10,10 @@
class Textarea extends Component
{
public ?string $modelBinding = null;
public ?string $htmlId = null;
/**
* Create a new component instance.
*/
@ -27,6 +31,7 @@ public function __construct(
public bool $readonly = false,
public bool $allowTab = false,
public bool $spellcheck = false,
public bool $autofocus = false,
public ?string $helper = null,
public bool $realtimeValidation = false,
public bool $allowToPeak = true,
@ -53,11 +58,27 @@ public function __construct(
*/
public function render(): View|Closure|string
{
// Store original ID for wire:model binding (property name)
$this->modelBinding = $this->id;
if (is_null($this->id)) {
$this->id = new Cuid2;
// Don't create wire:model binding for auto-generated IDs
$this->modelBinding = 'null';
}
// Generate unique HTML ID by adding random suffix
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->modelBinding && $this->modelBinding !== 'null') {
// Use original ID with random suffix for uniqueness
$uniqueSuffix = new Cuid2;
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
} else {
$this->htmlId = (string) $this->id;
}
if (is_null($this->name)) {
$this->name = $this->id;
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
// $this->label = Str::title($this->label);

View file

@ -378,6 +378,16 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
if ($serviceLabels) {
$middlewares_from_labels = $serviceLabels->map(function ($item) {
// Handle array values from YAML parsing (e.g., "traefik.enable: true" becomes an array)
if (is_array($item)) {
// Convert array to string format "key=value"
$key = collect($item)->keys()->first();
$value = collect($item)->values()->first();
$item = "$key=$value";
}
if (! is_string($item)) {
return null;
}
if (preg_match('/traefik\.http\.middlewares\.(.*?)(\.|$)/', $item, $matches)) {
return $matches[1];
}
@ -1120,6 +1130,76 @@ function escapeDollarSign($value)
return str_replace($search, $replace, $value);
}
/**
* Escape a value for use in a bash .env file that will be sourced with 'source' command
* Wraps the value in single quotes and escapes any single quotes within the value
*
* @param string|null $value The value to escape
* @return string The escaped value wrapped in single quotes
*/
function escapeBashEnvValue(?string $value): string
{
// Handle null or empty values
if ($value === null || $value === '') {
return "''";
}
// Replace single quotes with '\'' (end quote, escaped quote, start quote)
// This is the standard way to escape single quotes in bash single-quoted strings
$escaped = str_replace("'", "'\\''", $value);
// Wrap in single quotes
return "'{$escaped}'";
}
/**
* Escape a value for bash double-quoted strings (allows $VAR expansion)
*
* This function wraps values in double quotes while escaping special characters,
* but preserves valid bash variable references like $VAR and ${VAR}.
*
* @param string|null $value The value to escape
* @return string The escaped value wrapped in double quotes
*/
function escapeBashDoubleQuoted(?string $value): string
{
// Handle null or empty values
if ($value === null || $value === '') {
return '""';
}
// Step 1: Escape backslashes first (must be done before other escaping)
$escaped = str_replace('\\', '\\\\', $value);
// Step 2: Escape double quotes
$escaped = str_replace('"', '\\"', $escaped);
// Step 3: Escape backticks (command substitution)
$escaped = str_replace('`', '\\`', $escaped);
// Step 4: Escape invalid $ patterns while preserving valid variable references
// Valid patterns to keep:
// - $VAR_NAME (alphanumeric + underscore, starting with letter or _)
// - ${VAR_NAME} (brace expansion)
// - $0-$9 (positional parameters)
// Invalid patterns to escape: $&, $#, $$, $*, $@, $!, $(, etc.
// Match $ followed by anything that's NOT a valid variable start
// Valid variable starts: letter, underscore, digit (for $0-$9), or open brace
$escaped = preg_replace(
'/\$(?![a-zA-Z_0-9{])/',
'\\\$',
$escaped
);
// Preserve pre-escaped dollars inside double quotes: turn \\$ back into \$
// (keeps tests like "path\\to\\file" intact while restoring \$ semantics)
$escaped = preg_replace('/\\\\(?=\$)/', '\\\\', $escaped);
// Wrap in double quotes
return "\"{$escaped}\"";
}
/**
* Generate Docker build arguments from environment variables collection
* Returns only keys (no values) since values are sourced from environment via export

View file

@ -16,6 +16,101 @@
use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2;
/**
* Validates a Docker Compose YAML string for command injection vulnerabilities.
* This should be called BEFORE saving to database to prevent malicious data from being stored.
*
* @param string $composeYaml The raw Docker Compose YAML content
*
* @throws \Exception If the compose file contains command injection attempts
*/
function validateDockerComposeForInjection(string $composeYaml): void
{
try {
$parsed = Yaml::parse($composeYaml);
} catch (\Exception $e) {
throw new \Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
}
if (! is_array($parsed) || ! isset($parsed['services']) || ! is_array($parsed['services'])) {
throw new \Exception('Docker Compose file must contain a "services" section');
}
// Validate service names
foreach ($parsed['services'] as $serviceName => $serviceConfig) {
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.',
0,
$e
);
}
// Validate volumes in this service (both string and array formats)
if (isset($serviceConfig['volumes']) && is_array($serviceConfig['volumes'])) {
foreach ($serviceConfig['volumes'] as $volume) {
if (is_string($volume)) {
// String format: "source:target" or "source:target:mode"
validateVolumeStringForInjection($volume);
} elseif (is_array($volume)) {
// Array format: {type: bind, source: ..., target: ...}
if (isset($volume['source'])) {
$source = $volume['source'];
if (is_string($source)) {
// Allow simple env vars and env vars with defaults (validated in parseDockerVolumeString)
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $source);
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault) {
try {
validateShellSafePath($source, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
$e
);
}
}
}
}
if (isset($volume['target'])) {
$target = $volume['target'];
if (is_string($target)) {
try {
validateShellSafePath($target, 'volume target');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
$e
);
}
}
}
}
}
}
}
}
/**
* Validates a Docker volume string (format: "source:target" or "source:target:mode")
*
* @param string $volumeString The volume string to validate
*
* @throws \Exception If the volume string contains command injection attempts
*/
function validateVolumeStringForInjection(string $volumeString): void
{
// Canonical parsing also validates and throws on unsafe input
parseDockerVolumeString($volumeString);
}
function parseDockerVolumeString(string $volumeString): array
{
$volumeString = trim($volumeString);
@ -212,6 +307,46 @@ function parseDockerVolumeString(string $volumeString): array
// Otherwise keep the variable as-is for later expansion (no default value)
}
// Validate source path for command injection attempts
// We validate the final source value after environment variable processing
if ($source !== null) {
// Allow simple environment variables like ${VAR_NAME} or ${VAR}
// but validate everything else for shell metacharacters
$sourceStr = is_string($source) ? $source : $source;
// Skip validation for simple environment variable references
// Pattern: ${WORD_CHARS} with no special characters inside
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceStr);
if (! $isSimpleEnvVar) {
try {
validateShellSafePath($sourceStr, 'volume source');
} catch (\Exception $e) {
// Re-throw with more context about the volume string
throw new \Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
}
// Also validate target path
if ($target !== null) {
$targetStr = is_string($target) ? $target : $target;
// Target paths in containers are typically absolute paths, so we validate them too
// but they're less likely to be dangerous since they're not used in host commands
// Still, defense in depth is important
try {
validateShellSafePath($targetStr, 'volume target');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
return [
'source' => $source !== null ? str($source) : null,
'target' => $target !== null ? str($target) : null,
@ -265,6 +400,16 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$allMagicEnvironments = collect([]);
foreach ($services as $serviceName => $service) {
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
}
$magicEnvironments = collect([]);
$image = data_get_str($service, 'image');
$environment = collect(data_get($service, 'environment', []));
@ -561,6 +706,33 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$content = data_get($volume, 'content');
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
$sourceValue = $source->value();
// Allow simple environment variable references
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
if (! $isSimpleEnvVar) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
}
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
$foundConfig = $fileStorages->whereMountPath($target)->first();
if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content');
@ -1178,6 +1350,16 @@ function serviceParser(Service $resource): Collection
$allMagicEnvironments = collect([]);
// Presave services
foreach ($services as $serviceName => $service) {
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
}
$image = data_get_str($service, 'image');
$isDatabase = isDatabaseImage($image, $service);
if ($isDatabase) {
@ -1575,6 +1757,33 @@ function serviceParser(Service $resource): Collection
$content = data_get($volume, 'content');
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
$sourceValue = $source->value();
// Allow simple environment variable references
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
if (! $isSimpleEnvVar) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
}
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
$foundConfig = $fileStorages->whereMountPath($target)->first();
if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content');

View file

@ -104,6 +104,48 @@ function sanitize_string(?string $input = null): ?string
return $sanitized;
}
/**
* Validate that a path or identifier is safe for use in shell commands.
*
* This function prevents command injection by rejecting strings that contain
* shell metacharacters or command substitution patterns.
*
* @param string $input The path or identifier to validate
* @param string $context Descriptive name for error messages (e.g., 'volume source', 'service name')
* @return string The validated input (unchanged if valid)
*
* @throws \Exception If dangerous characters are detected
*/
function validateShellSafePath(string $input, string $context = 'path'): string
{
// List of dangerous shell metacharacters that enable command injection
$dangerousChars = [
'`' => 'backtick (command substitution)',
'$(' => 'command substitution',
'${' => 'variable substitution with potential command injection',
'|' => 'pipe operator',
'&' => 'background/AND operator',
';' => 'command separator',
"\n" => 'newline (command separator)',
"\r" => 'carriage return',
"\t" => 'tab (token separator)',
'>' => 'output redirection',
'<' => 'input redirection',
];
// Check for dangerous characters
foreach ($dangerousChars as $char => $description) {
if (str_contains($input, $char)) {
throw new \Exception(
"Invalid {$context}: contains forbidden character '{$char}' ({$description}). ".
'Shell metacharacters are not allowed for security reasons.'
);
}
}
return $input;
}
function generate_readme_file(string $name, string $updated_at): string
{
$name = sanitize_string($name);
@ -1285,6 +1327,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($serviceLabels->count() > 0) {
$removedLabels = collect([]);
$serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) {
// Handle array values from YAML (e.g., "traefik.enable: true" becomes an array)
if (is_array($serviceLabel)) {
$removedLabels->put($serviceLabelName, $serviceLabel);
return false;
}
if (! str($serviceLabel)->contains('=')) {
$removedLabels->put($serviceLabelName, $serviceLabel);
@ -1294,6 +1342,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
return $serviceLabel;
});
foreach ($removedLabels as $removedLabelName => $removedLabel) {
// Convert array values to strings
if (is_array($removedLabel)) {
$removedLabel = (string) collect($removedLabel)->first();
}
$serviceLabels->push("$removedLabelName=$removedLabel");
}
}
@ -2005,6 +2057,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($serviceLabels->count() > 0) {
$removedLabels = collect([]);
$serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) {
// Handle array values from YAML (e.g., "traefik.enable: true" becomes an array)
if (is_array($serviceLabel)) {
$removedLabels->put($serviceLabelName, $serviceLabel);
return false;
}
if (! str($serviceLabel)->contains('=')) {
$removedLabels->put($serviceLabelName, $serviceLabel);
@ -2014,6 +2072,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
return $serviceLabel;
});
foreach ($removedLabels as $removedLabelName => $removedLabel) {
// Convert array values to strings
if (is_array($removedLabel)) {
$removedLabel = (string) collect($removedLabel)->first();
}
$serviceLabels->push("$removedLabelName=$removedLabel");
}
}

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.0.0-beta.435',
'version' => '4.0.0-beta.436',
'helper_version' => '1.0.11',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),

View file

@ -90,7 +90,7 @@
|
*/
'legacy_model_binding' => true,
'legacy_model_binding' => false,
/*
|---------------------------------------------------------------------------

View file

@ -14,6 +14,21 @@ class ApplicationSeeder extends Seeder
*/
public function run(): void
{
Application::create([
'name' => 'Docker Compose Example',
'repository_project_id' => 603035348,
'git_repository' => 'coollabsio/coolify-examples',
'git_branch' => 'v4.x',
'base_directory' => '/docker-compose',
'docker_compose_location' => 'docker-compose-test.yaml',
'build_pack' => 'dockercompose',
'ports_exposes' => '80',
'environment_id' => 1,
'destination_id' => 0,
'destination_type' => StandaloneDocker::class,
'source_id' => 1,
'source_type' => GithubApp::class,
]);
Application::create([
'name' => 'NodeJS Fastify Example',
'fqdn' => 'http://nodejs.127.0.0.1.sslip.io',

View file

@ -16,6 +16,7 @@ public function run(): void
InstanceSettings::create([
'id' => 0,
'is_registration_enabled' => true,
'is_api_enabled' => isDev(),
'smtp_enabled' => true,
'smtp_host' => 'coolify-mail',
'smtp_port' => 1025,

View file

@ -23,7 +23,7 @@
"auth.failed": "These credentials do not match our records.",
"auth.failed.callback": "Failed to process callback from login provider.",
"auth.failed.password": "The provided password is incorrect.",
"auth.failed.email": "We can't find a user with that e-mail address.",
"auth.failed.email": "If an account exists with this email address, you will receive a password reset link shortly.",
"auth.throttle": "Too many login attempts. Please try again in :seconds seconds.",
"input.name": "Name",
"input.email": "Email",

22
lang/en/passwords.php Normal file
View file

@ -0,0 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reset Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| outcome such as failure due to an invalid password / reset token.
|
*/
'reset' => 'Your password has been reset.',
'sent' => 'If an account exists with this email address, you will receive a password reset link shortly.',
'throttled' => 'Please wait before retrying.',
'token' => 'This password reset token is invalid.',
'user' => 'If an account exists with this email address, you will receive a password reset link shortly.',
];

630
package-lock.json generated

File diff suppressed because it is too large Load diff

28
public/svgs/cap.svg Normal file
View file

@ -0,0 +1,28 @@
<svg width="1313" height="491" viewBox="0 0 1313 491" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M866.989 285.729C861.288 315.455 848.331 339.107 828.117 356.686C808.163 374.005 782.248 382.665 750.375 382.665C724.201 382.665 701.266 376.461 681.571 364.053C662.135 351.386 647.106 334.197 636.48 312.482C625.855 290.769 620.543 266.341 620.543 239.199C620.543 212.057 625.726 187.501 636.091 165.529C646.717 143.556 661.876 126.367 681.571 113.958C701.266 101.292 724.201 94.959 750.375 94.959C780.694 94.959 805.831 103.231 825.785 119.775C845.74 136.318 858.956 158.679 865.435 186.854L814.513 189.568C810.625 173.542 803.11 161.005 791.967 151.957C780.824 142.651 766.83 137.998 749.986 137.998C732.363 137.998 717.592 142.393 705.672 151.182C694.011 159.97 685.329 172.119 679.628 187.63C673.927 202.881 671.076 220.07 671.076 239.199C671.076 258.328 673.927 275.39 679.628 290.382C685.329 305.375 694.011 317.395 705.672 326.441C717.592 335.231 732.363 339.624 749.986 339.624C767.608 339.624 781.991 334.584 793.134 324.504C804.535 314.164 812.051 300.333 815.68 283.014L866.989 285.729Z" fill="#0A486B"/>
<path d="M888.669 233.385C892.816 212.188 902.792 195.773 918.601 184.142C934.408 172.251 954.752 166.305 979.629 166.305C1009.17 166.305 1031.59 173.803 1046.88 188.796C1062.17 203.788 1069.81 225.631 1069.81 254.323V326.056C1069.81 331.485 1070.85 335.362 1072.92 337.689C1075.25 339.756 1078.49 340.789 1082.64 340.789H1090.41V376.463L1078.36 376.85H1076.81C1069.81 377.11 1062.68 376.591 1055.43 375.3C1048.43 374.007 1042.21 371.034 1036.77 366.381C1031.33 361.728 1028.22 354.619 1027.44 345.055C1022.26 355.654 1013.84 364.314 1002.17 371.034C990.513 377.754 976.26 381.116 959.416 381.116C938.167 381.116 920.415 375.817 906.162 365.218C892.168 354.361 885.172 340.145 885.172 322.566C885.172 309.641 888.15 299.171 894.111 291.158C900.331 283.145 908.883 276.812 919.766 272.159C930.909 267.506 945.422 263.5 963.304 260.139L1021.22 248.507C1020.96 232.738 1017.46 221.107 1010.73 213.611C1003.99 205.855 993.623 201.978 979.629 201.978C968.745 201.978 959.675 204.951 952.42 210.896C945.422 216.582 940.628 224.984 938.037 236.1L888.669 233.385ZM934.927 321.016C934.927 328.512 938.037 334.716 944.255 339.626C950.734 344.279 959.934 346.606 971.855 346.606C981.443 346.606 989.995 344.279 997.509 339.626C1005.28 334.975 1011.25 327.995 1015.39 318.689C1019.8 309.124 1022 297.621 1022 284.179V282.241L982.35 289.221L975.352 290.384C965.765 292.194 958.249 294.002 952.807 295.811C947.624 297.621 943.349 300.594 939.98 304.73C936.611 308.607 934.927 314.036 934.927 321.016Z" fill="#0A486B"/>
<path d="M1115.57 170.958H1161.05L1162.61 213.998L1158.72 208.958C1163.91 195.256 1172.07 184.787 1183.21 177.55C1194.61 170.054 1208.35 166.305 1224.41 166.305C1243.59 166.305 1259.79 171.087 1273 180.653C1286.22 190.216 1296.07 203.142 1302.55 219.427C1309.28 235.454 1312.65 253.547 1312.65 273.711C1312.65 293.874 1309.28 312.097 1302.55 328.383C1296.07 344.409 1286.22 357.205 1273 366.768C1259.79 376.334 1243.59 381.116 1224.41 381.116C1213.79 381.116 1204.07 379.436 1195.26 376.074C1186.71 372.714 1179.32 367.804 1173.11 361.341C1167.14 354.878 1162.48 346.865 1159.11 337.3L1163.78 333.423V434.624H1115.57V170.958ZM1159.89 273.711C1159.89 286.118 1161.7 297.491 1165.33 307.831C1169.22 317.913 1175.05 326.056 1182.82 332.259C1190.6 338.463 1200.06 341.565 1211.2 341.565C1228.04 341.565 1240.74 335.103 1249.29 322.179C1258.1 309.254 1262.51 293.098 1262.51 273.711C1262.51 254.582 1258.1 238.427 1249.29 225.242C1240.74 212.059 1228.04 205.468 1211.2 205.468C1200.06 205.468 1190.6 208.699 1182.82 215.161C1175.05 221.365 1169.22 229.637 1165.33 239.977C1161.7 250.059 1159.89 261.303 1159.89 273.711Z" fill="#0A486B"/>
<path d="M399.238 0H91.3073C40.8797 0 0 40.8797 0 91.3073V399.238C0 449.665 40.8797 490.545 91.3073 490.545H399.238C449.665 490.545 490.545 449.665 490.545 399.238V91.3073C490.545 40.8797 449.665 0 399.238 0Z" fill="url(#paint0_linear_4636_5)"/>
<path d="M245.882 439.039C352.557 439.039 439.034 352.562 439.034 245.888C439.034 139.213 352.557 52.7363 245.882 52.7363C139.207 52.7363 52.7305 139.213 52.7305 245.888C52.7305 352.562 139.207 439.039 245.882 439.039Z" fill="url(#paint1_radial_4636_5)"/>
<path d="M246.5 410.833C337.259 410.833 410.833 337.259 410.833 246.5C410.833 155.742 337.259 82.168 246.5 82.168C155.742 82.168 82.168 155.742 82.168 246.5C82.168 337.259 155.742 410.833 246.5 410.833Z" fill="url(#paint2_linear_4636_5)"/>
<path d="M246.499 370.36C314.906 370.36 370.362 314.904 370.362 246.497C370.362 178.09 314.906 122.635 246.499 122.635C178.092 122.635 122.637 178.09 122.637 246.497C122.637 314.904 178.092 370.36 246.499 370.36Z" fill="url(#paint3_radial_4636_5)"/>
<defs>
<linearGradient id="paint0_linear_4636_5" x1="245.272" y1="0" x2="245.272" y2="490.545" gradientUnits="userSpaceOnUse">
<stop stop-color="#A8DBF8"/>
<stop offset="1" stop-color="#BCCBFF"/>
</linearGradient>
<radialGradient id="paint1_radial_4636_5" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(245.882 178.974) rotate(107.38) scale(272.507 272.507)">
<stop offset="0.0625" stop-color="#84D3FF"/>
<stop offset="0.411458" stop-color="#17ABFE"/>
<stop offset="0.90625" stop-color="#0770D1"/>
</radialGradient>
<linearGradient id="paint2_linear_4636_5" x1="246.5" y1="82.1672" x2="246.5" y2="410.832" gradientUnits="userSpaceOnUse">
<stop stop-color="#BBE8FF"/>
<stop offset="1" stop-color="#018FEF"/>
</linearGradient>
<radialGradient id="paint3_radial_4636_5" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(265.197 246.497) rotate(98.5836) scale(125.265 125.265)">
<stop stop-color="#7EC3FF"/>
<stop offset="1" stop-color="#EAF5FF"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

30
public/svgs/signoz.svg Normal file
View file

@ -0,0 +1,30 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="400" rx="80" fill="url(#paint0_radial_4733_781)"/>
<g filter="url(#filter0_di_4733_781)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M44 199.765L44.0022 199.754L44.0069 199.73L44.0182 199.675L44.0481 199.534C44.0714 199.426 44.1015 199.292 44.1394 199.131C44.2153 198.809 44.3224 198.382 44.4678 197.856C44.7587 196.805 45.203 195.358 45.8587 193.567C47.1706 189.983 49.3257 185.027 52.7826 179.099C59.7091 167.222 71.7901 151.567 92.5794 135.281C113.473 118.913 133.761 110.11 148.904 105.394C156.476 103.036 162.774 101.697 167.241 100.942C169.475 100.565 171.254 100.333 172.508 100.194C173.135 100.125 173.632 100.078 173.989 100.048C174.168 100.033 174.311 100.022 174.419 100.014L174.554 100.005L174.601 100.002L174.627 100C174.627 100 174.634 99.9996 175.158 108.869C175.682 117.738 175.688 117.738 175.688 117.738L175.68 117.739C175.644 117.741 175.573 117.746 175.469 117.755C175.26 117.773 174.915 117.805 174.444 117.857C173.501 117.962 172.051 118.148 170.163 118.467C166.929 119.013 162.416 119.947 156.957 121.52C156.788 121.577 156.619 121.634 156.448 121.693C145.468 125.449 129.786 132.792 111.452 147.138C93.1643 161.448 82.886 174.942 77.2007 184.71C74.3549 189.599 72.6507 193.571 71.6684 196.266C71.1771 197.614 70.8661 198.643 70.683 199.309C70.5974 199.62 70.5398 199.852 70.5048 200C70.5398 200.148 70.5974 200.38 70.683 200.691C70.8661 201.357 71.1771 202.386 71.6684 203.734C72.6507 206.429 74.3549 210.401 77.2007 215.29C82.886 225.058 93.1643 238.552 111.452 252.862C129.786 267.208 145.468 274.551 156.448 278.307C156.619 278.366 156.788 278.423 156.957 278.48C162.416 280.053 166.929 280.987 170.163 281.533C172.051 281.852 173.501 282.038 174.444 282.143C174.915 282.195 175.26 282.227 175.469 282.245C175.573 282.254 175.644 282.259 175.68 282.261L175.688 282.262C175.688 282.262 175.682 282.262 175.158 291.131C174.634 300 174.627 300 174.627 300L174.619 300L174.601 299.998L174.554 299.995L174.419 299.986C174.311 299.978 174.168 299.967 173.989 299.952C173.632 299.922 173.135 299.875 172.508 299.806C171.254 299.667 169.475 299.435 167.241 299.058C162.774 298.303 156.476 296.964 148.904 294.606C133.761 289.89 113.473 281.087 92.5794 264.719C71.7901 248.433 59.7091 232.778 52.7826 220.901C49.3257 214.973 47.1706 210.017 45.8587 206.433C45.203 204.642 44.7587 203.195 44.4678 202.144C44.3224 201.618 44.2153 201.191 44.1394 200.869C44.1015 200.708 44.0714 200.574 44.0481 200.466L44.0182 200.325L44.0069 200.27L44.0022 200.246L44 200.235V199.765Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M356 199.765L355.998 199.754L355.993 199.73L355.982 199.675L355.952 199.534C355.929 199.426 355.899 199.292 355.861 199.131C355.785 198.809 355.678 198.382 355.532 197.856C355.241 196.805 354.797 195.358 354.141 193.567C352.829 189.983 350.674 185.027 347.217 179.099C340.291 167.222 328.21 151.567 307.421 135.281C286.527 118.913 266.239 110.11 251.096 105.394C243.524 103.036 237.226 101.697 232.759 100.942C230.525 100.565 228.746 100.333 227.492 100.194C226.865 100.125 226.368 100.078 226.011 100.048C225.833 100.033 225.689 100.022 225.581 100.014L225.446 100.005L225.399 100.002L225.373 100C225.373 100 225.366 99.9996 224.842 108.869C224.318 117.738 224.312 117.738 224.312 117.738L224.32 117.739C224.356 117.741 224.427 117.746 224.531 117.755C224.74 117.773 225.085 117.805 225.556 117.857C226.499 117.962 227.949 118.148 229.837 118.467C233.071 119.013 237.584 119.947 243.043 121.52C243.212 121.577 243.381 121.634 243.552 121.693C254.532 125.449 270.214 132.792 288.548 147.138C306.836 161.448 317.114 174.942 322.799 184.71C325.645 189.599 327.349 193.571 328.332 196.266C328.823 197.614 329.134 198.643 329.317 199.309C329.403 199.62 329.46 199.852 329.495 200C329.46 200.148 329.403 200.38 329.317 200.691C329.134 201.357 328.823 202.386 328.332 203.734C327.349 206.429 325.645 210.401 322.799 215.29C317.114 225.058 306.836 238.552 288.548 252.862C270.214 267.208 254.532 274.551 243.552 278.307C243.381 278.366 243.212 278.423 243.043 278.48C237.584 280.053 233.071 280.987 229.837 281.533C227.949 281.852 226.499 282.038 225.556 282.143C225.085 282.195 224.74 282.227 224.531 282.245C224.427 282.254 224.356 282.259 224.32 282.261L224.312 282.262C224.312 282.262 224.318 282.262 224.842 291.131C225.366 300 225.373 300 225.373 300L225.381 300L225.399 299.998L225.446 299.995L225.581 299.986C225.689 299.978 225.833 299.967 226.011 299.952C226.368 299.922 226.865 299.875 227.492 299.806C228.746 299.667 230.525 299.435 232.759 299.058C237.226 298.303 243.524 296.964 251.096 294.606C266.239 289.89 286.527 281.087 307.421 264.719C328.21 248.433 340.291 232.778 347.217 220.901C350.674 214.973 352.829 210.017 354.141 206.433C354.797 204.642 355.241 203.195 355.532 202.144C355.678 201.618 355.785 201.191 355.861 200.869C355.899 200.708 355.929 200.574 355.952 200.466L355.982 200.325L355.993 200.27L355.998 200.246L356 200.235V199.765Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M273.369 199.992C273.369 239.375 241.671 271.301 202.57 271.301C163.468 271.301 131.771 239.375 131.771 199.992C131.771 160.609 163.468 128.682 202.57 128.682C241.671 128.682 273.369 160.609 273.369 199.992ZM184.87 159.88C184.87 159.88 176.363 169.911 173.808 177.708C172.16 182.734 171.595 191.078 171.595 191.078H149.47C149.47 191.078 149.47 184.393 153.895 173.251C158.32 162.109 162.745 159.88 162.745 159.88H184.87ZM173.808 222.276C176.363 230.072 184.87 240.103 184.87 240.103H162.745C162.745 240.103 158.32 237.875 153.895 226.733C149.47 215.591 149.47 208.906 149.47 208.906H171.595C171.595 208.906 172.16 217.249 173.808 222.276Z" fill="white"/>
</g>
<defs>
<filter id="filter0_di_4733_781" x="20" y="92" width="360" height="248" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="16"/>
<feGaussianBlur stdDeviation="12"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.368384 0 0 0 0 0.0623777 0 0 0 0 0.0623777 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4733_781"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4733_781" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="16"/>
<feGaussianBlur stdDeviation="12"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_4733_781"/>
</filter>
<radialGradient id="paint0_radial_4733_781" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(200 200) rotate(45.6903) scale(234.776)">
<stop offset="0.329632" stop-color="#F76526"/>
<stop offset="1" stop-color="#F43030"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -32,7 +32,7 @@ @utility apexcharts-tooltip-custom-title {
}
@utility input-sticky {
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base;
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning;
}
@utility input-sticky-active {
@ -46,20 +46,20 @@ @utility input-focus {
/* input, select before */
@utility input-select {
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 dark:disabled:ring-transparent;
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-2 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 dark:disabled:ring-transparent;
}
/* Readonly */
@utility input {
@apply dark:read-only:text-neutral-500 dark:read-only:ring-0 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200;
@apply input-select;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base;
@apply focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning;
}
@utility select {
@apply w-full;
@apply input-select;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base;
@apply focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning;
}
@utility button {

View file

@ -1,29 +1,51 @@
<x-layout-simple>
<div class="flex items-center justify-center h-screen">
<div>
<div class="flex flex-col items-center pb-8">
<div class="text-5xl font-bold tracking-tight text-center dark:text-white">Coolify</div>
{{-- <x-version /> --}}
</div>
<div class="w-96">
<form action="/user/confirm-password" method="POST" class="flex flex-col gap-2">
@csrf
<x-forms.input required type="password" name="password" label="{{ __('input.password') }}" />
<x-forms.button type="submit">{{ __('auth.confirm_password') }}</x-forms.button>
</form>
@if ($errors->any())
<div class="text-xs text-center text-error">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
<section class="bg-gray-50 dark:bg-base">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<p class="text-lg dark:text-neutral-400">
Confirm Your Password
</p>
</div>
<div class="space-y-6">
@if (session('status'))
<div class="p-4 bg-success/10 border border-success rounded-lg">
<p class="text-sm text-success">{{ session('status') }}</p>
</div>
@endif
@if ($errors->any())
<div class="p-4 bg-error/10 border border-error rounded-lg">
@foreach ($errors->all() as $error)
<p class="text-sm text-error">{{ $error }}</p>
@endforeach
</div>
@endif
<div class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<div class="flex gap-3">
<svg class="size-5 flex-shrink-0 mt-0.5 text-coollabs dark:text-warning" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm dark:text-neutral-400">
This is a secure area. Please confirm your password before continuing.
</p>
</div>
</div>
@endif
@if (session('status'))
<div class="mb-4 font-medium text-green-600">
{{ session('status') }}
</div>
@endif
<form action="/user/confirm-password" method="POST" class="flex flex-col gap-4">
@csrf
<x-forms.input required type="password" name="password" label="{{ __('input.password') }}" />
<x-forms.button class="w-full justify-center py-3 box-boarding" type="submit" isHighlighted>
{{ __('auth.confirm_password') }}
</x-forms.button>
</form>
</div>
</div>
</div>
</div>
</section>
</x-layout-simple>

View file

@ -1,42 +1,88 @@
<x-layout-simple>
<section class="bg-gray-50 dark:bg-base">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<a class="flex items-center mb-1 text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</a> <div class="flex items-center gap-2">
{{ __('auth.forgot_password_heading') }}
</div>
<div
class="w-full bg-white shadow-sm md:mt-0 sm:max-w-md xl:p-0 dark:bg-base ">
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<p class="text-lg dark:text-neutral-400">
{{ __('auth.forgot_password_heading') }}
</p>
</div>
<div class="space-y-6">
@if (session('status'))
<div class="mb-6 p-4 bg-success/10 border border-success rounded-lg">
<div class="flex gap-3">
<svg class="size-5 text-success flex-shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
<p class="text-sm text-success">{{ session('status') }}</p>
</div>
</div>
@endif
@if ($errors->any())
<div class="mb-6 p-4 bg-error/10 border border-error rounded-lg">
@foreach ($errors->all() as $error)
<p class="text-sm text-error">{{ $error }}</p>
@endforeach
</div>
@endif
@if (is_transactional_emails_enabled())
<form action="/forgot-password" method="POST" class="flex flex-col gap-2">
@csrf
<x-forms.input required type="email" name="email" label="{{ __('input.email') }}" />
<x-forms.button type="submit">{{ __('auth.forgot_password_send_email') }}</x-forms.button>
</form>
@else
<div>Transactional emails are not active on this instance.</div>
<div>See how to set it in our <a class="dark:text-white" target="_blank"
href="{{ config('constants.urls.docs') }}">docs</a>, or how to
manually reset password.
<form action="/forgot-password" method="POST" class="flex flex-col gap-4">
@csrf
<x-forms.input required type="email" name="email" label="{{ __('input.email') }}" />
<x-forms.button class="w-full justify-center py-3 box-boarding" type="submit" isHighlighted>
{{ __('auth.forgot_password_send_email') }}
</x-forms.button>
</form>
@else
<div class="p-4 bg-warning/10 border border-warning rounded-lg mb-6">
<div class="flex gap-3">
<svg class="size-5 text-warning flex-shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd" />
</svg>
<div>
<p class="font-bold text-warning mb-2">Email Not Configured</p>
<p class="text-sm dark:text-white text-black mb-2">
Transactional emails are not active on this instance.
</p>
<p class="text-sm dark:text-white text-black">
See how to set it in our <a class="font-bold underline hover:text-coollabs"
target="_blank" href="{{ config('constants.urls.docs') }}">documentation</a>, or
learn how to manually reset your password.
</p>
</div>
</div>
</div>
@endif
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
Remember your password?
</span>
</div>
</div>
@endif
@if ($errors->any())
<div class="text-xs text-center text-error">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
@endif
@if (session('status'))
<div class="mb-4 text-xs font-medium text-green-600">
{{ session('status') }}
</div>
@endif
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
Back to Login
</a>
</div>
</div>
</div>
</section>
</x-layout-simple>
</x-layout-simple>

View file

@ -1,79 +1,102 @@
<x-layout-simple>
<section class="bg-gray-50 dark:bg-base">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<a class="flex items-center mb-6 text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</a>
<div class="w-full bg-white shadow-sm md:mt-0 sm:max-w-md xl:p-0 dark:bg-base ">
@if ($errors->any())
<div class="text-center text-error">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
@endif
<div class="p-6 space-y-4 md:space-y-3 sm:p-8">
<form action="/login" method="POST" class="flex flex-col gap-2">
@csrf
@env('local')
<x-forms.input value="test@example.com" type="email" autocomplete="email" name="email"
required label="{{ __('input.email') }}" />
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
</div>
<x-forms.input value="password" type="password" autocomplete="current-password" name="password"
required label="{{ __('input.password') }}" />
<a href="/forgot-password" class="text-xs">
{{ __('auth.forgot_password_link') }}
</a>
@else
<x-forms.input type="email" name="email" autocomplete="email" required
label="{{ __('input.email') }}" />
<x-forms.input type="password" name="password" autocomplete="current-password" required
label="{{ __('input.password') }}" />
<a href="/forgot-password" class="text-xs">
{{ __('auth.forgot_password_link') }}
</a>
@endenv
<x-forms.button class="mt-4" type="submit">{{ __('auth.login') }}</x-forms.button>
@if (session('error'))
<div class="mb-4 font-medium text-red-600">
{{ session('error') }}
</div>
@endif
@if (!$is_registration_enabled)
<div class="text-center text-neutral-500">{{ __('auth.registration_disabled') }}</div>
@endif
@if (session('status'))
<div class="mb-4 font-medium text-green-600">
{{ session('status') }}
</div>
@endif
</form>
@if ($is_registration_enabled)
<a href="/register" class="button bg-coollabs-gradient">
{{ __('auth.register_now') }}
</a>
@endif
@if ($enabled_oauth_providers->isNotEmpty())
<div class="relative">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t dark:border-coolgray-200"></div>
</div>
<div class="relative flex justify-center">
<span class="px-2 text-sm dark:text-neutral-500 dark:bg-base">or</span>
</div>
<div class="space-y-6">
@if (session('status'))
<div class="mb-6 p-4 bg-success/10 border border-success rounded-lg">
<p class="text-sm text-success">{{ session('status') }}</p>
</div>
@endif
@foreach ($enabled_oauth_providers as $provider_setting)
<x-forms.button class="w-full" type="button"
onclick="document.location.href='/auth/{{ $provider_setting->provider }}/redirect'">
{{ __("auth.login.$provider_setting->provider") }}
@if (session('error'))
<div class="mb-6 p-4 bg-error/10 border border-error rounded-lg">
<p class="text-sm text-error">{{ session('error') }}</p>
</div>
@endif
@if ($errors->any())
<div class="mb-6 p-4 bg-error/10 border border-error rounded-lg">
@foreach ($errors->all() as $error)
<p class="text-sm text-error">{{ $error }}</p>
@endforeach
</div>
@endif
<form action="/login" method="POST" class="flex flex-col gap-4">
@csrf
@env('local')
<x-forms.input value="test@example.com" type="email" autocomplete="email" name="email" required
label="{{ __('input.email') }}" />
<x-forms.input value="password" type="password" autocomplete="current-password" name="password"
required label="{{ __('input.password') }}" />
@else
<x-forms.input type="email" name="email" autocomplete="email" required
label="{{ __('input.email') }}" />
<x-forms.input type="password" name="password" autocomplete="current-password" required
label="{{ __('input.password') }}" />
@endenv
<div class="flex items-center justify-between">
<a href="/forgot-password"
class="text-sm dark:text-neutral-400 hover:text-coollabs dark:hover:text-warning hover:underline transition-colors">
{{ __('auth.forgot_password_link') }}
</a>
</div>
<x-forms.button class="w-full justify-center py-3 box-boarding" type="submit" isHighlighted>
{{ __('auth.login') }}
</x-forms.button>
@endforeach
</form>
@if ($is_registration_enabled)
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
Don't have an account?
</span>
</div>
</div>
<a href="/register"
class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
{{ __('auth.register_now') }}
</a>
@else
<div class="mt-6 text-center text-sm text-neutral-500 dark:text-neutral-400">
{{ __('auth.registration_disabled') }}
</div>
@endif
@if ($enabled_oauth_providers->isNotEmpty())
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">or
continue with</span>
</div>
</div>
<div class="flex flex-col gap-3">
@foreach ($enabled_oauth_providers as $provider_setting)
<x-forms.button class="w-full justify-center" type="button"
onclick="document.location.href='/auth/{{ $provider_setting->provider }}/redirect'">
{{ __("auth.login.$provider_setting->provider") }}
</x-forms.button>
@endforeach
</div>
@endif
</div>
</div>
</div>
</section>
</x-layout-simple>
</x-layout-simple>

View file

@ -11,22 +11,43 @@ function getOldOrLocal($key, $localValue)
<x-layout-simple>
<section class="bg-gray-50 dark:bg-base">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<a class="flex items-center mb-6 text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</a>
<div class="w-full bg-white rounded-lg shadow-sm md:mt-0 sm:max-w-md xl:p-0 dark:bg-base">
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
<div>
<h1
class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
Create an account
</h1>
@if ($isFirstUser)
<div class="text-xs dark:text-warning">This user will be the root user (full admin access).
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<p class="text-lg dark:text-neutral-400">
Create your account
</p>
</div>
<div class="space-y-6">
@if ($isFirstUser)
<div class="mb-6 p-4 bg-warning/10 border border-warning rounded-lg">
<div class="flex gap-3">
<svg class="size-5 text-warning flex-shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
<div>
<p class="font-bold text-warning">Root User Setup</p>
<p class="text-sm dark:text-white text-black">This user will be the root user with full admin access.</p>
</div>
</div>
@endif
</div>
<form action="/register" method="POST" class="flex flex-col gap-2">
</div>
@endif
@if ($errors->any())
<div class="mb-6 p-4 bg-error/10 border border-error rounded-lg">
@foreach ($errors->all() as $error)
<p class="text-sm text-error">{{ $error }}</p>
@endforeach
</div>
@endif
<form action="/register" method="POST" class="flex flex-col gap-4">
@csrf
<x-forms.input id="name" required type="text" name="name" value="{{ $name }}"
label="{{ __('input.name') }}" />
@ -36,15 +57,32 @@ class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl
label="{{ __('input.password') }}" />
<x-forms.input id="password_confirmation" required type="password" name="password_confirmation"
label="{{ __('input.password.again') }}" />
<div class="text-xs w-full">Your password should be min 8 characters long and contain
at least one uppercase letter, one lowercase letter, one number, and one symbol.</div>
<div class="flex flex-col gap-4 pt-8 w-full">
<x-forms.button class="w-full" type="submit">Register</x-forms.button>
<a href="/login" class="w-full text-xs">
{{ __('auth.already_registered') }}
</a>
<div class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<p class="text-xs dark:text-neutral-400">
Your password should be min 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one symbol.
</p>
</div>
<x-forms.button class="w-full justify-center py-3 box-boarding mt-2" type="submit" isHighlighted>
Create Account
</x-forms.button>
</form>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
Already have an account?
</span>
</div>
</div>
<a href="/login" class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
{{ __('auth.already_registered') }}
</a>
</div>
</div>
</div>

View file

@ -1,39 +1,80 @@
<x-layout-simple>
<section class="bg-gray-50 dark:bg-base">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<a class="flex items-center text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</a>
<div class="flex items-center justify-center pb-6 text-center">
{{ __('auth.reset_password') }}
</div>
<div class="w-full bg-white shadow-sm md:mt-0 sm:max-w-md xl:p-0 dark:bg-base ">
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
<form action="/reset-password" method="POST" class="flex flex-col gap-2">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<p class="text-lg dark:text-neutral-400">
{{ __('auth.reset_password') }}
</p>
</div>
<div class="space-y-6">
@if (session('status'))
<div class="mb-6 p-4 bg-success/10 border border-success rounded-lg">
<div class="flex gap-3">
<svg class="size-5 text-success flex-shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
<p class="text-sm text-success">{{ session('status') }}</p>
</div>
</div>
@endif
@if ($errors->any())
<div class="mb-6 p-4 bg-error/10 border border-error rounded-lg">
@foreach ($errors->all() as $error)
<p class="text-sm text-error">{{ $error }}</p>
@endforeach
</div>
@endif
<div class="mb-6">
<p class="text-sm dark:text-neutral-400">
Enter your new password below. Make sure it's strong and secure.
</p>
</div>
<form action="/reset-password" method="POST" class="flex flex-col gap-4">
@csrf
<input hidden id="token" name="token" value="{{ request()->route('token') }}">
<input hidden value="{{ request()->query('email') }}" type="email" name="email"
label="{{ __('input.email') }}" />
<div class="flex flex-col gap-2">
<x-forms.input required type="password" id="password" name="password"
label="{{ __('input.password') }}" />
<x-forms.input required type="password" id="password_confirmation"
name="password_confirmation" label="{{ __('input.password.again') }}" />
<x-forms.input required type="password" id="password" name="password"
label="{{ __('input.password') }}" />
<x-forms.input required type="password" id="password_confirmation"
name="password_confirmation" label="{{ __('input.password.again') }}" />
<div class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<p class="text-xs dark:text-neutral-400">
Your password should be min 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one symbol.
</p>
</div>
<x-forms.button type="submit">{{ __('auth.reset_password') }}</x-forms.button>
<x-forms.button class="w-full justify-center py-3 box-boarding mt-2" type="submit" isHighlighted>
{{ __('auth.reset_password') }}
</x-forms.button>
</form>
@if ($errors->any())
<div class="text-xs text-center text-error">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
@endif
@if (session('status'))
<div class="mb-4 font-medium text-green-600">
{{ session('status') }}
<div class="relative flex justify-center text-sm">
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
Remember your password?
</span>
</div>
@endif
</div>
<a href="/login" class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
Back to Login
</a>
</div>
</div>
</div>

View file

@ -1,40 +1,137 @@
<x-layout-simple>
<section class="bg-gray-50 dark:bg-base" x-data="{ showRecovery: false }">
<section class="bg-gray-50 dark:bg-base" x-data="{
showRecovery: false,
digits: ['', '', '', '', '', ''],
code: '',
focusNext(event) {
const nextInput = event.target.nextElementSibling;
if (nextInput && nextInput.tagName === 'INPUT') {
nextInput.focus();
}
},
focusPrevious(event) {
if (event.key === 'Backspace' && !event.target.value) {
const prevInput = event.target.previousElementSibling;
if (prevInput && prevInput.tagName === 'INPUT') {
prevInput.focus();
}
}
},
updateCode() {
this.code = this.digits.join('');
if (this.digits.every(d => d !== '') && this.digits.length === 6) {
this.$nextTick(() => {
const form = document.querySelector('form[action=\'/two-factor-challenge\']');
if (form) form.submit();
});
}
},
pasteCode(event) {
event.preventDefault();
const paste = (event.clipboardData || window.clipboardData).getData('text');
const pasteDigits = paste.replace(/\D/g, '').slice(0, 6).split('');
const container = event.target.closest('.flex');
const inputs = container.querySelectorAll('input[type=text]');
pasteDigits.forEach((digit, index) => {
if (index < 6 && inputs[index]) {
this.digits[index] = digit;
}
});
this.updateCode();
if (pasteDigits.length > 0 && inputs.length > 0) {
const lastIndex = Math.min(pasteDigits.length - 1, 5);
inputs[lastIndex].focus();
}
}
}">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<a class="flex items-center mb-6 text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</a>
<div class="w-full bg-white shadow-sm md:mt-0 sm:max-w-md xl:p-0 dark:bg-base ">
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
<form action="/two-factor-challenge" method="POST" class="flex flex-col gap-2">
@csrf
<div>
<x-forms.input type="number" name="code" autocomplete="one-time-code" label="{{ __('input.code') }}" />
<div x-show="!showRecovery"
class="pt-2 text-xs cursor-pointer hover:underline dark:hover:text-white"
x-on:click="showRecovery = !showRecovery">Enter
Recovery Code
</div>
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<p class="text-lg dark:text-neutral-400">
Two-Factor Authentication
</p>
</div>
<div class="space-y-6">
@if (session('status'))
<div class="p-4 bg-success/10 border border-success rounded-lg">
<p class="text-sm text-success">{{ session('status') }}</p>
</div>
<div x-show="showRecovery" x-cloak>
<x-forms.input name="recovery_code" label="{{ __('input.recovery_code') }}" />
</div>
<x-forms.button type="submit">{{ __('auth.login') }}</x-forms.button>
</form>
@endif
@if ($errors->any())
<div class="text-xs text-center text-error">
<div class="p-4 bg-error/10 border border-error rounded-lg">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
<p class="text-sm text-error">{{ $error }}</p>
@endforeach
</div>
@endif
@if (session('status'))
<div class="mb-4 font-medium text-green-600">
{{ session('status') }}
<div x-show="!showRecovery"
class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<div class="flex gap-3">
<svg class="size-5 flex-shrink-0 mt-0.5 text-coollabs dark:text-warning"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm dark:text-neutral-400">
Enter the verification code from your authenticator app to continue.
</p>
</div>
@endif
</div>
<form action="/two-factor-challenge" method="POST" class="flex flex-col gap-4">
@csrf
<div x-show="!showRecovery">
<input type="hidden" name="code" x-model="code">
<div class="flex gap-2 justify-center" @paste="pasteCode($event)">
<template x-for="(digit, index) in digits" :key="index">
<input type="text" inputmode="numeric" maxlength="1" x-model="digits[index]"
@input="if ($event.target.value) { focusNext($event); updateCode(); }"
@keydown="focusPrevious($event)"
class="w-12 h-14 text-center text-2xl font-bold bg-white dark:bg-coolgray-100 border-2 border-neutral-200 dark:border-coolgray-300 rounded-lg focus:border-coollabs dark:focus:border-warning focus:outline-none focus:ring-0 transition-colors"
autocomplete="off" />
</template>
</div>
<button type="button" x-on:click="showRecovery = !showRecovery"
class="mt-4 text-sm dark:text-neutral-400 hover:text-black dark:hover:text-white hover:underline transition-colors cursor-pointer">
Use Recovery Code Instead
</button>
</div>
<div x-show="showRecovery" x-cloak>
<x-forms.input name="recovery_code" label="{{ __('input.recovery_code') }}" />
<button type="button" x-on:click="showRecovery = !showRecovery"
class="mt-2 text-sm dark:text-neutral-400 hover:text-black dark:hover:text-white hover:underline transition-colors cursor-pointer">
Use Authenticator Code Instead
</button>
</div>
<x-forms.button class="w-full justify-center py-3 box-boarding" type="submit" isHighlighted>
{{ __('auth.login') }}
</x-forms.button>
</form>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
Need help?
</span>
</div>
</div>
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
Back to Login
</a>
</div>
</div>
</div>
</section>
</x-layout-simple>
</x-layout-simple>

View file

@ -32,14 +32,14 @@
<input type="checkbox" @disabled($disabled) {{ $attributes->merge(['class' => $defaultClass]) }}
wire:loading.attr="disabled"
wire:click='{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}'
wire:model={{ $id }} @if ($checked) checked @endif />
wire:model={{ $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif />
@else
@if ($domValue)
<input type="checkbox" @disabled($disabled) {{ $attributes->merge(['class' => $defaultClass]) }}
value={{ $domValue }} @if ($checked) checked @endif />
value={{ $domValue }} id="{{ $htmlId }}" @if ($checked) checked @endif />
@else
<input type="checkbox" @disabled($disabled) {{ $attributes->merge(['class' => $defaultClass]) }}
wire:model={{ $value ?? $id }} @if ($checked) checked @endif />
wire:model={{ $value ?? $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif />
@endif
@endif
</label>

View file

@ -16,7 +16,7 @@
<div x-data="{
open: false,
search: '',
selected: @entangle($id).live,
selected: @entangle($modelBinding).live,
options: [],
filteredOptions: [],
@ -98,12 +98,12 @@
{{-- Unified Input Container with Tags Inside --}}
<div @click="$refs.searchInput.focus()"
class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 w-full text-sm rounded-sm border-0 ring-1 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text px-1 focus-within:ring-2 focus-within:ring-coollabs dark:focus-within:ring-warning text-black dark:text-white"
class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 w-full text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text px-1 focus-within:border-l-4 focus-within:border-l-coollabs dark:focus-within:border-l-warning text-black dark:text-white"
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}"
wire:loading.class="opacity-50"
wire:dirty.class="dark:ring-warning ring-warning">
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4">
{{-- Selected Tags Inside Input --}}
<template x-for="value in selected" :key="value">
@ -161,7 +161,7 @@ class="w-4 h-4 rounded border-neutral-300 dark:border-neutral-600 bg-white dark:
<div x-data="{
open: false,
search: '',
selected: @entangle(($attributes->whereStartsWith('wire:model')->first() ? $attributes->wire('model')->value() : $id)).live,
selected: @entangle(($attributes->whereStartsWith('wire:model')->first() ? $attributes->wire('model')->value() : $modelBinding)).live,
options: [],
filteredOptions: [],
@ -229,12 +229,12 @@ class="w-4 h-4 rounded border-neutral-300 dark:border-neutral-600 bg-white dark:
{{-- Input Container --}}
<div @click="openDropdown()"
class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 ring-1 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text focus-within:ring-2 focus-within:ring-coollabs dark:focus-within:ring-warning text-black dark:text-white"
class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text focus-within:border-l-4 focus-within:border-l-coollabs dark:focus-within:border-l-warning text-black dark:text-white"
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}"
wire:loading.class="opacity-50"
wire:dirty.class="dark:ring-warning ring-warning">
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4">
{{-- Display Selected Value or Search Input --}}
<div class="flex-1 flex items-center min-w-0 px-1">
@ -284,7 +284,7 @@ class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200"
</div>
@endif
@error($id)
@error($modelBinding)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>

View file

@ -27,9 +27,9 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
@endif
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
@if ($id !== 'null') wire:model={{ $id }} @endif
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}"
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif
wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}"
@if ($autofocus) x-ref="autofocusInput" @endif>
@ -38,19 +38,19 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
@else
<input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly)
@if ($id !== 'null') wire:model={{ $id }} @endif
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled"
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif
wire:loading.attr="disabled"
type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}"
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"
maxlength="{{ $attributes->get('maxlength') }}"
@if ($id !== 'null') id={{ $id }} @endif name="{{ $name }}"
@if ($htmlId !== 'null') id={{ $htmlId }} @endif name="{{ $name }}"
placeholder="{{ $attributes->get('placeholder') }}"
@if ($autofocus) x-ref="autofocusInput" @endif>
@endif
@if (!$label && $helper)
<x-helper :helper="$helper" />
@endif
@error($id)
@error($modelBinding)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>

View file

@ -81,8 +81,13 @@
document.getElementById(monacoId).addEventListener('monaco-editor-focused', (event) => {
editor.focus();
});
updatePlaceholder(editor.getValue());
@if ($autofocus)
// Auto-focus the editor
setTimeout(() => editor.focus(), 100);
@endif
$watch('monacoContent', value => {
if (editor.getValue() !== value) {
@ -99,7 +104,7 @@
}, 5);" :id="monacoId">
</div>
<div class="relative z-10 w-full h-full">
<div x-ref="monacoEditorElement" class="w-full h-96 text-md {{ $readonly ? 'opacity-65' : '' }}"></div>
<div x-ref="monacoEditorElement" class="w-full h-[calc(100vh-20rem)] min-h-96 text-md {{ $readonly ? 'opacity-65' : '' }}"></div>
<div x-ref="monacoPlaceholderElement" x-show="monacoPlaceholder" @click="monacoEditorFocus()"
:style="'font-size: ' + monacoFontSize"
class="w-full text-sm font-mono absolute z-50 text-gray-500 ml-14 -translate-x-0.5 mt-0.5 left-0 top-0"

View file

@ -11,11 +11,11 @@ class="flex gap-1 items-center mb-1 text-sm font-medium {{ $disabled ? 'text-neu
</label>
@endif
<select {{ $attributes->merge(['class' => $defaultClass]) }} @disabled($disabled) @required($required)
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" name={{ $id }}
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} @else wire:model={{ $id }} @endif>
wire:loading.attr="disabled" name={{ $modelBinding }} id="{{ $htmlId }}"
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @else wire:model={{ $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif>
{{ $slot }}
</select>
@error($id)
@error($modelBinding)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>

View file

@ -25,9 +25,9 @@ function handleKeydown(e) {
</label>
@endif
@if ($useMonacoEditor)
<x-forms.monaco-editor id="{{ $id }}" language="{{ $monacoEditorLanguage }}" name="{{ $name }}"
name="{{ $id }}" model="{{ $value ?? $id }}" wire:model="{{ $value ?? $id }}"
readonly="{{ $readonly }}" label="dockerfile" />
<x-forms.monaco-editor id="{{ $modelBinding }}" language="{{ $monacoEditorLanguage }}" name="{{ $name }}"
name="{{ $modelBinding }}" model="{{ $value ?? $modelBinding }}" wire:model="{{ $value ?? $modelBinding }}"
readonly="{{ $readonly }}" label="dockerfile" autofocus="{{ $autofocus }}" />
@else
@if ($type === 'password')
<div class="relative" x-data="{ type: 'password' }">
@ -45,34 +45,34 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer
@endif
<input x-cloak x-show="type === 'password'" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClassInput]) }} @required($required)
@if ($id !== 'null') wire:model={{ $id }} @endif
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}"
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif
wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}">
<textarea minlength="{{ $minlength }}" maxlength="{{ $maxlength }}" x-cloak x-show="type !== 'password'"
placeholder="{{ $placeholder }}" {{ $attributes->merge(['class' => $defaultClass]) }}
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $id }}"
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"
@else
wire:model={{ $value ?? $id }}
wire:dirty.class="dark:ring-warning ring-warning" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $id }}"
name="{{ $name }}" name={{ $id }}></textarea>
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
name="{{ $name }}" name={{ $modelBinding }}
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>
</div>
@else
<textarea minlength="{{ $minlength }}" maxlength="{{ $maxlength }}"
{{ $allowTab ? '@keydown.tab=handleKeydown' : '' }} placeholder="{{ $placeholder }}"
{{ !$spellcheck ? 'spellcheck=false' : '' }} {{ $attributes->merge(['class' => $defaultClass]) }}
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $id }}"
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"
@else
wire:model={{ $value ?? $id }}
wire:dirty.class="dark:ring-warning ring-warning" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $id }}"
name="{{ $name }}" name={{ $id }}></textarea>
wire:model={{ $value ?? $modelBinding }} wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
name="{{ $name }}" name={{ $modelBinding }}
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>
@endif
@endif
@error($id)
@error($modelBinding)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>

View file

@ -13,9 +13,8 @@
</p>
</div>
<div
class="bg-white dark:bg-coolgray-100 rounded-lg shadow-sm border border-neutral-200 dark:border-coolgray-300 p-8 text-left">
<h2 class="text-sm font-bold uppercase tracking-wide dark:text-neutral-400 mb-4">
<div class="text-left space-y-4 p-8 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<h2 class="text-sm font-bold uppercase tracking-wide dark:text-neutral-400">
What You'll Set Up
</h2>
<div class="space-y-3">
@ -67,11 +66,15 @@ class="bg-white dark:bg-coolgray-100 rounded-lg shadow-sm border border-neutral-
</div>
</div>
<div class="flex justify-center pt-4">
<div class="flex flex-col items-center gap-3 pt-4">
<x-forms.button class="justify-center px-12 py-4 text-lg font-bold box-boarding"
wire:click="explanation">
Start Setup
Let's go!
</x-forms.button>
<button wire:click="skipBoarding"
class="text-sm dark:text-neutral-400 hover:text-coollabs dark:hover:text-warning hover:underline transition-colors">
Skip Setup
</button>
</div>
</div>
@elseif ($currentState === 'explanation')
@ -161,34 +164,36 @@ class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:b
</div>
</button>
@can('viewAny', App\Models\CloudProviderToken::class)
<x-modal-input title="Connect a Hetzner Server" isFullWidth>
<x-slot:content>
<div
class="group relative box-without-bg cursor-pointer hover:border-coollabs transition-all duration-200 p-6 h-full min-h-[210px]">
<div class="flex flex-col gap-4 text-left">
<div class="flex items-center justify-between">
<svg class="size-10" viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#D50C2D" rx="8" />
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z"
fill="white" />
</svg>
<span
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Recommended
</span>
</div>
<div>
<h3 class="text-xl font-bold mb-2">Hetzner Cloud</h3>
<p class="text-sm dark:text-neutral-400">
Deploy servers directly from your Hetzner Cloud account.
</p>
@if ($currentState === 'select-server-type')
<x-modal-input title="Connect a Hetzner Server" isFullWidth>
<x-slot:content>
<div
class="group relative box-without-bg cursor-pointer hover:border-coollabs transition-all duration-200 p-6 h-full min-h-[210px]">
<div class="flex flex-col gap-4 text-left">
<div class="flex items-center justify-between">
<svg class="size-10" viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#D50C2D" rx="8" />
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z"
fill="white" />
</svg>
<span
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Recommended
</span>
</div>
<div>
<h3 class="text-xl font-bold mb-2">Hetzner Cloud</h3>
<p class="text-sm dark:text-neutral-400">
Deploy servers directly from your Hetzner Cloud account.
</p>
</div>
</div>
</div>
</div>
</x-slot:content>
<livewire:server.new.by-hetzner :private_keys="$this->privateKeys" :limit_reached="false" />
</x-modal-input>
</x-slot:content>
<livewire:server.new.by-hetzner :private_keys="$this->privateKeys" :limit_reached="false" />
</x-modal-input>
@endif
@endcan
</div>
@ -643,9 +648,8 @@ class="p-6 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-2
</p>
</div>
<div
class="bg-white dark:bg-coolgray-100 rounded-lg shadow-sm border border-neutral-200 dark:border-coolgray-300 p-8 text-left">
<h2 class="text-sm font-bold uppercase tracking-wide dark:text-neutral-400 mb-4">
<div class="text-left space-y-4 p-8 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<h2 class="text-sm font-bold uppercase tracking-wide dark:text-neutral-400">
What's Configured
</h2>
<div class="space-y-3">

View file

@ -282,7 +282,7 @@ class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen
<input type="text" x-model="searchQuery"
placeholder="Search resources, paths, everything (type new for create)..." x-ref="searchInput"
x-init="$watch('modalOpen', value => { if (value) setTimeout(() => $refs.searchInput.focus(), 100) })"
class="w-full pl-12 pr-32 py-4 text-base bg-white dark:bg-coolgray-100 border-none rounded-lg shadow-xl ring-1 ring-neutral-200 dark:ring-coolgray-300 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base" />
class="w-full pl-12 pr-32 py-4 text-base bg-white dark:bg-coolgray-100 border-none rounded-lg shadow-xl ring-1 ring-neutral-200 dark:ring-coolgray-300 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning" />
<div class="absolute inset-y-0 right-2 flex items-center gap-2 pointer-events-none">
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-500">
/ or ⌘K to focus

View file

@ -16,14 +16,14 @@
<div>General configuration for your application.</div>
<div class="flex flex-col gap-2 py-4">
<div class="flex flex-col items-end gap-2 xl:flex-row">
<x-forms.input x-bind:disabled="shouldDisable()" id="application.name" label="Name" required />
<x-forms.input x-bind:disabled="shouldDisable()" id="application.description" label="Description" />
<x-forms.input x-bind:disabled="shouldDisable()" id="name" label="Name" required />
<x-forms.input x-bind:disabled="shouldDisable()" id="description" label="Description" />
</div>
@if (!$application->dockerfile && $application->build_pack !== 'dockerimage')
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<x-forms.select x-bind:disabled="shouldDisable()" wire:model.live="application.build_pack"
<x-forms.select x-bind:disabled="shouldDisable()" wire:model.live="build_pack"
label="Build Pack" required>
<option value="nixpacks">Nixpacks</option>
<option value="static">Static</option>
@ -31,7 +31,7 @@
<option value="dockercompose">Docker Compose</option>
</x-forms.select>
@if ($application->settings->is_static || $application->build_pack === 'static')
<x-forms.select x-bind:disabled="!canUpdate" id="application.static_image"
<x-forms.select x-bind:disabled="!canUpdate" id="static_image"
label="Static Image" required>
<option value="nginx:alpine">nginx:alpine</option>
<option disabled value="apache:alpine">apache:alpine</option>
@ -66,7 +66,7 @@
</div>
@endif
@if ($application->settings->is_static || $application->build_pack === 'static')
<x-forms.textarea id="application.custom_nginx_configuration"
<x-forms.textarea id="custom_nginx_configuration"
placeholder="Empty means default configuration will be used." label="Custom Nginx Configuration"
helper="You can add custom Nginx configuration here." x-bind:disabled="!canUpdate" />
@can('update', $application)
@ -77,25 +77,25 @@
@endif
<div class="w-96 pb-6">
@if ($application->could_set_build_commands())
<x-forms.checkbox instantSave id="application.settings.is_static" label="Is it a static site?"
<x-forms.checkbox instantSave id="is_static" label="Is it a static site?"
helper="If your application is a static site or the final build assets should be served as a static site, enable this."
x-bind:disabled="!canUpdate" />
@endif
@if ($application->settings->is_static && $application->build_pack !== 'static')
<x-forms.checkbox label="Is it a SPA (Single Page Application)?"
helper="If your application is a SPA, enable this." id="application.settings.is_spa" instantSave
helper="If your application is a SPA, enable this." id="is_spa" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
@endif
</div>
@if ($application->build_pack !== 'dockercompose')
<div class="flex items-end gap-2">
@if ($application->settings->is_container_label_readonly_enabled == false)
<x-forms.input placeholder="https://coolify.io" wire:model.blur="application.fqdn"
<x-forms.input placeholder="https://coolify.io" wire:model="application.fqdn"
label="Domains" readonly
helper="Readonly labels are disabled. You can set the domains in the labels section."
x-bind:disabled="!canUpdate" />
@else
<x-forms.input placeholder="https://coolify.io" wire:model.blur="application.fqdn"
<x-forms.input placeholder="https://coolify.io" wire:model="application.fqdn"
label="Domains"
helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. "
x-bind:disabled="!canUpdate" />
@ -121,7 +121,7 @@
x-bind:disabled="!canUpdate" />
@endif
@else
<x-forms.select label="Direction" id="application.redirect" required
<x-forms.select label="Direction" id="redirect" required
helper="You must need to add www and non-www as an A DNS record. Make sure the www domain is added under Domains."
x-bind:disabled="!canUpdate">
<option value="both">Allow www & non-www.</option>
@ -164,15 +164,15 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
<div class="flex flex-col gap-2 xl:flex-row">
@if ($application->build_pack === 'dockerimage')
@if ($application->destination->server->isSwarm())
<x-forms.input required id="application.docker_registry_image_name" label="Docker Image"
<x-forms.input required id="docker_registry_image_name" label="Docker Image"
x-bind:disabled="!canUpdate" />
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag or Hash"
<x-forms.input id="docker_registry_image_tag" label="Docker Image Tag or Hash"
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
x-bind:disabled="!canUpdate" />
@else
<x-forms.input id="application.docker_registry_image_name" label="Docker Image"
<x-forms.input id="docker_registry_image_name" label="Docker Image"
x-bind:disabled="!canUpdate" />
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag or Hash"
<x-forms.input id="docker_registry_image_tag" label="Docker Image Tag or Hash"
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
x-bind:disabled="!canUpdate" />
@endif
@ -181,18 +181,18 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
$application->destination->server->isSwarm() ||
$application->additional_servers->count() > 0 ||
$application->settings->is_build_server_enabled)
<x-forms.input id="application.docker_registry_image_name" required label="Docker Image"
<x-forms.input id="docker_registry_image_name" required label="Docker Image"
placeholder="Required!" x-bind:disabled="!canUpdate" />
<x-forms.input id="application.docker_registry_image_tag"
<x-forms.input id="docker_registry_image_tag"
helper="If set, it will tag the built image with this tag too. <br><br>Example: If you set it to 'latest', it will push the image with the commit sha tag + with the latest tag."
placeholder="Empty means latest will be used." label="Docker Image Tag"
x-bind:disabled="!canUpdate" />
@else
<x-forms.input id="application.docker_registry_image_name"
<x-forms.input id="docker_registry_image_name"
helper="Empty means it won't push the image to a docker registry. Pre-tag the image with your registry url if you want to push it to a private registry (default: Dockerhub). <br><br>Example: ghcr.io/myimage"
placeholder="Empty means it won't push the image to a docker registry."
label="Docker Image" x-bind:disabled="!canUpdate" />
<x-forms.input id="application.docker_registry_image_tag"
<x-forms.input id="docker_registry_image_tag"
placeholder="Empty means only push commit sha tag."
helper="If set, it will tag the built image with this tag too. <br><br>Example: If you set it to 'latest', it will push the image with the commit sha tag + with the latest tag."
label="Docker Image Tag" x-bind:disabled="!canUpdate" />
@ -206,20 +206,20 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
<x-forms.input
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse --security-opt apparmor:unconfined --ulimit nofile=1024:1024 --tmpfs /run:rw,noexec,nosuid,size=65536k --hostname=myapp"
id="application.custom_docker_run_options" label="Custom Docker Options"
id="custom_docker_run_options" label="Custom Docker Options"
x-bind:disabled="!canUpdate" />
@else
@if ($application->could_set_build_commands())
@if ($application->build_pack === 'nixpacks')
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
id="application.install_command" label="Install Command"
id="install_command" label="Install Command"
x-bind:disabled="!canUpdate" />
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
id="application.build_command" label="Build Command"
id="build_command" label="Build Command"
x-bind:disabled="!canUpdate" />
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
id="application.start_command" label="Start Command"
id="start_command" label="Start Command"
x-bind:disabled="!canUpdate" />
</div>
<div class="pt-1 text-xs">Nixpacks will detect the required configuration
@ -239,16 +239,16 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@endcan
<div class="flex gap-2">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="/"
id="application.base_directory" label="Base Directory"
id="base_directory" label="Base Directory"
helper="Directory to use as root. Useful for monorepos." />
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="/docker-compose.yaml"
id="application.docker_compose_location" label="Docker Compose Location"
id="docker_compose_location" label="Docker Compose Location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($application->base_directory . $application->docker_compose_location, '/') }}</span>" />
</div>
<div class="w-96">
<x-forms.checkbox instantSave
id="application.settings.is_preserve_repository_enabled"
id="is_preserve_repository_enabled"
label="Preserve Repository During Deployment"
helper="Git repository (based on the base directory settings) will be copied to the deployment directory."
x-bind:disabled="shouldDisable()" />
@ -261,12 +261,12 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
<div class="flex gap-2">
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose build"
id="application.docker_compose_custom_build_command"
id="docker_compose_custom_build_command"
helper="If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>So in your case, use: <span class='dark:text-warning'>docker compose -f .{{ Str::start($application->base_directory . $application->docker_compose_location, '/') }} build</span>"
label="Custom Build Command" />
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose up -d"
id="application.docker_compose_custom_start_command"
id="docker_compose_custom_start_command"
helper="If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>So in your case, use: <span class='dark:text-warning'>docker compose -f .{{ Str::start($application->base_directory . $application->docker_compose_location, '/') }} up -d</span>"
label="Custom Start Command" />
</div>
@ -274,36 +274,36 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
<div class="pt-4">
<x-forms.textarea
helper="Order-based pattern matching to filter Git webhook deployments. Supports wildcards (*, **, ?) and negation (!). Last matching pattern wins."
placeholder="services/api/**" id="application.watch_paths"
placeholder="services/api/**" id="watch_paths"
label="Watch Paths" x-bind:disabled="shouldDisable()" />
</div>
@endif
</div>
@else
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input placeholder="/" id="application.base_directory"
<x-forms.input placeholder="/" id="base_directory"
label="Base Directory"
helper="Directory to use as root. Useful for monorepos."
x-bind:disabled="!canUpdate" />
@if ($application->build_pack === 'dockerfile' && !$application->dockerfile)
<x-forms.input placeholder="/Dockerfile" id="application.dockerfile_location"
<x-forms.input placeholder="/Dockerfile" id="dockerfile_location"
label="Dockerfile Location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($application->base_directory . $application->dockerfile_location, '/') }}</span>"
x-bind:disabled="!canUpdate" />
@endif
@if ($application->build_pack === 'dockerfile')
<x-forms.input id="application.dockerfile_target_build"
<x-forms.input id="dockerfile_target_build"
label="Docker Build Stage Target"
helper="Useful if you have multi-staged dockerfile."
x-bind:disabled="!canUpdate" />
@endif
@if ($application->could_set_build_commands())
@if ($application->settings->is_static)
<x-forms.input placeholder="/dist" id="application.publish_directory"
<x-forms.input placeholder="/dist" id="publish_directory"
label="Publish Directory" required x-bind:disabled="!canUpdate" />
@else
<x-forms.input placeholder="/" id="application.publish_directory"
<x-forms.input placeholder="/" id="publish_directory"
label="Publish Directory" x-bind:disabled="!canUpdate" />
@endif
@endif
@ -313,21 +313,21 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
<div class="pb-4">
<x-forms.textarea
helper="Order-based pattern matching to filter Git webhook deployments. Supports wildcards (*, **, ?) and negation (!). Last matching pattern wins."
placeholder="src/pages/**" id="application.watch_paths"
placeholder="src/pages/**" id="watch_paths"
label="Watch Paths" x-bind:disabled="!canUpdate" />
</div>
@endif
<x-forms.input
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse --security-opt apparmor:unconfined --ulimit nofile=1024:1024 --tmpfs /run:rw,noexec,nosuid,size=65536k --hostname=myapp"
id="application.custom_docker_run_options" label="Custom Docker Options"
id="custom_docker_run_options" label="Custom Docker Options"
x-bind:disabled="!canUpdate" />
@if ($application->build_pack !== 'dockercompose')
<div class="pt-2 w-96">
<x-forms.checkbox
helper="Use a build server to build your application. You can configure your build server in the Server settings. For more info, check the <a href='https://coolify.io/docs/knowledge-base/server/build-server' class='underline' target='_blank'>documentation</a>."
instantSave id="application.settings.is_build_server_enabled"
instantSave id="is_build_server_enabled"
label="Use a Build Server?" x-bind:disabled="!canUpdate" />
</div>
@endif
@ -344,18 +344,18 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@endcan
</div>
@if ($application->settings->is_raw_compose_deployment_enabled)
<x-forms.textarea rows="10" readonly id="application.docker_compose_raw"
<x-forms.textarea rows="10" readonly id="docker_compose_raw"
label="Docker Compose Content (applicationId: {{ $application->id }})"
helper="You need to modify the docker compose file in the git repository."
monacoEditorLanguage="yaml" useMonacoEditor />
@else
@if ((int) $application->compose_parsing_version >= 3)
<x-forms.textarea rows="10" readonly id="application.docker_compose_raw"
<x-forms.textarea rows="10" readonly id="docker_compose_raw"
label="Docker Compose Content (raw)"
helper="You need to modify the docker compose file in the git repository."
monacoEditorLanguage="yaml" useMonacoEditor />
@endif
<x-forms.textarea rows="10" readonly id="application.docker_compose"
<x-forms.textarea rows="10" readonly id="docker_compose"
label="Docker Compose Content"
helper="You need to modify the docker compose file in the git repository."
monacoEditorLanguage="yaml" useMonacoEditor />
@ -363,45 +363,45 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
<div class="w-96">
<x-forms.checkbox label="Escape special characters in labels?"
helper="By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$.<br><br>If you want to use env variables inside the labels, turn this off."
id="application.settings.is_container_label_escape_enabled" instantSave
id="is_container_label_escape_enabled" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
{{-- <x-forms.checkbox label="Readonly labels"
helper="Labels are readonly by default. Readonly means that edits you do to the labels could be lost and Coolify will autogenerate the labels for you. If you want to edit the labels directly, disable this option. <br><br>Be careful, it could break the proxy configuration after you restart the container as Coolify will now NOT autogenerate the labels for you (ofc you can always reset the labels to the coolify defaults manually)."
id="application.settings.is_container_label_readonly_enabled" instantSave></x-forms.checkbox> --}}
id="is_container_label_readonly_enabled" instantSave></x-forms.checkbox> --}}
</div>
@endif
@if ($application->dockerfile)
<x-forms.textarea label="Dockerfile" id="application.dockerfile" monacoEditorLanguage="dockerfile"
<x-forms.textarea label="Dockerfile" id="dockerfile" monacoEditorLanguage="dockerfile"
useMonacoEditor rows="6" x-bind:disabled="!canUpdate"> </x-forms.textarea>
@endif
@if ($application->build_pack !== 'dockercompose')
<h3 class="pt-8">Network</h3>
<div class="flex flex-col gap-2 xl:flex-row">
@if ($application->settings->is_static || $application->build_pack === 'static')
<x-forms.input id="application.ports_exposes" label="Ports Exposes" readonly
<x-forms.input id="ports_exposes" label="Ports Exposes" readonly
x-bind:disabled="!canUpdate" />
@else
@if ($application->settings->is_container_label_readonly_enabled === false)
<x-forms.input placeholder="3000,3001" id="application.ports_exposes"
<x-forms.input placeholder="3000,3001" id="ports_exposes"
label="Ports Exposes" readonly
helper="Readonly labels are disabled. You can set the ports manually in the labels section."
x-bind:disabled="!canUpdate" />
@else
<x-forms.input placeholder="3000,3001" id="application.ports_exposes"
<x-forms.input placeholder="3000,3001" id="ports_exposes"
label="Ports Exposes" required
helper="A comma separated list of ports your application uses. The first port will be used as default healthcheck port if nothing defined in the Healthcheck menu. Be sure to set this correctly."
x-bind:disabled="!canUpdate" />
@endif
@endif
@if (!$application->destination->server->isSwarm())
<x-forms.input placeholder="3000:3000" id="application.ports_mappings" label="Ports Mappings"
<x-forms.input placeholder="3000:3000" id="ports_mappings" label="Ports Mappings"
helper="A comma separated list of ports you would like to map to the host system. Useful when you do not want to use domains.<br><br><span class='inline-block font-bold dark:text-warning'>Example:</span><br>3000:3000,3002:3002<br><br>Rolling update is not supported if you have a port mapped to the host."
x-bind:disabled="!canUpdate" />
@endif
@if (!$application->destination->server->isSwarm())
<x-forms.input id="application.custom_network_aliases" label="Network Aliases"
<x-forms.input id="custom_network_aliases" label="Network Aliases"
helper="A comma separated list of custom network aliases you would like to add for container in Docker network.<br><br><span class='inline-block font-bold dark:text-warning'>Example:</span><br>api.internal,api.local"
wire:model="application.custom_network_aliases" x-bind:disabled="!canUpdate" />
wire:model="custom_network_aliases" x-bind:disabled="!canUpdate" />
@endif
</div>
@ -409,14 +409,14 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
<div>
<div class="w-96">
<x-forms.checkbox helper="This will add the proper proxy labels to the container." instantSave
label="Enable" id="application.is_http_basic_auth_enabled"
label="Enable" id="is_http_basic_auth_enabled"
x-bind:disabled="!canUpdate" />
</div>
@if ($application->is_http_basic_auth_enabled)
<div class="flex gap-2 py-2">
<x-forms.input id="application.http_basic_auth_username" label="Username" required
<x-forms.input id="http_basic_auth_username" label="Username" required
x-bind:disabled="!canUpdate" />
<x-forms.input id="application.http_basic_auth_password" type="password" label="Password"
<x-forms.input id="http_basic_auth_password" type="password" label="Password"
required x-bind:disabled="!canUpdate" />
</div>
@endif
@ -432,11 +432,11 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
<div class="w-96">
<x-forms.checkbox label="Readonly labels"
helper="Labels are readonly by default. Readonly means that edits you do to the labels could be lost and Coolify will autogenerate the labels for you. If you want to edit the labels directly, disable this option. <br><br>Be careful, it could break the proxy configuration after you restart the container as Coolify will now NOT autogenerate the labels for you (ofc you can always reset the labels to the coolify defaults manually)."
id="application.settings.is_container_label_readonly_enabled" instantSave
id="is_container_label_readonly_enabled" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
<x-forms.checkbox label="Escape special characters in labels?"
helper="By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$.<br><br>If you want to use env variables inside the labels, turn this off."
id="application.settings.is_container_label_escape_enabled" instantSave
id="is_container_label_escape_enabled" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
</div>
@can('update', $application)
@ -455,21 +455,21 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
<h3 class="pt-8">Pre/Post Deployment Commands</h3>
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="php artisan migrate"
id="application.pre_deployment_command" label="Pre-deployment "
id="pre_deployment_command" label="Pre-deployment "
helper="An optional script or command to execute in the existing container before the deployment begins.<br>It is always executed with 'sh -c', so you do not need add it manually." />
@if ($application->build_pack === 'dockercompose')
<x-forms.input x-bind:disabled="shouldDisable()" id="application.pre_deployment_command_container"
<x-forms.input x-bind:disabled="shouldDisable()" id="pre_deployment_command_container"
label="Container Name"
helper="The name of the container to execute within. You can leave it blank if your application only has one container." />
@endif
</div>
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="php artisan migrate"
id="application.post_deployment_command" label="Post-deployment "
id="post_deployment_command" label="Post-deployment "
helper="An optional script or command to execute in the newly built container after the deployment completes.<br>It is always executed with 'sh -c', so you do not need add it manually." />
@if ($application->build_pack === 'dockercompose')
<x-forms.input x-bind:disabled="shouldDisable()"
id="application.post_deployment_command_container" label="Container Name"
id="post_deployment_command_container" label="Container Name"
helper="The name of the container to execute within. You can leave it blank if your application only has one container." />
@endif
</div>

View file

@ -1,7 +1,7 @@
<form wire:submit="save" class="flex items-end gap-2">
<x-forms.input helper="One domain per preview." label="Domains for {{ str($serviceName)->headline() }}"
id="service.domain" canGate="update" :canResource="$preview->application"></x-forms.input>
<x-forms.input helper="One domain per preview." label="Domains for {{ str($serviceName)->headline() }}" id="domain"
canGate="update" :canResource="$preview->application"></x-forms.input>
<x-forms.button type="submit">Save</x-forms.button>
<x-forms.button wire:click="generate">Generate
Domain</x-forms.button>
</form>
</form>

View file

@ -112,7 +112,7 @@ class="dark:text-warning">{{ $application->destination->server->name }}</span>.<
<form wire:submit="save_preview('{{ $preview->id }}')"
class="flex items-end gap-2 pt-4">
<x-forms.input label="Domain" helper="One domain per preview."
id="application.previews.{{ $previewName }}.fqdn" canGate="update" :canResource="$application"></x-forms.input>
id="previewFqdns.{{ $previewName }}" canGate="update" :canResource="$application"></x-forms.input>
@can('update', $application)
<x-forms.button type="submit">Save</x-forms.button>
<x-forms.button wire:click="generate_preview('{{ $preview->id }}')">Generate
@ -130,7 +130,7 @@ class="flex items-end gap-2 pt-4">
@else
<form wire:submit="save_preview('{{ $preview->id }}')" class="flex items-end gap-2 pt-4">
<x-forms.input label="Domain" helper="One domain per preview."
id="application.previews.{{ $previewName }}.fqdn" canGate="update" :canResource="$application"></x-forms.input>
id="previewFqdns.{{ $previewName }}" canGate="update" :canResource="$application"></x-forms.input>
@can('update', $application)
<x-forms.button type="submit">Save</x-forms.button>
<x-forms.button wire:click="generate_preview('{{ $preview->id }}')">Generate

View file

@ -7,7 +7,7 @@
<x-forms.button type="submit">Save</x-forms.button>
</div>
<x-forms.textarea useMonacoEditor monacoEditorLanguage="yaml" label="Docker Compose file" rows="20"
id="dockerComposeRaw"
id="dockerComposeRaw" autofocus
placeholder='services:
ghost:
documentation: https://ghost.org/docs/config

View file

@ -6,7 +6,7 @@
<h2>Dockerfile</h2>
<x-forms.button type="submit">Save</x-forms.button>
</div>
<x-forms.textarea rows="20" id="dockerfile"
<x-forms.textarea useMonacoEditor monacoEditorLanguage="dockerfile" rows="20" id="dockerfile" autofocus
placeholder='FROM nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View file

@ -23,16 +23,16 @@
</div>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$database" label="Name" id="database.human_name" placeholder="Name"></x-forms.input>
<x-forms.input canGate="update" :canResource="$database" label="Description" id="database.description"></x-forms.input>
<x-forms.input canGate="update" :canResource="$database" label="Name" id="humanName" placeholder="Name"></x-forms.input>
<x-forms.input canGate="update" :canResource="$database" label="Description" id="description"></x-forms.input>
<x-forms.input canGate="update" :canResource="$database" required
helper="You can change the image you would like to deploy.<br><br><span class='dark:text-warning'>WARNING. You could corrupt your data. Only do it if you know what you are doing.</span>"
label="Image" id="database.image"></x-forms.input>
label="Image" id="image"></x-forms.input>
</div>
<div class="flex items-end gap-2">
<x-forms.input canGate="update" :canResource="$database" placeholder="5432" disabled="{{ $database->is_public }}" id="database.public_port"
<x-forms.input canGate="update" :canResource="$database" placeholder="5432" disabled="{{ $database->is_public }}" id="publicPort"
label="Public Port" />
<x-forms.checkbox canGate="update" :canResource="$database" instantSave id="database.is_public" label="Make it publicly available" />
<x-forms.checkbox canGate="update" :canResource="$database" instantSave id="isPublic" label="Make it publicly available" />
</div>
@if ($db_url_public)
<x-forms.input label="Database IP:PORT (public)"
@ -44,9 +44,9 @@
<div class="w-96">
<x-forms.checkbox canGate="update" :canResource="$database" instantSave="instantSaveExclude" label="Exclude from service status"
helper="If you do not need to monitor this resource, enable. Useful if this service is optional."
id="database.exclude_from_status"></x-forms.checkbox>
id="excludeFromStatus"></x-forms.checkbox>
<x-forms.checkbox canGate="update" :canResource="$database" helper="Drain logs to your configured log drain endpoint in your Server settings."
instantSave="instantSaveLogDrain" id="database.is_log_drain_enabled" label="Drain Logs" />
instantSave="instantSaveLogDrain" id="isLogDrainEnabled" label="Drain Logs" />
</div>
</form>
</div>

View file

@ -6,24 +6,24 @@
<div x-cloak x-show="raw" class="font-mono">
<div x-cloak x-show="showNormalTextarea">
<x-forms.textarea rows="20" id="service.docker_compose_raw">
<x-forms.textarea rows="20" id="dockerComposeRaw">
</x-forms.textarea>
</div>
<div x-cloak x-show="!showNormalTextarea">
<x-forms.textarea allowTab useMonacoEditor monacoEditorLanguage="yaml" rows="20"
id="service.docker_compose_raw">
id="dockerComposeRaw">
</x-forms.textarea>
</div>
</div>
<div x-cloak x-show="raw === false" class="font-mono">
<x-forms.textarea rows="20" readonly id="service.docker_compose">
<x-forms.textarea rows="20" readonly id="dockerCompose">
</x-forms.textarea>
</div>
<div class="pt-2 flex gap-2">
<div class="flex flex-col gap-2">
<x-forms.checkbox label="Escape special characters in labels?"
helper="By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$.<br><br>If you want to use env variables inside the labels, turn this off."
id="service.is_container_label_escape_enabled" instantSave></x-forms.checkbox>
id="isContainerLabelEscapeEnabled" instantSave></x-forms.checkbox>
<x-forms.checkbox label="Show Normal Textarea" x-model="showNormalTextarea"></x-forms.checkbox>
</div>

View file

@ -3,7 +3,7 @@
<div class="pb-2">Note: If a service has a defined port, do not delete it. <br>If you want to use your custom
domain, you can add it with a port.</div>
<x-forms.input canGate="update" :canResource="$application" placeholder="https://app.coolify.io" label="Domains"
id="application.fqdn"
id="fqdn"
helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. "></x-forms.input>
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
</form>

View file

@ -60,12 +60,12 @@
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
<div class="w-96">
<x-forms.checkbox instantSave label="Is this based on the Git repository?"
id="fileStorage.is_based_on_git"></x-forms.checkbox>
id="isBasedOnGit"></x-forms.checkbox>
</div>
@endif
<x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
rows="20" id="fileStorage.content"
rows="20" id="content"
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
<x-forms.button class="w-full" type="submit">Save</x-forms.button>
@ -74,12 +74,12 @@
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
<div class="w-96">
<x-forms.checkbox disabled label="Is this based on the Git repository?"
id="fileStorage.is_based_on_git"></x-forms.checkbox>
id="isBasedOnGit"></x-forms.checkbox>
</div>
@endif
<x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
rows="20" id="fileStorage.content" disabled></x-forms.textarea>
rows="20" id="content" disabled></x-forms.textarea>
@endcan
@endif
@else
@ -88,12 +88,12 @@
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
<div class="w-96">
<x-forms.checkbox disabled label="Is this based on the Git repository?"
id="fileStorage.is_based_on_git"></x-forms.checkbox>
id="isBasedOnGit"></x-forms.checkbox>
</div>
@endif
<x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
rows="20" id="fileStorage.content" disabled></x-forms.textarea>
rows="20" id="content" disabled></x-forms.textarea>
@endif
@endif
</form>

View file

@ -23,48 +23,48 @@
</div>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$application" label="Name" id="application.human_name"
<x-forms.input canGate="update" :canResource="$application" label="Name" id="humanName"
placeholder="Human readable name"></x-forms.input>
<x-forms.input canGate="update" :canResource="$application" label="Description"
id="application.description"></x-forms.input>
id="description"></x-forms.input>
</div>
<div class="flex gap-2">
@if (!$application->serviceType()?->contains(str($application->image)->before(':')))
@if ($application->required_fqdn)
<x-forms.input canGate="update" :canResource="$application" required placeholder="https://app.coolify.io"
label="Domains" id="application.fqdn"
label="Domains" id="fqdn"
helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. "></x-forms.input>
@else
<x-forms.input canGate="update" :canResource="$application" placeholder="https://app.coolify.io"
label="Domains" id="application.fqdn"
label="Domains" id="fqdn"
helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. "></x-forms.input>
@endif
@endif
<x-forms.input canGate="update" :canResource="$application"
helper="You can change the image you would like to deploy.<br><br><span class='dark:text-warning'>WARNING. You could corrupt your data. Only do it if you know what you are doing.</span>"
label="Image" id="application.image"></x-forms.input>
label="Image" id="image"></x-forms.input>
</div>
</div>
<h3 class="py-2 pt-4">Advanced</h3>
<div class="w-96 flex flex-col gap-1">
@if (str($application->image)->contains('pocketbase'))
<x-forms.checkbox canGate="update" :canResource="$application" instantSave id="application.is_gzip_enabled"
<x-forms.checkbox canGate="update" :canResource="$application" instantSave id="isGzipEnabled"
label="Enable Gzip Compression"
helper="Pocketbase does not need gzip compression, otherwise SSE will not work." disabled />
@else
<x-forms.checkbox canGate="update" :canResource="$application" instantSave id="application.is_gzip_enabled"
<x-forms.checkbox canGate="update" :canResource="$application" instantSave id="isGzipEnabled"
label="Enable Gzip Compression"
helper="You can disable gzip compression if you want. Some services are compressing data by default. In this case, you do not need this." />
@endif
<x-forms.checkbox canGate="update" :canResource="$application" instantSave id="application.is_stripprefix_enabled"
<x-forms.checkbox canGate="update" :canResource="$application" instantSave id="isStripprefixEnabled"
label="Strip Prefixes"
helper="Strip Prefix is used to remove prefixes from paths. Like /api/ to /api." />
<x-forms.checkbox canGate="update" :canResource="$application" instantSave label="Exclude from service status"
helper="If you do not need to monitor this resource, enable. Useful if this service is optional."
id="application.exclude_from_status"></x-forms.checkbox>
id="excludeFromStatus"></x-forms.checkbox>
<x-forms.checkbox canGate="update" :canResource="$application"
helper="Drain logs to your configured log drain endpoint in your Server settings."
instantSave="instantSaveAdvanced" id="application.is_log_drain_enabled" label="Drain Logs" />
instantSave="instantSaveAdvanced" id="isLogDrainEnabled" label="Drain Logs" />
</div>
</form>

View file

@ -15,11 +15,11 @@
<div>Configuration</div>
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$service" id="service.name" required label="Service Name" placeholder="My super WordPress site" />
<x-forms.input canGate="update" :canResource="$service" id="service.description" label="Description" />
<x-forms.input canGate="update" :canResource="$service" id="name" required label="Service Name" placeholder="My super WordPress site" />
<x-forms.input canGate="update" :canResource="$service" id="description" label="Description" />
</div>
<div class="w-96">
<x-forms.checkbox canGate="update" :canResource="$service" instantSave id="service.connect_to_docker_network" label="Connect To Predefined Network"
<x-forms.checkbox canGate="update" :canResource="$service" instantSave id="connectToDockerNetwork" label="Connect To Predefined Network"
helper="By default, you do not reach the Coolify defined networks.<br>Starting a docker compose based resource will have an internal network. <br>If you connect to a Coolify defined network, you maybe need to use different internal DNS names to connect to a resource.<br><br>For more information, check <a class='underline dark:text-white' target='_blank' href='https://coolify.io/docs/knowledge-base/docker/compose#connect-to-predefined-networks'>this</a>." />
</div>
@if ($fields->count() > 0)

View file

@ -2,7 +2,7 @@
<div class="flex items-center gap-2">
<h2>Healthchecks</h2>
<x-forms.button canGate="update" :canResource="$resource" type="submit">Save</x-forms.button>
@if (!$resource->health_check_enabled)
@if (!$healthCheckEnabled)
<x-modal-confirmation title="Confirm Healthcheck Enable?" buttonTitle="Enable Healthcheck"
submitAction="toggleHealthcheck" :actions="['Enable healthcheck for this resource.']"
warningMessage="If the health check fails, your application will become inaccessible. Please review the <a href='https://coolify.io/docs/knowledge-base/health-checks' target='_blank' class='underline text-white'>Health Checks</a> guide before proceeding!"
@ -15,37 +15,37 @@
</div>
<div class="mt-1 pb-4">Define how your resource's health should be checked.</div>
<div class="flex flex-col gap-4">
@if ($resource->custom_healthcheck_found)
@if ($customHealthcheckFound)
<x-callout type="warning" title="Caution">
<p>A custom health check has been detected. If you enable this health check, it will disable the custom one and use this instead.</p>
</x-callout>
@endif
<div class="flex gap-2">
<x-forms.select canGate="update" :canResource="$resource" id="resource.health_check_method" label="Method" required>
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckMethod" label="Method" required>
<option value="GET">GET</option>
<option value="POST">POST</option>
</x-forms.select>
<x-forms.select canGate="update" :canResource="$resource" id="resource.health_check_scheme" label="Scheme" required>
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckScheme" label="Scheme" required>
<option value="http">http</option>
<option value="https">https</option>
</x-forms.select>
<x-forms.input canGate="update" :canResource="$resource" id="resource.health_check_host" placeholder="localhost" label="Host" required />
<x-forms.input canGate="update" :canResource="$resource" type="number" id="resource.health_check_port"
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckHost" placeholder="localhost" label="Host" required />
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckPort"
helper="If no port is defined, the first exposed port will be used." placeholder="80" label="Port" />
<x-forms.input canGate="update" :canResource="$resource" id="resource.health_check_path" placeholder="/health" label="Path" required />
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckPath" placeholder="/health" label="Path" required />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource" type="number" id="resource.health_check_return_code" placeholder="200" label="Return Code"
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckReturnCode" placeholder="200" label="Return Code"
required />
<x-forms.input canGate="update" :canResource="$resource" id="resource.health_check_response_text" placeholder="OK" label="Response Text" />
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckResponseText" placeholder="OK" label="Response Text" />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number" id="resource.health_check_interval" placeholder="30"
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number" id="healthCheckInterval" placeholder="30"
label="Interval (s)" required />
<x-forms.input canGate="update" :canResource="$resource" type="number" id="resource.health_check_timeout" placeholder="30" label="Timeout (s)"
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckTimeout" placeholder="30" label="Timeout (s)"
required />
<x-forms.input canGate="update" :canResource="$resource" type="number" id="resource.health_check_retries" placeholder="3" label="Retries" required />
<x-forms.input canGate="update" :canResource="$resource" min=1 type="number" id="resource.health_check_start_period" placeholder="30"
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckRetries" placeholder="3" label="Retries" required />
<x-forms.input canGate="update" :canResource="$resource" min=1 type="number" id="healthCheckStartPeriod" placeholder="30"
label="Start Period (s)" required />
</div>
</div>

View file

@ -9,32 +9,32 @@
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource" placeholder="1.5"
helper="0 means use all CPUs. Floating point number, like 0.002 or 1.5. More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/engine/reference/run/#cpu-share-constraint'>here</a>."
label="Number of CPUs" id="resource.limits_cpus" />
label="Number of CPUs" id="limitsCpus" />
<x-forms.input canGate="update" :canResource="$resource" placeholder="0-2"
helper="Empty means, use all CPU sets. 0-2 will use CPU 0, CPU 1 and CPU 2. More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/engine/reference/run/#cpu-share-constraint'>here</a>."
label="CPU sets to use" id="resource.limits_cpuset" />
label="CPU sets to use" id="limitsCpuset" />
<x-forms.input canGate="update" :canResource="$resource" placeholder="1024"
helper="More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/engine/reference/run/#cpu-share-constraint'>here</a>."
label="CPU Weight" id="resource.limits_cpu_shares" />
label="CPU Weight" id="limitsCpuShares" />
</div>
<h3 class="pt-4">Limit Memory</h3>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource"
helper="Examples: 69b (byte) or 420k (kilobyte) or 1337m (megabyte) or 1g (gigabyte).<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_reservation'>here</a>."
label="Soft Memory Limit" id="resource.limits_memory_reservation" />
label="Soft Memory Limit" id="limitsMemoryReservation" />
<x-forms.input canGate="update" :canResource="$resource"
helper="0-100.<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_swappiness'>here</a>."
type="number" min="0" max="100" label="Swappiness"
id="resource.limits_memory_swappiness" />
id="limitsMemorySwappiness" />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource"
helper="Examples: 69b (byte) or 420k (kilobyte) or 1337m (megabyte) or 1g (gigabyte).<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_limit'>here</a>."
label="Maximum Memory Limit" id="resource.limits_memory" />
label="Maximum Memory Limit" id="limitsMemory" />
<x-forms.input canGate="update" :canResource="$resource"
helper="Examples:69b (byte) or 420k (kilobyte) or 1337m (megabyte) or 1g (gigabyte).<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#memswap_limit'>here</a>."
label="Maximum Swap Limit" id="resource.limits_memory_swap" />
label="Maximum Swap Limit" id="limitsMemorySwap" />
</div>
</div>
</form>

View file

@ -9,47 +9,47 @@
@if (
$storage->resource_type === 'App\Models\ServiceApplication' ||
$storage->resource_type === 'App\Models\ServiceDatabase')
<x-forms.input id="storage.name" label="Volume Name" required readonly
<x-forms.input id="name" label="Volume Name" required readonly
helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing." />
@else
<x-forms.input id="storage.name" label="Volume Name" required readonly
<x-forms.input id="name" label="Volume Name" required readonly
helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing." />
@endif
@if ($isService || $startedAt)
<x-forms.input id="storage.host_path" readonly helper="Directory on the host system."
<x-forms.input id="hostPath" readonly helper="Directory on the host system."
label="Source Path"
helper="Warning: Changing the source path after the initial start could cause problems. Only use it when you know what are you doing." />
<x-forms.input id="storage.mount_path" label="Destination Path"
<x-forms.input id="mountPath" label="Destination Path"
helper="Directory inside the container." required readonly />
@else
<x-forms.input id="storage.host_path" readonly helper="Directory on the host system."
<x-forms.input id="hostPath" readonly helper="Directory on the host system."
label="Source Path"
helper="Warning: Changing the source path after the initial start could cause problems. Only use it when you know what are you doing." />
<x-forms.input id="storage.mount_path" label="Destination Path"
<x-forms.input id="mountPath" label="Destination Path"
helper="Directory inside the container." required readonly />
@endif
</div>
@else
<div class="flex gap-2 items-end w-full">
<x-forms.input id="storage.name" required readonly />
<x-forms.input id="storage.host_path" readonly />
<x-forms.input id="storage.mount_path" required readonly />
<x-forms.input id="name" required readonly />
<x-forms.input id="hostPath" readonly />
<x-forms.input id="mountPath" required readonly />
</div>
@endif
@else
@can('update', $resource)
@if ($isFirst)
<div class="flex gap-2 items-end w-full">
<x-forms.input id="storage.name" label="Volume Name" required />
<x-forms.input id="storage.host_path" helper="Directory on the host system." label="Source Path" />
<x-forms.input id="storage.mount_path" label="Destination Path"
<x-forms.input id="name" label="Volume Name" required />
<x-forms.input id="hostPath" helper="Directory on the host system." label="Source Path" />
<x-forms.input id="mountPath" label="Destination Path"
helper="Directory inside the container." required />
</div>
@else
<div class="flex gap-2 items-end w-full">
<x-forms.input id="storage.name" required />
<x-forms.input id="storage.host_path" />
<x-forms.input id="storage.mount_path" required />
<x-forms.input id="name" required />
<x-forms.input id="hostPath" />
<x-forms.input id="mountPath" required />
</div>
@endif
<div class="flex gap-2">
@ -67,17 +67,17 @@
@else
@if ($isFirst)
<div class="flex gap-2 items-end w-full">
<x-forms.input id="storage.name" label="Volume Name" required disabled />
<x-forms.input id="storage.host_path" helper="Directory on the host system." label="Source Path"
<x-forms.input id="name" label="Volume Name" required disabled />
<x-forms.input id="hostPath" helper="Directory on the host system." label="Source Path"
disabled />
<x-forms.input id="storage.mount_path" label="Destination Path"
<x-forms.input id="mountPath" label="Destination Path"
helper="Directory inside the container." required disabled />
</div>
@else
<div class="flex gap-2 items-end w-full">
<x-forms.input id="storage.name" required disabled />
<x-forms.input id="storage.host_path" disabled />
<x-forms.input id="storage.mount_path" required disabled />
<x-forms.input id="name" required disabled />
<x-forms.input id="hostPath" disabled />
<x-forms.input id="mountPath" required disabled />
</div>
@endif
@endcan

View file

@ -27,8 +27,8 @@
</div>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$private_key" id="private_key.name" label="Name" required />
<x-forms.input canGate="update" :canResource="$private_key" id="private_key.description" label="Description" />
<x-forms.input canGate="update" :canResource="$private_key" id="name" label="Name" required />
<x-forms.input canGate="update" :canResource="$private_key" id="description" label="Description" />
</div>
<div>
<div class="flex items-end gap-2 py-2 ">
@ -46,17 +46,17 @@
Hide
</div>
</div>
@if (data_get($private_key, 'is_git_related'))
@if ($isGitRelated)
<div class="w-48">
<x-forms.checkbox id="private_key.is_git_related" disabled label="Is used by a Git App?" />
<x-forms.checkbox id="isGitRelated" disabled label="Is used by a Git App?" />
</div>
@endif
<div x-cloak x-show="!showPrivateKey">
<x-forms.input canGate="update" :canResource="$private_key" allowToPeak="false" type="password" rows="10" id="private_key.private_key"
<x-forms.input canGate="update" :canResource="$private_key" allowToPeak="false" type="password" rows="10" id="privateKeyValue"
required disabled />
</div>
<div x-cloak x-show="showPrivateKey">
<x-forms.textarea canGate="update" :canResource="$private_key" rows="10" id="private_key.private_key" required />
<x-forms.textarea canGate="update" :canResource="$private_key" rows="10" id="privateKeyValue" required />
</div>
</div>
</div>

View file

@ -26,7 +26,7 @@
<div class="pb-6 w-96">
<x-forms.checkbox canGate="update" :canResource="$server"
helper="If set, all resources will only have docker container labels for {{ str($server->proxyType())->title() }}.<br>For applications, labels needs to be regenerated manually. <br>Resources needs to be restarted."
id="server.settings.generate_exact_labels"
id="generateExactLabels"
label="Generate labels only for {{ str($server->proxyType())->title() }}" instantSave />
<x-forms.checkbox canGate="update" :canResource="$server" instantSave="instantSaveRedirect"
id="redirectEnabled" label="Override default request handler"

View file

@ -42,7 +42,7 @@
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<div class="flex items-end gap-2 w-full">
<x-forms.input canGate="update" :canResource="$github_app" id="github_app.name" label="App Name" />
<x-forms.input canGate="update" :canResource="$github_app" id="name" label="App Name" />
<x-forms.button canGate="update" :canResource="$github_app" wire:click.prevent="updateGithubAppName">
Sync Name
</x-forms.button>
@ -64,41 +64,41 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
@endcan
</div>
</div>
<x-forms.input canGate="update" :canResource="$github_app" id="github_app.organization" label="Organization"
<x-forms.input canGate="update" :canResource="$github_app" id="organization" label="Organization"
placeholder="If empty, personal user will be used" />
@if (!isCloud())
<div class="w-48">
<x-forms.checkbox canGate="update" :canResource="$github_app" label="System Wide?"
helper="If checked, this GitHub App will be available for everyone in this Coolify instance."
instantSave id="github_app.is_system_wide" />
instantSave id="isSystemWide" />
</div>
@endif
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="github_app.html_url" label="HTML Url" />
<x-forms.input canGate="update" :canResource="$github_app" id="github_app.api_url" label="API Url" />
<x-forms.input canGate="update" :canResource="$github_app" id="htmlUrl" label="HTML Url" />
<x-forms.input canGate="update" :canResource="$github_app" id="apiUrl" label="API Url" />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="github_app.custom_user" label="User"
<x-forms.input canGate="update" :canResource="$github_app" id="customUser" label="User"
required />
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="github_app.custom_port"
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="customPort"
label="Port" required />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="github_app.app_id"
<x-forms.input canGate="update" :canResource="$github_app" type="number" id="appId"
label="App Id" required />
<x-forms.input canGate="update" :canResource="$github_app" type="number"
id="github_app.installation_id" label="Installation Id" required />
id="installationId" label="Installation Id" required />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$github_app" id="github_app.client_id" label="Client Id"
<x-forms.input canGate="update" :canResource="$github_app" id="clientId" label="Client Id"
type="password" required />
<x-forms.input canGate="update" :canResource="$github_app" id="github_app.client_secret"
<x-forms.input canGate="update" :canResource="$github_app" id="clientSecret"
label="Client Secret" type="password" required />
<x-forms.input canGate="update" :canResource="$github_app" id="github_app.webhook_secret"
<x-forms.input canGate="update" :canResource="$github_app" id="webhookSecret"
label="Webhook Secret" type="password" required />
</div>
<div class="flex gap-2">
<x-forms.select canGate="update" :canResource="$github_app" id="github_app.private_key_id"
<x-forms.select canGate="update" :canResource="$github_app" id="privateKeyId"
label="Private Key" required>
@if (blank($github_app->private_key_id))
<option value="0" selected>Select a private key</option>
@ -121,14 +121,14 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
@endcan
</div>
<div class="flex gap-2">
<x-forms.input id="github_app.contents" helper="read - mandatory." label="Content" readonly
<x-forms.input id="contents" helper="read - mandatory." label="Content" readonly
placeholder="N/A" />
<x-forms.input id="github_app.metadata" helper="read - mandatory." label="Metadata" readonly
<x-forms.input id="metadata" helper="read - mandatory." label="Metadata" readonly
placeholder="N/A" />
{{-- <x-forms.input id="github_app.administration"
{{-- <x-forms.input id="administration"
helper="read:write access needed to setup servers as GitHub Runner." label="Administration"
readonly placeholder="N/A" /> --}}
<x-forms.input id="github_app.pull_requests"
<x-forms.input id="pullRequests"
helper="write access needed to use deployment status update in previews."
label="Pull Request" readonly placeholder="N/A" />
</div>

View file

@ -6,7 +6,7 @@
<div class="subtitle">{{ $storage->name }}</div>
<div class="flex items-center gap-2 pb-4">
<div>Current Status:</div>
@if ($storage->is_usable)
@if ($isUsable)
<span
class="px-2 py-1 text-xs font-semibold text-green-800 bg-green-100 rounded dark:text-green-100 dark:bg-green-800">
Usable
@ -32,19 +32,19 @@ class="px-2 py-1 text-xs font-semibold text-red-800 bg-red-100 rounded dark:text
@endcan
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$storage" label="Name" id="storage.name" />
<x-forms.input canGate="update" :canResource="$storage" label="Description" id="storage.description" />
<x-forms.input canGate="update" :canResource="$storage" label="Name" id="name" />
<x-forms.input canGate="update" :canResource="$storage" label="Description" id="description" />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$storage" required label="Endpoint" id="storage.endpoint" />
<x-forms.input canGate="update" :canResource="$storage" required label="Bucket" id="storage.bucket" />
<x-forms.input canGate="update" :canResource="$storage" required label="Region" id="storage.region" />
<x-forms.input canGate="update" :canResource="$storage" required label="Endpoint" id="endpoint" />
<x-forms.input canGate="update" :canResource="$storage" required label="Bucket" id="bucket" />
<x-forms.input canGate="update" :canResource="$storage" required label="Region" id="region" />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$storage" required type="password" label="Access Key"
id="storage.key" />
id="key" />
<x-forms.input canGate="update" :canResource="$storage" required type="password" label="Secret Key"
id="storage.secret" />
id="secret" />
</div>
@can('validateConnection', $storage)
<x-forms.button class="mt-4" isHighlighted wire:click="testConnection">

View file

@ -11,8 +11,8 @@
</div>
<div class="flex items-end gap-2 pb-6">
<x-forms.input id="team.name" label="Name" required canGate="update" :canResource="$team" />
<x-forms.input id="team.description" label="Description" canGate="update" :canResource="$team" />
<x-forms.input id="name" label="Name" required canGate="update" :canResource="$team" />
<x-forms.input id="description" label="Description" canGate="update" :canResource="$team" />
@can('update', $team)
<x-forms.button type="submit">
Save

View file

@ -66,6 +66,7 @@
Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']);
Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']);
Route::get('/deployments/{uuid}', [DeployController::class, 'deployment_by_uuid'])->middleware(['api.ability:read']);
Route::post('/deployments/{uuid}/cancel', [DeployController::class, 'cancel_deployment'])->middleware(['api.ability:deploy']);
Route::get('/deployments/applications/{uuid}', [DeployController::class, 'get_application_deployments'])->middleware(['api.ability:read']);
Route::get('/servers', [ServersController::class, 'servers'])->middleware(['api.ability:read']);
@ -104,6 +105,7 @@
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:write']);
Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']);
Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']);
Route::patch('/github-apps/{github_app_id}', [GithubController::class, 'update_github_app'])->middleware(['api.ability:write']);
Route::delete('/github-apps/{github_app_id}', [GithubController::class, 'delete_github_app'])->middleware(['api.ability:write']);
@ -124,6 +126,7 @@
Route::get('/databases/{uuid}/backups', [DatabasesController::class, 'database_backup_details_uuid'])->middleware(['api.ability:read']);
Route::get('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions', [DatabasesController::class, 'list_backup_executions'])->middleware(['api.ability:read']);
Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
Route::post('/databases/{uuid}/backups', [DatabasesController::class, 'create_backup'])->middleware(['api.ability:write']);
Route::patch('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'update_backup'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']);

View file

@ -0,0 +1,75 @@
# documentation: https://cap.so
# slogan: Cap is the open source alternative to Loom. Lightweight, powerful, and cross-platform. Record and share in seconds.
# tags: cap,loom,open,source,low,code
# logo: svgs/cap.svg
# port: 5679
# Storage Configuration:
# Option 1: Remote S3-compatible storage (AWS S3, Cloudflare R2, etc.)
# Set these environment variables:
# - CAP_AWS_ACCESS_KEY: Your S3/R2 access key
# - CAP_AWS_SECRET_KEY: Your S3/R2 secret key
# - CAP_AWS_BUCKET: Your S3/R2 bucket name
# - CAP_AWS_REGION: Your S3/R2 region (e.g., us-east-1, auto for R2)
# - CAP_AWS_ENDPOINT: Your S3/R2 endpoint URL
# - S3_PUBLIC_ENDPOINT: Public endpoint for your bucket (same as CAP_AWS_ENDPOINT for most cases)
# - S3_INTERNAL_ENDPOINT: Internal endpoint (same as CAP_AWS_ENDPOINT for most cases)
# - S3_PATH_STYLE: true for R2/most S3-compatible, false for AWS S3 virtual-hosted style
#
# Option 2: Local MinIO storage
# Deploy MinIO as a separate service in the same network and set:
# - CAP_AWS_ACCESS_KEY: MinIO root user
# - CAP_AWS_SECRET_KEY: MinIO root password
# - CAP_AWS_BUCKET: Your bucket name (e.g., capso)
# - CAP_AWS_REGION: us-east-1 (or any region)
# - CAP_AWS_ENDPOINT: http://minio:9000 (internal MinIO endpoint)
# - S3_PUBLIC_ENDPOINT: http://your-minio-domain:9000 (public MinIO endpoint)
# - S3_INTERNAL_ENDPOINT: http://minio:9000 (internal MinIO endpoint)
# - S3_PATH_STYLE: true
services:
cap-web:
image: 'ghcr.io/capsoftware/cap-web:latest'
environment:
- SERVICE_URL_CAP_3000
- 'DATABASE_URL=mysql://$SERVICE_USER_MYSQL:$SERVICE_PASSWORD_MYSQL@cap-db:3306/${MYSQL_DATABASE:-planetscale}'
- 'WEB_URL=${SERVICE_URL_CAP}'
- 'NEXTAUTH_URL=${SERVICE_URL_CAP}'
- 'DATABASE_ENCRYPTION_KEY=${SERVICE_PASSWORD_64_DATABASEENCRYPTIONKEY}'
- 'NEXTAUTH_SECRET=${SERVICE_PASSWORD_64_NEXTAUTHSECRET}'
- 'CAP_AWS_ACCESS_KEY=${CAP_AWS_ACCESS_KEY:?}'
- 'CAP_AWS_SECRET_KEY=${CAP_AWS_SECRET_KEY:?}'
- 'CAP_AWS_BUCKET=${CAP_AWS_BUCKET:?}'
- 'CAP_AWS_REGION=${CAP_AWS_REGION:?}'
- 'S3_PUBLIC_ENDPOINT=${S3_PUBLIC_ENDPOINT:?}'
- 'S3_INTERNAL_ENDPOINT=${S3_INTERNAL_ENDPOINT:?}'
- 'NEXT_RUNTIME=nodejs'
- 'S3_PATH_STYLE=${S3_PATH_STYLE:-true}'
- 'CAP_AWS_ENDPOINT=${CAP_AWS_ENDPOINT:?}'
depends_on:
cap-db:
condition: service_healthy
cap-db:
image: 'mysql:8.0'
environment:
- 'MYSQL_USER=${SERVICE_USER_MYSQL}'
- 'MYSQL_PASSWORD=${SERVICE_PASSWORD_MYSQL}'
- 'MYSQL_DATABASE=${MYSQL_DATABASE:-planetscale}'
- 'MYSQL_ROOT_PASSWORD=${SERVICE_PASSWORD_MYSQLROOT}'
- 'MYSQL_ALLOW_EMPTY_PASSWORD=${MYSQL_ALLOW_EMPTY_PASSWORD:-yes}'
command:
- '--max_connections=1000'
- '--default-authentication-plugin=mysql_native_password'
healthcheck:
test:
- CMD
- mysqladmin
- ping
- '-h'
- 127.0.0.1
interval: 10s
timeout: 10s
retries: 5
volumes:
- 'cap_db:/var/lib/mysql'

View file

@ -28,6 +28,13 @@ services:
- 'ENTE_JWT_SECRET=${SERVICE_REALBASE64_JWT}'
- 'ENTE_INTERNAL_ADMIN=${ENTE_INTERNAL_ADMIN:-1580559962386438}'
- 'ENTE_INTERNAL_DISABLE_REGISTRATION=${ENTE_INTERNAL_DISABLE_REGISTRATION:-false}'
- 'ENTE_SMTP_HOST=${ENTE_SMTP_HOST:-smtp.gmail.com}'
- 'ENTE_SMTP_PORT=${ENTE_SMTP_PORT:-587}'
- 'ENTE_SMTP_USERNAME=${ENTE_SMTP_USERNAME}'
- 'ENTE_SMTP_PASSWORD=${ENTE_SMTP_PASSWORD}'
- 'ENTE_SMTP_EMAIL=${ENTE_SMTP_EMAIL}'
- 'ENTE_SMTP_SENDER_NAME=${ENTE_SMTP_SENDER_NAME}'
- 'ENTE_SMTP_ENCRYPTION=${ENTE_SMTP_ENCRYPTION:-tls}'
volumes:
- 'museum-data:/data'
- 'museum-config:/config'
@ -46,7 +53,6 @@ services:
timeout: 10s
retries: 3
web:
image: 'ghcr.io/ente-io/web:ca03165f5e7f2a50105e6e40019c17ae6cdd934f' # Released at 2025-10-08T00:57:05-07:00
environment:
@ -63,7 +69,6 @@ services:
retries: 3
start_period: 10s
postgres:
image: 'postgres:15-alpine'
environment:
@ -80,7 +85,6 @@ services:
timeout: 5s
retries: 5
minio:
image: 'quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z' # Released at 2025-09-07T16-13-09Z
command: 'server /data --console-address ":9001"'
@ -100,7 +104,6 @@ services:
interval: 5s
timeout: 20s
retries: 10
minio-init:
image: 'minio/mc:RELEASE.2025-08-13T08-35-41Z' # Released at 2025-08-13T08-35-41Z
@ -126,4 +129,4 @@ services:
mc mb minio/wasabi-eu-central-2-v3 --ignore-existing;
mc mb minio/scw-eu-fr-v3 --ignore-existing;
echo 'MinIO buckets and CORS configured';
"
"

View file

@ -39,6 +39,14 @@ services:
- ENTE_S3_B2_EU_CEN_REGION=${S3_STORAGE_REGION:-us-east-1}
- ENTE_S3_B2_EU_CEN_BUCKET=${S3_STORAGE_BUCKET:?}
- ENTE_SMTP_HOST=${ENTE_SMTP_HOST:-smtp.gmail.com}
- ENTE_SMTP_PORT=${ENTE_SMTP_PORT:-587}
- ENTE_SMTP_USERNAME=${ENTE_SMTP_USERNAME}
- ENTE_SMTP_PASSWORD=${ENTE_SMTP_PASSWORD}
- ENTE_SMTP_EMAIL=${ENTE_SMTP_EMAIL}
- ENTE_SMTP_SENDER_NAME=${ENTE_SMTP_SENDER_NAME}
- ENTE_SMTP_ENCRYPTION=${ENTE_SMTP_ENCRYPTION:-tls}
depends_on:
postgres:
condition: service_healthy
@ -77,4 +85,3 @@ services:
interval: 5s
timeout: 5s
retries: 10

View file

@ -7,7 +7,7 @@
services:
n8n:
image: docker.n8n.io/n8nio/n8n
image: docker.n8n.io/n8nio/n8n:1.114.4
environment:
- SERVICE_URL_N8N_5678
- N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N}
@ -29,7 +29,9 @@ services:
- N8N_RUNNERS_ENABLED=true
- OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true
- N8N_BLOCK_ENV_ACCESS_IN_NODE=${N8N_BLOCK_ENV_ACCESS_IN_NODE:-true}
- N8N_GIT_NODE_DISABLE_BARE_REPOS=${N8N_GIT_NODE_DISABLE_BARE_REPOS:-true}
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-true}
- N8N_PROXY_HOPS=${N8N_PROXY_HOPS:-1}
volumes:
- n8n-data:/home/node/.n8n
depends_on:
@ -44,7 +46,7 @@ services:
retries: 10
n8n-worker:
image: docker.n8n.io/n8nio/n8n
image: docker.n8n.io/n8nio/n8n:1.114.4
command: worker
environment:
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-Europe/Berlin}
@ -62,7 +64,9 @@ services:
- N8N_ENCRYPTION_KEY=${SERVICE_PASSWORD_ENCRYPTION}
- N8N_RUNNERS_ENABLED=true
- N8N_BLOCK_ENV_ACCESS_IN_NODE=${N8N_BLOCK_ENV_ACCESS_IN_NODE:-true}
- N8N_GIT_NODE_DISABLE_BARE_REPOS=${N8N_GIT_NODE_DISABLE_BARE_REPOS:-true}
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-true}
- N8N_PROXY_HOPS=${N8N_PROXY_HOPS:-1}
volumes:
- n8n-data:/home/node/.n8n
healthcheck:

View file

@ -7,7 +7,7 @@
services:
n8n:
image: docker.n8n.io/n8nio/n8n
image: docker.n8n.io/n8nio/n8n:1.114.4
environment:
- SERVICE_URL_N8N_5678
- N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N}
@ -22,6 +22,11 @@ services:
- DB_POSTGRESDB_USER=$SERVICE_USER_POSTGRES
- DB_POSTGRESDB_SCHEMA=public
- DB_POSTGRESDB_PASSWORD=$SERVICE_PASSWORD_POSTGRES
- N8N_RUNNERS_ENABLED=${N8N_RUNNERS_ENABLED:-true}
- N8N_BLOCK_ENV_ACCESS_IN_NODE=${N8N_BLOCK_ENV_ACCESS_IN_NODE:-true}
- N8N_GIT_NODE_DISABLE_BARE_REPOS=${N8N_GIT_NODE_DISABLE_BARE_REPOS:-true}
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=${N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS:-true}
- N8N_PROXY_HOPS=${N8N_PROXY_HOPS:-1}
volumes:
- n8n-data:/home/node/.n8n
depends_on:

Some files were not shown because too many files have changed in this diff Show more