Merge branch 'next' into v4.x

This commit is contained in:
Andras Bacsai authored and GitHub committed 2025-09-25 14:39:23 +02:00
commit b83223ff6e
110 files changed
+4692 -2449

No files matched your search

@@ -1,6 +1,6 @@
<?php
namespace App\Console\Commands;
namespace App\Console\Commands\Cloud;
use App\Actions\Stripe\CancelSubscription;
use App\Actions\User\DeleteUserResources;
@@ -0,0 +1,879 @@
<?php
namespace App\Console\Commands\Cloud;
use App\Models\Team;
use Illuminate\Console\Command;
class CloudFixSubscription extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cloud:fix-subscription
{--fix-canceled-subs : Fix canceled subscriptions in database}
{--verify-all : Verify all active subscriptions against Stripe}
{--fix-verified : Fix discrepancies found during verification}
{--dry-run : Show what would be fixed without making changes}
{--one : Only fix the first found subscription}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fix Cloud subscriptions';
/**
* Execute the console command.
*/
public function handle()
{
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
if ($this->option('verify-all')) {
return $this->verifyAllActiveSubscriptions($stripe);
}
if ($this->option('fix-canceled-subs') || $this->option('dry-run')) {
return $this->fixCanceledSubscriptions($stripe);
}
$activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
$out = fopen('php://output', 'w');
// CSV header
fputcsv($out, [
'team_id',
'invoice_status',
'stripe_customer_url',
'stripe_subscription_id',
'subscription_status',
'subscription_url',
'note',
]);
foreach ($activeSubscribers as $team) {
$stripeSubscriptionId = $team->subscription->stripe_subscription_id;
$stripeInvoicePaid = $team->subscription->stripe_invoice_paid;
$stripeCustomerId = $team->subscription->stripe_customer_id;
if (! $stripeSubscriptionId && str($stripeInvoicePaid)->lower() != 'past_due') {
fputcsv($out, [
$team->id,
$stripeInvoicePaid,
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
null,
null,
null,
'Missing subscription ID while invoice not past_due',
]);
continue;
}
if (! $stripeSubscriptionId) {
// No subscription ID and invoice is past_due, still record for visibility
fputcsv($out, [
$team->id,
$stripeInvoicePaid,
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
null,
null,
null,
'Missing subscription ID',
]);
continue;
}
$subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId);
if ($subscription->status === 'active') {
continue;
}
fputcsv($out, [
$team->id,
$stripeInvoicePaid,
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
$stripeSubscriptionId,
$subscription->status,
"https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}",
'Subscription not active',
]);
}
fclose($out);
}
/**
* Fix canceled subscriptions in the database
*/
private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
{
$isDryRun = $this->option('dry-run');
$checkOne = $this->option('one');
if ($isDryRun) {
$this->info('DRY RUN MODE - No changes will be made');
if ($checkOne) {
$this->info('Checking only the first canceled subscription...');
} else {
$this->info('Checking for canceled subscriptions...');
}
} else {
if ($checkOne) {
$this->info('Checking and fixing only the first canceled subscription...');
} else {
$this->info('Checking and fixing canceled subscriptions...');
}
}
$teamsWithSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
$toFixCount = 0;
$fixedCount = 0;
$errors = [];
$canceledSubscriptions = [];
foreach ($teamsWithSubscriptions as $team) {
$subscription = $team->subscription;
if (! $subscription->stripe_subscription_id) {
continue;
}
try {
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
if ($stripeSubscription->status === 'canceled') {
$toFixCount++;
// Get team members' emails
$memberEmails = $team->members->pluck('email')->toArray();
$canceledSubscriptions[] = [
'team_id' => $team->id,
'team_name' => $team->name,
'customer_id' => $subscription->stripe_customer_id,
'subscription_id' => $subscription->stripe_subscription_id,
'status' => 'canceled',
'member_emails' => $memberEmails,
'subscription_model' => $subscription->toArray(),
];
if ($isDryRun) {
$this->warn('Would fix canceled subscription:');
$this->line(" Team ID: {$team->id}");
$this->line(" Team Name: {$team->name}");
$this->line(' Team Members: '.implode(', ', $memberEmails));
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
$this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}");
$this->line(' Current Subscription Data:');
foreach ($subscription->getAttributes() as $key => $value) {
if (is_null($value)) {
$this->line(" - {$key}: null");
} elseif (is_bool($value)) {
$this->line(" - {$key}: ".($value ? 'true' : 'false'));
} else {
$this->line(" - {$key}: {$value}");
}
}
$this->newLine();
} else {
$this->warn("Found canceled subscription for Team ID: {$team->id}");
// Send internal notification with all details before fixing
$notificationMessage = "Fixing canceled subscription:\n";
$notificationMessage .= "Team ID: {$team->id}\n";
$notificationMessage .= "Team Name: {$team->name}\n";
$notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n";
$notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n";
$notificationMessage .= "Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}\n";
$notificationMessage .= "Subscription Data:\n";
foreach ($subscription->getAttributes() as $key => $value) {
if (is_null($value)) {
$notificationMessage .= " - {$key}: null\n";
} elseif (is_bool($value)) {
$notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n";
} else {
$notificationMessage .= " - {$key}: {$value}\n";
}
}
send_internal_notification($notificationMessage);
// Apply the same logic as customer.subscription.deleted webhook
$team->subscriptionEnded();
$fixedCount++;
$this->info(" ✓ Fixed subscription for Team ID: {$team->id}");
$this->line(' Team Members: '.implode(', ', $memberEmails));
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
$this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}");
}
// Break if --one flag is set
if ($checkOne) {
break;
}
}
} catch (\Stripe\Exception\InvalidRequestException $e) {
if ($e->getStripeCode() === 'resource_missing') {
$toFixCount++;
// Get team members' emails
$memberEmails = $team->members->pluck('email')->toArray();
$canceledSubscriptions[] = [
'team_id' => $team->id,
'team_name' => $team->name,
'customer_id' => $subscription->stripe_customer_id,
'subscription_id' => $subscription->stripe_subscription_id,
'status' => 'missing',
'member_emails' => $memberEmails,
'subscription_model' => $subscription->toArray(),
];
if ($isDryRun) {
$this->error('Would fix missing subscription (not found in Stripe):');
$this->line(" Team ID: {$team->id}");
$this->line(" Team Name: {$team->name}");
$this->line(' Team Members: '.implode(', ', $memberEmails));
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
$this->line(" Subscription ID (missing): {$subscription->stripe_subscription_id}");
$this->line(' Current Subscription Data:');
foreach ($subscription->getAttributes() as $key => $value) {
if (is_null($value)) {
$this->line(" - {$key}: null");
} elseif (is_bool($value)) {
$this->line(" - {$key}: ".($value ? 'true' : 'false'));
} else {
$this->line(" - {$key}: {$value}");
}
}
$this->newLine();
} else {
$this->error("Subscription not found in Stripe for Team ID: {$team->id}");
// Send internal notification with all details before fixing
$notificationMessage = "Fixing missing subscription (not found in Stripe):\n";
$notificationMessage .= "Team ID: {$team->id}\n";
$notificationMessage .= "Team Name: {$team->name}\n";
$notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n";
$notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n";
$notificationMessage .= "Subscription ID (missing): {$subscription->stripe_subscription_id}\n";
$notificationMessage .= "Subscription Data:\n";
foreach ($subscription->getAttributes() as $key => $value) {
if (is_null($value)) {
$notificationMessage .= " - {$key}: null\n";
} elseif (is_bool($value)) {
$notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n";
} else {
$notificationMessage .= " - {$key}: {$value}\n";
}
}
send_internal_notification($notificationMessage);
// Apply the same logic as customer.subscription.deleted webhook
$team->subscriptionEnded();
$fixedCount++;
$this->info(" ✓ Fixed missing subscription for Team ID: {$team->id}");
$this->line(' Team Members: '.implode(', ', $memberEmails));
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
}
// Break if --one flag is set
if ($checkOne) {
break;
}
} else {
$errors[] = "Team ID {$team->id}: ".$e->getMessage();
}
} catch (\Exception $e) {
$errors[] = "Team ID {$team->id}: ".$e->getMessage();
}
}
$this->newLine();
$this->info('Summary:');
if ($isDryRun) {
$this->info(" - Found {$toFixCount} canceled/missing subscriptions that would be fixed");
if ($toFixCount > 0) {
$this->newLine();
$this->comment('Run with --fix-canceled-subs to apply these changes');
}
} else {
$this->info(" - Fixed {$fixedCount} canceled/missing subscriptions");
}
if (! empty($errors)) {
$this->newLine();
$this->error('Errors encountered:');
foreach ($errors as $error) {
$this->error(" - {$error}");
}
}
return 0;
}
/**
* Verify all active subscriptions against Stripe API
*/
private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
{
$isDryRun = $this->option('dry-run');
$shouldFix = $this->option('fix-verified');
$this->info('Verifying all active subscriptions against Stripe...');
if ($isDryRun) {
$this->info('DRY RUN MODE - No changes will be made');
}
if ($shouldFix && ! $isDryRun) {
$this->warn('FIX MODE - Discrepancies will be corrected');
}
// Get all teams with active subscriptions
$teamsWithActiveSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
$totalCount = $teamsWithActiveSubscriptions->count();
$this->info("Found {$totalCount} teams with active subscriptions in database");
$this->newLine();
$out = fopen('php://output', 'w');
// CSV header
fputcsv($out, [
'team_id',
'team_name',
'customer_id',
'subscription_id',
'db_status',
'stripe_status',
'action',
'member_emails',
'customer_url',
'subscription_url',
]);
$stats = [
'total' => $totalCount,
'valid_active' => 0,
'valid_past_due' => 0,
'canceled' => 0,
'missing' => 0,
'invalid' => 0,
'fixed' => 0,
'errors' => 0,
];
$processedCount = 0;
foreach ($teamsWithActiveSubscriptions as $team) {
$subscription = $team->subscription;
$memberEmails = $team->members->pluck('email')->toArray();
// Database state
$dbStatus = 'active';
if ($subscription->stripe_past_due) {
$dbStatus = 'past_due';
}
$stripeStatus = null;
$action = 'none';
if (! $subscription->stripe_subscription_id) {
$this->line("Team {$team->id}: Missing subscription ID, searching in Stripe...");
$foundResult = null;
$searchMethod = null;
// Search by customer ID
if ($subscription->stripe_customer_id) {
$this->line(" → Searching by customer ID: {$subscription->stripe_customer_id}");
$foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id);
if ($foundResult) {
$searchMethod = $foundResult['method'];
}
} else {
$this->line(' → No customer ID available');
}
// Search by emails if not found
if (! $foundResult && count($memberEmails) > 0) {
$foundResult = $this->searchSubscriptionsByEmails($stripe, $memberEmails);
if ($foundResult) {
$searchMethod = $foundResult['method'];
// Update customer ID if different
if (isset($foundResult['customer_id']) && $subscription->stripe_customer_id !== $foundResult['customer_id']) {
if ($isDryRun) {
$this->warn(" ⚠ Would update customer ID from {$subscription->stripe_customer_id} to {$foundResult['customer_id']}");
} elseif ($shouldFix) {
$subscription->update(['stripe_customer_id' => $foundResult['customer_id']]);
$this->info(" ✓ Updated customer ID to {$foundResult['customer_id']}");
}
}
}
}
if ($foundResult && isset($foundResult['subscription'])) {
// Check if it's an active/past_due subscription
if (in_array($foundResult['status'], ['active', 'past_due'])) {
// Found an active subscription, handle update
$result = $this->handleFoundSubscription(
$team,
$subscription,
$foundResult['subscription'],
$searchMethod,
$isDryRun,
$shouldFix,
$stats
);
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$result['id'],
$dbStatus,
$result['status'],
$result['action'],
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
$result['url'],
]);
} else {
// Found subscription but it's canceled/expired - needs to be deactivated
$this->warn(" → Found {$foundResult['status']} subscription {$foundResult['subscription']->id} - needs deactivation");
$result = $this->handleMissingSubscription($team, $subscription, $foundResult['status'], $isDryRun, $shouldFix, $stats);
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$foundResult['subscription']->id,
$dbStatus,
$foundResult['status'],
'needs_fix',
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
"https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}",
]);
}
} else {
// No subscription found at all
$this->line(' → No subscription found');
$stripeStatus = 'not_found';
$result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats);
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
'N/A',
$dbStatus,
$result['status'],
$result['action'],
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
'N/A',
]);
}
} else {
// First validate the subscription ID format
if (! str_starts_with($subscription->stripe_subscription_id, 'sub_')) {
$this->warn(" ⚠ Invalid subscription ID format (doesn't start with 'sub_')");
}
try {
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
$stripeStatus = $stripeSubscription->status;
// Determine if action is needed
switch ($stripeStatus) {
case 'active':
$stats['valid_active']++;
$action = 'valid';
break;
case 'past_due':
$stats['valid_past_due']++;
$action = 'valid';
// Ensure past_due flag is set
if (! $subscription->stripe_past_due) {
if ($isDryRun) {
$this->info("Would set stripe_past_due=true for Team {$team->id}");
} elseif ($shouldFix) {
$subscription->update(['stripe_past_due' => true]);
}
}
break;
case 'canceled':
case 'incomplete_expired':
case 'unpaid':
case 'incomplete':
$stats['canceled']++;
$action = 'needs_fix';
// Only output problematic subscriptions
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
$stripeStatus,
$action,
implode(', ', $memberEmails),
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
"https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}",
]);
if ($isDryRun) {
$this->info("Would deactivate subscription for Team {$team->id} - status: {$stripeStatus}");
} elseif ($shouldFix) {
$this->fixSubscription($team, $subscription, $stripeStatus);
$stats['fixed']++;
}
break;
default:
$stats['invalid']++;
$action = 'unknown';
// Only output problematic subscriptions
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
$stripeStatus,
$action,
implode(', ', $memberEmails),
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
"https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}",
]);
break;
}
} catch (\Stripe\Exception\InvalidRequestException $e) {
$this->error(' → Error: '.$e->getMessage());
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
// Subscription doesn't exist, try to find by customer ID
$this->warn(" → Subscription not found, checking customer's subscriptions...");
$foundResult = null;
if ($subscription->stripe_customer_id) {
$foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id);
}
if ($foundResult && isset($foundResult['subscription']) && in_array($foundResult['status'], ['active', 'past_due'])) {
// Found an active subscription with different ID
$this->warn(" → ID mismatch! DB: {$subscription->stripe_subscription_id}, Stripe: {$foundResult['subscription']->id}");
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
"WRONG ID: {$subscription->stripe_subscription_id} → {$foundResult['subscription']->id}",
$dbStatus,
$foundResult['status'],
'id_mismatch',
implode(', ', $memberEmails),
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
"https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}",
]);
if ($isDryRun) {
$this->warn(" → Would update subscription ID to {$foundResult['subscription']->id}");
} elseif ($shouldFix) {
$subscription->update([
'stripe_subscription_id' => $foundResult['subscription']->id,
'stripe_invoice_paid' => true,
'stripe_past_due' => $foundResult['status'] === 'past_due',
]);
$stats['fixed']++;
$this->info(' → Updated subscription ID');
}
$stats[$foundResult['status'] === 'active' ? 'valid_active' : 'valid_past_due']++;
} else {
// No active subscription found
$stripeStatus = $foundResult ? $foundResult['status'] : 'not_found';
$result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats);
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
$result['status'],
$result['action'],
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
$foundResult && isset($foundResult['subscription']) ? "https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}" : 'N/A',
]);
}
} else {
// Other API error
$stats['errors']++;
$this->error(' → API Error - not marking as deleted');
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
'error: '.$e->getStripeCode(),
'error',
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
$subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A',
]);
}
} catch (\Exception $e) {
$this->error(' → Unexpected error: '.$e->getMessage());
$stats['errors']++;
fputcsv($out, [
$team->id,
$team->name,
$subscription->stripe_customer_id,
$subscription->stripe_subscription_id,
$dbStatus,
'error',
'error',
implode(', ', $memberEmails),
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
$subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A',
]);
}
}
$processedCount++;
if ($processedCount % 100 === 0) {
$this->info("Processed {$processedCount}/{$totalCount} subscriptions...");
}
}
fclose($out);
// Print summary
$this->newLine(2);
$this->info('=== Verification Summary ===');
$this->info("Total subscriptions checked: {$stats['total']}");
$this->newLine();
$this->info('Valid subscriptions in Stripe:');
$this->line(" - Active: {$stats['valid_active']}");
$this->line(" - Past Due: {$stats['valid_past_due']}");
$validTotal = $stats['valid_active'] + $stats['valid_past_due'];
$this->info(" Total valid: {$validTotal}");
$this->newLine();
$this->warn('Invalid subscriptions:');
$this->line(" - Canceled/Expired: {$stats['canceled']}");
$this->line(" - Missing/Not Found: {$stats['missing']}");
$this->line(" - Unknown status: {$stats['invalid']}");
$invalidTotal = $stats['canceled'] + $stats['missing'] + $stats['invalid'];
$this->warn(" Total invalid: {$invalidTotal}");
if ($stats['errors'] > 0) {
$this->newLine();
$this->error("Errors encountered: {$stats['errors']}");
}
if ($shouldFix && ! $isDryRun) {
$this->newLine();
$this->info("Fixed subscriptions: {$stats['fixed']}");
} elseif ($invalidTotal > 0 && ! $shouldFix) {
$this->newLine();
$this->comment('Run with --fix-verified to fix the discrepancies');
}
return 0;
}
/**
* Fix a subscription based on its status
*/
private function fixSubscription($team, $subscription, $status)
{
$message = "Fixing subscription for Team ID: {$team->id} (Status: {$status})\n";
$message .= "Team Name: {$team->name}\n";
$message .= "Customer ID: {$subscription->stripe_customer_id}\n";
$message .= "Subscription ID: {$subscription->stripe_subscription_id}\n";
send_internal_notification($message);
// Call the team's subscription ended method which properly cleans up
$team->subscriptionEnded();
}
/**
* Search for subscriptions by customer ID
*/
private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false)
{
try {
$subscriptions = $stripe->subscriptions->all([
'customer' => $customerId,
'limit' => 10,
'status' => 'all',
]);
$this->line(' → Found '.count($subscriptions->data).' subscription(s) for customer');
// Look for active/past_due first
foreach ($subscriptions->data as $sub) {
$this->line(" - Subscription {$sub->id}: status={$sub->status}");
if (in_array($sub->status, ['active', 'past_due'])) {
$this->info(" ✓ Found active/past_due subscription: {$sub->id}");
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id'];
}
}
// If not requiring active and there are subscriptions, return first one
if (! $requireActive && count($subscriptions->data) > 0) {
$sub = $subscriptions->data[0];
$this->warn(" ⚠ Only found {$sub->status} subscription: {$sub->id}");
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id_first'];
}
return null;
} catch (\Exception $e) {
$this->error(' → Error searching by customer ID: '.$e->getMessage());
return null;
}
}
/**
* Search for subscriptions by team member emails
*/
private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails)
{
$this->line(' → Searching by team member emails...');
foreach ($emails as $email) {
$this->line(" → Checking email: {$email}");
try {
$customers = $stripe->customers->all([
'email' => $email,
'limit' => 5,
]);
if (count($customers->data) === 0) {
$this->line(' - No customers found');
continue;
}
$this->line(' - Found '.count($customers->data).' customer(s)');
foreach ($customers->data as $customer) {
$this->line(" - Checking customer {$customer->id}");
$result = $this->searchSubscriptionsByCustomer($stripe, $customer->id, true);
if ($result) {
$result['method'] = "email:{$email}";
$result['customer_id'] = $customer->id;
return $result;
}
}
} catch (\Exception $e) {
$this->error(" - Error searching for email {$email}: ".$e->getMessage());
}
}
return null;
}
/**
* Handle found subscription update (only for active/past_due subscriptions)
*/
private function handleFoundSubscription($team, $subscription, $foundSub, $searchMethod, $isDryRun, $shouldFix, &$stats)
{
$stripeStatus = $foundSub->status;
$this->info(" ✓ FOUND active/past_due subscription {$foundSub->id} (status: {$stripeStatus})");
// Only update if it's active or past_due
if (! in_array($stripeStatus, ['active', 'past_due'])) {
$this->error(" ERROR: handleFoundSubscription called with {$stripeStatus} subscription!");
return [
'id' => $foundSub->id,
'status' => $stripeStatus,
'action' => 'error',
'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}",
];
}
if ($isDryRun) {
$this->warn(" → Would update subscription ID to {$foundSub->id} (status: {$stripeStatus})");
} elseif ($shouldFix) {
$subscription->update([
'stripe_subscription_id' => $foundSub->id,
'stripe_invoice_paid' => true,
'stripe_past_due' => $stripeStatus === 'past_due',
]);
$stats['fixed']++;
$this->info(" → Updated subscription ID to {$foundSub->id}");
}
// Update stats
$stats[$stripeStatus === 'active' ? 'valid_active' : 'valid_past_due']++;
return [
'id' => "FOUND: {$foundSub->id}",
'status' => $stripeStatus,
'action' => "will_update (via {$searchMethod})",
'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}",
];
}
/**
* Handle missing subscription
*/
private function handleMissingSubscription($team, $subscription, $status, $isDryRun, $shouldFix, &$stats)
{
$stats['missing']++;
if ($isDryRun) {
$statusMsg = $status !== 'not_found' ? "status: {$status}" : 'no subscription found in Stripe';
$this->warn(" → Would deactivate subscription - {$statusMsg}");
} elseif ($shouldFix) {
$this->fixSubscription($team, $subscription, $status);
$stats['fixed']++;
$this->info(' → Deactivated subscription');
}
return [
'id' => 'N/A',
'status' => $status,
'action' => 'needs_fix',
'url' => 'N/A',
];
}
}
@@ -1,49 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Team;
use Illuminate\Console\Command;
class CloudCheckSubscription extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cloud:check-subscription';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Check Cloud subscriptions';
/**
* Execute the console command.
*/
public function handle()
{
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
foreach ($activeSubscribers as $team) {
$stripeSubscriptionId = $team->subscription->stripe_subscription_id;
$stripeInvoicePaid = $team->subscription->stripe_invoice_paid;
$stripeCustomerId = $team->subscription->stripe_customer_id;
if (! $stripeSubscriptionId) {
echo "Team {$team->id} has no subscription, but invoice status is: {$stripeInvoicePaid}\n";
echo "Link on Stripe: https://dashboard.stripe.com/customers/{$stripeCustomerId}\n";
continue;
}
$subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId);
if ($subscription->status === 'active') {
continue;
}
echo "Subscription {$stripeSubscriptionId} is not active ({$subscription->status})\n";
echo "Link on Stripe: https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}\n";
}
}
}
@@ -1,101 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Events\ServerReachabilityChanged;
use App\Models\Team;
use Illuminate\Console\Command;
class CloudCleanupSubscriptions extends Command
{
protected $signature = 'cloud:cleanup-subs';
protected $description = 'Cleanup subcriptions teams';
public function handle()
{
try {
if (! isCloud()) {
$this->error('This command can only be run on cloud');
return;
}
$this->info('Cleaning up subcriptions teams');
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$teams = Team::all()->filter(function ($team) {
return $team->id !== 0;
})->sortBy('id');
foreach ($teams as $team) {
if ($team) {
$this->info("Checking team {$team->id}");
}
if (! data_get($team, 'subscription')) {
$this->disableServers($team);
continue;
}
// If the team has no subscription id and the invoice is paid, we need to reset the invoice paid status
if (! (data_get($team, 'subscription.stripe_subscription_id'))) {
$this->info("Resetting invoice paid status for team {$team->id}");
$team->subscription->update([
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_subscription_id' => null,
]);
$this->disableServers($team);
continue;
} else {
$subscription = $stripe->subscriptions->retrieve(data_get($team, 'subscription.stripe_subscription_id'), []);
$status = data_get($subscription, 'status');
if ($status === 'active') {
$team->subscription->update([
'stripe_invoice_paid' => true,
'stripe_trial_already_ended' => false,
]);
continue;
}
$this->info('Subscription status: '.$status);
$this->info('Subscription id: '.data_get($team, 'subscription.stripe_subscription_id'));
$confirm = $this->confirm('Do you want to cancel the subscription?', true);
if (! $confirm) {
$this->info("Skipping team {$team->id}");
} else {
$this->info("Cancelling subscription for team {$team->id}");
$team->subscription->update([
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
'stripe_subscription_id' => null,
]);
$this->disableServers($team);
}
}
}
} catch (\Exception $e) {
$this->error($e->getMessage());
return;
}
}
private function disableServers(Team $team)
{
foreach ($team->servers as $server) {
if ($server->settings->is_usable === true || $server->settings->is_reachable === true || $server->ip !== '1.2.3.4') {
$this->info("Disabling server {$server->id} {$server->name}");
$server->settings()->update([
'is_usable' => false,
'is_reachable' => false,
]);
$server->update([
'ip' => '1.2.3.4',
]);
ServerReachabilityChanged::dispatch($server);
}
}
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ApplicationConfigurationChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public ?int $teamId = null;
public function __construct($teamId = null)
{
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
$teamId = auth()->user()->currentTeam()->id;
}
$this->teamId = $teamId;
}
public function broadcastOn(): array
{
if (is_null($this->teamId)) {
return [];
}
return [
new PrivateChannel("team.{$this->teamId}"),
];
}
}
@@ -3380,11 +3380,12 @@ class ApplicationsController extends Controller
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
$errors = [];
$fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) {
$domain = trim($domain);
if (filter_var($domain, FILTER_VALIDATE_URL) === false) {
$errors[] = 'Invalid domain: '.$domain;
}
return str($domain)->trim()->lower();
return str($domain)->lower();
});
if (count($errors) > 0) {
return response()->json([
@@ -9,11 +9,15 @@ use App\Actions\Database\StopDatabase;
use App\Actions\Database\StopDatabaseProxy;
use App\Enums\NewDatabaseTypes;
use App\Http\Controllers\Controller;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\DeleteResourceJob;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA;
class DatabasesController extends Controller
@@ -79,13 +83,88 @@ class DatabasesController extends Controller
foreach ($projects as $project) {
$databases = $databases->merge($project->databases());
}
$databases = $databases->map(function ($database) {
$databaseIds = $databases->pluck('id')->toArray();
$backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('latest_log')
->whereIn('database_id', $databaseIds)
->get()
->groupBy('database_id');
$databases = $databases->map(function ($database) use ($backupConfigs) {
$database->backup_configs = $backupConfigs->get($database->id, collect())->values();
return $this->removeSensitiveData($database);
});
return response()->json($databases);
}
#[OA\Get(
summary: 'Get',
description: 'Get backups details by database UUID.',
path: '/databases/{uuid}/backups',
operationId: 'get-database-backups-by-uuid',
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',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all backups for a database',
content: new OA\JsonContent(
type: 'string',
example: 'Content is very complex. Will be implemented later.',
),
),
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',
),
]
)]
public function database_backup_details_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $request->uuid) {
return response()->json(['message' => 'UUID is required.'], 404);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('view', $database);
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('executions')->where('database_id', $database->id)->get();
return response()->json($backupConfig);
}
#[OA\Get(
summary: 'Get',
description: 'Get database by UUID.',
@@ -248,6 +327,7 @@ class DatabasesController extends Controller
return invalidTokenResponse();
}
// this check if the request is a valid json
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
@@ -499,7 +579,8 @@ class DatabasesController extends Controller
$whatToDoWithDatabaseProxy = 'start';
}
$database->update($request->all());
// Only update database fields, not backup configuration
$database->update($request->only($allowedFields));
if ($whatToDoWithDatabaseProxy === 'start') {
StartDatabaseProxy::dispatch($database);
@@ -512,6 +593,197 @@ class DatabasesController extends Controller
]);
}
#[OA\Patch(
summary: 'Update',
description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID',
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}',
operationId: 'update-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',
)
),
new OA\Parameter(
name: 'scheduled_backup_uuid',
in: 'path',
description: 'UUID of the backup configuration.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
requestBody: new OA\RequestBody(
description: 'Database backup configuration data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'],
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'],
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'],
'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'],
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
'dump_all' => ['type' => 'boolean', 'description' => 'Whether all databases are dumped or not'],
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
],
),
)
),
responses: [
new OA\Response(
response: 200,
description: 'Database backup configuration updated',
),
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',
),
]
)]
public function update_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();
}
// this check if the request is a valid json
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'save_s3' => 'boolean',
'backup_now' => 'boolean|nullable',
'enabled' => 'boolean',
'dump_all' => 'boolean',
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
'databases_to_backup' => 'string|nullable',
'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly',
'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);
}
// Validate scheduled_backup_uuid is provided
if (! $request->scheduled_backup_uuid) {
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
}
$uuid = $request->uuid;
removeUnnecessaryFieldsFromRequest($request);
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
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);
}
}
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
->where('uuid', $request->scheduled_backup_uuid)
->first();
if (! $backupConfig) {
return response()->json(['message' => 'Backup config not found.'], 404);
}
$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']);
}
$backupConfig->update($backupData);
if ($request->backup_now) {
dispatch(new DatabaseBackupJob($backupConfig));
}
return response()->json([
'message' => 'Database backup configuration updated',
]);
}
#[OA\Post(
summary: 'Create (PostgreSQL)',
description: 'Create a new PostgreSQL database.',
@@ -1630,6 +1902,344 @@ class DatabasesController extends Controller
]);
}
#[OA\Delete(
summary: 'Delete backup configuration',
description: 'Deletes a backup configuration and all its executions.',
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}',
operationId: 'delete-backup-configuration-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
required: true,
description: 'UUID of the database',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'scheduled_backup_uuid',
in: 'path',
required: true,
description: 'UUID of the backup configuration to delete',
schema: new OA\Schema(type: 'string', format: 'uuid')
),
new OA\Parameter(
name: 'delete_s3',
in: 'query',
required: false,
description: 'Whether to delete all backup files from S3',
schema: new OA\Schema(type: 'boolean', default: false)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Backup configuration deleted.',
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup configuration and all executions deleted.'),
]
)
),
new OA\Response(
response: 404,
description: 'Backup configuration not found.',
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup configuration not found.'),
]
)
),
]
)]
public function delete_backup_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// Validate scheduled_backup_uuid is provided
if (! $request->scheduled_backup_uuid) {
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
// Find the backup configuration by its UUID
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
->where('uuid', $request->scheduled_backup_uuid)
->first();
if (! $backup) {
return response()->json(['message' => 'Backup configuration not found.'], 404);
}
$deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN);
try {
DB::beginTransaction();
// Get all executions for this backup configuration
$executions = $backup->executions()->get();
// Delete all execution files (locally and optionally from S3)
foreach ($executions as $execution) {
if ($execution->filename) {
deleteBackupsLocally($execution->filename, $database->destination->server);
if ($deleteS3 && $backup->s3) {
deleteBackupsS3($execution->filename, $backup->s3);
}
}
$execution->delete();
}
// Delete the backup configuration itself
$backup->delete();
DB::commit();
return response()->json([
'message' => 'Backup configuration and all executions deleted.',
]);
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500);
}
}
#[OA\Delete(
summary: 'Delete backup execution',
description: 'Deletes a specific backup execution.',
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}',
operationId: 'delete-backup-execution-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
required: true,
description: 'UUID of the database',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'scheduled_backup_uuid',
in: 'path',
required: true,
description: 'UUID of the backup configuration',
schema: new OA\Schema(type: 'string', format: 'uuid')
),
new OA\Parameter(
name: 'execution_uuid',
in: 'path',
required: true,
description: 'UUID of the backup execution to delete',
schema: new OA\Schema(type: 'string', format: 'uuid')
),
new OA\Parameter(
name: 'delete_s3',
in: 'query',
required: false,
description: 'Whether to delete the backup from S3',
schema: new OA\Schema(type: 'boolean', default: false)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Backup execution deleted.',
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup execution deleted.'),
]
)
),
new OA\Response(
response: 404,
description: 'Backup execution not found.',
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup execution not found.'),
]
)
),
]
)]
public function delete_execution_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// Validate parameters
if (! $request->scheduled_backup_uuid) {
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
}
if (! $request->execution_uuid) {
return response()->json(['message' => 'Execution UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
// Find the backup configuration by its UUID
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
->where('uuid', $request->scheduled_backup_uuid)
->first();
if (! $backup) {
return response()->json(['message' => 'Backup configuration not found.'], 404);
}
// Find the specific execution
$execution = $backup->executions()->where('uuid', $request->execution_uuid)->first();
if (! $execution) {
return response()->json(['message' => 'Backup execution not found.'], 404);
}
$deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN);
try {
if ($execution->filename) {
deleteBackupsLocally($execution->filename, $database->destination->server);
if ($deleteS3 && $backup->s3) {
deleteBackupsS3($execution->filename, $backup->s3);
}
}
$execution->delete();
return response()->json([
'message' => 'Backup execution deleted.',
]);
} catch (\Exception $e) {
return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500);
}
}
#[OA\Get(
summary: 'List backup executions',
description: 'Get all executions for a specific backup configuration.',
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions',
operationId: 'list-backup-executions',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
required: true,
description: 'UUID of the database',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'scheduled_backup_uuid',
in: 'path',
required: true,
description: 'UUID of the backup configuration',
schema: new OA\Schema(type: 'string', format: 'uuid')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of backup executions',
content: new OA\JsonContent(
type: 'object',
properties: [
'executions' => new OA\Schema(
type: 'array',
items: new OA\Schema(
type: 'object',
properties: [
'uuid' => ['type' => 'string'],
'filename' => ['type' => 'string'],
'size' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
'message' => ['type' => 'string'],
'status' => ['type' => 'string'],
]
)
),
]
)
),
new OA\Response(
response: 404,
description: 'Backup configuration not found.',
),
]
)]
public function list_backup_executions(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// Validate scheduled_backup_uuid is provided
if (! $request->scheduled_backup_uuid) {
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
// Find the backup configuration by its UUID
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
->where('uuid', $request->scheduled_backup_uuid)
->first();
if (! $backup) {
return response()->json(['message' => 'Backup configuration not found.'], 404);
}
// Get all executions for this backup configuration
$executions = $backup->executions()
->orderBy('created_at', 'desc')
->get()
->map(function ($execution) {
return [
'uuid' => $execution->uuid,
'filename' => $execution->filename,
'size' => $execution->size,
'created_at' => $execution->created_at->toIso8601String(),
'message' => $execution->message,
'status' => $execution->status,
];
});
return response()->json([
'executions' => $executions,
]);
}
#[OA\Get(
summary: 'Start',
description: 'Start database. `Post` request is also accepted.',
@@ -0,0 +1,661 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use OpenApi\Attributes as OA;
class GithubController extends Controller
{
#[OA\Post(
summary: 'Create GitHub App',
description: 'Create a new GitHub app.',
path: '/github-apps',
operationId: 'create-github-app',
security: [
['bearerAuth' => []],
],
tags: ['GitHub Apps'],
requestBody: new OA\RequestBody(
description: 'GitHub app creation payload.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'Name of the GitHub app.'],
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'Organization to associate the app with.'],
'api_url' => ['type' => 'string', 'description' => 'API URL for the GitHub app (e.g., https://api.github.com).'],
'html_url' => ['type' => 'string', 'description' => 'HTML URL for the GitHub app (e.g., https://github.com).'],
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'],
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'],
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID from GitHub.'],
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID.'],
'client_id' => ['type' => 'string', 'description' => 'GitHub OAuth App Client ID.'],
'client_secret' => ['type' => 'string', 'description' => 'GitHub OAuth App Client Secret.'],
'webhook_secret' => ['type' => 'string', 'description' => 'Webhook secret for GitHub webhooks.'],
'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'],
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'],
],
required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'GitHub app created successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
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'],
'team_id' => ['type' => 'integer'],
]
)
),
]
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_github_app(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$allowedFields = [
'name',
'organization',
'api_url',
'html_url',
'custom_user',
'custom_port',
'app_id',
'installation_id',
'client_id',
'client_secret',
'webhook_secret',
'private_key_uuid',
'is_system_wide',
];
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'organization' => 'nullable|string|max:255',
'api_url' => 'required|string|url',
'html_url' => 'required|string|url',
'custom_user' => 'nullable|string|max:255',
'custom_port' => 'nullable|integer|min:1|max:65535',
'app_id' => 'required|integer',
'installation_id' => 'required|integer',
'client_id' => 'required|string|max:255',
'client_secret' => 'required|string',
'webhook_secret' => 'required|string',
'private_key_uuid' => 'required|string',
'is_system_wide' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
try {
// Verify the private key belongs to the team
$privateKey = PrivateKey::where('uuid', $request->input('private_key_uuid'))
->where('team_id', $teamId)
->first();
if (! $privateKey) {
return response()->json([
'message' => 'Private key not found or does not belong to your team.',
], 404);
}
$payload = [
'uuid' => Str::uuid(),
'name' => $request->input('name'),
'organization' => $request->input('organization'),
'api_url' => $request->input('api_url'),
'html_url' => $request->input('html_url'),
'custom_user' => $request->input('custom_user', 'git'),
'custom_port' => $request->input('custom_port', 22),
'app_id' => $request->input('app_id'),
'installation_id' => $request->input('installation_id'),
'client_id' => $request->input('client_id'),
'client_secret' => $request->input('client_secret'),
'webhook_secret' => $request->input('webhook_secret'),
'private_key_id' => $privateKey->id,
'is_public' => false,
'team_id' => $teamId,
];
if (! isCloud()) {
$payload['is_system_wide'] = $request->input('is_system_wide', false);
}
$githubApp = GithubApp::create($payload);
return response()->json($githubApp, 201);
} catch (\Throwable $e) {
return handleError($e);
}
}
#[OA\Get(
path: '/github-apps/{github_app_id}/repositories',
summary: 'Load Repositories for a GitHub App',
description: 'Fetch repositories from GitHub for a given GitHub app.',
operationId: 'load-repositories',
tags: ['GitHub Apps'],
security: [
['bearerAuth' => []],
],
parameters: [
new OA\Parameter(
name: 'github_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitHub App ID'
),
],
responses: [
new OA\Response(
response: 200,
description: 'Repositories loaded successfully.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'repositories' => new OA\Items(
type: 'array',
items: new OA\Schema(type: 'object')
),
]
)
)
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function load_repositories($github_app_id)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
$token = generateGithubInstallationToken($githubApp);
$repositories = collect();
$page = 1;
$maxPages = 100; // Safety limit: max 10,000 repositories
while ($page <= $maxPages) {
$response = Http::GitHub($githubApp->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get('/installation/repositories', [
'per_page' => 100,
'page' => $page,
]);
if ($response->status() !== 200) {
return response()->json([
'message' => $response->json()['message'] ?? 'Failed to load repositories',
], $response->status());
}
$json = $response->json();
$repos = $json['repositories'] ?? [];
if (empty($repos)) {
break; // No more repositories to load
}
$repositories = $repositories->concat($repos);
$page++;
}
return response()->json([
'repositories' => $repositories->sortBy('name')->values(),
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
}
}
#[OA\Get(
path: '/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches',
summary: 'Load Branches for a GitHub Repository',
description: 'Fetch branches from GitHub for a given repository.',
operationId: 'load-branches',
tags: ['GitHub Apps'],
security: [
['bearerAuth' => []],
],
parameters: [
new OA\Parameter(
name: 'github_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitHub App ID'
),
new OA\Parameter(
name: 'owner',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string'),
description: 'Repository owner'
),
new OA\Parameter(
name: 'repo',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string'),
description: 'Repository name'
),
],
responses: [
new OA\Response(
response: 200,
description: 'Branches loaded successfully.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'branches' => new OA\Items(
type: 'array',
items: new OA\Schema(type: 'object')
),
]
)
)
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function load_branches($github_app_id, $owner, $repo)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
$token = generateGithubInstallationToken($githubApp);
$response = Http::GitHub($githubApp->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get("/repos/{$owner}/{$repo}/branches");
if ($response->status() !== 200) {
return response()->json([
'message' => 'Error loading branches from GitHub.',
'error' => $response->json('message'),
], $response->status());
}
$branches = $response->json();
return response()->json([
'branches' => $branches,
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
}
}
/**
* Update a GitHub app.
*/
#[OA\Patch(
path: '/github-apps/{github_app_id}',
operationId: 'updateGithubApp',
security: [
['bearerAuth' => []],
],
tags: ['GitHub Apps'],
summary: 'Update GitHub App',
description: 'Update an existing GitHub app.',
parameters: [
new OA\Parameter(
name: 'github_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitHub App ID'
),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'GitHub App name'],
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'GitHub organization'],
'api_url' => ['type' => 'string', 'description' => 'GitHub API URL'],
'html_url' => ['type' => 'string', 'description' => 'GitHub HTML URL'],
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'],
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'],
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID'],
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID'],
'client_id' => ['type' => 'string', 'description' => 'GitHub Client ID'],
'client_secret' => ['type' => 'string', 'description' => 'GitHub Client Secret'],
'webhook_secret' => ['type' => 'string', 'description' => 'GitHub Webhook Secret'],
'private_key_uuid' => ['type' => 'string', 'description' => 'Private key UUID'],
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'],
]
)
)
),
responses: [
new OA\Response(
response: 200,
description: 'GitHub app updated successfully',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'GitHub app updated successfully'],
'data' => ['type' => 'object', 'description' => 'Updated GitHub app data'],
]
)
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'GitHub app not found'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function update_github_app(Request $request, $github_app_id)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
// Define allowed fields for update
$allowedFields = [
'name',
'organization',
'api_url',
'html_url',
'custom_user',
'custom_port',
'app_id',
'installation_id',
'client_id',
'client_secret',
'webhook_secret',
'private_key_uuid',
];
if (! isCloud()) {
$allowedFields[] = 'is_system_wide';
}
$payload = $request->only($allowedFields);
// Validate the request
$rules = [];
if (isset($payload['name'])) {
$rules['name'] = 'string';
}
if (isset($payload['organization'])) {
$rules['organization'] = 'nullable|string';
}
if (isset($payload['api_url'])) {
$rules['api_url'] = 'url';
}
if (isset($payload['html_url'])) {
$rules['html_url'] = 'url';
}
if (isset($payload['custom_user'])) {
$rules['custom_user'] = 'string';
}
if (isset($payload['custom_port'])) {
$rules['custom_port'] = 'integer|min:1|max:65535';
}
if (isset($payload['app_id'])) {
$rules['app_id'] = 'integer';
}
if (isset($payload['installation_id'])) {
$rules['installation_id'] = 'integer';
}
if (isset($payload['client_id'])) {
$rules['client_id'] = 'string';
}
if (isset($payload['client_secret'])) {
$rules['client_secret'] = 'string';
}
if (isset($payload['webhook_secret'])) {
$rules['webhook_secret'] = 'string';
}
if (isset($payload['private_key_uuid'])) {
$rules['private_key_uuid'] = 'string|uuid';
}
if (! isCloud() && isset($payload['is_system_wide'])) {
$rules['is_system_wide'] = 'boolean';
}
$validator = customApiValidator($payload, $rules);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation error',
'errors' => $validator->errors(),
], 422);
}
// Handle private_key_uuid -> private_key_id conversion
if (isset($payload['private_key_uuid'])) {
$privateKey = PrivateKey::where('team_id', $teamId)
->where('uuid', $payload['private_key_uuid'])
->first();
if (! $privateKey) {
return response()->json([
'message' => 'Private key not found or does not belong to your team',
], 404);
}
unset($payload['private_key_uuid']);
$payload['private_key_id'] = $privateKey->id;
}
// Update the GitHub app
$githubApp->update($payload);
return response()->json([
'message' => 'GitHub app updated successfully',
'data' => $githubApp,
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
}
}
/**
* Delete a GitHub app.
*/
#[OA\Delete(
path: '/github-apps/{github_app_id}',
operationId: 'deleteGithubApp',
security: [
['bearerAuth' => []],
],
tags: ['GitHub Apps'],
summary: 'Delete GitHub App',
description: 'Delete a GitHub app if it\'s not being used by any applications.',
parameters: [
new OA\Parameter(
name: 'github_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitHub App ID'
),
],
responses: [
new OA\Response(
response: 200,
description: 'GitHub app deleted successfully',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'GitHub app deleted successfully'],
]
)
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'GitHub app not found'),
new OA\Response(
response: 409,
description: 'Conflict - GitHub app is in use',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'This GitHub app is being used by 5 application(s). Please delete all applications first.'],
]
)
)
),
]
)]
public function delete_github_app($github_app_id)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
// Check if the GitHub app is being used by any applications
if ($githubApp->applications->isNotEmpty()) {
$count = $githubApp->applications->count();
return response()->json([
'message' => "This GitHub app is being used by {$count} application(s). Please delete all applications first.",
], 409);
}
$githubApp->delete();
return response()->json([
'message' => 'GitHub app deleted successfully',
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
}
}
}
@@ -179,6 +179,8 @@ class TeamController extends Controller
$members = $team->members;
$members->makeHidden([
'pivot',
'email_change_code',
'email_change_code_expires_at',
]);
return response()->json(
@@ -264,6 +266,8 @@ class TeamController extends Controller
$team = auth()->user()->currentTeam();
$team->members->makeHidden([
'pivot',
'email_change_code',
'email_change_code_expires_at',
]);
return response()->json(
+113 -5
View File
@@ -5,6 +5,7 @@ namespace App\Jobs;
use App\Actions\Docker\GetContainersStatus;
use App\Enums\ApplicationDeploymentStatus;
use App\Enums\ProcessStatus;
use App\Events\ApplicationConfigurationChanged;
use App\Events\ServiceStatusChanged;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
@@ -17,6 +18,7 @@ use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Notifications\Application\DeploymentFailed;
use App\Notifications\Application\DeploymentSuccess;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\ExecuteRemoteCommand;
use Carbon\Carbon;
use Exception;
@@ -38,7 +40,7 @@ use Yosymfony\Toml\Toml;
class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, ExecuteRemoteCommand, InteractsWithQueue, Queueable, SerializesModels;
use Dispatchable, EnvironmentVariableAnalyzer, ExecuteRemoteCommand, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
@@ -147,6 +149,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private Collection $saved_outputs;
private ?string $secrets_hash_key = null;
private ?string $full_healthcheck_url = null;
private string $serverUser = 'root';
@@ -638,6 +642,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
$build_args_string = $this->build_args->implode(' ');
// Escape single quotes for bash -c context used by executeInDocker
$build_args_string = str_replace("'", "'\\''", $build_args_string);
$command .= " {$build_args_string}";
$this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.');
}
@@ -2707,27 +2713,82 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->application_deployment_queue->addLogEntry('New container started.');
}
private function analyzeBuildTimeVariables($variables)
{
$userDefinedVariables = collect([]);
$dbVariables = $this->pull_request_id === 0
? $this->application->environment_variables()
->where('is_buildtime', true)
->pluck('key')
: $this->application->environment_variables_preview()
->where('is_buildtime', true)
->pluck('key');
foreach ($variables as $key => $value) {
if ($dbVariables->contains($key)) {
$userDefinedVariables->put($key, $value);
}
}
if ($userDefinedVariables->isEmpty()) {
return;
}
$variablesArray = $userDefinedVariables->toArray();
$warnings = self::analyzeBuildVariables($variablesArray);
if (empty($warnings)) {
return;
}
$this->application_deployment_queue->addLogEntry('----------------------------------------');
foreach ($warnings as $warning) {
$messages = self::formatBuildWarning($warning);
foreach ($messages as $message) {
$this->application_deployment_queue->addLogEntry($message, type: 'warning');
}
$this->application_deployment_queue->addLogEntry('');
}
// Add general advice
$this->application_deployment_queue->addLogEntry('💡 Tips to resolve build issues:', type: 'info');
$this->application_deployment_queue->addLogEntry(' 1. Set these variables as "Runtime only" in the environment variables settings', type: 'info');
$this->application_deployment_queue->addLogEntry(' 2. Use different values for build-time (e.g., NODE_ENV=development for build)', type: 'info');
$this->application_deployment_queue->addLogEntry(' 3. Consider using multi-stage Docker builds to separate build and runtime environments', type: 'info');
}
private function generate_build_env_variables()
{
if ($this->application->build_pack === 'nixpacks') {
$variables = collect($this->nixpacks_plan_json->get('variables'));
} else {
// Generate environment variables for build process (filters by is_buildtime = true)
$this->generate_env_variables();
$variables = collect([])->merge($this->env_args);
}
// Check if build secrets are enabled and BuildKit is supported
// Analyze build variables for potential issues
if ($variables->isNotEmpty()) {
$this->analyzeBuildTimeVariables($variables);
}
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
$this->generate_build_secrets($variables);
$this->build_args = '';
} else {
// Fall back to traditional build args
$secrets_hash = '';
if ($variables->isNotEmpty()) {
$secrets_hash = $this->generate_secrets_hash($variables);
}
$this->build_args = $variables->map(function ($value, $key) {
$value = escapeshellarg($value);
return "--build-arg {$key}={$value}";
});
if ($secrets_hash) {
$this->build_args->push("--build-arg COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
}
}
}
@@ -2746,13 +2807,18 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return '';
}
return $variables
$secrets_hash = $this->generate_secrets_hash($variables);
$env_flags = $variables
->map(function ($env) {
$escaped_value = escapeshellarg($env->real_value);
return "-e {$env->key}={$escaped_value}";
})
->implode(' ');
$env_flags .= " -e COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}";
return $env_flags;
}
private function generate_build_secrets(Collection $variables)
@@ -2768,6 +2834,36 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return "--secret id={$key},env={$key}";
})
->implode(' ');
$this->build_secrets .= ' --secret id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
}
private function generate_secrets_hash($variables)
{
if (! $this->secrets_hash_key) {
$this->secrets_hash_key = bin2hex(random_bytes(32));
}
if ($variables instanceof Collection) {
$secrets_string = $variables
->mapWithKeys(function ($value, $key) {
return [$key => $value];
})
->sortKeys()
->map(function ($value, $key) {
return "{$key}={$value}";
})
->implode('|');
} else {
$secrets_string = $variables
->map(function ($env) {
return "{$env->key}={$env->real_value}";
})
->sort()
->implode('|');
}
return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key);
}
private function add_build_env_variables_to_dockerfile()
@@ -2809,6 +2905,12 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
}
}
if ($envs->isNotEmpty()) {
$secrets_hash = $this->generate_secrets_hash($envs);
$dockerfile->splice(1, 0, ["ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"]);
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
@@ -2850,6 +2952,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
// Generate mount strings for all secrets
$mountStrings = $variables->map(fn ($env) => "--mount=type=secret,id={$env->key},env={$env->key}")->implode(' ');
// Add mount for the secrets hash to ensure cache invalidation
$mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
$modified = false;
$dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) {
$trimmed = ltrim($line);
@@ -3186,6 +3291,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
queue_next_deployment($this->application);
if ($status === ApplicationDeploymentStatus::FINISHED->value) {
ray($this->application->team()->id);
event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) {
$this->deploy_to_additional_destinations();
}
+11 -2
View File
@@ -74,8 +74,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
$this->onQueue('high');
$this->timeout = $backup->timeout;
$this->backup_log_uuid = (string) new Cuid2;
}
public function handle(): void
@@ -288,6 +286,17 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->backup_dir = backup_dir().'/coolify'."/coolify-db-$ip";
}
foreach ($databasesToBackup as $database) {
// Generate unique UUID for each database backup execution
$attempts = 0;
do {
$this->backup_log_uuid = (string) new Cuid2;
$exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists();
$attempts++;
if ($attempts >= 3 && $exists) {
throw new \Exception('Unable to generate unique UUID for backup execution after 3 attempts');
}
} while ($exists);
$size = 0;
try {
if (str($databaseType)->contains('postgres')) {
+52 -6
View File
@@ -93,20 +93,66 @@ class StripeProcessJob implements ShouldQueue
break;
case 'invoice.paid':
$customerId = data_get($data, 'customer');
$invoiceAmount = data_get($data, 'amount_paid', 0);
$subscriptionId = data_get($data, 'subscription');
$planId = data_get($data, 'lines.data.0.plan.id');
if (Str::contains($excludedPlans, $planId)) {
// send_internal_notification('Subscription excluded.');
break;
}
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if ($subscription) {
$subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
} else {
if (! $subscription) {
throw new \RuntimeException("No subscription found for customer: {$customerId}");
}
if ($subscription->stripe_subscription_id) {
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
switch ($stripeSubscription->status) {
case 'active':
$subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
]);
break;
case 'past_due':
$subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => true,
]);
break;
case 'canceled':
case 'incomplete_expired':
case 'unpaid':
send_internal_notification(
"Invoice paid for {$stripeSubscription->status} subscription. ".
"Customer: {$customerId}, Amount: \${$invoiceAmount}"
);
break;
default:
VerifyStripeSubscriptionStatusJob::dispatch($subscription)
->delay(now()->addSeconds(20));
break;
}
} catch (\Exception $e) {
VerifyStripeSubscriptionStatusJob::dispatch($subscription)
->delay(now()->addSeconds(20));
send_internal_notification(
'Failed to verify subscription status in invoice.paid: '.$e->getMessage()
);
}
} else {
VerifyStripeSubscriptionStatusJob::dispatch($subscription)
->delay(now()->addSeconds(20));
}
break;
case 'invoice.payment_failed':
$customerId = data_get($data, 'customer');
@@ -0,0 +1,106 @@
<?php
namespace App\Jobs;
use App\Models\Subscription;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class VerifyStripeSubscriptionStatusJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [10, 30, 60];
public function __construct(public Subscription $subscription)
{
$this->onQueue('high');
}
public function handle(): void
{
// If no subscription ID yet, try to find it via customer
if (! $this->subscription->stripe_subscription_id &&
$this->subscription->stripe_customer_id) {
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$subscriptions = $stripe->subscriptions->all([
'customer' => $this->subscription->stripe_customer_id,
'limit' => 1,
]);
if ($subscriptions->data) {
$this->subscription->update([
'stripe_subscription_id' => $subscriptions->data[0]->id,
]);
}
} catch (\Exception $e) {
// Continue without subscription ID
}
}
if (! $this->subscription->stripe_subscription_id) {
return;
}
try {
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
$stripeSubscription = $stripe->subscriptions->retrieve(
$this->subscription->stripe_subscription_id
);
switch ($stripeSubscription->status) {
case 'active':
$this->subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => false,
'stripe_cancel_at_period_end' => $stripeSubscription->cancel_at_period_end,
]);
break;
case 'past_due':
// Keep subscription active but mark as past_due
$this->subscription->update([
'stripe_invoice_paid' => true,
'stripe_past_due' => true,
'stripe_cancel_at_period_end' => $stripeSubscription->cancel_at_period_end,
]);
break;
case 'canceled':
case 'incomplete_expired':
case 'unpaid':
// Ensure subscription is marked as inactive
$this->subscription->update([
'stripe_invoice_paid' => false,
'stripe_past_due' => false,
]);
// Trigger subscription ended logic if canceled
if ($stripeSubscription->status === 'canceled') {
$team = $this->subscription->team;
if ($team) {
$team->subscriptionEnded();
}
}
break;
default:
send_internal_notification(
'Unknown subscription status in VerifyStripeSubscriptionStatusJob: '.$stripeSubscription->status.
' for customer: '.$this->subscription->stripe_customer_id
);
break;
}
} catch (\Exception $e) {
send_internal_notification(
'VerifyStripeSubscriptionStatusJob failed for subscription ID '.$this->subscription->id.': '.$e->getMessage()
);
}
}
}
+8 -7
View File
@@ -210,10 +210,10 @@ class General extends Component
}
}
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots to use underscores for HTML form binding
// Convert service names with dots and dashes to use underscores for HTML form binding
$sanitizedDomains = [];
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
$sanitizedKey = str($serviceName)->slug('_')->toString();
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$sanitizedDomains[$sanitizedKey] = $domain;
}
$this->parsedServiceDomains = $sanitizedDomains;
@@ -305,10 +305,10 @@ class General extends Component
// Refresh parsedServiceDomains to reflect any changes in docker_compose_domains
$this->application->refresh();
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots to use underscores for HTML form binding
// Convert service names with dots and dashes to use underscores for HTML form binding
$sanitizedDomains = [];
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
$sanitizedKey = str($serviceName)->slug('_')->toString();
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$sanitizedDomains[$sanitizedKey] = $domain;
}
$this->parsedServiceDomains = $sanitizedDomains;
@@ -334,7 +334,7 @@ class General extends Component
$uuid = new Cuid2;
$domain = generateUrl(server: $this->application->destination->server, random: $uuid);
$sanitizedKey = str($serviceName)->slug('_')->toString();
$sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString();
$this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain;
// Convert back to original service names for storage
@@ -344,7 +344,7 @@ class General extends Component
$originalServiceName = $key;
if (isset($this->parsedServices['services'])) {
foreach ($this->parsedServices['services'] as $originalName => $service) {
if (str($originalName)->slug('_')->toString() === $key) {
if (str($originalName)->replace('-', '_')->replace('.', '_')->toString() === $key) {
$originalServiceName = $originalName;
break;
}
@@ -547,9 +547,10 @@ class General extends Component
$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) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->trim()->lower();
return str($domain)->lower();
});
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
@@ -72,10 +72,13 @@ class PreviewsCompose extends Component
$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";
}
@@ -143,7 +143,13 @@ class GithubPrivateRepository extends Component
protected function loadBranchByPage()
{
$response = Http::withToken($this->token)->get("{$this->github_app->api_url}/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches?per_page=100&page={$this->page}");
$response = Http::GitHub($this->github_app->api_url, $this->token)
->timeout(20)
->retry(3, 200, throw: false)
->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [
'per_page' => 100,
'page' => $this->page,
]);
$json = $response->json();
if ($response->status() !== 200) {
return $this->dispatch('error', $json['message']);
+2 -1
View File
@@ -41,9 +41,10 @@ class EditDomain extends Component
$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) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->trim()->lower();
return str($domain)->lower();
});
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
$warning = sslipDomainWarning($this->application->fqdn);
@@ -149,9 +149,10 @@ class ServiceApplicationView extends Component
$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) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->trim()->lower();
return str($domain)->lower();
});
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
$warning = sslipDomainWarning($this->application->fqdn);
@@ -20,7 +20,15 @@ class ConfigurationChecker extends Component
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
protected $listeners = ['configurationChanged'];
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'configurationChanged',
'configurationChanged' => 'configurationChanged',
];
}
public function mount()
{
@@ -2,12 +2,13 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Traits\EnvironmentVariableAnalyzer;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Add extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, EnvironmentVariableAnalyzer;
public $parameters;
@@ -27,6 +28,8 @@ class Add extends Component
public bool $is_buildtime = true;
public array $problematicVariables = [];
protected $listeners = ['clearAddEnv' => 'clear'];
protected $rules = [
@@ -50,6 +53,7 @@ class Add extends Component
public function mount()
{
$this->parameters = get_route_parameters();
$this->problematicVariables = self::getProblematicVariablesForFrontend();
}
public function submit()
@@ -4,13 +4,14 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Models\SharedEnvironmentVariable;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests, EnvironmentVariableProtection;
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
public $parameters;
@@ -48,6 +49,8 @@ class Show extends Component
public bool $is_redis_credential = false;
public array $problematicVariables = [];
protected $listeners = [
'refreshEnvs' => 'refresh',
'refresh',
@@ -77,6 +80,7 @@ class Show extends Component
if ($this->type === 'standalone-redis' && ($this->env->key === 'REDIS_PASSWORD' || $this->env->key === 'REDIS_USERNAME')) {
$this->is_redis_credential = true;
}
$this->problematicVariables = self::getProblematicVariablesForFrontend();
}
public function getResourceProperty()
+1 -1
View File
@@ -8,7 +8,7 @@ class Metrics extends Component
{
public $resource;
public $chartId = 'container-cpu';
public $chartId = 'metrics';
public $data;
-33
View File
@@ -27,9 +27,6 @@ class Advanced extends Component
#[Validate(['integer', 'min:1'])]
public int $dynamicTimeout = 1;
#[Validate(['boolean'])]
public bool $isTerminalEnabled = false;
public function mount(string $server_uuid)
{
try {
@@ -42,36 +39,7 @@ class Advanced extends Component
}
}
public function toggleTerminal($password)
{
try {
// Check if user is admin or owner
if (! auth()->user()->isAdmin()) {
throw new \Exception('Only team administrators and owners can modify terminal access.');
}
// Verify password unless two-step confirmation is disabled
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
}
// Toggle the terminal setting
$this->server->settings->is_terminal_enabled = ! $this->server->settings->is_terminal_enabled;
$this->server->settings->save();
// Update the local property
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
$status = $this->isTerminalEnabled ? 'enabled' : 'disabled';
$this->dispatch('success', "Terminal access has been {$status}.");
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
@@ -88,7 +56,6 @@ class Advanced extends Component
$this->dynamicTimeout = $this->server->settings->dynamic_timeout;
$this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold;
$this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency;
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
}
}
+13 -10
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Server\PrivateKey;
use App\Models\PrivateKey;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Show extends Component
@@ -35,19 +36,20 @@ class Show extends Component
return;
}
$originalPrivateKeyId = $this->server->getOriginal('private_key_id');
try {
$this->authorize('update', $this->server);
$this->server->update(['private_key_id' => $privateKeyId]);
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(justCheckingNewKey: true);
if ($uptime) {
$this->dispatch('success', 'Private key updated successfully.');
} else {
throw new \Exception($error);
}
DB::transaction(function () use ($ownedPrivateKey) {
$this->server->privateKey()->associate($ownedPrivateKey);
$this->server->save();
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(justCheckingNewKey: true);
if (! $uptime) {
throw new \Exception($error);
}
});
$this->dispatch('success', 'Private key updated successfully.');
$this->dispatch('refreshServerShow');
} catch (\Exception $e) {
$this->server->update(['private_key_id' => $originalPrivateKeyId]);
$this->server->refresh();
$this->server->validateConnection();
$this->dispatch('error', $e->getMessage());
}
@@ -59,6 +61,7 @@ class Show extends Component
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
if ($uptime) {
$this->dispatch('success', 'Server is reachable.');
$this->dispatch('refreshServerShow');
} else {
$this->dispatch('error', 'Server is not reachable.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.<br><br>Error: '.$error);
+1 -1
View File
@@ -45,7 +45,7 @@ class Proxy extends Component
public function getConfigurationFilePathProperty()
{
return $this->server->proxyPath().'/docker-compose.yml';
return $this->server->proxyPath().'docker-compose.yml';
}
public function changeProxy()
@@ -0,0 +1,85 @@
<?php
namespace App\Livewire\Server\Security;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
class TerminalAccess extends Component
{
use AuthorizesRequests;
public Server $server;
public array $parameters = [];
#[Validate(['boolean'])]
public bool $isTerminalEnabled = false;
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->authorize('update', $this->server);
$this->parameters = get_route_parameters();
$this->syncData();
} catch (\Throwable) {
return redirect()->route('server.index');
}
}
public function toggleTerminal($password)
{
try {
$this->authorize('update', $this->server);
// Check if user is admin or owner
if (! auth()->user()->isAdmin()) {
throw new \Exception('Only team administrators and owners can modify terminal access.');
}
// Verify password unless two-step confirmation is disabled
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
}
// Toggle the terminal setting
$this->server->settings->is_terminal_enabled = ! $this->server->settings->is_terminal_enabled;
$this->server->settings->save();
// Update the local property
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
$status = $this->isTerminalEnabled ? 'enabled' : 'disabled';
$this->dispatch('success', "Terminal access has been {$status}.");
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->authorize('update', $this->server);
$this->validate();
// No other fields to sync for terminal access
} else {
$this->isTerminalEnabled = $this->server->settings->is_terminal_enabled;
}
}
public function render()
{
return view('livewire.server.security.terminal-access');
}
}
+3 -3
View File
@@ -271,7 +271,7 @@ class Show extends Component
$this->authorize('manageSentinel', $this->server);
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
$this->server->restartSentinel($customImage);
$this->dispatch('success', 'Restarting Sentinel.');
$this->dispatch('info', 'Restarting Sentinel.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -355,7 +355,7 @@ class Show extends Component
public function instantSave()
{
try {
$this->submit();
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -365,7 +365,7 @@ class Show extends Component
{
try {
$this->syncData(true);
$this->dispatch('success', 'Server updated.');
$this->dispatch('success', 'Server settings updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
+1 -1
View File
@@ -146,7 +146,7 @@ class ValidateAndInstall extends Component
StartProxy::dispatch($this->server);
} else {
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
$this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not instaled. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
$this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
$this->server->update([
'validation_logs' => $this->error,
]);
+2
View File
@@ -48,6 +48,8 @@ class InviteLink extends Component
if (auth()->user()->role() === 'admin' && $this->role === 'owner') {
throw new \Exception('Admins cannot invite owners.');
}
$this->email = strtolower($this->email);
$member_emails = currentTeam()->members()->get()->pluck('email');
if ($member_emails->contains($this->email)) {
return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.');
+162 -16
View File
@@ -1479,14 +1479,14 @@ class Application extends BaseModel
if ($this->docker_compose_domains) {
$json = collect(json_decode($this->docker_compose_domains));
foreach ($json as $key => $value) {
if (str($key)->contains('-')) {
if (str($key)->contains('-') || str($key)->contains('.')) {
$key = str($key)->replace('-', '_')->replace('.', '_');
}
$json->put((string) $key, $value);
}
$services = collect(data_get($parsedServices, 'services', []));
foreach ($services as $name => $service) {
if (str($name)->contains('-')) {
if (str($name)->contains('-') || str($name)->contains('.')) {
$replacedName = str($name)->replace('-', '_')->replace('.', '_');
$services->put((string) $replacedName, $service);
$services->forget((string) $name);
@@ -1503,6 +1503,7 @@ class Application extends BaseModel
} else {
$this->docker_compose_domains = null;
}
ray($this->docker_compose_domains);
$this->save();
}
@@ -1555,40 +1556,185 @@ class Application extends BaseModel
return $command;
}
private function parseWatchPaths($value)
{
if ($value) {
$watch_paths = collect(explode("\n", $value))
->map(function (string $path): string {
// Trim whitespace and remove leading slashes to normalize paths
$path = trim($path);
return ltrim($path, '/');
})
->filter(function (string $path): bool {
return strlen($path) > 0;
});
return trim($watch_paths->implode("\n"));
}
}
public function watchPaths(): Attribute
{
return Attribute::make(
set: function ($value) {
if ($value) {
return trim($value);
return $this->parseWatchPaths($value);
}
}
);
}
public function matchWatchPaths(Collection $modified_files, ?Collection $watch_paths): Collection
{
return self::matchPaths($modified_files, $watch_paths);
}
/**
* Static method to match paths against watch patterns with negation support
* Uses order-based matching: last matching pattern wins
*/
public static function matchPaths(Collection $modified_files, ?Collection $watch_paths): Collection
{
if (is_null($watch_paths) || $watch_paths->isEmpty()) {
return collect([]);
}
return $modified_files->filter(function ($file) use ($watch_paths) {
$shouldInclude = null; // null means no patterns matched
// Process patterns in order - last match wins
foreach ($watch_paths as $pattern) {
$pattern = trim($pattern);
if (empty($pattern)) {
continue;
}
$isExclusion = str_starts_with($pattern, '!');
$matchPattern = $isExclusion ? substr($pattern, 1) : $pattern;
if (self::globMatch($matchPattern, $file)) {
// This pattern matches - it determines the current state
$shouldInclude = ! $isExclusion;
}
}
// If no patterns matched and we only have exclusion patterns, include by default
if ($shouldInclude === null) {
// Check if we only have exclusion patterns
$hasInclusionPatterns = $watch_paths->contains(fn ($p) => ! str_starts_with(trim($p), '!'));
return ! $hasInclusionPatterns;
}
return $shouldInclude;
})->values();
}
/**
* Check if a path matches a glob pattern
* Supports: *, **, ?, [abc], [!abc]
*/
public static function globMatch(string $pattern, string $path): bool
{
$regex = self::globToRegex($pattern);
return preg_match($regex, $path) === 1;
}
/**
* Convert a glob pattern to a regular expression
*/
public static function globToRegex(string $pattern): string
{
$regex = '';
$inGroup = false;
$chars = str_split($pattern);
$len = count($chars);
for ($i = 0; $i < $len; $i++) {
$c = $chars[$i];
switch ($c) {
case '*':
// Check for **
if ($i + 1 < $len && $chars[$i + 1] === '*') {
// ** matches any number of directories
$regex .= '.*';
$i++; // Skip next *
// Skip optional /
if ($i + 1 < $len && $chars[$i + 1] === '/') {
$i++;
}
} else {
// * matches anything except /
$regex .= '[^/]*';
}
break;
case '?':
// ? matches any single character except /
$regex .= '[^/]';
break;
case '[':
// Character class
$inGroup = true;
$regex .= '[';
// Check for negation
if ($i + 1 < $len && ($chars[$i + 1] === '!' || $chars[$i + 1] === '^')) {
$regex .= '^';
$i++;
}
break;
case ']':
if ($inGroup) {
$inGroup = false;
$regex .= ']';
} else {
$regex .= preg_quote($c, '#');
}
break;
case '.':
case '(':
case ')':
case '+':
case '{':
case '}':
case '$':
case '^':
case '|':
case '\\':
// Escape regex special characters
$regex .= '\\'.$c;
break;
default:
$regex .= $c;
break;
}
}
// Wrap in delimiters and anchors
return '#^'.$regex.'$#';
}
public function isWatchPathsTriggered(Collection $modified_files): bool
{
if (is_null($this->watch_paths)) {
return false;
}
$watch_paths = collect(explode("\n", $this->watch_paths))
->map(function (string $path): string {
return trim($path);
})
->filter(function (string $path): bool {
return strlen($path) > 0;
});
$this->watch_paths = $this->parseWatchPaths($this->watch_paths);
$this->save();
$watch_paths = collect(explode("\n", $this->watch_paths));
// If no valid patterns after filtering, don't trigger
if ($watch_paths->isEmpty()) {
return false;
}
$matches = $modified_files->filter(function ($file) use ($watch_paths) {
return $watch_paths->contains(function ($glob) use ($file) {
return fnmatch($glob, $file);
});
});
$matches = $this->matchWatchPaths($modified_files, $watch_paths);
return $matches->count() > 0;
}
+15
View File
@@ -10,6 +10,21 @@ class ScheduledDatabaseBackup extends BaseModel
{
protected $guarded = [];
public static function ownedByCurrentTeam()
{
return ScheduledDatabaseBackup::whereRelation('team', 'id', currentTeam()->id)->orderBy('name');
}
public static function ownedByCurrentTeamAPI(int $teamId)
{
return ScheduledDatabaseBackup::whereRelation('team', 'id', $teamId)->orderBy('name');
}
public function team()
{
return $this->belongsTo(Team::class);
}
public function database(): MorphTo
{
return $this->morphTo();
+3 -1
View File
@@ -10,6 +10,7 @@ use App\Notifications\Channels\SendsSlack;
use App\Traits\HasNotificationSettings;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;
use OpenApi\Attributes as OA;
@@ -37,7 +38,7 @@ use OpenApi\Attributes as OA;
class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack
{
use HasNotificationSettings, HasSafeStringAttribute, Notifiable;
use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable;
protected $guarded = [];
@@ -193,6 +194,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
public function subscriptionEnded()
{
$this->subscription->update([
'stripe_subscription_id' => null,
'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => false,
+8
View File
@@ -15,6 +15,14 @@ class TeamInvitation extends Model
'via',
];
/**
* Set the email attribute to lowercase.
*/
public function setEmailAttribute(string $value): void
{
$this->attributes['email'] = strtolower($value);
}
public function team()
{
return $this->belongsTo(Team::class);
+2 -2
View File
@@ -31,7 +31,7 @@ class ValidGitRepositoryUrl implements ValidationRule
$dangerousChars = [
';', '|', '&', '$', '`', '(', ')', '{', '}',
'[', ']', '<', '>', '\n', '\r', '\0', '"', "'",
'\\', '!', '?', '*', '~', '^', '%', '=', '+',
'\\', '!', '?', '*', '^', '%', '=', '+',
'#', // Comment character that could hide commands
];
@@ -85,7 +85,7 @@ class ValidGitRepositoryUrl implements ValidationRule
}
// Validate SSH URL format (git@host:user/repo.git)
if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.]+$/', $value)) {
if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.~]+$/', $value)) {
$fail('The :attribute is not a valid SSH repository URL.');
return;
+6 -1
View File
@@ -3,6 +3,7 @@
namespace App\Traits;
use App\Livewire\GlobalSearch;
use Illuminate\Database\Eloquent\Model;
trait ClearsGlobalSearchCache
{
@@ -65,7 +66,11 @@ trait ClearsGlobalSearchCache
{
// For database models, team is accessed through environment.project.team
if (method_exists($this, 'team')) {
$team = $this->team();
if ($this instanceof \App\Models\Server) {
$team = $this->team;
} else {
$team = $this->team();
}
if (filled($team)) {
return is_object($team) ? $team->id : null;
}
+221
View File
@@ -0,0 +1,221 @@
<?php
namespace App\Traits;
trait EnvironmentVariableAnalyzer
{
/**
* List of environment variables that commonly cause build issues when set to production values.
* Each entry contains the variable pattern and associated metadata.
*/
protected static function getProblematicBuildVariables(): array
{
return [
'NODE_ENV' => [
'problematic_values' => ['production', 'prod'],
'affects' => 'Node.js/npm/yarn/bun/pnpm',
'issue' => 'Skips devDependencies installation which are often required for building (webpack, typescript, etc.)',
'recommendation' => 'Uncheck "Available at Buildtime" or use "development" during build',
],
'NPM_CONFIG_PRODUCTION' => [
'problematic_values' => ['true', '1', 'yes'],
'affects' => 'npm/pnpm',
'issue' => 'Forces npm to skip devDependencies',
'recommendation' => 'Remove from build-time variables or set to false',
],
'YARN_PRODUCTION' => [
'problematic_values' => ['true', '1', 'yes'],
'affects' => 'Yarn/pnpm',
'issue' => 'Forces yarn to skip devDependencies',
'recommendation' => 'Remove from build-time variables or set to false',
],
'COMPOSER_NO_DEV' => [
'problematic_values' => ['1', 'true', 'yes'],
'affects' => 'PHP/Composer',
'issue' => 'Skips require-dev packages which may include build tools',
'recommendation' => 'Set as "Runtime only" or remove from build-time variables',
],
'MIX_ENV' => [
'problematic_values' => ['prod', 'production'],
'affects' => 'Elixir/Phoenix',
'issue' => 'Production mode may skip development dependencies needed for compilation',
'recommendation' => 'Use "dev" for build or set as "Runtime only"',
],
'RAILS_ENV' => [
'problematic_values' => ['production'],
'affects' => 'Ruby on Rails',
'issue' => 'May affect asset precompilation and dependency handling',
'recommendation' => 'Consider using "development" for build phase',
],
'RACK_ENV' => [
'problematic_values' => ['production'],
'affects' => 'Ruby/Rack',
'issue' => 'May affect dependency handling and build behavior',
'recommendation' => 'Consider using "development" for build phase',
],
'BUNDLE_WITHOUT' => [
'problematic_values' => ['development', 'test', 'development:test'],
'affects' => 'Ruby/Bundler',
'issue' => 'Excludes gem groups that may contain build dependencies',
'recommendation' => 'Remove from build-time variables or adjust groups',
],
'FLASK_ENV' => [
'problematic_values' => ['production'],
'affects' => 'Python/Flask',
'issue' => 'May affect debug mode and development tools availability',
'recommendation' => 'Usually safe, but consider "development" for complex builds',
],
'DJANGO_SETTINGS_MODULE' => [
'problematic_values' => [], // Check if contains 'production' or 'prod'
'affects' => 'Python/Django',
'issue' => 'Production settings may disable debug tools needed during build',
'recommendation' => 'Use development settings for build phase',
'check_function' => 'checkDjangoSettings',
],
'APP_ENV' => [
'problematic_values' => ['production', 'prod'],
'affects' => 'Laravel/Symfony',
'issue' => 'May affect dependency installation and build optimizations',
'recommendation' => 'Consider using "local" or "development" for build',
],
'ASPNETCORE_ENVIRONMENT' => [
'problematic_values' => ['Production'],
'affects' => '.NET/ASP.NET Core',
'issue' => 'May affect build-time configurations and optimizations',
'recommendation' => 'Usually safe, but verify build requirements',
],
'CI' => [
'problematic_values' => ['true', '1', 'yes'],
'affects' => 'Various tools',
'issue' => 'Changes behavior in many tools (disables interactivity, changes caching)',
'recommendation' => 'Usually beneficial for builds, but be aware of behavior changes',
],
];
}
/**
* Analyze an environment variable for potential build issues.
* Always returns a warning if the key is in our list, regardless of value.
*/
public static function analyzeBuildVariable(string $key, string $value): ?array
{
$problematicVars = self::getProblematicBuildVariables();
// Direct key match
if (isset($problematicVars[$key])) {
$config = $problematicVars[$key];
// Check if it has a custom check function
if (isset($config['check_function'])) {
$method = $config['check_function'];
if (method_exists(self::class, $method)) {
return self::{$method}($key, $value, $config);
}
}
// Always return warning for known problematic variables
return [
'variable' => $key,
'value' => $value,
'affects' => $config['affects'],
'issue' => $config['issue'],
'recommendation' => $config['recommendation'],
];
}
return null;
}
/**
* Analyze multiple environment variables for potential build issues.
*/
public static function analyzeBuildVariables(array $variables): array
{
$warnings = [];
foreach ($variables as $key => $value) {
$warning = self::analyzeBuildVariable($key, $value);
if ($warning) {
$warnings[] = $warning;
}
}
return $warnings;
}
/**
* Custom check for Django settings module.
*/
protected static function checkDjangoSettings(string $key, string $value, array $config): ?array
{
// Always return warning for DJANGO_SETTINGS_MODULE when it's set as build-time
return [
'variable' => $key,
'value' => $value,
'affects' => $config['affects'],
'issue' => $config['issue'],
'recommendation' => $config['recommendation'],
];
}
/**
* Generate a formatted warning message for deployment logs.
*/
public static function formatBuildWarning(array $warning): array
{
$messages = [
"⚠️ Build-time environment variable warning: {$warning['variable']}={$warning['value']}",
" Affects: {$warning['affects']}",
" Issue: {$warning['issue']}",
" Recommendation: {$warning['recommendation']}",
];
return $messages;
}
/**
* Check if a variable should show a warning in the UI.
*/
public static function shouldShowBuildWarning(string $key): bool
{
return isset(self::getProblematicBuildVariables()[$key]);
}
/**
* Get UI warning message for a specific variable.
*/
public static function getUIWarningMessage(string $key): ?string
{
$problematicVars = self::getProblematicBuildVariables();
if (! isset($problematicVars[$key])) {
return null;
}
$config = $problematicVars[$key];
$problematicValuesStr = implode(', ', $config['problematic_values']);
return "Setting {$key} to {$problematicValuesStr} as a build-time variable may cause issues. {$config['issue']} Consider: {$config['recommendation']}";
}
/**
* Get problematic variables configuration for frontend use.
*/
public static function getProblematicVariablesForFrontend(): array
{
$vars = self::getProblematicBuildVariables();
$result = [];
foreach ($vars as $key => $config) {
// Skip the check_function as it's PHP-specific
$result[$key] = [
'problematic_values' => $config['problematic_values'],
'affects' => $config['affects'],
'issue' => $config['issue'],
'recommendation' => $config['recommendation'],
];
}
return $result;
}
}
+4 -4
View File
@@ -202,13 +202,13 @@ trait ExecuteRemoteCommand
if ($this->save) {
if (data_get($this->saved_outputs, $this->save, null) === null) {
data_set($this->saved_outputs, $this->save, str());
$this->saved_outputs->put($this->save, str());
}
if ($append) {
$this->saved_outputs[$this->save] .= str($sanitized_output)->trim();
$this->saved_outputs[$this->save] = str($this->saved_outputs[$this->save]);
$current_value = $this->saved_outputs->get($this->save);
$this->saved_outputs->put($this->save, str($current_value.str($sanitized_output)->trim()));
} else {
$this->saved_outputs[$this->save] = str($sanitized_output)->trim();
$this->saved_outputs->put($this->save, str($sanitized_output)->trim());
}
}
});