Merge pull request #1223 from coollabsio/next

v4.0.0-beta.35
This commit is contained in:
Andras Bacsai 2023-09-13 12:34:45 +02:00 committed by GitHub
commit 12fc5a8f91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 501 additions and 84 deletions

View File

@ -13,7 +13,7 @@ env:
jobs: jobs:
amd64: amd64:
runs-on: ubuntu-latest runs-on: [self-hosted, x64]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Login to ghcr.io - name: Login to ghcr.io
@ -52,7 +52,7 @@ jobs:
push: true push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:next-aarch64 tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:next-aarch64
merge-manifest: merge-manifest:
runs-on: ubuntu-latest runs-on: [self-hosted, x64]
permissions: permissions:
contents: read contents: read
packages: write packages: write

View File

@ -6,7 +6,9 @@
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\TeamInvitation; use App\Models\TeamInvitation;
use App\Models\User; use App\Models\User;
use App\Models\Waitlist; use App\Models\Waitlist;
@ -24,30 +26,31 @@
use Mail; use Mail;
use Str; use Str;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\select; use function Laravel\Prompts\select;
use function Laravel\Prompts\text; use function Laravel\Prompts\text;
class TestEmail extends Command class Emails extends Command
{ {
/** /**
* The name and signature of the console command. * The name and signature of the console command.
* *
* @var string * @var string
*/ */
protected $signature = 'email:test'; protected $signature = 'emails';
/** /**
* The console command description. * The console command description.
* *
* @var string * @var string
*/ */
protected $description = 'Send a test email to the admin'; protected $description = 'Send out test / prod emails';
/** /**
* Execute the console command. * Execute the console command.
*/ */
private ?MailMessage $mail = null; private ?MailMessage $mail = null;
private string $email = 'andras.bacsai@protonmail.com'; private ?string $email = null;
public function handle() public function handle()
{ {
$type = select( $type = select(
@ -62,9 +65,14 @@ public function handle()
'invitation-link' => 'Invitation Link', 'invitation-link' => 'Invitation Link',
'waitlist-invitation-link' => 'Waitlist Invitation Link', 'waitlist-invitation-link' => 'Waitlist Invitation Link',
'waitlist-confirmation' => 'Waitlist Confirmation', 'waitlist-confirmation' => 'Waitlist Confirmation',
'realusers-before-trial' => 'REAL - Registered Users Before Trial without Subscription',
'realusers-server-lost-connection' => 'REAL - Server Lost Connection',
], ],
); );
$emailsGathered = ['realusers-before-trial','realusers-server-lost-connection'];
if (!in_array($type, $emailsGathered)) {
$this->email = text('Email Address to send to'); $this->email = text('Email Address to send to');
}
set_transanctional_email_settings(); set_transanctional_email_settings();
$this->mail = new MailMessage(); $this->mail = new MailMessage();
@ -159,16 +167,73 @@ public function handle()
$found = Waitlist::where('email', $this->email)->first(); $found = Waitlist::where('email', $this->email)->first();
if ($found) { if ($found) {
SendConfirmationForWaitlistJob::dispatch($this->email, $found->uuid); SendConfirmationForWaitlistJob::dispatch($this->email, $found->uuid);
} else { } else {
throw new Exception('Waitlist not found'); throw new Exception('Waitlist not found');
} }
break; 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;
} }
} }
private function sendEmail() }
$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(string $email = null)
{ {
if ($email) {
$this->email = $email;
}
Mail::send( Mail::send(
[], [],
[], [],
@ -177,5 +242,6 @@ private function sendEmail()
->subject($this->mail->subject) ->subject($this->mail->subject)
->html((string)$this->mail->render()) ->html((string)$this->mail->render())
); );
$this->info("Email sent to $this->email successfully. 📧");
} }
} }

View File

