diff --git a/.github/workflows/development-build.yml b/.github/workflows/development-build.yml index 681cbda3a..51415444f 100644 --- a/.github/workflows/development-build.yml +++ b/.github/workflows/development-build.yml @@ -13,7 +13,7 @@ env: jobs: amd64: - runs-on: ubuntu-latest + runs-on: [self-hosted, x64] steps: - uses: actions/checkout@v3 - name: Login to ghcr.io @@ -52,7 +52,7 @@ jobs: push: true tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:next-aarch64 merge-manifest: - runs-on: ubuntu-latest + runs-on: [self-hosted, x64] permissions: contents: read packages: write diff --git a/app/Console/Commands/TestEmail.php b/app/Console/Commands/Emails.php similarity index 67% rename from app/Console/Commands/TestEmail.php rename to app/Console/Commands/Emails.php index 4e29d7962..67c50675f 100644 --- a/app/Console/Commands/TestEmail.php +++ b/app/Console/Commands/Emails.php @@ -6,7 +6,9 @@ use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\ScheduledDatabaseBackup; +use App\Models\Server; use App\Models\StandalonePostgresql; +use App\Models\Team; use App\Models\TeamInvitation; use App\Models\User; use App\Models\Waitlist; @@ -24,30 +26,31 @@ use Mail; use Str; +use function Laravel\Prompts\confirm; use function Laravel\Prompts\select; use function Laravel\Prompts\text; -class TestEmail extends Command +class Emails extends Command { /** * The name and signature of the console command. * * @var string */ - protected $signature = 'email:test'; + protected $signature = 'emails'; /** * The console command description. * * @var string */ - protected $description = 'Send a test email to the admin'; + protected $description = 'Send out test / prod emails'; /** * Execute the console command. */ private ?MailMessage $mail = null; - private string $email = 'andras.bacsai@protonmail.com'; + private ?string $email = null; public function handle() { $type = select( @@ -62,9 +65,14 @@ public function handle() 'invitation-link' => 'Invitation Link', 'waitlist-invitation-link' => 'Waitlist Invitation Link', 'waitlist-confirmation' => 'Waitlist Confirmation', + 'realusers-before-trial' => 'REAL - Registered Users Before Trial without Subscription', + 'realusers-server-lost-connection' => 'REAL - Server Lost Connection', ], ); - $this->email = text('Email Address to send to'); + $emailsGathered = ['realusers-before-trial','realusers-server-lost-connection']; + if (!in_array($type, $emailsGathered)) { + $this->email = text('Email Address to send to'); + } set_transanctional_email_settings(); $this->mail = new MailMessage(); @@ -159,16 +167,73 @@ public function handle() $found = Waitlist::where('email', $this->email)->first(); if ($found) { SendConfirmationForWaitlistJob::dispatch($this->email, $found->uuid); - } else { throw new Exception('Waitlist not found'); } + break; + case 'realusers-before-trial': + $this->mail = new MailMessage(); + $this->mail->view('emails.before-trial-conversion'); + $this->mail->subject('Trial period has been added for all subscription plans.'); + $teams = Team::doesntHave('subscription')->where('id', '!=', 0)->get(); + if (!$teams || $teams->isEmpty()) { + echo 'No teams found.' . PHP_EOL; + return; + } + $emails = []; + foreach ($teams as $team) { + foreach ($team->members as $member) { + if ($member->email) { + $emails[] = $member->email; + } + } + } + $emails = array_unique($emails); + $this->info("Sending to " . count($emails) . " emails."); + foreach ($emails as $email) { + $this->info($email); + } + $confirmed = confirm('Are you sure?'); + if ($confirmed) { + foreach ($emails as $email) { + $this->sendEmail($email); + } + } + break; + case 'realusers-server-lost-connection': + $serverId = text('Server Id'); + $server = Server::find($serverId); + if (!$server) { + throw new Exception('Server not found'); + } + $admins = []; + $members = $server->team->members; + foreach ($members as $member) { + if ($member->isAdmin()) { + $admins[] = $member->email; + } + } + $this->info('Sending to ' . count($admins) . ' admins.'); + foreach ($admins as $admin) { + $this->info($admin); + } + $this->mail = new MailMessage(); + $this->mail->view('emails.server-lost-connection', [ + 'name' => $server->name, + ]); + $this->mail->subject('Action required: Server ' . $server->name . ' lost connection.'); + foreach ($admins as $email) { + $this->sendEmail($email); + } break; } } - private function sendEmail() + private function sendEmail(string $email = null) { + if ($email) { + $this->email = $email; + } Mail::send( [], [], @@ -177,5 +242,6 @@ private function sendEmail() ->subject($this->mail->subject) ->html((string)$this->mail->render()) ); + $this->info("Email sent to $this->email successfully. 📧"); } } diff --git a/app/Console/Commands/WaitlistInvite.php b/app/Console/Commands/WaitlistInvite.php index dd629d706..f3eefbcfa 100644 --- a/app/Console/Commands/WaitlistInvite.php +++ b/app/Console/Commands/WaitlistInvite.php @@ -92,7 +92,6 @@ private function remove_from_waitlist() } private function send_email() { - ray($this->next_patient->email, $this->password); $token = Crypt::encryptString("{$this->next_patient->email}@@@$this->password"); $loginLink = route('auth.link', ['token' => $token]); $mail = new MailMessage(); diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 85fa80aac..ae40fec49 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -11,6 +11,7 @@ use App\Jobs\DockerCleanupJob; use App\Jobs\InstanceAutoUpdateJob; use App\Jobs\ProxyContainerStatusJob; +use App\Jobs\ServerDetailsCheckJob; use App\Models\Application; use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; @@ -24,20 +25,25 @@ class Kernel extends ConsoleKernel protected function schedule(Schedule $schedule): void { if (isDev()) { - $schedule->command('horizon:snapshot')->everyMinute(); - $schedule->job(new CleanupInstanceStuffsJob)->everyMinute(); + $schedule->job(new ServerDetailsCheckJob(Server::find(0)))->everyTenMinutes()->onOneServer(); + // $schedule->command('horizon:snapshot')->everyMinute(); + // $schedule->job(new CleanupInstanceStuffsJob)->everyMinute(); // $schedule->job(new CheckResaleLicenseJob)->hourly(); - $schedule->job(new DockerCleanupJob)->everyOddHour(); + // $schedule->job(new DockerCleanupJob)->everyOddHour(); + // $this->instance_auto_update($schedule); + // $this->check_scheduled_backups($schedule); + // $this->check_resources($schedule); + // $this->check_proxies($schedule); } else { $schedule->command('horizon:snapshot')->everyFiveMinutes(); $schedule->job(new CleanupInstanceStuffsJob)->everyTenMinutes()->onOneServer(); $schedule->job(new CheckResaleLicenseJob)->hourly()->onOneServer(); $schedule->job(new DockerCleanupJob)->everyTenMinutes()->onOneServer(); + $this->instance_auto_update($schedule); + $this->check_scheduled_backups($schedule); + $this->check_resources($schedule); + $this->check_proxies($schedule); } - $this->instance_auto_update($schedule); - $this->check_scheduled_backups($schedule); - $this->check_resources($schedule); - $this->check_proxies($schedule); } private function check_proxies($schedule) { diff --git a/app/Http/Livewire/Help.php b/app/Http/Livewire/Help.php index 9f3ca434f..d467a11f9 100644 --- a/app/Http/Livewire/Help.php +++ b/app/Http/Livewire/Help.php @@ -30,7 +30,7 @@ public function submit() try { $this->rateLimit(1, 60); $this->validate(); - $subscriptionType = auth()->user()?->subscription?->type() ?? 'unknown'; + $subscriptionType = auth()->user()?->subscription?->type() ?? 'Free'; $debug = "Route: {$this->path}"; $mail = new MailMessage(); $mail->view( @@ -41,7 +41,7 @@ public function submit() ] ); $mail->subject("[HELP - {$subscriptionType}]: {$this->subject}"); - send_user_an_email($mail, 'hi@coollabs.io'); + send_user_an_email($mail, 'hi@coollabs.io', auth()->user()?->email); $this->emit('success', 'Your message has been sent successfully. We will get in touch with you as soon as possible.'); } catch (\Throwable $e) { return general_error_handler($e, $this); diff --git a/app/Http/Livewire/Project/Application/General.php b/app/Http/Livewire/Project/Application/General.php index 0f68a8a2c..bd0006b23 100644 --- a/app/Http/Livewire/Project/Application/General.php +++ b/app/Http/Livewire/Project/Application/General.php @@ -48,7 +48,6 @@ class General extends Component 'application.ports_exposes' => 'required', 'application.ports_mappings' => 'nullable', 'application.dockerfile' => 'nullable', - 'application.nixpkgsarchive' => 'nullable', ]; protected $validationAttributes = [ 'application.name' => 'name', @@ -67,7 +66,6 @@ class General extends Component 'application.ports_exposes' => 'Ports exposes', 'application.ports_mappings' => 'Ports mappings', 'application.dockerfile' => 'Dockerfile', - 'application.nixpkgsarchive' => 'Nixpkgs archive', ]; public function instantSave() diff --git a/app/Http/Livewire/Server/New/ByIp.php b/app/Http/Livewire/Server/New/ByIp.php index d0b44ecaf..20377dc5d 100644 --- a/app/Http/Livewire/Server/New/ByIp.php +++ b/app/Http/Livewire/Server/New/ByIp.php @@ -58,7 +58,7 @@ public function submit() { $this->validate(); try { - if (!$this->private_key_id) { + if (is_null($this->private_key_id)) { return $this->emit('error', 'You must select a private key'); } $server = Server::create([ diff --git a/app/Http/Livewire/Server/Proxy/Status.php b/app/Http/Livewire/Server/Proxy/Status.php index 9ecf318a8..a0b90b7be 100644 --- a/app/Http/Livewire/Server/Proxy/Status.php +++ b/app/Http/Livewire/Server/Proxy/Status.php @@ -17,7 +17,7 @@ public function proxyStatusUpdated() public function getProxyStatus() { try { - if (data_get($this->server, 'settings.is_usable') && data_get($this->server, 'settings.is_reachable')) { + if ($this->server->isFunctional()) { $container = getContainerStatus(server: $this->server, container_id: 'coolify-proxy'); $this->server->proxy->status = $container; $this->server->save(); diff --git a/app/Http/Livewire/Subscription/PricingPlans.php b/app/Http/Livewire/Subscription/PricingPlans.php index 80cc81dcf..9e26e3b9d 100644 --- a/app/Http/Livewire/Subscription/PricingPlans.php +++ b/app/Http/Livewire/Subscription/PricingPlans.php @@ -8,8 +8,13 @@ class PricingPlans extends Component { + public bool $isTrial = false; + public function mount() { + $this->isTrial = !data_get(currentTeam(),'subscription.stripe_trial_already_ended'); + } public function subscribeStripe($type) { + $team = currentTeam(); Stripe::setApiKey(config('subscription.stripe_api_key')); switch ($type) { case 'basic-monthly': @@ -50,10 +55,23 @@ public function subscribeStripe($type) 'automatic_tax' => [ 'enabled' => true, ], + 'mode' => 'subscription', 'success_url' => route('dashboard', ['success' => true]), 'cancel_url' => route('subscription.index', ['cancelled' => true]), ]; + + if (!data_get($team,'subscription.stripe_trial_already_ended')) { + $payload['subscription_data'] = [ + 'trial_period_days' => config('constants.limits.trial_period'), + 'trial_settings' => [ + 'end_behavior' => [ + 'missing_payment_method' => 'cancel', + ] + ], + ]; + $payload['payment_method_collection'] = 'if_required'; + } $customer = currentTeam()->subscription?->stripe_customer_id ?? null; if ($customer) { $payload['customer'] = $customer; diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7a61029e0..81778203d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -413,7 +413,6 @@ private function cleanup_git() private function generate_nixpacks_confs() { - ray('nixpkgsarchive', $this->application->nixpkgsarchive); $this->execute_remote_command( [ "echo -n 'Generating nixpacks configuration.'", @@ -422,13 +421,6 @@ private function generate_nixpacks_confs() [$this->execute_in_builder("cp {$this->workdir}/.nixpacks/Dockerfile {$this->workdir}/Dockerfile")], [$this->execute_in_builder("rm -f {$this->workdir}/.nixpacks/Dockerfile")] ); - - // if ($this->application->nixpkgsarchive) { - // $this->execute_remote_command([ - // $this->execute_in_builder("cat {$this->workdir}/nixpacks.toml"), "hidden" => true, "save" => 'nixpacks_toml' - // ]); - - // } } private function nixpacks_build_cmd() @@ -508,7 +500,7 @@ private function generate_compose_file() $this->destination->network => [ 'external' => true, 'name' => $this->destination->network, - 'attachable' => true, + 'attachable' => true ] ] ]; @@ -648,6 +640,10 @@ private function set_labels_for_applications() private function generate_healthcheck_commands() { + if ($this->application->dockerfile) { + // TODO: disabled HC because there are several ways to hc a simple docker image, hard to figure out a good way. Like some docker images (pocketbase) does not have curl. + return 'exit 0'; + } if (!$this->application->health_check_port) { $this->application->health_check_port = $this->application->ports_exposes_array[0]; } diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index fbb9ac2c7..b1fccbe4b 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -36,11 +36,11 @@ public function handle(): void return; } try { - ray()->showQueries()->color('orange'); + // ray()->showQueries()->color('orange'); $servers = Server::all(); foreach ($servers as $server) { if ( - !$server->settings->is_reachable && !$server->settings->is_usable + !$server->isFunctional() ) { continue; } diff --git a/app/Jobs/ServerDetailsCheckJob.php b/app/Jobs/ServerDetailsCheckJob.php new file mode 100644 index 000000000..606931c42 --- /dev/null +++ b/app/Jobs/ServerDetailsCheckJob.php @@ -0,0 +1,79 @@ +server->uuid)]; + } + + public function uniqueId(): string + { + return $this->server->uuid; + } + + public function handle(): void + { + try { + ray()->clearAll(); + $containers = instant_remote_process(["docker container inspect $(docker container ls -q) --format '{{json .}}'"], $this->server); + $containers = format_docker_command_output_to_json($containers); + $applications = $this->server->applications(); + // ray($applications); + // ray(format_docker_command_output_to_json($containers)); + foreach ($applications as $application) { + $uuid = data_get($application, 'uuid'); + $foundContainer = $containers->filter(function ($value, $key) use ($uuid) { + $image = data_get($value, 'Config.Image'); + return Str::startsWith($image, $uuid); + })->first(); + + if ($foundContainer) { + $containerStatus = data_get($foundContainer, 'State.Status'); + $databaseStatus = data_get($application, 'status'); + ray($containerStatus, $databaseStatus); + if ($containerStatus !== $databaseStatus) { + // $application->update(['status' => $containerStatus]); + } + } + } + // foreach ($containers as $container) { + // $labels = format_docker_labels_to_json(data_get($container,'Config.Labels')); + // $foundLabel = $labels->filter(fn ($value, $key) => Str::startsWith($key, 'coolify.applicationId')); + // if ($foundLabel->count() > 0) { + // $appFound = $applications->where('id', $foundLabel['coolify.applicationId'])->first(); + // if ($appFound) { + // $containerStatus = data_get($container, 'State.Status'); + // $databaseStatus = data_get($appFound, 'status'); + // ray($containerStatus, $databaseStatus); + // } + // } + // } + } catch (\Throwable $e) { + // send_internal_notification('ServerDetailsCheckJob failed with: ' . $e->getMessage()); + ray($e->getMessage()); + throw $e; + } + } +} diff --git a/app/Jobs/SubscriptionTrialEndedJob.php b/app/Jobs/SubscriptionTrialEndedJob.php new file mode 100755 index 000000000..39acd19a2 --- /dev/null +++ b/app/Jobs/SubscriptionTrialEndedJob.php @@ -0,0 +1,44 @@ +team); + $mail = new MailMessage(); + $mail->subject('Action required: You trial in Coolify Cloud ended.'); + $mail->view('emails.trial-ended', [ + 'stripeCustomerPortal' => $session->url, + ]); + $this->team->members()->each(function ($member) use ($mail) { + if ($member->isAdmin()) { + ray('Sending trial ended email to ' . $member->email); + send_user_an_email($mail, $member->email); + send_internal_notification('Trial reminder email sent to ' . $member->email); + } + }); + } catch (\Throwable $e) { + send_internal_notification('SubscriptionTrialEndsSoonJob failed with: ' . $e->getMessage()); + ray($e->getMessage()); + throw $e; + } + } +} diff --git a/app/Jobs/SubscriptionTrialEndsSoonJob.php b/app/Jobs/SubscriptionTrialEndsSoonJob.php new file mode 100755 index 000000000..84bd8ee66 --- /dev/null +++ b/app/Jobs/SubscriptionTrialEndsSoonJob.php @@ -0,0 +1,44 @@ +team); + $mail = new MailMessage(); + $mail->subject('You trial in Coolify Cloud ends soon.'); + $mail->view('emails.trial-ends-soon', [ + 'stripeCustomerPortal' => $session->url, + ]); + $this->team->members()->each(function ($member) use ($mail) { + if ($member->isAdmin()) { + ray('Sending trial ending email to ' . $member->email); + send_user_an_email($mail, $member->email); + send_internal_notification('Trial reminder email sent to ' . $member->email); + } + }); + } catch (\Throwable $e) { + send_internal_notification('SubscriptionTrialEndsSoonJob failed with: ' . $e->getMessage()); + ray($e->getMessage()); + throw $e; + } + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index 973d2d628..f45cf3b2d 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -153,4 +153,7 @@ public function isProxyShouldRun() } return $shouldRun; } + public function isFunctional() { + return $this->settings->is_reachable && $this->settings->is_usable; + } } diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 4acd7fe8a..d69d95981 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -33,7 +33,7 @@ public function type() } if (isStripe()) { if (!$this->stripe_plan_id) { - return 'unknown'; + return 'zero'; } $subscription = Subscription::where('id', $this->id)->first(); if (!$subscription) { @@ -54,6 +54,6 @@ public function type() return Str::of($stripePlanId)->after('stripe_price_id_')->before('_')->lower(); } } - return 'unknown'; + return 'zero'; } } diff --git a/app/Models/Team.php b/app/Models/Team.php index 485811a17..558c8dc3b 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -120,4 +120,20 @@ public function s3s() { return $this->hasMany(S3Storage::class); } + public function trialEnded() { + foreach ($this->servers as $server) { + $server->settings()->update([ + 'is_usable' => false, + 'is_reachable' => false, + ]); + } + } + public function trialEndedButSubscribed() { + foreach ($this->servers as $server) { + $server->settings()->update([ + 'is_usable' => true, + 'is_reachable' => true, + ]); + } + } } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 080448f71..e3629e71c 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -26,8 +26,11 @@ function format_docker_command_output_to_json($rawOutput): Collection ->map(fn ($outputLine) => json_decode($outputLine, true, flags: JSON_THROW_ON_ERROR)); } -function format_docker_labels_to_json($rawOutput): Collection +function format_docker_labels_to_json(string|Array $rawOutput): Collection { + if (is_array($rawOutput)) { + return collect($rawOutput); + } $outputLines = explode(PHP_EOL, $rawOutput); return collect($outputLines) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index edb54ab64..f42ac83d7 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -262,21 +262,33 @@ function send_internal_notification(string $message): void ray($e->getMessage()); } } -function send_user_an_email(MailMessage $mail, string $email): void +function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null): void { $settings = InstanceSettings::get(); $type = set_transanctional_email_settings($settings); if (!$type) { throw new Exception('No email settings found.'); } - Mail::send( - [], - [], - fn (Message $message) => $message - ->to($email) - ->subject($mail->subject) - ->html((string) $mail->render()) - ); + if ($cc) { + Mail::send( + [], + [], + fn (Message $message) => $message + ->to($email) + ->cc($cc) + ->subject($mail->subject) + ->html((string) $mail->render()) + ); + } else { + Mail::send( + [], + [], + fn (Message $message) => $message + ->to($email) + ->subject($mail->subject) + ->html((string) $mail->render()) + ); + } } function isEmailEnabled($notifiable) { diff --git a/config/constants.php b/config/constants.php index 0021f8a5c..4cb112855 100644 --- a/config/constants.php +++ b/config/constants.php @@ -10,6 +10,7 @@ ], ], 'limits' => [ + 'trial_period'=> 14, 'server' => [ 'zero' => 0, 'self-hosted' => 999999999999, diff --git a/config/sentry.php b/config/sentry.php index 417d21025..3e42e7029 100644 --- a/config/sentry.php +++ b/config/sentry.php @@ -7,7 +7,7 @@ // The release version of your application // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) - 'release' => '4.0.0-beta.34', + 'release' => '4.0.0-beta.35', 'server_name' => env('APP_ID', 'coolify'), // When left empty or `null` the Laravel environment will be used 'environment' => config('app.env'), diff --git a/config/version.php b/config/version.php index 44c9c0c1a..c31bdf903 100644 --- a/config/version.php +++ b/config/version.php @@ -1,3 +1,3 @@ dropColumn('nixpkgsarchive'); + }); + } + + public function down(): void + { + Schema::table('teams', function (Blueprint $table) { + $table->string('nixpkgsarchive')->nullable(); + }); + } +}; diff --git a/database/migrations/2023_08_22_071059_add_stripe_trial_ended.php b/database/migrations/2023_08_22_071059_add_stripe_trial_ended.php new file mode 100644 index 000000000..591f8382d --- /dev/null +++ b/database/migrations/2023_08_22_071059_add_stripe_trial_ended.php @@ -0,0 +1,30 @@ +boolean('stripe_trial_already_ended')->default(false)->after('stripe_cancel_at_period_end'); + + + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn('stripe_trial_already_ended'); + }); + } +}; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 15b6e5703..41ab319b4 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -23,7 +23,7 @@ class="text-xs normal-case hover:no-underline btn btn-sm bg-coollabs-gradient">