mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-18 14:13:31 +00:00
Merge branch 'next' into v4.x
This commit is contained in:
110 files changed
+4692
-2449
No files matched your search
+1
-1
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -8,7 +8,7 @@ class Metrics extends Component
|
||||
{
|
||||
public $resource;
|
||||
|
||||
public $chartId = 'container-cpu';
|
||||
public $chartId = 'metrics';
|
||||
|
||||
public $data;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user