@ -92,7 +92,6 @@ private function remove_from_waitlist()
} }
private function send_email() private function send_email()
{ {
ray($this->next_patient->email, $this->password);
$token = Crypt::encryptString("{$this->next_patient->email}@@@$this->password"); $token = Crypt::encryptString("{$this->next_patient->email}@@@$this->password");
$loginLink = route('auth.link', ['token' => $token]); $loginLink = route('auth.link', ['token' => $token]);
$mail = new MailMessage(); $mail = new MailMessage();

View File

@ -11,6 +11,7 @@
use App\Jobs\DockerCleanupJob; use App\Jobs\DockerCleanupJob;
use App\Jobs\InstanceAutoUpdateJob; use App\Jobs\InstanceAutoUpdateJob;
use App\Jobs\ProxyContainerStatusJob; use App\Jobs\ProxyContainerStatusJob;
use App\Jobs\ServerDetailsCheckJob;
use App\Models\Application; use App\Models\Application;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
@ -24,21 +25,26 @@ class Kernel extends ConsoleKernel
protected function schedule(Schedule $schedule): void protected function schedule(Schedule $schedule): void
{ {
if (isDev()) { if (isDev()) {
$schedule->command('horizon:snapshot')->everyMinute(); $schedule->job(new ServerDetailsCheckJob(Server::find(0)))->everyTenMinutes()->onOneServer();
$schedule->job(new CleanupInstanceStuffsJob)->everyMinute(); // $schedule->command('horizon:snapshot')->everyMinute();
// $schedule->job(new CleanupInstanceStuffsJob)->everyMinute();
// $schedule->job(new CheckResaleLicenseJob)->hourly(); // $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 { } else {
$schedule->command('horizon:snapshot')->everyFiveMinutes(); $schedule->command('horizon:snapshot')->everyFiveMinutes();
$schedule->job(new CleanupInstanceStuffsJob)->everyTenMinutes()->onOneServer(); $schedule->job(new CleanupInstanceStuffsJob)->everyTenMinutes()->onOneServer();
$schedule->job(new CheckResaleLicenseJob)->hourly()->onOneServer(); $schedule->job(new CheckResaleLicenseJob)->hourly()->onOneServer();
$schedule->job(new DockerCleanupJob)->everyTenMinutes()->onOneServer(); $schedule->job(new DockerCleanupJob)->everyTenMinutes()->onOneServer();
}
$this->instance_auto_update($schedule); $this->instance_auto_update($schedule);
$this->check_scheduled_backups($schedule); $this->check_scheduled_backups($schedule);
$this->check_resources($schedule); $this->check_resources($schedule);
$this->check_proxies($schedule); $this->check_proxies($schedule);
} }
}
private function check_proxies($schedule) private function check_proxies($schedule)
{ {
$servers = Server::all()->where('settings.is_usable', true)->where('settings.is_reachable', true)->whereNotNull('proxy.type')->where('proxy.type', '!=', ProxyTypes::NONE->value); $servers = Server::all()->where('settings.is_usable', true)->where('settings.is_reachable', true)->whereNotNull('proxy.type')->where('proxy.type', '!=', ProxyTypes::NONE->value);

View File

@ -30,7 +30,7 @@ public function submit()
try { try {
$this->rateLimit(1, 60); $this->rateLimit(1, 60);
$this->validate(); $this->validate();
$subscriptionType = auth()->user()?->subscription?->type() ?? 'unknown'; $subscriptionType = auth()->user()?->subscription?->type() ?? 'Free';
$debug = "Route: {$this->path}"; $debug = "Route: {$this->path}";
$mail = new MailMessage(); $mail = new MailMessage();
$mail->view( $mail->view(
@ -41,7 +41,7 @@ public function submit()
] ]
); );
$mail->subject("[HELP - {$subscriptionType}]: {$this->subject}"); $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.'); $this->emit('success', 'Your message has been sent successfully. We will get in touch with you as soon as possible.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return general_error_handler($e, $this); return general_error_handler($e, $this);

View File

@ -48,7 +48,6 @@ class General extends Component
'application.ports_exposes' => 'required', 'application.ports_exposes' => 'required',
'application.ports_mappings' => 'nullable', 'application.ports_mappings' => 'nullable',
'application.dockerfile' => 'nullable', 'application.dockerfile' => 'nullable',
'application.nixpkgsarchive' => 'nullable',
]; ];
protected $validationAttributes = [ protected $validationAttributes = [
'application.name' => 'name', 'application.name' => 'name',
@ -67,7 +66,6 @@ class General extends Component
'application.ports_exposes' => 'Ports exposes', 'application.ports_exposes' => 'Ports exposes',
'application.ports_mappings' => 'Ports mappings', 'application.ports_mappings' => 'Ports mappings',
'application.dockerfile' => 'Dockerfile', 'application.dockerfile' => 'Dockerfile',
'application.nixpkgsarchive' => 'Nixpkgs archive',
]; ];
public function instantSave() public function instantSave()

View File

@ -58,7 +58,7 @@ public function submit()
{ {
$this->validate(); $this->validate();
try { try {
if (!$this->private_key_id) { if (is_null($this->private_key_id)) {
return $this->emit('error', 'You must select a private key'); return $this->emit('error', 'You must select a private key');
} }
$server = Server::create([ $server = Server::create([

View File

@ -17,7 +17,7 @@ public function proxyStatusUpdated()
public function getProxyStatus() public function getProxyStatus()
{ {
try { 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'); $container = getContainerStatus(server: $this->server, container_id: 'coolify-proxy');
$this->server->proxy->status = $container; $this->server->proxy->status = $container;
$this->server->save(); $this->server->save();

View File

@ -8,8 +8,13 @@
class PricingPlans extends Component 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) public function subscribeStripe($type)
{ {
$team = currentTeam();
Stripe::setApiKey(config('subscription.stripe_api_key')); Stripe::setApiKey(config('subscription.stripe_api_key'));
switch ($type) { switch ($type) {
case 'basic-monthly': case 'basic-monthly':
@ -50,10 +55,23 @@ public function subscribeStripe($type)
'automatic_tax' => [ 'automatic_tax' => [
'enabled' => true, 'enabled' => true,
], ],
'mode' => 'subscription', 'mode' => 'subscription',
'success_url' => route('dashboard', ['success' => true]), 'success_url' => route('dashboard', ['success' => true]),
'cancel_url' => route('subscription.index', ['cancelled' => 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; $customer = currentTeam()->subscription?->stripe_customer_id ?? null;
if ($customer) { if ($customer) {
$payload['customer'] = $customer; $payload['customer'] = $customer;

View File

@ -413,7 +413,6 @@ private function cleanup_git()
private function generate_nixpacks_confs() private function generate_nixpacks_confs()
{ {
ray('nixpkgsarchive', $this->application->nixpkgsarchive);
$this->execute_remote_command( $this->execute_remote_command(
[ [
"echo -n 'Generating nixpacks configuration.'", "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("cp {$this->workdir}/.nixpacks/Dockerfile {$this->workdir}/Dockerfile")],
[$this->execute_in_builder("rm -f {$this->workdir}/.nixpacks/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() private function nixpacks_build_cmd()
@ -508,7 +500,7 @@ private function generate_compose_file()
$this->destination->network => [ $this->destination->network => [
'external' => true, 'external' => true,
'name' => $this->destination->network, 'name' => $this->destination->network,
'attachable' => true, 'attachable' => true
] ]
] ]
]; ];
@ -648,6 +640,10 @@ private function set_labels_for_applications()
private function generate_healthcheck_commands() 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) { if (!$this->application->health_check_port) {
$this->application->health_check_port = $this->application->ports_exposes_array[0]; $this->application->health_check_port = $this->application->ports_exposes_array[0];
} }

View File

@ -36,11 +36,11 @@ public function handle(): void
return; return;
} }
try { try {
ray()->showQueries()->color('orange'); // ray()->showQueries()->color('orange');
$servers = Server::all(); $servers = Server::all();
foreach ($servers as $server) { foreach ($servers as $server) {
if ( if (
!$server->settings->is_reachable && !$server->settings->is_usable !$server->isFunctional()
) { ) {
continue; continue;
} }

View File

@ -0,0 +1,79 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Str;
class ServerDetailsCheckJob implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 120;
public function __construct(public Server $server)
{
}
public function middleware(): array
{
return [new WithoutOverlapping($this->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;
}
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Jobs;
use App\Models\Team;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SubscriptionTrialEndedJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public Team $team
) {
}
public function handle(): void
{
try {
$session = getStripeCustomerPortalSession($this->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;
}
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Jobs;
use App\Models\Team;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SubscriptionTrialEndsSoonJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public Team $team
) {
}
public function handle(): void
{
try {
$session = getStripeCustomerPortalSession($this->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;
}
}
}

View File

@ -153,4 +153,7 @@ public function isProxyShouldRun()
} }
return $shouldRun; return $shouldRun;
} }
public function isFunctional() {
return $this->settings->is_reachable && $this->settings->is_usable;
}
} }

View File

@ -33,7 +33,7 @@ public function type()
} }
if (isStripe()) { if (isStripe()) {
if (!$this->stripe_plan_id) { if (!$this->stripe_plan_id) {
return 'unknown'; return 'zero';
} }
$subscription = Subscription::where('id', $this->id)->first(); $subscription = Subscription::where('id', $this->id)->first();
if (!$subscription) { if (!$subscription) {
@ -54,6 +54,6 @@ public function type()
return Str::of($stripePlanId)->after('stripe_price_id_')->before('_')->lower(); return Str::of($stripePlanId)->after('stripe_price_id_')->before('_')->lower();
} }
} }
return 'unknown'; return 'zero';
} }
} }

View File

@ -120,4 +120,20 @@ public function s3s()
{ {
return $this->hasMany(S3Storage::class); 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,
]);
}
}
} }

View File

@ -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)); ->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); $outputLines = explode(PHP_EOL, $rawOutput);
return collect($outputLines) return collect($outputLines)

View File

@ -262,13 +262,24 @@ function send_internal_notification(string $message): void
ray($e->getMessage()); 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(); $settings = InstanceSettings::get();
$type = set_transanctional_email_settings($settings); $type = set_transanctional_email_settings($settings);
if (!$type) { if (!$type) {
throw new Exception('No email settings found.'); throw new Exception('No email settings found.');
} }
if ($cc) {
Mail::send(
[],
[],
fn (Message $message) => $message
->to($email)
->cc($cc)
->subject($mail->subject)
->html((string) $mail->render())
);
} else {
Mail::send( Mail::send(
[], [],
[], [],
@ -277,6 +288,7 @@ function send_user_an_email(MailMessage $mail, string $email): void
->subject($mail->subject) ->subject($mail->subject)
->html((string) $mail->render()) ->html((string) $mail->render())
); );
}
} }
function isEmailEnabled($notifiable) function isEmailEnabled($notifiable)
{ {

View File

@ -10,6 +10,7 @@
], ],
], ],
'limits' => [ 'limits' => [
'trial_period'=> 14,
'server' => [ 'server' => [
'zero' => 0, 'zero' => 0,
'self-hosted' => 999999999999, 'self-hosted' => 999999999999,

View File

@ -7,7 +7,7 @@
// The release version of your application // The release version of your application
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) // 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'), 'server_name' => env('APP_ID', 'coolify'),
// When left empty or `null` the Laravel environment will be used // When left empty or `null` the Laravel environment will be used
'environment' => config('app.env'), 'environment' => config('app.env'),

View File

@ -1,3 +1,3 @@
<?php <?php
return '4.0.0-beta.34'; return '4.0.0-beta.35';

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('applications', function (Blueprint $table) {
$table->dropColumn('nixpkgsarchive');
});
}
public function down(): void
{
Schema::table('teams', function (Blueprint $table) {
$table->string('nixpkgsarchive')->nullable();
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -35,8 +35,10 @@ class="text-xs normal-case hover:no-underline btn btn-sm bg-coollabs-gradient">
@else @else
<x-forms.input type="email" name="email" label="{{ __('input.email') }}" autofocus /> <x-forms.input type="email" name="email" label="{{ __('input.email') }}" autofocus />
<x-forms.input type="password" name="password" label="{{ __('input.password') }}" /> <x-forms.input type="password" name="password" label="{{ __('input.password') }}" />
<a href="/forgot-password" class="text-xs">
{{ __('auth.forgot_password') }}?
</a>
@endenv @endenv
<x-forms.button type="submit">{{ __('auth.login') }}</x-forms.button> <x-forms.button type="submit">{{ __('auth.login') }}</x-forms.button>
@if (!$is_registration_enabled) @if (!$is_registration_enabled)
<div class="text-center ">{{ __('auth.registration_disabled') }}</div> <div class="text-center ">{{ __('auth.registration_disabled') }}</div>

View File

@ -3,4 +3,4 @@
Thank you,<br> Thank you,<br>
{{ config('app.name') ?? 'Coolify' }} {{ config('app.name') ?? 'Coolify' }}
{{ Illuminate\Mail\Markdown::parse('[Contact Support](https://docs.coollabs.io)') }} {{ Illuminate\Mail\Markdown::parse('[Contact Support](https://docs.coollabs.io/contact)') }}

View File

@ -10,6 +10,18 @@
</svg> </svg>
</a> </a>
</li> </li>
<li title="Help" class="mt-auto">
<div class="justify-center icons" wire:click="help" onclick="help.showModal()">
<svg class="{{ request()->is('help*') ? 'text-warning icon' : 'icon' }}" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0-18 0m9 4v.01" />
<path d="M12 13a2 2 0 0 0 .914-3.782a1.98 1.98 0 0 0-2.414.483" />
</g>
</svg>
</div>
</li>
<li class="pb-6" title="Logout"> <li class="pb-6" title="Logout">
<form action="/logout" method="POST" class=" hover:bg-transparent"> <form action="/logout" method="POST" class=" hover:bg-transparent">
@csrf @csrf

View File

@ -21,6 +21,7 @@ class="sr-only">
</label> </label>
</fieldset> </fieldset>
</div> </div>
<div class="py-2 text-center"><span class="font-bold text-warning">{{config('constants.limits.trial_period')}} days trial</span> included on all plans, without credit card details.</div>
<div x-show="selected === 'monthly'" class="flex justify-center h-10 mt-3 text-sm leading-6 "> <div x-show="selected === 'monthly'" class="flex justify-center h-10 mt-3 text-sm leading-6 ">
<div>Save <span class="font-bold text-warning">1 month</span> annually with the yearly plans. <div>Save <span class="font-bold text-warning">1 month</span> annually with the yearly plans.
</div> </div>

View File

@ -0,0 +1,8 @@
<x-emails.layout>
We would like to inform you that a {{config('constants.limits.trial_period')}} days of trial has been added to all subscription plans.
You can try out Coolify, without payment information for free. If you like it, you can upgrade to a paid plan at any time.
[Click here](https://app.coolify.io/subscription) to start your trial.
</x-emails.layout>

View File

@ -0,0 +1,7 @@
<x-emails.layout>
Coolify Cloud cannot connect to your server ({{$name}}). Please check your server and make sure it is running.
If you have any questions, please contact us.
</x-emails.layout>

View File

@ -0,0 +1,7 @@
<x-emails.layout>
Your trial ended. All automations and integrations are disabled for all of your servers.
Please update payment details [here]({{$stripeCustomerPortal}}) or in [Coolify Cloud](https://app.coolify.io) to continue using our services.
</x-emails.layout>

View File

@ -0,0 +1,7 @@
<x-emails.layout>
Your trial ends soon. Please update payment details [here]({{$stripeCustomerPortal}}),
Your servers & deployed resources will be untouched, but you won't be able to deploy new resources and lost all automations and integrations.
</x-emails.layout>

View File

@ -28,14 +28,12 @@
<body> <body>
@livewireScripts @livewireScripts
@if (isSubscriptionActive() || isDev())
<dialog id="help" class="modal"> <dialog id="help" class="modal">
<livewire:help /> <livewire:help />
<form method="dialog" class="modal-backdrop"> <form method="dialog" class="modal-backdrop">
<button>close</button> <button>close</button>
</form> </form>
</dialog> </dialog>
@endif
<x-toaster-hub /> <x-toaster-hub />
<x-version class="fixed left-2 bottom-1" /> <x-version class="fixed left-2 bottom-1" />
<script> <script>

View File

@ -1,5 +1,5 @@
<div> <div>
@if ($server->settings->is_usable) @if ($server->isFunctional())
<div class="flex items-end gap-2"> <div class="flex items-end gap-2">
<h2>Destinations</h2> <h2>Destinations</h2>
<a href="{{ route('destination.new', ['server_id' => $server->id]) }}"> <a href="{{ route('destination.new', ['server_id' => $server->id]) }}">

View File

@ -32,10 +32,6 @@
<option value="dockerfile">Dockerfile</option> <option value="dockerfile">Dockerfile</option>
<option disabled value="compose">Compose</option> <option disabled value="compose">Compose</option>
</x-forms.select> </x-forms.select>
{{-- @if ($application->build_pack === 'nixpacks')
<x-forms.input id="application.nixpkgsarchive" label="NixPackages Archive (nixpkgsArchive)"
helper="You can customize the NixPackages archive to use."> </x-forms.input>
@endif --}}
</div> </div>
@if ($application->settings->is_static) @if ($application->settings->is_static)
<x-forms.select id="application.static_image" label="Static Image" required> <x-forms.select id="application.static_image" label="Static Image" required>

View File

@ -20,7 +20,7 @@
@endif @endif
</div> </div>
@if (!$server->settings->is_reachable || !$server->settings->is_usable) @if (!$server->isFunctional())
You can't use this server until it is validated. You can't use this server until it is validated.
@else @else
Server validated. Server validated.
@ -57,7 +57,7 @@
Install Docker Engine 24.0 Install Docker Engine 24.0
</x-forms.button> </x-forms.button>
@endif @endif
@if ($server->settings->is_usable) @if ($server->isFunctional())
<h3 class="py-4">Settings</h3> <h3 class="py-4">Settings</h3>
<x-forms.input id="cleanup_after_percentage" label="Disk Cleanup threshold (%)" required <x-forms.input id="cleanup_after_percentage" label="Disk Cleanup threshold (%)" required
helper="Disk cleanup job will be executed if disk usage is more than this number." /> helper="Disk cleanup job will be executed if disk usage is more than this number." />

View File

@ -1,5 +1,5 @@
<div> <div>
@if (data_get($server,'settings.is_usable')) @if ($server->isFunctional())
@if (data_get($server,'proxy.type')) @if (data_get($server,'proxy.type'))
<x-modal submitWireAction="proxyStatusUpdated" modalId="startProxy"> <x-modal submitWireAction="proxyStatusUpdated" modalId="startProxy">
<x-slot:modalBody> <x-slot:modalBody>

View File

@ -2,29 +2,29 @@
@if (config('subscription.provider') === 'stripe') @if (config('subscription.provider') === 'stripe')
<x-slot:basic> <x-slot:basic>
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-basic" <x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-basic"
class="w-full h-10 buyme" wire:click="subscribeStripe('basic-monthly')"> Subscribe class="w-full h-10 buyme" wire:click="subscribeStripe('basic-monthly')"> {{$isTrial ? 'Start Trial' : 'Subscribe' }}
</x-forms.button> </x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-basic" <x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-basic"
class="w-full h-10 buyme" wire:click="subscribeStripe('basic-yearly')"> Subscribe class="w-full h-10 buyme" wire:click="subscribeStripe('basic-yearly')"> {{$isTrial ? 'Start Trial' : 'Subscribe' }}
</x-forms.button> </x-forms.button>
</x-slot:basic> </x-slot:basic>
<x-slot:pro> <x-slot:pro>
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-pro" <x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-pro"
class="w-full h-10 buyme" wire:click="subscribeStripe('pro-monthly')"> Subscribe class="w-full h-10 buyme" wire:click="subscribeStripe('pro-monthly')"> {{$isTrial ? 'Start Trial' : 'Subscribe' }}
</x-forms.button> </x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-pro" class="w-full h-10 buyme" <x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-pro" class="w-full h-10 buyme"
wire:click="subscribeStripe('pro-yearly')"> Subscribe wire:click="subscribeStripe('pro-yearly')"> {{$isTrial ? 'Start Trial' : 'Subscribe' }}
</x-forms.button> </x-forms.button>
</x-slot:pro> </x-slot:pro>
<x-slot:ultimate> <x-slot:ultimate>
<x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-ultimate" <x-forms.button x-show="selected === 'monthly'" x-cloak aria-describedby="tier-ultimate"
class="w-full h-10 buyme" wire:click="subscribeStripe('ultimate-monthly')"> Subscribe class="w-full h-10 buyme" wire:click="subscribeStripe('ultimate-monthly')"> {{$isTrial ? 'Start Trial' : 'Subscribe' }}
</x-forms.button> </x-forms.button>
<x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-ultimate" <x-forms.button x-show="selected === 'yearly'" x-cloak aria-describedby="tier-ultimate"
class="w-full h-10 buyme" wire:click="subscribeStripe('ultimate-yearly')"> Subscribe class="w-full h-10 buyme" wire:click="subscribeStripe('ultimate-yearly')"> {{$isTrial ? 'Start Trial' : 'Subscribe' }}
</x-forms.button> </x-forms.button>
</x-slot:ultimate> </x-slot:ultimate>
@endif @endif

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Jobs\SubscriptionInvoiceFailedJob; use App\Jobs\SubscriptionTrialEndedJob;
use App\Jobs\SubscriptionTrialEndsSoonJob;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Models\GithubApp; use App\Models\GithubApp;
@ -118,6 +119,11 @@
return response('Nothing to do. No applications found.'); return response('Nothing to do. No applications found.');
} }
foreach ($applications as $application) { foreach ($applications as $application) {
$isFunctional = $application->destination->server->isFunctional();
if (!$isFunctional) {
ray('Server is not functional: ' . $application->destination->server->name);
continue;
}
if ($x_github_event === 'push') { if ($x_github_event === 'push') {
if ($application->isDeployable()) { if ($application->isDeployable()) {
ray('Deploying ' . $application->name . ' with branch ' . $branch); ray('Deploying ' . $application->name . ' with branch ' . $branch);
@ -271,20 +277,17 @@
case 'invoice.paid': case 'invoice.paid':
$customerId = data_get($data, 'customer'); $customerId = data_get($data, 'customer');
$subscription = Subscription::where('stripe_customer_id', $customerId)->firstOrFail(); $subscription = Subscription::where('stripe_customer_id', $customerId)->firstOrFail();
$planId = data_get($data, 'lines.data.0.plan.id');
$subscription->update([ $subscription->update([
'stripe_plan_id' => $planId,
'stripe_invoice_paid' => true, 'stripe_invoice_paid' => true,
]); ]);
break; break;
// case 'invoice.payment_failed':
// $customerId = data_get($data, 'customer');
// $subscription = Subscription::where('stripe_customer_id', $customerId)->first();
// if ($subscription) {
// SubscriptionInvoiceFailedJob::dispatch($subscription->team);
// }
// break;
case 'customer.subscription.updated': case 'customer.subscription.updated':
$customerId = data_get($data, 'customer'); $customerId = data_get($data, 'customer');
$subscription = Subscription::where('stripe_customer_id', $customerId)->firstOrFail(); $subscription = Subscription::where('stripe_customer_id', $customerId)->firstOrFail();
$trialEndedAlready = data_get($subscription, 'stripe_trial_already_ended');
$status = data_get($data, 'status');
$subscriptionId = data_get($data, 'items.data.0.subscription'); $subscriptionId = data_get($data, 'items.data.0.subscription');
$planId = data_get($data, 'items.data.0.plan.id'); $planId = data_get($data, 'items.data.0.plan.id');
$cancelAtPeriodEnd = data_get($data, 'cancel_at_period_end'); $cancelAtPeriodEnd = data_get($data, 'cancel_at_period_end');
@ -297,7 +300,19 @@
'stripe_plan_id' => $planId, 'stripe_plan_id' => $planId,
'stripe_cancel_at_period_end' => $cancelAtPeriodEnd, 'stripe_cancel_at_period_end' => $cancelAtPeriodEnd,
]); ]);
ray($feedback, $comment, $alreadyCancelAtPeriodEnd, $cancelAtPeriodEnd); if ($status === 'paused') {
$subscription->update([
'stripe_invoice_paid' => false,
]);
send_internal_notification('Subscription paused for team: ' . $subscription->team->id);
}
// Trial ended but subscribed, reactive servers
if ($trialEndedAlready && $status === 'active') {
$team = data_get($subscription, 'team');
$team->trialEndedButSubscribed();
}
if ($feedback) { if ($feedback) {
$reason = "Cancellation feedback for {$subscription->team->id}: '" . $feedback . "'"; $reason = "Cancellation feedback for {$subscription->team->id}: '" . $feedback . "'";
if ($comment) { if ($comment) {
@ -305,7 +320,6 @@
} }
send_internal_notification($reason); send_internal_notification($reason);
} }
ray($alreadyCancelAtPeriodEnd !== $cancelAtPeriodEnd);
if ($alreadyCancelAtPeriodEnd !== $cancelAtPeriodEnd) { if ($alreadyCancelAtPeriodEnd !== $cancelAtPeriodEnd) {
if ($cancelAtPeriodEnd) { if ($cancelAtPeriodEnd) {
send_internal_notification('Subscription cancelled at period end for team: ' . $subscription->team->id); send_internal_notification('Subscription cancelled at period end for team: ' . $subscription->team->id);
@ -315,16 +329,44 @@
} }
break; break;
case 'customer.subscription.deleted': case 'customer.subscription.deleted':
// End subscription
$customerId = data_get($data, 'customer'); $customerId = data_get($data, 'customer');
$subscription = Subscription::where('stripe_customer_id', $customerId)->firstOrFail(); $subscription = Subscription::where('stripe_customer_id', $customerId)->firstOrFail();
$team = data_get($subscription, 'team');
$team->trialEnded();
$subscription->update([ $subscription->update([
'stripe_subscription_id' => null, 'stripe_subscription_id' => null,
'stripe_plan_id' => null, 'stripe_plan_id' => null,
'stripe_cancel_at_period_end' => false, 'stripe_cancel_at_period_end' => false,
'stripe_invoice_paid' => false, 'stripe_invoice_paid' => false,
'stripe_trial_already_ended' => true,
]); ]);
send_internal_notification('Subscription cancelled: ' . $subscription->team->id); send_internal_notification('Subscription cancelled: ' . $subscription->team->id);
break; break;
case 'customer.subscription.trial_will_end':
$customerId = data_get($data, 'customer');
$subscription = Subscription::where('stripe_customer_id', $customerId)->firstOrFail();
$team = data_get($subscription, 'team');
if (!$team) {
throw new Exception('No team found for subscription: ' . $subscription->id);
}
SubscriptionTrialEndsSoonJob::dispatch($team);
break;
case 'customer.subscription.paused':
$customerId = data_get($data, 'customer');
$subscription = Subscription::where('stripe_customer_id', $customerId)->firstOrFail();
$team = data_get($subscription, 'team');
if (!$team) {
throw new Exception('No team found for subscription: ' . $subscription->id);
}
$team->trialEnded();
$subscription->update([
'stripe_trial_already_ended' => true,
'stripe_invoice_paid' => false,
]);
SubscriptionTrialEndedJob::dispatch($team);
send_internal_notification('Subscription paused for team: ' . $subscription->team->id);
break;
default: default:
// Unhandled event type // Unhandled event type
} }

View File

@ -4,7 +4,7 @@
"version": "3.12.36" "version": "3.12.36"
}, },
"v4": { "v4": {
"version": "4.0.0-beta.34" "version": "4.0.0-beta.35"
} }
} }
} }