Merge branch 'next' into fix/service-update-required-params

This commit is contained in:
Andras Bacsai
2025-08-17 18:43:36 +02:00
committed by GitHub
343 changed files with 9046 additions and 2733 deletions

View File

@@ -13,6 +13,7 @@ Coolify is an open-source, self-hostable platform for deploying applications and
- `npm run build` - Build frontend assets for production
### Backend Development
Only run artisan commands inside "coolify" container when in development.
- `php artisan serve` - Start Laravel development server
- `php artisan migrate` - Run database migrations
- `php artisan queue:work` - Start queue worker for background jobs

View File

@@ -53,6 +53,7 @@ Thank you so much!
## Big Sponsors
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [GlueOps](https://www.glueops.dev?ref=coolify.io) - DevOps automation and infrastructure management
* [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
@@ -87,8 +88,11 @@ Thank you so much!
* [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
## Small Sponsors
<a href="https://open-elements.com/?utm_source=coolify.io"><img width="60px" alt="OpenElements" src="https://github.com/OpenElements.png"/></a>
<a href="https://xaman.app/?utm_source=coolify.io"><img width="60px" alt="XamanApp" src="https://github.com/XamanApp.png"/></a>
<a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a>
<a href="https://evercam.io/?utm_source=coolify.io"><img width="60px" alt="Evercam" src="https://github.com/evercam.png"/></a>
<a href="https://github.com/iujlaki"><img width="60px" alt="Imre Ujlaki" src="https://github.com/iujlaki.png"/></a>

View File

@@ -185,6 +185,8 @@ class StartPostgresql
}
}
$command = ['postgres'];
if (filled($this->database->postgres_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
@@ -195,29 +197,25 @@ class StartPostgresql
'read_only' => true,
]]
);
$docker_compose['services'][$container_name]['command'] = [
'postgres',
'-c',
'config_file=/etc/postgresql/postgresql.conf',
];
$command = array_merge($command, ['-c', 'config_file=/etc/postgresql/postgresql.conf']);
}
if ($this->database->enable_ssl) {
$docker_compose['services'][$container_name]['command'] = [
'postgres',
'-c',
'ssl=on',
'-c',
'ssl_cert_file=/var/lib/postgresql/certs/server.crt',
'-c',
'ssl_key_file=/var/lib/postgresql/certs/server.key',
];
$command = array_merge($command, [
'-c', 'ssl=on',
'-c', 'ssl_cert_file=/var/lib/postgresql/certs/server.crt',
'-c', 'ssl_key_file=/var/lib/postgresql/certs/server.key',
]);
}
// Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (count($command) > 1) {
$docker_compose['services'][$container_name]['command'] = $command;
}
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View File

@@ -16,7 +16,7 @@ class Services extends Command
/**
* {@inheritdoc}
*/
protected $description = 'Generate service-templates.yaml based on /templates/compose directory';
protected $description = 'Generates service-templates json file based on /templates/compose directory';
public function handle(): int
{
@@ -33,7 +33,10 @@ class Services extends Command
];
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesJson.PHP_EOL);
file_put_contents(base_path('templates/'.config('constants.services.file_name')), $serviceTemplatesJson.PHP_EOL);
// Generate service-templates.json with SERVICE_URL changed to SERVICE_FQDN
$this->generateServiceTemplatesWithFqdn();
return self::SUCCESS;
}
@@ -86,4 +89,143 @@ class Services extends Command
return $payload;
}
private function generateServiceTemplatesWithFqdn(): void
{
$serviceTemplatesWithFqdn = collect(array_merge(
glob(base_path('templates/compose/*.yaml')),
glob(base_path('templates/compose/*.yml'))
))
->mapWithKeys(function ($file): array {
$file = basename($file);
$parsed = $this->processFileWithFqdn($file);
return $parsed === false ? [] : [
Arr::pull($parsed, 'name') => $parsed,
];
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesWithFqdn.PHP_EOL);
// Generate service-templates-raw.json with non-base64 encoded compose content
// $this->generateServiceTemplatesRaw();
}
private function processFileWithFqdn(string $file): false|array
{
$content = file_get_contents(base_path("templates/compose/$file"));
$data = collect(explode(PHP_EOL, $content))->mapWithKeys(function ($line): array {
preg_match('/^#(?<key>.*):(?<value>.*)$/U', $line, $m);
return $m ? [trim($m['key']) => trim($m['value'])] : [];
});
if (str($data->get('ignore'))->toBoolean()) {
return false;
}
$documentation = $data->get('documentation');
$documentation = $documentation ? $documentation.'?utm_source=coolify.io' : 'https://coolify.io/docs';
// Replace SERVICE_URL with SERVICE_FQDN in the content
$modifiedContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $content);
$json = Yaml::parse($modifiedContent);
$compose = base64_encode(Yaml::dump($json, 10, 2));
$tags = str($data->get('tags'))->lower()->explode(',')->map(fn ($tag) => trim($tag))->filter();
$tags = $tags->isEmpty() ? null : $tags->all();
$payload = [
'name' => pathinfo($file, PATHINFO_FILENAME),
'documentation' => $documentation,
'slogan' => $data->get('slogan', str($file)->headline()),
'compose' => $compose,
'tags' => $tags,
'logo' => $data->get('logo', 'svgs/default.webp'),
'minversion' => $data->get('minversion', '0.0.0'),
];
if ($port = $data->get('port')) {
$payload['port'] = $port;
}
if ($envFile = $data->get('env_file')) {
$envFileContent = file_get_contents(base_path("templates/compose/$envFile"));
// Also replace SERVICE_URL with SERVICE_FQDN in env file content
$modifiedEnvContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $envFileContent);
$payload['envs'] = base64_encode($modifiedEnvContent);
}
return $payload;
}
private function generateServiceTemplatesRaw(): void
{
$serviceTemplatesRaw = collect(array_merge(
glob(base_path('templates/compose/*.yaml')),
glob(base_path('templates/compose/*.yml'))
))
->mapWithKeys(function ($file): array {
$file = basename($file);
$parsed = $this->processFileWithFqdnRaw($file);
return $parsed === false ? [] : [
Arr::pull($parsed, 'name') => $parsed,
];
})->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
file_put_contents(base_path('templates/service-templates-raw.json'), $serviceTemplatesRaw.PHP_EOL);
}
private function processFileWithFqdnRaw(string $file): false|array
{
$content = file_get_contents(base_path("templates/compose/$file"));
$data = collect(explode(PHP_EOL, $content))->mapWithKeys(function ($line): array {
preg_match('/^#(?<key>.*):(?<value>.*)$/U', $line, $m);
return $m ? [trim($m['key']) => trim($m['value'])] : [];
});
if (str($data->get('ignore'))->toBoolean()) {
return false;
}
$documentation = $data->get('documentation');
$documentation = $documentation ? $documentation.'?utm_source=coolify.io' : 'https://coolify.io/docs';
// Replace SERVICE_URL with SERVICE_FQDN in the content
$modifiedContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $content);
$json = Yaml::parse($modifiedContent);
$compose = Yaml::dump($json, 10, 2); // Not base64 encoded
$tags = str($data->get('tags'))->lower()->explode(',')->map(fn ($tag) => trim($tag))->filter();
$tags = $tags->isEmpty() ? null : $tags->all();
$payload = [
'name' => pathinfo($file, PATHINFO_FILENAME),
'documentation' => $documentation,
'slogan' => $data->get('slogan', str($file)->headline()),
'compose' => $compose,
'tags' => $tags,
'logo' => $data->get('logo', 'svgs/default.webp'),
'minversion' => $data->get('minversion', '0.0.0'),
];
if ($port = $data->get('port')) {
$payload['port'] = $port;
}
if ($envFile = $data->get('env_file')) {
$envFileContent = file_get_contents(base_path("templates/compose/$envFile"));
// Also replace SERVICE_URL with SERVICE_FQDN in env file content (not base64 encoded)
$modifiedEnvContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $envFileContent);
$payload['envs'] = $modifiedEnvContent;
}
return $payload;
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Enums\ActivityTypes;
use App\Enums\ApplicationDeploymentStatus;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\PullChangelogFromGitHub;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\ScheduledDatabaseBackup;
@@ -64,6 +65,7 @@ class Init extends Command
try {
$this->cleanupUnnecessaryDynamicProxyConfiguration();
$this->pullTemplatesFromCDN();
$this->pullChangelogFromGitHub();
} catch (\Throwable $e) {
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
}
@@ -74,6 +76,7 @@ class Init extends Command
try {
$this->cleanupInProgressApplicationDeployments();
$this->pullTemplatesFromCDN();
$this->pullChangelogFromGitHub();
} catch (\Throwable $e) {
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
}
@@ -105,7 +108,17 @@ class Init extends Command
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
if ($response->successful()) {
$services = $response->json();
File::put(base_path('templates/service-templates.json'), json_encode($services));
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
}
}
private function pullChangelogFromGitHub()
{
try {
PullChangelogFromGitHub::dispatch();
echo "Changelog fetch initiated\n";
} catch (\Throwable $e) {
echo "Could not fetch changelog from GitHub: {$e->getMessage()}\n";
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Console\Commands;
use Carbon\Carbon;
use Illuminate\Console\Command;
class InitChangelog extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'changelog:init {month? : Month in YYYY-MM format (defaults to current month)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Initialize a new monthly changelog file with example structure';
/**
* Execute the console command.
*/
public function handle()
{
$month = $this->argument('month') ?: Carbon::now()->format('Y-m');
// Validate month format
if (! preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
$this->error('Invalid month format. Use YYYY-MM format with valid months 01-12 (e.g., 2025-08)');
return self::FAILURE;
}
$changelogsDir = base_path('changelogs');
$filePath = $changelogsDir."/{$month}.json";
// Create changelogs directory if it doesn't exist
if (! is_dir($changelogsDir)) {
mkdir($changelogsDir, 0755, true);
$this->info("Created changelogs directory: {$changelogsDir}");
}
// Check if file already exists
if (file_exists($filePath)) {
if (! $this->confirm("File {$month}.json already exists. Overwrite?")) {
$this->info('Operation cancelled');
return self::SUCCESS;
}
}
// Parse the month for example data
$carbonMonth = Carbon::createFromFormat('Y-m', $month);
$monthName = $carbonMonth->format('F Y');
$sampleDate = $carbonMonth->addDays(14)->toISOString(); // Mid-month
// Get version from config
$version = 'v'.config('constants.coolify.version');
// Create example changelog structure
$exampleData = [
'entries' => [
[
'version' => $version,
'title' => 'Example Feature Release',
'content' => "This is an example changelog entry for {$monthName}. Replace this with your actual release notes. Include details about new features, improvements, bug fixes, and any breaking changes.",
'published_at' => $sampleDate,
],
],
];
// Write the file
$jsonContent = json_encode($exampleData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (file_put_contents($filePath, $jsonContent) === false) {
$this->error("Failed to create changelog file: {$filePath}");
return self::FAILURE;
}
$this->info("✅ Created changelog file: changelogs/{$month}.json");
$this->line(" Example entry created for {$monthName}");
$this->line(' Edit the file to add your actual changelog entries');
// Show the file contents
if ($this->option('verbose')) {
$this->newLine();
$this->line('File contents:');
$this->line($jsonContent);
}
return self::SUCCESS;
}
}

View File

@@ -45,7 +45,7 @@ class SyncBunny extends Command
$install_script = 'install.sh';
$upgrade_script = 'upgrade.sh';
$production_env = '.env.production';
$service_template = 'service-templates.json';
$service_template = config('constants.services.file_name');
$versions = 'versions.json';
$compose_file_location = "$parent_dir/$compose_file";
@@ -102,7 +102,7 @@ class SyncBunny extends Command
}
}
if ($only_template) {
$this->info('About to sync service-templates.json to BunnyCDN.');
$this->info('About to sync '.config('constants.services.file_name').' to BunnyCDN.');
$confirmed = confirm('Are you sure you want to sync?');
if (! $confirmed) {
return;

View File

@@ -6,6 +6,7 @@ use App\Jobs\CheckAndStartSentinelJob;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\PullChangelogFromGitHub;
use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob;
use App\Jobs\ScheduledJobManager;
@@ -67,6 +68,7 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->command('cleanup:unreachable-servers')->daily()->onOneServer();
$this->scheduleInstance->job(new PullTemplatesFromCDN)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
$this->scheduleInstance->job(new PullChangelogFromGitHub)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
$this->scheduleInstance->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer();
$this->scheduleUpdates();

View File

@@ -447,4 +447,255 @@ class ProjectController extends Controller
return response()->json(['message' => 'Project deleted.']);
}
#[OA\Get(
summary: 'List Environments',
description: 'List all environments in a project.',
path: '/projects/{uuid}/environments',
operationId: 'get-environments',
security: [
['bearerAuth' => []],
],
tags: ['Projects'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'List of environments',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Environment')
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
description: 'Project not found.',
),
]
)]
public function get_environments(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $request->uuid) {
return response()->json(['message' => 'Project UUID is required.'], 422);
}
$project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first();
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$environments = $project->environments()->select('id', 'name', 'uuid')->get();
return response()->json(serializeApiResponse($environments));
}
#[OA\Post(
summary: 'Create Environment',
description: 'Create environment in project.',
path: '/projects/{uuid}/environments',
operationId: 'create-environment',
security: [
['bearerAuth' => []],
],
tags: ['Projects'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
description: 'Environment created.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the environment.'],
],
),
),
),
responses: [
new OA\Response(
response: 201,
description: 'Environment created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'uuid' => ['type' => 'string', 'example' => 'env123', 'description' => 'The UUID of the environment.'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
description: 'Project not found.',
),
new OA\Response(
response: 409,
description: 'Environment with this name already exists.',
),
]
)]
public function create_environment(Request $request)
{
$allowedFields = ['name'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255|required',
]);
$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);
}
if (! $request->uuid) {
return response()->json(['message' => 'Project UUID is required.'], 422);
}
$project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first();
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$existingEnvironment = $project->environments()->where('name', $request->name)->first();
if ($existingEnvironment) {
return response()->json(['message' => 'Environment with this name already exists.'], 409);
}
$environment = $project->environments()->create([
'name' => $request->name,
]);
return response()->json([
'uuid' => $environment->uuid,
])->setStatusCode(201);
}
#[OA\Delete(
summary: 'Delete Environment',
description: 'Delete environment by name or UUID. Environment must be empty.',
path: '/projects/{uuid}/environments/{environment_name_or_uuid}',
operationId: 'delete-environment',
security: [
['bearerAuth' => []],
],
tags: ['Projects'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Environment deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Environment deleted.'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
description: 'Environment has resources, so it cannot be deleted.',
),
new OA\Response(
response: 404,
description: 'Project or environment not found.',
),
]
)]
public function delete_environment(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $request->uuid) {
return response()->json(['message' => 'Project UUID is required.'], 422);
}
if (! $request->environment_name_or_uuid) {
return response()->json(['message' => 'Environment name or UUID is required.'], 422);
}
$project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first();
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$environment = $project->environments()->whereName($request->environment_name_or_uuid)->first();
if (! $environment) {
$environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first();
}
if (! $environment) {
return response()->json(['message' => 'Environment not found.'], 404);
}
if (! $environment->isEmpty()) {
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
}
$environment->delete();
return response()->json(['message' => 'Environment deleted.']);
}
}

View File

@@ -1428,6 +1428,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->pull_request_id !== 0) {
$local_branch = "pull/{$this->pull_request_id}/head";
}
// Build an exact refspec for ls-remote so we don't match similarly named branches (e.g., changeset-release/main)
if ($this->pull_request_id === 0) {
$lsRemoteRef = "refs/heads/{$local_branch}";
} else {
if ($this->git_type === 'github' || $this->git_type === 'gitea') {
$lsRemoteRef = "refs/pull/{$this->pull_request_id}/head";
} elseif ($this->git_type === 'gitlab') {
$lsRemoteRef = "refs/merge-requests/{$this->pull_request_id}/head";
} else {
// Fallback to the original value if provider-specific ref is unknown
$lsRemoteRef = $local_branch;
}
}
$private_key = data_get($this->application, 'private_key.private_key');
if ($private_key) {
$private_key = base64_encode($private_key);
@@ -1442,7 +1455,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
],
[
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$local_branch}"),
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"),
'hidden' => true,
'save' => 'git_commit_sha',
]
@@ -1450,7 +1463,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} else {
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$local_branch}"),
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"),
'hidden' => true,
'save' => 'git_commit_sha',
],

View File

@@ -351,6 +351,12 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$size = $this->calculate_size();
if ($this->backup->save_s3) {
$this->upload_to_s3();
// If local backup is disabled, delete the local file immediately after S3 upload
if ($this->backup->disable_local_backup) {
deleteBackupsLocally($this->backup_location, $this->server);
$this->add_to_backup_output('Local backup file deleted after S3 upload (disable_local_backup enabled).');
}
}
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Jobs;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
class PullChangelogFromGitHub implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 30;
public function __construct()
{
$this->onQueue('high');
}
public function handle(): void
{
try {
$response = Http::retry(3, 1000)
->timeout(30)
->get('https://api.github.com/repos/coollabsio/coolify/releases?per_page=10');
if ($response->successful()) {
$releases = $response->json();
$changelog = $this->transformReleasesToChangelog($releases);
// Group entries by month and save them
$this->saveChangelogEntries($changelog);
} else {
send_internal_notification('PullChangelogFromGitHub failed with: '.$response->status().' '.$response->body());
}
} catch (\Throwable $e) {
send_internal_notification('PullChangelogFromGitHub failed with: '.$e->getMessage());
}
}
private function transformReleasesToChangelog(array $releases): array
{
$entries = [];
foreach ($releases as $release) {
// Skip drafts and pre-releases if desired
if ($release['draft']) {
continue;
}
$publishedAt = Carbon::parse($release['published_at']);
$entry = [
'tag_name' => $release['tag_name'],
'title' => $release['name'] ?: $release['tag_name'],
'content' => $release['body'] ?: 'No release notes available.',
'published_at' => $publishedAt->toISOString(),
];
$entries[] = $entry;
}
return $entries;
}
private function saveChangelogEntries(array $entries): void
{
// Create changelogs directory if it doesn't exist
$changelogsDir = base_path('changelogs');
if (! File::exists($changelogsDir)) {
File::makeDirectory($changelogsDir, 0755, true);
}
// Group entries by year-month
$groupedEntries = [];
foreach ($entries as $entry) {
$date = Carbon::parse($entry['published_at']);
$monthKey = $date->format('Y-m');
if (! isset($groupedEntries[$monthKey])) {
$groupedEntries[$monthKey] = [];
}
$groupedEntries[$monthKey][] = $entry;
}
// Save each month's entries to separate files
foreach ($groupedEntries as $month => $monthEntries) {
// Sort entries by published date (newest first)
usort($monthEntries, function ($a, $b) {
return Carbon::parse($b['published_at'])->timestamp - Carbon::parse($a['published_at'])->timestamp;
});
$monthData = [
'entries' => $monthEntries,
'last_updated' => now()->toISOString(),
];
$filePath = base_path("changelogs/{$month}.json");
File::put($filePath, json_encode($monthData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
}
}

View File

@@ -31,7 +31,7 @@ class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
if ($response->successful()) {
$services = $response->json();
File::put(base_path('templates/service-templates.json'), json_encode($services));
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
} else {
send_internal_notification('PullTemplatesAndVersions failed with: '.$response->status().' '.$response->body());
}

View File

@@ -4,7 +4,6 @@ namespace App\Livewire\Project\Application;
use App\Actions\Application\GenerateConfig;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use Illuminate\Support\Collection;
use Livewire\Component;
use Spatie\Url\Url;
@@ -228,7 +227,18 @@ class General extends Component
return;
}
$this->application->parse();
// 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
$sanitizedDomains = [];
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
$sanitizedKey = str($serviceName)->slug('_')->toString();
$sanitizedDomains[$sanitizedKey] = $domain;
}
$this->parsedServiceDomains = $sanitizedDomains;
$showToast && $this->dispatch('success', 'Docker compose file loaded.');
$this->dispatch('compose_loaded');
$this->dispatch('refreshStorages');
@@ -246,7 +256,7 @@ class General extends Component
public function generateDomain(string $serviceName)
{
$uuid = new Cuid2;
$domain = generateFqdn($this->application->destination->server, $uuid);
$domain = generateUrl(server: $this->application->destination->server, random: $uuid);
$sanitizedKey = str($serviceName)->slug('_')->toString();
$this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain;
@@ -270,7 +280,6 @@ class General extends Component
$this->application->save();
$this->dispatch('success', 'Domain generated.');
if ($this->application->build_pack === 'dockercompose') {
$this->updateServiceEnvironmentVariables();
$this->loadComposeFile(showToast: false);
}
@@ -317,7 +326,7 @@ class General extends Component
{
$server = data_get($this->application, 'destination.server');
if ($server) {
$fqdn = generateFqdn($server, $this->application->uuid);
$fqdn = generateFqdn(server: $server, random: $this->application->uuid, parserVersion: $this->application->compose_parsing_version);
$this->application->fqdn = $fqdn;
$this->application->save();
$this->resetDefaultLabels();
@@ -344,7 +353,7 @@ class General extends Component
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
if ($this->application->build_pack === 'dockercompose') {
$this->loadComposeFile();
$this->loadComposeFile(showToast: false);
}
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
@@ -421,7 +430,7 @@ class General extends Component
}
if ($this->application->build_pack === 'dockercompose' && $this->initialDockerComposeLocation !== $this->application->docker_compose_location) {
$compose_return = $this->loadComposeFile();
$compose_return = $this->loadComposeFile(showToast: false);
if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) {
return;
}
@@ -453,45 +462,23 @@ class General extends Component
$this->application->publish_directory = rtrim($this->application->publish_directory, '/');
}
if ($this->application->build_pack === 'dockercompose') {
// Convert sanitized service names back to original names for storage
$originalDomains = [];
foreach ($this->parsedServiceDomains as $key => $value) {
// Find the original service name by checking parsed services
$originalServiceName = $key;
if (isset($this->parsedServices['services'])) {
foreach ($this->parsedServices['services'] as $originalName => $service) {
if (str($originalName)->slug('_')->toString() === $key) {
$originalServiceName = $originalName;
break;
}
}
}
$originalDomains[$originalServiceName] = $value;
}
$this->application->docker_compose_domains = json_encode($originalDomains);
foreach ($originalDomains as $serviceName => $service) {
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) {
$domain = data_get($service, 'domain');
if ($domain) {
if (! validate_dns_entry($domain, $this->application->destination->server)) {
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
}
}
}
check_domain_usage(resource: $this->application);
}
}
if ($this->application->isDirty('docker_compose_domains')) {
$this->application->save();
$this->resetDefaultLabels();
}
}
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
// Update SERVICE_FQDN_ and SERVICE_URL_ environment variables for Docker Compose applications
if ($this->application->build_pack === 'dockercompose') {
$this->updateServiceEnvironmentVariables();
}
$showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!');
} catch (\Throwable $e) {
$originalFqdn = $this->application->getOriginal('fqdn');
@@ -525,25 +512,33 @@ class General extends Component
foreach ($domains as $serviceName => $service) {
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_');
$domain = data_get($service, 'domain');
// Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed
$this->application->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
->delete();
$this->application->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
->delete();
if ($domain) {
// Create or update SERVICE_FQDN_ and SERVICE_URL_ variables
$fqdn = Url::fromString($domain);
$port = $fqdn->getPort();
$path = $fqdn->getPath();
$fqdnValue = $fqdn->getScheme().'://'.$fqdn->getHost();
if ($path !== '/') {
$fqdnValue = $fqdnValue.$path;
}
$urlValue = str($domain)->after('://');
$urlValue = $fqdn->getScheme().'://'.$fqdn->getHost();
if ($path !== '/') {
$urlValue = $urlValue.$path;
}
$fqdnValue = str($domain)->after('://');
if ($path !== '/') {
$fqdnValue = $fqdnValue.$path;
}
// Create/update SERVICE_FQDN_
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
], [
'value' => $fqdnValue,
@@ -552,21 +547,16 @@ class General extends Component
]);
// Create/update SERVICE_URL_
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceNameFormatted}",
], [
'value' => $urlValue,
'is_build_time' => false,
'is_preview' => false,
]);
// Create/update port-specific variables if port exists
if ($port) {
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
if (filled($port)) {
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
], [
'value' => $fqdnValue,
@@ -574,9 +564,7 @@ class General extends Component
'is_preview' => false,
]);
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
], [
'value' => $urlValue,
@@ -584,17 +572,6 @@ class General extends Component
'is_preview' => false,
]);
}
} else {
// Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed
EnvironmentVariable::where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
->delete();
EnvironmentVariable::where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
->delete();
}
}
}

View File

@@ -48,7 +48,7 @@ class PreviewsCompose extends Component
$random = new Cuid2;
// Generate a unique domain like main app services do
$generated_fqdn = generateFqdn($server, $random);
$generated_fqdn = generateFqdn(server: $server, random: $random, parserVersion: $this->preview->application->compose_parsing_version);
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn);

View File

@@ -129,7 +129,7 @@ class CloneMe extends Component
$uuid = (string) new Cuid2;
$url = $application->fqdn;
if ($this->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
$url = generateFqdn($this->server, $uuid);
$url = generateFqdn(server: $this->server, random: $uuid, parserVersion: $application->compose_parsing_version);
}
$newApplication = $application->replicate([

View File

@@ -64,6 +64,9 @@ class BackupEdit extends Component
#[Validate(['required', 'boolean'])]
public bool $saveS3 = false;
#[Validate(['required', 'boolean'])]
public bool $disableLocalBackup = false;
#[Validate(['nullable', 'integer'])]
public ?int $s3StorageId = 1;
@@ -98,6 +101,7 @@ class BackupEdit extends Component
$this->backup->database_backup_retention_days_s3 = $this->databaseBackupRetentionDaysS3;
$this->backup->database_backup_retention_max_storage_s3 = $this->databaseBackupRetentionMaxStorageS3;
$this->backup->save_s3 = $this->saveS3;
$this->backup->disable_local_backup = $this->disableLocalBackup;
$this->backup->s3_storage_id = $this->s3StorageId;
$this->backup->databases_to_backup = $this->databasesToBackup;
$this->backup->dump_all = $this->dumpAll;
@@ -115,6 +119,7 @@ class BackupEdit extends Component
$this->databaseBackupRetentionDaysS3 = $this->backup->database_backup_retention_days_s3;
$this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3;
$this->saveS3 = $this->backup->save_s3;
$this->disableLocalBackup = $this->backup->disable_local_backup ?? false;
$this->s3StorageId = $this->backup->s3_storage_id;
$this->databasesToBackup = $this->backup->databases_to_backup;
$this->dumpAll = $this->backup->dump_all;
@@ -193,6 +198,12 @@ class BackupEdit extends Component
if (! is_numeric($this->backup->s3_storage_id)) {
$this->backup->s3_storage_id = null;
}
// Validate that disable_local_backup can only be true when S3 backup is enabled
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
throw new \Exception('Local backup can only be disabled when S3 backup is enabled.');
}
$isValid = validate_cron_expression($this->backup->frequency);
if (! $isValid) {
throw new \Exception('Invalid Cron / Human expression');

View File

@@ -132,6 +132,12 @@ class ExecuteContainerCommand extends Component
});
}
}
// Sort containers alphabetically by name
$this->containers = $this->containers->sortBy(function ($container) {
return data_get($container, 'container.Names');
});
if ($this->containers->count() === 1) {
$this->selected_container = data_get($this->containers->first(), 'container.Names');
}

View File

@@ -61,7 +61,7 @@ class ResourceOperations extends Component
$url = $this->resource->fqdn;
if ($server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
$url = generateFqdn($server, $uuid);
$url = generateFqdn(server: $server, random: $uuid, parserVersion: $this->resource->compose_parsing_version);
}
$new_resource = $this->resource->replicate([

View File

@@ -6,6 +6,7 @@ use App\Actions\Server\CheckUpdates;
use App\Actions\Server\UpdatePackage;
use App\Events\ServerPackageUpdated;
use App\Models\Server;
use App\Notifications\Server\ServerPatchCheck;
use Livewire\Component;
class Patches extends Component
@@ -96,6 +97,89 @@ class Patches extends Component
}
}
public function sendTestEmail()
{
if (! isDev()) {
$this->dispatch('error', message: 'Test email functionality is only available in development mode.');
return;
}
try {
// Get current patch data or create test data if none exists
$testPatchData = $this->createTestPatchData();
// Send test notification
$this->server->team->notify(new ServerPatchCheck($this->server, $testPatchData));
$this->dispatch('success', 'Test email sent successfully! Check your email inbox.');
} catch (\Exception $e) {
$this->dispatch('error', message: 'Failed to send test email: '.$e->getMessage());
}
}
private function createTestPatchData(): array
{
// If we have real patch data, use it
if (isset($this->updates) && is_array($this->updates) && count($this->updates) > 0) {
return [
'total_updates' => $this->totalUpdates,
'updates' => $this->updates,
'osId' => $this->osId,
'package_manager' => $this->packageManager,
];
}
// Otherwise create realistic test data
return [
'total_updates' => 8,
'updates' => [
[
'package' => 'docker-ce',
'current_version' => '24.0.7-1',
'new_version' => '25.0.1-1',
],
[
'package' => 'nginx',
'current_version' => '1.20.2-1',
'new_version' => '1.22.1-1',
],
[
'package' => 'kernel-generic',
'current_version' => '5.15.0-89',
'new_version' => '5.15.0-91',
],
[
'package' => 'openssh-server',
'current_version' => '8.9p1-3',
'new_version' => '9.0p1-1',
],
[
'package' => 'curl',
'current_version' => '7.81.0-1',
'new_version' => '7.85.0-1',
],
[
'package' => 'git',
'current_version' => '2.34.1-1',
'new_version' => '2.39.1-1',
],
[
'package' => 'python3',
'current_version' => '3.10.6-1',
'new_version' => '3.11.0-1',
],
[
'package' => 'htop',
'current_version' => '3.2.1-1',
'new_version' => '3.2.2-1',
],
],
'osId' => $this->osId ?? 'ubuntu',
'package_manager' => $this->packageManager ?? 'apt',
];
}
public function render()
{
return view('livewire.server.security.patches');

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Livewire;
use App\Jobs\PullChangelogFromGitHub;
use App\Services\ChangelogService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class SettingsDropdown extends Component
{
public $showWhatsNewModal = false;
public function getUnreadCountProperty()
{
return Auth::user()->getUnreadChangelogCount();
}
public function getEntriesProperty()
{
$user = Auth::user();
return app(ChangelogService::class)->getEntriesForUser($user);
}
public function openWhatsNewModal()
{
$this->showWhatsNewModal = true;
}
public function closeWhatsNewModal()
{
$this->showWhatsNewModal = false;
}
public function markAsRead($identifier)
{
app(ChangelogService::class)->markAsReadForUser($identifier, Auth::user());
}
public function markAllAsRead()
{
app(ChangelogService::class)->markAllAsReadForUser(Auth::user());
}
public function manualFetchChangelog()
{
if (! isDev()) {
return;
}
try {
PullChangelogFromGitHub::dispatch();
$this->dispatch('success', 'Changelog fetch initiated! Check back in a few moments.');
} catch (\Throwable $e) {
$this->dispatch('error', 'Failed to fetch changelog: '.$e->getMessage());
}
}
public function render()
{
return view('livewire.settings-dropdown', [
'entries' => $this->entries,
'unreadCount' => $this->unreadCount,
]);
}
}

View File

@@ -59,7 +59,7 @@ class Index extends Component
return null;
})->filter();
});
})->sortBy('name');
}
public function updatedSelectedUuid()

View File

@@ -111,7 +111,7 @@ class Application extends BaseModel
{
use HasConfiguration, HasFactory, SoftDeletes;
private static $parserVersion = '4';
private static $parserVersion = '5';
protected $guarded = [];
@@ -1353,7 +1353,7 @@ class Application extends BaseModel
public function parse(int $pull_request_id = 0, ?int $preview_id = null)
{
if ((int) $this->compose_parsing_version >= 3) {
return newParser($this, $pull_request_id, $preview_id);
return applicationParser($this, $pull_request_id, $preview_id);
} elseif ($this->docker_compose_raw) {
return parseDockerComposeFile(resource: $this, isNew: false, pull_request_id: $pull_request_id, preview_id: $preview_id);
} else {
@@ -1442,7 +1442,21 @@ class Application extends BaseModel
$parsedServices = $this->parse();
if ($this->docker_compose_domains) {
$json = collect(json_decode($this->docker_compose_domains));
$names = collect(data_get($parsedServices, 'services'))->keys()->toArray();
foreach ($json as $key => $value) {
if (str($key)->contains('-')) {
$key = str($key)->replace('-', '_');
}
$json->put((string) $key, $value);
}
$services = collect(data_get($parsedServices, 'services', []));
foreach ($services as $name => $service) {
if (str($name)->contains('-')) {
$replacedName = str($name)->replace('-', '_');
$services->put((string) $replacedName, $service);
$services->forget((string) $name);
}
}
$names = collect($services)->keys()->toArray();
$jsonNames = $json->keys()->toArray();
$diff = array_diff($jsonNames, $names);
$json = $json->filter(function ($value, $key) use ($diff) {

View File

@@ -74,7 +74,7 @@ class ApplicationPreview extends BaseModel
public function generate_preview_fqdn()
{
if (is_null($this->fqdn) && $this->application->fqdn) {
if (empty($this->fqdn) && $this->application->fqdn) {
if (str($this->application->fqdn)->contains(',')) {
$url = Url::fromString(str($this->application->fqdn)->explode(',')[0]);
$preview_fqdn = getFqdnWithoutPort(str($this->application->fqdn)->explode(',')[0]);

View File

@@ -42,7 +42,7 @@ class Service extends BaseModel
{
use HasFactory, SoftDeletes;
private static $parserVersion = '4';
private static $parserVersion = '5';
protected $guarded = [];
@@ -255,6 +255,19 @@ class Service extends BaseModel
continue;
}
switch ($image) {
case $image->contains('drizzle-team/gateway'):
$data = collect([]);
$masterpass = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_DRIZZLE')->first();
$data = $data->merge([
'Master Password' => [
'key' => data_get($masterpass, 'key'),
'value' => data_get($masterpass, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
$fields->put('Drizzle', $data->toArray());
break;
case $image->contains('castopod'):
$data = collect([]);
$disable_https = $this->environment_variables()->where('key', 'CP_DISABLE_HTTPS')->first();
@@ -1277,7 +1290,7 @@ class Service extends BaseModel
public function parse(bool $isNew = false): Collection
{
if ((int) $this->compose_parsing_version >= 3) {
return newParser($this);
return serviceParser($this);
} elseif ($this->docker_compose_raw) {
return parseDockerComposeFile($this, $isNew);
} else {

View File

@@ -203,6 +203,16 @@ class User extends Authenticatable implements SendsEmail
return $this->belongsToMany(Team::class)->withPivot('role');
}
public function changelogReads()
{
return $this->hasMany(UserChangelogRead::class);
}
public function getUnreadChangelogCount(): int
{
return app(\App\Services\ChangelogService::class)->getUnreadCountForUser($this);
}
public function getRecipients(): array
{
return [$this->email];

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserChangelogRead extends Model
{
protected $fillable = [
'user_id',
'release_tag',
'read_at',
];
protected $casts = [
'read_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public static function markAsRead(int $userId, string $identifier): void
{
self::firstOrCreate([
'user_id' => $userId,
'release_tag' => $identifier,
], [
'read_at' => now(),
]);
}
public static function isReadByUser(int $userId, string $identifier): bool
{
return self::where('user_id', $userId)
->where('release_tag', $identifier)
->exists();
}
public static function getReadIdentifiersForUser(int $userId): array
{
return self::where('user_id', $userId)
->pluck('release_tag')
->toArray();
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Notifications\Channels;
use App\Models\Team;
use Exception;
use Illuminate\Notifications\Notification;
use Resend;
@@ -11,21 +13,53 @@ class EmailChannel
public function send(SendsEmail $notifiable, Notification $notification): void
{
try {
// Get team and validate membership before proceeding
$team = data_get($notifiable, 'id');
$members = Team::find($team)->members;
$useInstanceEmailSettings = $notifiable->emailNotificationSettings->use_instance_email_settings;
$isTransactionalEmail = data_get($notification, 'isTransactionalEmail', false);
$customEmails = data_get($notification, 'emails', null);
if ($useInstanceEmailSettings || $isTransactionalEmail) {
$settings = instanceSettings();
} else {
$settings = $notifiable->emailNotificationSettings;
}
$isResendEnabled = $settings->resend_enabled;
$isSmtpEnabled = $settings->smtp_enabled;
if ($customEmails) {
$recipients = [$customEmails];
} else {
$recipients = $notifiable->getRecipients();
}
// Validate team membership for all recipients
if (count($recipients) === 0) {
throw new Exception('No email recipients found');
}
foreach ($recipients as $recipient) {
// Check if the recipient is part of the team
if (! $members->contains('email', $recipient)) {
$emailSettings = $notifiable->emailNotificationSettings;
data_set($emailSettings, 'smtp_password', '********');
data_set($emailSettings, 'resend_api_key', '********');
send_internal_notification(sprintf(
"Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s",
$recipient,
$team,
get_class($notification),
get_class($notifiable),
json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
));
throw new Exception('Recipient is not part of the team');
}
}
$mailMessage = $notification->toMail($notifiable);
if ($isResendEnabled) {
@@ -66,5 +100,15 @@ class EmailChannel
$mailer->send($email);
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error('EmailChannel failed: '.$e->getMessage(), [
'notification' => get_class($notification),
'notifiable' => get_class($notifiable),
'team_id' => data_get($notifiable, 'id'),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw $e;
}
}
}

View File

@@ -21,7 +21,7 @@ class S3StoragePolicy
*/
public function view(User $user, S3Storage $storage): bool
{
return $user->teams()->where('id', $storage->team_id)->exists();
return $user->teams()->get()->firstWhere('id', $storage->team_id)->exists();
}
/**
@@ -37,7 +37,7 @@ class S3StoragePolicy
*/
public function update(User $user, Server $server): bool
{
return $user->teams()->get()->firstWhere('id', $server->team_id) !== null;
return $user->teams()->get()->firstWhere('id', $server->team_id)->exists() && $user->isAdmin();
}
/**
@@ -45,7 +45,7 @@ class S3StoragePolicy
*/
public function delete(User $user, S3Storage $storage): bool
{
return $user->teams()->where('id', $storage->team_id)->exists();
return $user->teams()->get()->firstWhere('id', $storage->team_id)->exists() && $user->isAdmin();
}
/**

View File

@@ -0,0 +1,300 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Models\UserChangelogRead;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Spatie\LaravelMarkdown\MarkdownRenderer;
class ChangelogService
{
public function getEntries(int $recentMonths = 3): Collection
{
// For backward compatibility, check if old changelog.json exists
if (file_exists(base_path('changelog.json'))) {
$data = $this->fetchChangelogData();
if (! $data || ! isset($data['entries'])) {
return collect();
}
return collect($data['entries'])
->filter(fn ($entry) => $this->validateEntryData($entry))
->map(function ($entry) {
$entry['published_at'] = Carbon::parse($entry['published_at']);
$entry['content_html'] = $this->parseMarkdown($entry['content']);
return (object) $entry;
})
->filter(fn ($entry) => $entry->published_at <= now())
->sortBy('published_at')
->reverse()
->values();
}
// Load entries from recent months for performance
$availableMonths = $this->getAvailableMonths();
$monthsToLoad = $availableMonths->take($recentMonths);
return $monthsToLoad
->flatMap(fn ($month) => $this->getEntriesForMonth($month))
->sortBy('published_at')
->reverse()
->values();
}
public function getAllEntries(): Collection
{
$availableMonths = $this->getAvailableMonths();
return $availableMonths
->flatMap(fn ($month) => $this->getEntriesForMonth($month))
->sortBy('published_at')
->reverse()
->values();
}
public function getEntriesForUser(User $user): Collection
{
$entries = $this->getEntries();
$readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
return $entries->map(function ($entry) use ($readIdentifiers) {
$entry->is_read = in_array($entry->tag_name, $readIdentifiers);
return $entry;
})->sortBy([
['is_read', 'asc'], // unread first
['published_at', 'desc'], // then by date
])->values();
}
public function getUnreadCountForUser(User $user): int
{
if (isDev()) {
$entries = $this->getEntries();
$readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
return $entries->reject(fn ($entry) => in_array($entry->tag_name, $readIdentifiers))->count();
} else {
return Cache::remember(
'user_unread_changelog_count_'.$user->id,
now()->addHour(),
function () use ($user) {
$entries = $this->getEntries();
$readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id);
return $entries->reject(fn ($entry) => in_array($entry->tag_name, $readIdentifiers))->count();
}
);
}
}
public function getAvailableMonths(): Collection
{
$pattern = base_path('changelogs/*.json');
$files = glob($pattern);
if ($files === false) {
return collect();
}
return collect($files)
->map(fn ($file) => basename($file, '.json'))
->filter(fn ($name) => preg_match('/^\d{4}-\d{2}$/', $name))
->sort()
->reverse()
->values();
}
public function getEntriesForMonth(string $month): Collection
{
$path = base_path("changelogs/{$month}.json");
if (! file_exists($path)) {
return collect();
}
$content = file_get_contents($path);
if ($content === false) {
Log::error("Failed to read changelog file: {$month}.json");
return collect();
}
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::error("Invalid JSON in {$month}.json: ".json_last_error_msg());
return collect();
}
if (! isset($data['entries']) || ! is_array($data['entries'])) {
return collect();
}
return collect($data['entries'])
->filter(fn ($entry) => $this->validateEntryData($entry))
->map(function ($entry) {
$entry['published_at'] = Carbon::parse($entry['published_at']);
$entry['content_html'] = $this->parseMarkdown($entry['content']);
return (object) $entry;
})
->filter(fn ($entry) => $entry->published_at <= now())
->sortBy('published_at')
->reverse()
->values();
}
private function fetchChangelogData(): ?array
{
// Legacy support for old changelog.json
$path = base_path('changelog.json');
if (file_exists($path)) {
$content = file_get_contents($path);
if ($content === false) {
Log::error('Failed to read changelog.json file');
return null;
}
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::error('Invalid JSON in changelog.json: '.json_last_error_msg());
return null;
}
return $data;
}
// New monthly structure - combine all months
$allEntries = [];
foreach ($this->getAvailableMonths() as $month) {
$monthEntries = $this->getEntriesForMonth($month);
foreach ($monthEntries as $entry) {
$allEntries[] = (array) $entry;
}
}
return ['entries' => $allEntries];
}
public function markAsReadForUser(string $version, User $user): void
{
UserChangelogRead::markAsRead($user->id, $version);
Cache::forget('user_unread_changelog_count_'.$user->id);
}
public function markAllAsReadForUser(User $user): void
{
$entries = $this->getEntries();
foreach ($entries as $entry) {
UserChangelogRead::markAsRead($user->id, $entry->tag_name);
}
Cache::forget('user_unread_changelog_count_'.$user->id);
}
private function validateEntryData(array $data): bool
{
$required = ['tag_name', 'title', 'content', 'published_at'];
foreach ($required as $field) {
if (! isset($data[$field]) || empty($data[$field])) {
return false;
}
}
return true;
}
public function clearAllReadStatus(): array
{
try {
$count = UserChangelogRead::count();
UserChangelogRead::truncate();
// Clear all user caches
$this->clearAllUserCaches();
return [
'success' => true,
'message' => "Successfully cleared {$count} read status records",
];
} catch (\Exception $e) {
Log::error('Failed to clear read status: '.$e->getMessage());
return [
'success' => false,
'message' => 'Failed to clear read status: '.$e->getMessage(),
];
}
}
private function clearAllUserCaches(): void
{
$users = User::select('id')->get();
foreach ($users as $user) {
Cache::forget('user_unread_changelog_count_'.$user->id);
}
}
private function parseMarkdown(string $content): string
{
$renderer = app(MarkdownRenderer::class);
$html = $renderer->toHtml($content);
// Apply custom Tailwind CSS classes for dark mode compatibility
$html = $this->applyCustomStyling($html);
return $html;
}
private function applyCustomStyling(string $html): string
{
// Headers
$html = preg_replace('/<h1[^>]*>/', '<h1 class="text-xl font-bold dark:text-white mb-2">', $html);
$html = preg_replace('/<h2[^>]*>/', '<h2 class="text-lg font-semibold dark:text-white mb-2">', $html);
$html = preg_replace('/<h3[^>]*>/', '<h3 class="text-md font-semibold dark:text-white mb-1">', $html);
// Paragraphs
$html = preg_replace('/<p[^>]*>/', '<p class="mb-2 dark:text-neutral-300">', $html);
// Lists
$html = preg_replace('/<ul[^>]*>/', '<ul class="mb-2 ml-4 list-disc">', $html);
$html = preg_replace('/<ol[^>]*>/', '<ol class="mb-2 ml-4 list-decimal">', $html);
$html = preg_replace('/<li[^>]*>/', '<li class="dark:text-neutral-300">', $html);
// Code blocks and inline code
$html = preg_replace('/<pre[^>]*>/', '<pre class="bg-gray-100 dark:bg-coolgray-300 p-2 rounded text-sm overflow-x-auto my-2">', $html);
$html = preg_replace('/<code[^>]*>/', '<code class="bg-gray-100 dark:bg-coolgray-300 px-1 py-0.5 rounded text-sm">', $html);
// Links - Apply styling to existing markdown links
$html = preg_replace('/<a([^>]*)>/', '<a$1 class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">', $html);
// Convert plain URLs to clickable links (that aren't already in <a> tags)
$html = preg_replace('/(?<!href="|href=\')(?<!>)(?<!\/)(https?:\/\/[^\s<>"]+)(?![^<]*<\/a>)/', '<a href="$1" class="text-blue-500 hover:text-blue-600 underline" target="_blank" rel="noopener">$1</a>', $html);
// Strong/bold text
$html = preg_replace('/<strong[^>]*>/', '<strong class="font-semibold dark:text-white">', $html);
// Emphasis/italic text
$html = preg_replace('/<em[^>]*>/', '<em class="italic dark:text-neutral-300">', $html);
return $html;
}
}

View File

@@ -25,6 +25,7 @@ class Input extends Component
public string $autocomplete = 'off',
public ?int $minlength = null,
public ?int $maxlength = null,
public bool $autofocus = false,
) {}
public function render(): View|Closure|string

View File

@@ -237,6 +237,12 @@ function removeOldBackups($backup): void
{
try {
if ($backup->executions) {
// If local backup is disabled, mark all executions as having local storage deleted
if ($backup->disable_local_backup && $backup->save_s3) {
$backup->executions()
->where('local_storage_deleted', false)
->update(['local_storage_deleted' => true]);
} else {
$localBackupsToDelete = deleteOldBackupsLocally($backup);
if ($localBackupsToDelete->isNotEmpty()) {
$backup->executions()
@@ -244,6 +250,7 @@ function removeOldBackups($backup): void
->update(['local_storage_deleted' => true]);
}
}
}
if ($backup->save_s3 && $backup->executions) {
$s3BackupsToDelete = deleteOldBackupsFromS3($backup);
@@ -254,10 +261,18 @@ function removeOldBackups($backup): void
}
}
// Delete executions where both local and S3 storage are marked as deleted
// or where only S3 is enabled and S3 storage is deleted
if ($backup->disable_local_backup && $backup->save_s3) {
$backup->executions()
->where('s3_storage_deleted', true)
->delete();
} else {
$backup->executions()
->where('local_storage_deleted', true)
->where('s3_storage_deleted', true)
->delete();
}
} catch (\Exception $e) {
throw $e;

View File

@@ -256,12 +256,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
if (str($MINIO_BROWSER_REDIRECT_URL->value ?? '')->isEmpty()) {
$MINIO_BROWSER_REDIRECT_URL->update([
'value' => generateFqdn($server, 'console-'.$uuid, true),
'value' => generateFqdn(server: $server, random: 'console-'.$uuid, parserVersion: $resource->compose_parsing_version, forceHttps: true),
]);
}
if (str($MINIO_SERVER_URL->value ?? '')->isEmpty()) {
$MINIO_SERVER_URL->update([
'value' => generateFqdn($server, 'minio-'.$uuid, true),
'value' => generateFqdn(server: $server, random: 'minio-'.$uuid, parserVersion: $resource->compose_parsing_version, forceHttps: true),
]);
}
$payload = collect([
@@ -279,12 +279,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
if (str($LOGTO_ENDPOINT->value ?? '')->isEmpty()) {
$LOGTO_ENDPOINT->update([
'value' => generateFqdn($server, 'logto-'.$uuid),
'value' => generateFqdn(server: $server, random: 'logto-'.$uuid, parserVersion: $resource->compose_parsing_version),
]);
}
if (str($LOGTO_ADMIN_ENDPOINT->value ?? '')->isEmpty()) {
$LOGTO_ADMIN_ENDPOINT->update([
'value' => generateFqdn($server, 'logto-admin-'.$uuid),
'value' => generateFqdn(server: $server, random: 'logto-admin-'.$uuid, parserVersion: $resource->compose_parsing_version),
]);
}
$payload = collect([

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
<?php
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@@ -115,163 +114,71 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
$resource->image = $updatedImage;
$resource->save();
}
$serviceName = str($resource->name)->upper()->replace('-', '_');
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")->delete();
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")->delete();
if ($resource->fqdn) {
$resourceFqdns = str($resource->fqdn)->explode(',');
if ($resourceFqdns->count() === 1) {
$resourceFqdns = $resourceFqdns->first();
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
$url = Url::fromString($resourceFqdns);
$port = $url->getPort();
$path = $url->getPath();
$urlValue = $url->getScheme().'://'.$url->getHost();
$urlValue = ($path === '/') ? $urlValue : $urlValue.$path;
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $urlValue,
'is_build_time' => false,
'is_preview' => false,
]);
if ($port) {
$variableName = $variableName."_$port";
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $urlValue,
'is_build_time' => false,
'is_preview' => false,
]);
}
$variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_');
$fqdn = Url::fromString($resourceFqdns);
$port = $fqdn->getPort();
$path = $fqdn->getPath();
$fqdn = $fqdn->getScheme().'://'.$fqdn->getHost();
$fqdnValue = ($path === '/') ? $fqdn : $fqdn.$path;
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
if ($port) {
$variableName = $variableName."_$port";
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
}
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
$url = Url::fromString($fqdn);
$port = $url->getPort();
$path = $url->getPath();
$url = $url->getHost();
$urlValue = str($fqdn)->after('://');
$fqdn = $fqdn->getHost();
$fqdnValue = str($fqdn)->after('://');
if ($path !== '/') {
$urlValue = $urlValue.$path;
$fqdnValue = $fqdnValue.$path;
}
EnvironmentVariable::updateOrCreate([
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $urlValue,
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
if ($port) {
$variableName = $variableName."_$port";
EnvironmentVariable::updateOrCreate([
$resource->service->environment_variables()->updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $urlValue,
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
}
} elseif ($resourceFqdns->count() > 1) {
foreach ($resourceFqdns as $fqdn) {
$host = Url::fromString($fqdn);
$port = $host->getPort();
$url = $host->getHost();
$path = $host->getPath();
$host = $host->getScheme().'://'.$host->getHost();
if ($port) {
$port_envs = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'like', "SERVICE_FQDN_%_$port")
->get();
foreach ($port_envs as $port_env) {
$service_fqdn = str($port_env->key)->beforeLast('_')->after('SERVICE_FQDN_');
$env = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'SERVICE_FQDN_'.$service_fqdn)
->first();
if ($env) {
if ($path === '/') {
$env->value = $host;
} else {
$env->value = $host.$path;
}
$env->save();
}
if ($path === '/') {
$port_env->value = $host;
} else {
$port_env->value = $host.$path;
}
$port_env->save();
}
$port_envs_url = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'like', "SERVICE_URL_%_$port")
->get();
foreach ($port_envs_url as $port_env_url) {
$service_url = str($port_env_url->key)->beforeLast('_')->after('SERVICE_URL_');
$env = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'SERVICE_URL_'.$service_url)
->first();
if ($env) {
if ($path === '/') {
$env->value = $url;
} else {
$env->value = $url.$path;
}
$env->save();
}
if ($path === '/') {
$port_env_url->value = $url;
} else {
$port_env_url->value = $url.$path;
}
$port_env_url->save();
}
} else {
$variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_');
$generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', $variableName)
->first();
$fqdn = Url::fromString($fqdn);
$fqdn = $fqdn->getScheme().'://'.$fqdn->getHost().$fqdn->getPath();
if ($generatedEnv) {
$generatedEnv->value = $fqdn;
$generatedEnv->save();
}
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
$generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', $variableName)
->first();
$url = Url::fromString($fqdn);
$url = $url->getHost().$url->getPath();
if ($generatedEnv) {
$url = str($fqdn)->after('://');
$generatedEnv->value = $url;
$generatedEnv->save();
}
}
}
}
} else {
// If FQDN is removed, delete the corresponding environment variables
$serviceName = str($resource->name)->upper()->replace('-', '_');
EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")
->delete();
EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")
->delete();
}
} catch (\Throwable $e) {
return handleError($e);

File diff suppressed because it is too large Load Diff

2
changelogs/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -47,6 +47,7 @@
"socialiteproviders/zitadel": "^4.2",
"spatie/laravel-activitylog": "^4.10.2",
"spatie/laravel-data": "^4.17.0",
"spatie/laravel-markdown": "^2.7",
"spatie/laravel-ray": "^1.40.2",
"spatie/laravel-schemaless-attributes": "^2.5.1",
"spatie/url": "^2.4",

203
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "52a680a0eb446dcaa74bc35e158aca57",
"content-hash": "a78cf8fdfec25eac43de77c05640dc91",
"packages": [
{
"name": "amphp/amp",
@@ -7902,6 +7902,66 @@
],
"time": "2025-05-08T15:41:09+00:00"
},
{
"name": "spatie/commonmark-shiki-highlighter",
"version": "2.5.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/commonmark-shiki-highlighter.git",
"reference": "595c7e0b45d4a63b17dfc1ccbd13532d431ec351"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/commonmark-shiki-highlighter/zipball/595c7e0b45d4a63b17dfc1ccbd13532d431ec351",
"reference": "595c7e0b45d4a63b17dfc1ccbd13532d431ec351",
"shasum": ""
},
"require": {
"league/commonmark": "^2.4.2",
"php": "^8.0",
"spatie/shiki-php": "^2.2.2",
"symfony/process": "^5.4|^6.4|^7.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.19|^v3.49.0",
"phpunit/phpunit": "^9.5",
"spatie/phpunit-snapshot-assertions": "^4.2.7",
"spatie/ray": "^1.28"
},
"type": "commonmark-extension",
"autoload": {
"psr-4": {
"Spatie\\CommonMarkShikiHighlighter\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"role": "Developer"
}
],
"description": "Highlight code blocks with league/commonmark and Shiki",
"homepage": "https://github.com/spatie/commonmark-shiki-highlighter",
"keywords": [
"commonmark-shiki-highlighter",
"spatie"
],
"support": {
"source": "https://github.com/spatie/commonmark-shiki-highlighter/tree/2.5.1"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-01-13T11:25:47+00:00"
},
{
"name": "spatie/laravel-activitylog",
"version": "4.10.2",
@@ -8076,6 +8136,82 @@
],
"time": "2025-06-25T11:36:37+00:00"
},
{
"name": "spatie/laravel-markdown",
"version": "2.7.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-markdown.git",
"reference": "353e7f9fae62826e26cbadef58a12ecf39685280"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-markdown/zipball/353e7f9fae62826e26cbadef58a12ecf39685280",
"reference": "353e7f9fae62826e26cbadef58a12ecf39685280",
"shasum": ""
},
"require": {
"illuminate/cache": "^9.0|^10.0|^11.0|^12.0",
"illuminate/contracts": "^9.0|^10.0|^11.0|^12.0",
"illuminate/support": "^9.0|^10.0|^11.0|^12.0",
"illuminate/view": "^9.0|^10.0|^11.0|^12.0",
"league/commonmark": "^2.6.0",
"php": "^8.1",
"spatie/commonmark-shiki-highlighter": "^2.5",
"spatie/laravel-package-tools": "^1.4.3"
},
"require-dev": {
"brianium/paratest": "^6.2|^7.8",
"nunomaduro/collision": "^5.3|^6.0|^7.0|^8.0",
"orchestra/testbench": "^6.15|^7.0|^8.0|^10.0",
"pestphp/pest": "^1.22|^2.0|^3.7",
"phpunit/phpunit": "^9.3|^11.5.3",
"spatie/laravel-ray": "^1.23",
"spatie/pest-plugin-snapshots": "^1.1|^2.2|^3.0",
"vimeo/psalm": "^4.8|^6.7"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\LaravelMarkdown\\MarkdownServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\LaravelMarkdown\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"role": "Developer"
}
],
"description": "A highly configurable markdown renderer and Blade component for Laravel",
"homepage": "https://github.com/spatie/laravel-markdown",
"keywords": [
"Laravel-Markdown",
"laravel",
"spatie"
],
"support": {
"source": "https://github.com/spatie/laravel-markdown/tree/2.7.1"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-02-21T13:43:18+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.92.7",
@@ -8515,6 +8651,71 @@
],
"time": "2025-04-18T08:17:40+00:00"
},
{
"name": "spatie/shiki-php",
"version": "2.3.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/shiki-php.git",
"reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/shiki-php/zipball/a2e78a9ff8a1290b25d550be8fbf8285c13175c5",
"reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^8.0",
"symfony/process": "^5.4|^6.4|^7.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.0",
"pestphp/pest": "^1.8",
"phpunit/phpunit": "^9.5",
"spatie/pest-plugin-snapshots": "^1.1",
"spatie/ray": "^1.10"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\ShikiPhp\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Rias Van der Veken",
"email": "rias@spatie.be",
"role": "Developer"
},
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"role": "Developer"
}
],
"description": "Highlight code using Shiki in PHP",
"homepage": "https://github.com/spatie/shiki-php",
"keywords": [
"shiki",
"spatie"
],
"support": {
"source": "https://github.com/spatie/shiki-php/tree/2.3.2"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-02-21T14:16:57+00:00"
},
{
"name": "spatie/url",
"version": "2.4.0",

View File

@@ -22,7 +22,8 @@ return [
'services' => [
// Temporary disabled until cache is implemented
// 'official' => 'https://cdn.coollabs.io/coolify/service-templates.json',
'official' => 'https://raw.githubusercontent.com/coollabsio/coolify/main/templates/service-templates.json',
'official' => 'https://raw.githubusercontent.com/coollabsio/coolify/v4.x/templates/service-templates-latest.json',
'file_name' => 'service-templates-latest.json',
],
'terminal' => [

View File

@@ -65,6 +65,6 @@ return [
'client_secret' => env('ZITADEL_CLIENT_SECRET'),
'redirect' => env('ZITADEL_REDIRECT_URI'),
'base_url' => env('ZITADEL_BASE_URL'),
]
],
];

View File

@@ -0,0 +1,34 @@
<?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::create('user_changelog_reads', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('release_tag'); // GitHub tag_name (e.g., "v4.0.0-beta.420.6")
$table->timestamp('read_at');
$table->timestamps();
$table->unique(['user_id', 'release_tag']);
$table->index('user_id');
$table->index('release_tag');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_changelog_reads');
}
};

View File

@@ -0,0 +1,28 @@
<?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('scheduled_database_backups', function (Blueprint $table) {
$table->boolean('disable_local_backup')->default(false)->after('save_s3');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('scheduled_database_backups', function (Blueprint $table) {
$table->dropColumn('disable_local_backup');
});
}
};

View File

@@ -120,6 +120,7 @@ COPY --chown=www-data:www-data templates ./templates
COPY --chown=www-data:www-data resources/views ./resources/views
COPY --chown=www-data:www-data artisan artisan
COPY --chown=www-data:www-data openapi.yaml ./openapi.yaml
COPY --chown=www-data:www-data changelogs/ ./changelogs/
RUN composer dump-autoload

3
public/svgs/bluesky.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="600" height="530" xmlns="http://www.w3.org/2000/svg">
<path d="M135.72 44.03C202.216 93.951 273.74 195.17 300 249.49c26.262-54.316 97.782-155.54 164.28-205.46C512.26 8.009 590-19.862 590 68.825c0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.38-3.69-10.832-3.708-7.896-.017-2.936-1.193.516-3.707 7.896-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.45-163.25-81.433C20.15 217.613 9.997 86.535 9.997 68.825c0-88.687 77.742-60.816 125.72-24.795z" fill="#1185fe"/>
</svg>

After

Width:  |  Height:  |  Size: 665 B

BIN
public/svgs/drizzle.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -8,6 +8,7 @@
data_get($application, 'previews', collect([]))->count() > 0 ||
data_get($application, 'ports_mappings_array')) &&
data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true)
<div class="flex flex-col gap-1">
@if (data_get($application, 'gitBrancLocation'))
<a target="_blank" class="dropdown-item" href="{{ $application->gitBranchLocation }}">
<x-git-icon git="{{ $application->source?->getMorphClass() }}" />
@@ -19,14 +20,7 @@
@if (data_get($fqdn, 'domain'))
@foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
<a class="dropdown-item" target="_blank" href="{{ getFqdnWithoutPort($domain) }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 15l6 -6" />
<path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" />
<path
d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" />
</svg>{{ getFqdnWithoutPort($domain) }}
<x-external-link class="size-4" />{{ getFqdnWithoutPort($domain) }}
</a>
@endforeach
@endif
@@ -35,13 +29,7 @@
@if (data_get($application, 'fqdn'))
@foreach (str(data_get($application, 'fqdn'))->explode(',') as $fqdn)
<a class="dropdown-item" target="_blank" href="{{ getFqdnWithoutPort($fqdn) }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 15l6 -6" />
<path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" />
<path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" />
</svg>{{ getFqdnWithoutPort($fqdn) }}
<x-external-link class="size-4" />{{ getFqdnWithoutPort($fqdn) }}
</a>
@endforeach
@endif
@@ -52,15 +40,8 @@
@if (data_get($fqdn, 'domain'))
@foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
<a class="dropdown-item" target="_blank" href="{{ getFqdnWithoutPort($domain) }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 15l6 -6" />
<path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" />
<path
d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" />
</svg>PR{{ data_get($preview, 'pull_request_id') }} |
<x-external-link class="size-4" />PR{{ data_get($preview, 'pull_request_id') }}
|
{{ getFqdnWithoutPort($domain) }}
</a>
@endforeach
@@ -72,15 +53,7 @@
@if (data_get($preview, 'fqdn'))
<a class="dropdown-item" target="_blank"
href="{{ getFqdnWithoutPort(data_get($preview, 'fqdn')) }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 15l6 -6" />
<path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" />
<path
d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" />
</svg>
<x-external-link class="size-4" />
PR{{ data_get($preview, 'pull_request_id') }} |
{{ data_get($preview, 'fqdn') }}
</a>
@@ -92,42 +65,20 @@
@foreach ($application->ports_mappings_array as $port)
@if ($application->destination->server->id === 0)
<a class="dropdown-item" target="_blank" href="http://localhost:{{ explode(':', $port)[0] }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 15l6 -6" />
<path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" />
<path
d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" />
</svg>
<x-external-link class="size-4" />
Port {{ $port }}
</a>
@else
<a class="dropdown-item" target="_blank"
href="http://{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 15l6 -6" />
<path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" />
<path
d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" />
</svg>
<x-external-link class="size-4" />
{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}
</a>
@if (count($application->additional_servers) > 0)
@foreach ($application->additional_servers as $server)
<a class="dropdown-item" target="_blank"
href="http://{{ $server->ip }}:{{ explode(':', $port)[0] }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 15l6 -6" />
<path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" />
<path
d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" />
</svg>
<x-external-link class="size-4" />
{{ $server->ip }}:{{ explode(':', $port)[0] }}
</a>
@endforeach
@@ -135,6 +86,7 @@
@endif
@endforeach
@endif
</div>
@else
<div class="px-2 py-1.5 text-xs">No links available</div>
@endif

View File

@@ -1,7 +1,6 @@
<svg class="inline-flex w-3 h-3 ml-1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"
focusable="false" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none">
</path>
<path d="M9 5v2h6.59L4 18.59 5.41 20 17 8.41V15h2V5H9z" fill="currentColor">
</path>
@props(['class' => 'inline-flex w-3 h-3 dark:text-neutral-400 text-black'])
<svg {{ $attributes->merge(['class' => $class]) }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 356 B

View File

@@ -32,7 +32,7 @@
wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}">
aria-placeholder="{{ $attributes->get('placeholder') }}" @if ($autofocus) x-ref="autofocusInput" @endif>
</div>
@else
@@ -45,7 +45,7 @@
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"
maxlength="{{ $attributes->get('maxlength') }}"
@if ($id !== 'null') id={{ $id }} @endif name="{{ $name }}"
placeholder="{{ $attributes->get('placeholder') }}">
placeholder="{{ $attributes->get('placeholder') }}" @if ($autofocus) x-ref="autofocusInput" @endif>
@endif
@if (!$label && $helper)
<x-helper :helper="$helper" />

View File

@@ -1,4 +1,5 @@
<nav class="flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 dark:bg-base" x-data="{
<nav class="flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base"
x-data="{
switchWidth() {
if (this.full === 'full') {
localStorage.setItem('pageWidth', 'center');
@@ -81,39 +82,7 @@
<div class="text-2xl font-bold tracking-wide dark:text-white">Coolify</div>
<x-version />
</div>
<div class="pt-1">
<x-dropdown>
<x-slot:title>
<div class="flex justify-end w-8" x-show="theme === 'dark' || theme === 'system'">
<svg class="w-5 h-5 rounded-sm dark:fill-white" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
</svg>
</div>
<div class="flex justify-end w-8" x-show="theme === 'light'">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
</svg>
</div>
</x-slot:title>
<div class="flex flex-col gap-1">
<div class="font-bold border-b dark:border-coolgray-500 dark:text-white text-md">Color</div>
<button @click="setTheme('dark')" class="px-1 dropdown-item-no-padding">Dark</button>
<button @click="setTheme('light')" class="px-1 dropdown-item-no-padding">Light</button>
<button @click="setTheme('system')" class="px-1 dropdown-item-no-padding">System</button>
<div class="my-1 font-bold border-b dark:border-coolgray-500 dark:text-white text-md">Width</div>
<button @click="switchWidth()" class="px-1 dropdown-item-no-padding"
x-show="full === 'full'">Center</button>
<button @click="switchWidth()" class="px-1 dropdown-item-no-padding"
x-show="full === 'center'">Full</button>
<div class="my-1 font-bold border-b dark:border-coolgray-500 dark:text-white text-md">Zoom</div>
<button @click="setZoom(100)" class="px-1 dropdown-item-no-padding">100%</button>
<button @click="setZoom(90)" class="px-1 dropdown-item-no-padding">90%</button>
</div>
</x-dropdown>
</div>
<livewire:settings-dropdown />
</div>
<div class="px-2 pt-2 pb-7">
<livewire:switch-team />
@@ -196,8 +165,8 @@
class="{{ request()->is('storages*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('storage.index') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2">
<path d="M4 6a8 3 0 1 0 16 0A8 3 0 1 0 4 6" />
<path d="M4 6v6a8 3 0 0 0 16 0V6" />
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
@@ -211,8 +180,8 @@
class="{{ request()->is('shared-variables*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('shared-variables.index') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2">
<path
d="M5 4C2.5 9 2.5 14 5 20M19 4c2.5 5 2.5 10 0 16M9 9h1c1 0 1 1 2.016 3.527C13 15 13 16 14 16h1" />
<path d="M8 16c1.5 0 3-2 4-3.5S14.5 9 16 9" />

View File

@@ -5,13 +5,7 @@
</x-slot>
@foreach ($links as $link)
<a class="dropdown-item" target="_blank" href="{{ $link }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 15l6 -6" />
<path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" />
<path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" />
</svg>{{ $link }}
<x-external-link class="size-4" />{{ $link }}
</a>
@endforeach
</x-dropdown>

View File

@@ -6,7 +6,8 @@
@if ($exception->getMessage())
<p class="text-base leading-7 text-red-500">{{ $exception->getMessage() }}</p>
@else
<p class="text-base leading-7 text-neutral-300">The request could not be understood by the server due to
<p class="text-base leading-7 dark:text-neutral-400 text-black">The request could not be understood by the
server due to
malformed syntax.
</p>
@endif

View File

@@ -3,7 +3,7 @@
<div>
<p class="font-mono font-semibold text-7xl dark:text-warning">401</p>
<h1 class="mt-4 font-bold tracking-tight dark:text-white">You shall not pass!</h1>
<p class="text-base leading-7 text-neutral-300">You don't have permission to access this page.
<p class="text-base leading-7 dark:text-neutral-400 text-black">You don't have permission to access this page.
</p>
<div class="flex items-center mt-10 gap-x-6">
<a href="/">

View File

@@ -3,7 +3,7 @@
<div>
<p class="font-mono font-semibold text-7xl dark:text-warning">403</p>
<h1 class="mt-4 font-bold tracking-tight dark:text-white">You shall not pass!</h1>
<p class="text-base leading-7 text-neutral-300">You don't have permission to access this page.
<p class="text-base leading-7 dark:text-neutral-400 text-black">You don't have permission to access this page.
</p>
<div class="flex items-center mt-10 gap-x-6">
<a href="/">

View File

@@ -3,7 +3,7 @@
<div>
<p class="font-mono font-semibold text-7xl dark:text-warning">404</p>
<h1 class="mt-4 font-bold tracking-tight dark:text-white">How did you get here?</h1>
<p class="text-base leading-7 text-neutral-300">Sorry, we couldnt find the page youre looking
<p class="text-base leading-7 dark:text-neutral-400 text-black">Sorry, we couldnt find the page youre looking
for.
</p>
<div class="flex items-center mt-10 gap-x-6">

View File

@@ -3,7 +3,7 @@
<div>
<p class="font-mono font-semibold text-7xl dark:text-warning">419</p>
<h1 class="mt-4 font-bold tracking-tight dark:text-white">This page is definitely old, not like you!</h1>
<p class="text-base leading-7 text-neutral-300">Sorry, we couldnt find the page youre looking
<p class="text-base leading-7 dark:text-neutral-300 text-black">Sorry, we couldnt find the page youre looking
for.
</p>
<div class="flex items-center mt-10 gap-x-6">

View File

@@ -3,7 +3,8 @@
<div>
<p class="font-mono font-semibold text-7xl dark:text-warning">429</p>
<h1 class="mt-4 font-bold tracking-tight dark:text-white">Woah, slow down there!</h1>
<p class="text-base leading-7 text-neutral-300">You're making too many requests. Please wait a few
<p class="text-base leading-7 dark:text-neutral-400 text-black">You're making too many requests. Please wait a
few
seconds before trying again.
</p>
<div class="flex items-center mt-10 gap-x-6">

View File

@@ -3,7 +3,8 @@
<div class="w-full max-w-3xl px-8">
<p class="font-mono font-semibold text-red-500 text-[200px] leading-none">500</p>
<h1 class="text-3xl font-bold tracking-tight dark:text-white">Wait, this is not cool...</h1>
<p class="mt-2 text-lg leading-7 text-neutral-300">There has been an error with the following error message:</p>
<p class="mt-2 text-lg leading-7 dark:text-neutral-400 text-black">There has been an error with the following
error message:</p>
@if ($exception->getMessage() !== '')
<div class="mt-6 text-sm text-red-500">
{!! Purify::clean($exception->getMessage()) !!}
@@ -13,8 +14,8 @@
<a href="/">
<x-forms.button>Go back home</x-forms.button>
</a>
<a target="_blank" class="text-sm hover:text-neutral-300 flex items-center gap-1" href="{{ config('constants.urls.contact') }}">
Contact support
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact
support
<x-external-link />
</a>
</div>

View File

@@ -3,14 +3,13 @@
<div>
<p class="font-mono font-semibold text-7xl dark:text-warning">503</p>
<h1 class="mt-4 font-bold tracking-tight dark:text-white">We are working on serious things.</h1>
<p class="text-base leading-7 text-black dark:text-neutral-300">Service Unavailable. Be right back. Thanks for your
<p class="text-base leading-7 dark:text-neutral-400 text-black">Service Unavailable. Be right back. Thanks for
your
patience.
</p>
<div class="flex items-center mt-10 gap-x-6">
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact
support
<x-external-link />
</a>
</div>
</div>
</div>

View File

@@ -66,7 +66,8 @@
<h3 class="py-4">Deployments</h3>
<div class="flex flex-wrap w-full gap-4">
@foreach (data_get($application, 'previews') as $previewName => $preview)
<div class="flex flex-col w-full p-4 border dark:border-coolgray-200" wire:key="preview-container-{{ $preview->pull_request_id }}">
<div class="flex flex-col w-full p-4 border dark:border-coolgray-200"
wire:key="preview-container-{{ $preview->pull_request_id }}">
<div class="flex gap-2">PR #{{ data_get($preview, 'pull_request_id') }} |
@if (str(data_get($preview, 'status'))->startsWith('running'))
<x-status.running :status="data_get($preview, 'status')" />
@@ -85,6 +86,18 @@
PR on Git
<x-external-link />
</a>
@if (count($parameters) > 0)
|
<a
href="{{ route('project.application.deployment.index', [...$parameters, 'pull_request_id' => data_get($preview, 'pull_request_id')]) }}">
Deployment Logs
</a>
|
<a
href="{{ route('project.application.logs', [...$parameters, 'pull_request_id' => data_get($preview, 'pull_request_id')]) }}">
Application Logs
</a>
@endif
</div>
@if ($application->build_pack === 'dockercompose')
@@ -100,7 +113,8 @@
</form>
@else
@foreach (collect(json_decode($preview->docker_compose_domains)) as $serviceName => $service)
<livewire:project.application.previews-compose wire:key="preview-{{ $preview->pull_request_id }}-{{ $serviceName }}"
<livewire:project.application.previews-compose
wire:key="preview-{{ $preview->pull_request_id }}-{{ $serviceName }}"
:service="$service" :serviceName="$serviceName" :preview="$preview" />
@endforeach
@endif
@@ -114,21 +128,7 @@
Domain</x-forms.button>
</form>
@endif
<div class="flex items-center gap-2 pt-6">
@if (count($parameters) > 0)
<a
href="{{ route('project.application.deployment.index', [...$parameters, 'pull_request_id' => data_get($preview, 'pull_request_id')]) }}">
<x-forms.button>
Deployment Logs
</x-forms.button>
</a>
<a
href="{{ route('project.application.logs', [...$parameters, 'pull_request_id' => data_get($preview, 'pull_request_id')]) }}">
<x-forms.button>
Application Logs
</x-forms.button>
</a>
@endif
<div class="flex flex-col xl:flex-row xl:items-center gap-2 pt-6">
<div class="flex-1"></div>
<x-forms.button
wire:click="force_deploy_without_cache({{ data_get($preview, 'pull_request_id') }})">

View File

@@ -18,7 +18,7 @@
shortConfirmationLabel="Database Name" />
@endif
</div>
<div class="w-48 pb-2">
<div class="w-64 pb-2">
<x-forms.checkbox instantSave label="Backup Enabled" id="backupEnabled" />
@if ($s3s->count() > 0)
<x-forms.checkbox instantSave label="S3 Enabled" id="saveS3" />
@@ -26,11 +26,18 @@
<x-forms.checkbox instantSave helper="No validated S3 storage available." label="S3 Enabled" id="saveS3"
disabled />
@endif
@if ($backup->save_s3)
<x-forms.checkbox instantSave label="Disable Local Backup" id="disableLocalBackup"
helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3. This requires S3 backup to be enabled." />
@else
<x-forms.checkbox disabled label="Disable Local Backup" id="disableLocalBackup"
helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3. This requires S3 backup to be enabled." />
@endif
</div>
@if ($backup->save_s3)
<div class="pb-6">
<x-forms.select id="s3StorageId" label="S3 Storage" required>
<option value="default">Select a S3 storage</option>
<option value="default" disabled>Select a S3 storage</option>
@foreach ($s3s as $s3)
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
@endforeach

View File

@@ -1,12 +1,13 @@
<div>
<div x-data x-init="$nextTick(() => { if ($refs.autofocusInput) $refs.autofocusInput.focus(); })">
<h1>Create a new Application</h1>
<div class="pb-4">Deploy any public Git repositories.</div>
<!-- Repository URL Form -->
<form class="flex flex-col gap-2" wire:submit='loadBranch'>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<div class="flex gap-2 items-end">
<x-forms.input required id="repository_url" label="Repository URL (https://)"
helper="{!! __('repository.url') !!}" />
helper="{!! __('repository.url') !!}" autofocus />
<x-forms.button type="submit">
Check repository
</x-forms.button>
@@ -16,6 +17,9 @@
href="https://github.com/coollabsio/coolify-examples/" target="_blank">Coolify
Examples</a>.
</div>
</div>
</form>
@if ($branchFound)
@if ($rate_limit_remaining && $rate_limit_reset)
<div class="flex gap-2 py-2">
@@ -24,6 +28,9 @@
helper="Rate limit remaining: {{ $rate_limit_remaining }}<br>Rate limit reset at: {{ $rate_limit_reset }} UTC" />
</div>
@endif
<!-- Application Configuration Form -->
<form class="flex flex-col gap-2 pt-4" wire:submit='submit'>
<div class="flex flex-col gap-2 pb-6">
<div class="flex gap-2">
@if ($git_source === 'other')
@@ -45,13 +52,18 @@
@endif
</div>
@if ($build_pack === 'dockercompose')
<div x-data="{ baseDir: '{{ $base_directory }}', composeLocation: '{{ $docker_compose_location }}' }" class="gap-2 flex flex-col">
<x-forms.input placeholder="/" wire:model.blur-sm="base_directory" label="Base Directory"
helper="Directory to use as root. Useful for monorepos." />
helper="Directory to use as root. Useful for monorepos." x-model="baseDir" />
<x-forms.input placeholder="/docker-compose.yaml" wire:model.blur-sm="docker_compose_location"
label="Docker Compose Location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($base_directory . $docker_compose_location, '/') }}</span>" />
Compose file location in your repository:<span
class='dark:text-warning'>{{ Str::start($base_directory . $docker_compose_location, '/') }}</span>
label="Docker Compose Location" helper="It is calculated together with the Base Directory."
x-model="composeLocation" />
<div class="pt-2">
<span>
Compose file location in your repository: </span><span class='dark:text-warning'
x-text='(baseDir === "/" ? "" : baseDir) + (composeLocation.startsWith("/") ? composeLocation : "/" + composeLocation)'></span>
</div>
</div>
@else
<x-forms.input wire:model="base_directory" label="Base Directory"
helper="Directory to use as root. Useful for monorepos." />
@@ -64,21 +76,10 @@
helper="If your application is a static site or the final build assets should be served as a static site, enable this." />
</div>
@endif
{{-- <div class="w-64">
<x-forms.checkbox helper="If your repository contains a coolify.json file, it will be used to configure your application." instantSave id="checkCoolifyConfig" label="Use coolify.json if exists?" />
</div> --}}
{{-- @if ($build_pack === 'dockercompose' && isDev())
<div class="dark:text-warning">If you choose Docker Compose based deployments, you cannot
change it afterwards.</div>
<x-forms.checkbox instantSave label="New Compose Services (only in dev mode)"
id="new_compose_services"></x-forms.checkbox>
@endif --}}
</div>
<x-forms.button wire:click.prevent='submit'>
<x-forms.button type="submit">
Continue
</x-forms.button>
</form>
@endif
</div>
</div>
</form>
</div>

View File

@@ -2,6 +2,9 @@
<div>
<div class="flex gap-2">
<h2>Service Stack</h2>
@if (isDev())
<div>{{ $service->compose_parsing_version }}</div>
@endif
<x-forms.button wire:target='submit' type="submit">Save</x-forms.button>
<x-modal-input buttonTitle="Edit Compose File" title="Edit Docker Compose" :closeOutside="false">
<livewire:project.service.edit-compose serviceId="{{ $service->id }}" />

View File

@@ -9,7 +9,8 @@
</svg>
<div class="text-center">
<h3 class="text-lg font-medium">Terminal Not Available</h3>
<p class="mt-2 text-sm text-gray-500">No shell (bash/sh) is available in this container. Please
<p class="mt-2 text-sm text-neutral-300">No shell (bash/sh) is available in this container.
Please
ensure either bash or sh is installed to use the terminal.</p>
</div>
</div>

View File

@@ -22,6 +22,10 @@
soon.<br/>Status notifications sent every week.<br/>You can disable notifications in the <a class='dark:text-white underline' href='{{ route('notifications.email') }}'>notification settings</a>." />
<x-forms.button type="button" wire:click="$dispatch('checkForUpdatesDispatch')">
Check Now</x-forms.button>
@if (isDev())
<x-forms.button type="button" wire:click="sendTestEmail">
Send Test Email (dev only)</x-forms.button>
@endif
</div>
<div>Update your servers semi-automatically.</div>
<div>

View File

@@ -0,0 +1,337 @@
<div x-data="{
dropdownOpen: false,
search: '',
allEntries: [],
init() {
this.mounted();
// Load all entries when component initializes
this.allEntries = @js($entries->toArray());
},
markEntryAsRead(tagName) {
// Update the entry in our local Alpine data
const entry = this.allEntries.find(e => e.tag_name === tagName);
if (entry) {
entry.is_read = true;
}
// Call Livewire to update server-side
$wire.markAsRead(tagName);
},
markAllEntriesAsRead() {
// Update all entries in our local Alpine data
this.allEntries.forEach(entry => {
entry.is_read = true;
});
// Call Livewire to update server-side
$wire.markAllAsRead();
},
switchWidth() {
if (this.full === 'full') {
localStorage.setItem('pageWidth', 'center');
} else {
localStorage.setItem('pageWidth', 'full');
}
window.location.reload();
},
setZoom(zoom) {
localStorage.setItem('zoom', zoom);
window.location.reload();
},
setTheme(type) {
this.theme = type;
localStorage.setItem('theme', type);
this.queryTheme();
},
queryTheme() {
const darkModePreference = window.matchMedia('(prefers-color-scheme: dark)').matches;
const userSettings = localStorage.getItem('theme') || 'dark';
localStorage.setItem('theme', userSettings);
if (userSettings === 'dark') {
document.documentElement.classList.add('dark');
this.theme = 'dark';
} else if (userSettings === 'light') {
document.documentElement.classList.remove('dark');
this.theme = 'light';
} else if (darkModePreference) {
this.theme = 'system';
document.documentElement.classList.add('dark');
} else if (!darkModePreference) {
this.theme = 'system';
document.documentElement.classList.remove('dark');
}
},
mounted() {
this.full = localStorage.getItem('pageWidth');
this.zoom = localStorage.getItem('zoom');
this.queryTheme();
},
get filteredEntries() {
let entries = this.allEntries;
// Apply search filter if search term exists
if (this.search && this.search.trim() !== '') {
const searchLower = this.search.trim().toLowerCase();
entries = entries.filter(entry => {
return (entry.title?.toLowerCase().includes(searchLower) ||
entry.content?.toLowerCase().includes(searchLower) ||
entry.tag_name?.toLowerCase().includes(searchLower));
});
}
// Always sort: unread first, then by published date (newest first)
return entries.sort((a, b) => {
// First sort by read status (unread first)
if (a.is_read !== b.is_read) {
return a.is_read ? 1 : -1; // unread (false) comes before read (true)
}
// Then sort by published date (newest first)
return new Date(b.published_at) - new Date(a.published_at);
});
}
}" @click.outside="dropdownOpen = false">
<!-- Custom Dropdown without arrow -->
<div class="relative">
<button @click="dropdownOpen = !dropdownOpen"
class="relative p-2 dark:text-neutral-400 hover:dark:text-white transition-colors cursor-pointer"
title="Settings">
<!-- Gear Icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="Settings">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<!-- Unread Count Badge -->
@if ($unreadCount > 0)
<span
class="absolute -top-1 -right-1 bg-error text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
{{ $unreadCount > 9 ? '9+' : $unreadCount }}
</span>
@endif
</button>
<!-- Dropdown Menu -->
<div x-show="dropdownOpen" x-transition:enter="ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="ease-in duration-150" x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2" class="absolute right-0 top-full mt-1 z-50 w-48" x-cloak>
<div
class="p-1 bg-white border rounded-sm shadow-lg dark:bg-coolgray-200 dark:border-black border-neutral-300">
<div class="flex flex-col gap-1">
<!-- What's New Section -->
@if ($unreadCount > 0)
<button wire:click="openWhatsNewModal" @click="dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>What's New</span>
</div>
<span
class="bg-error text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{{ $unreadCount > 9 ? '9+' : $unreadCount }}
</span>
</button>
@else
<button wire:click="openWhatsNewModal" @click="dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>Changelog</span>
</button>
@endif
<!-- Divider -->
<div class="border-b dark:border-coolgray-500 border-neutral-300"></div>
<!-- Theme Section -->
<div class="font-bold border-b dark:border-coolgray-500 border-neutral-300 dark:text-white pb-1">
Appearance</div>
<button @click="setTheme('dark'); dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
<span>Dark</span>
</button>
<button @click="setTheme('light'); dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span>Light</span>
</button>
<button @click="setTheme('system'); dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span>System</span>
</button>
<!-- Width Section -->
<div
class="my-1 font-bold border-b dark:border-coolgray-500 border-neutral-300 dark:text-white text-md">
Width</div>
<button @click="switchWidth(); dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2" x-show="full === 'full'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h7" />
</svg>
<span>Center</span>
</button>
<button @click="switchWidth(); dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2" x-show="full === 'center'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
</svg>
<span>Full</span>
</button>
<!-- Zoom Section -->
<div
class="my-1 font-bold border-b dark:border-coolgray-500 border-neutral-300 dark:text-white text-md">
Zoom</div>
<button @click="setZoom(100); dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>100%</span>
</button>
<button @click="setZoom(90); dropdownOpen = false"
class="px-1 dropdown-item-no-padding flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 10h4v4h-4v-4z" />
</svg>
<span>90%</span>
</button>
</div>
</div>
</div>
</div>
<!-- What's New Modal -->
@if ($showWhatsNewModal)
<div class="fixed inset-0 z-50 flex items-center justify-center py-6 px-4"
@keydown.escape.window="$wire.closeWhatsNewModal()">
<!-- Background overlay -->
<div class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs" wire:click="closeWhatsNewModal">
</div>
<!-- Modal panel -->
<div
class="relative w-full h-full max-w-7xl py-6 border rounded-sm drop-shadow-sm bg-white border-neutral-200 dark:bg-base px-6 dark:border-coolgray-300 flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between pb-3">
<div>
<h3 class="text-2xl font-bold dark:text-white">
Changelog
</h3>
<p class="mt-1 text-sm dark:text-neutral-400">
Stay up to date with the latest features and improvements.
</p>
</div>
<div class="flex items-center gap-2">
@if (isDev())
<x-forms.button wire:click="manualFetchChangelog"
class="bg-coolgray-200 hover:bg-coolgray-300">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Fetch Latest
</x-forms.button>
@endif
@if ($unreadCount > 0)
<x-forms.button @click="markAllEntriesAsRead">
Mark all as read
</x-forms.button>
@endif
<button wire:click="closeWhatsNewModal"
class="flex items-center justify-center w-8 h-8 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 cursor-pointer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Search -->
<div class="pb-4 border-b dark:border-coolgray-200 flex-shrink-0">
<div class="relative">
<input x-model="search" placeholder="Search updates..." class="input pl-10" />
<svg class="absolute left-3 top-2 w-4 h-4 dark:text-neutral-400" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
<!-- Content -->
<div class="py-4 flex-1 overflow-y-auto scrollbar">
<div x-show="filteredEntries.length > 0">
<div class="space-y-4">
<template x-for="entry in filteredEntries" :key="entry.tag_name">
<div class="relative p-4 border dark:border-coolgray-300 rounded-sm"
:class="!entry.is_read ? 'dark:bg-coolgray-200 border-warning' : 'dark:bg-coolgray-100'">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span x-show="entry.title"
class="px-2 py-1 text-xs font-semibold dark:bg-coolgray-300 dark:text-neutral-200 rounded-sm"><a
:href="`https://github.com/coollabsio/coolify/releases/tag/${entry.tag_name}`"
target="_blank"
class="inline-flex items-center gap-1 hover:text-coolgray-500">
<span x-text="entry.title"></span>
<x-external-link />
</a></span>
<span class="text-xs dark:text-neutral-400"
x-text="new Date(entry.published_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })"></span>
</div>
<div class="dark:text-neutral-300 leading-relaxed max-w-none"
x-html="entry.content_html">
</div>
</div>
<button x-show="!entry.is_read" @click="markEntryAsRead(entry.tag_name)"
class="ml-4 px-3 py-1 text-xs dark:text-neutral-400 hover:dark:text-white border dark:border-neutral-600 rounded hover:dark:bg-neutral-700 transition-colors cursor-pointer"
title="Mark as read">
mark as read
</button>
</div>
</div>
</template>
</div>
</div>
<div x-show="filteredEntries.length === 0" class="text-center py-8">
<svg class="mx-auto h-12 w-12 dark:text-neutral-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="mt-2 text-sm font-medium dark:text-white">No updates found</h3>
<p class="mt-1 text-sm dark:text-neutral-400">
<span x-show="search.trim() !== ''">No updates match your search criteria.</span>
<span x-show="search.trim() === ''">There are no updates available at the moment.</span>
</p>
</div>
</div>
</div>
</div>
@endif
</div>

View File

@@ -45,7 +45,10 @@ Route::group([
Route::get('/projects', [ProjectController::class, 'projects'])->middleware(['api.ability:read']);
Route::get('/projects/{uuid}', [ProjectController::class, 'project_by_uuid'])->middleware(['api.ability:read']);
Route::get('/projects/{uuid}/environments', [ProjectController::class, 'get_environments'])->middleware(['api.ability:read']);
Route::get('/projects/{uuid}/{environment_name_or_uuid}', [ProjectController::class, 'environment_details'])->middleware(['api.ability:read']);
Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']);
Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:write']);
Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:read']);
Route::patch('/projects/{uuid}', [ProjectController::class, 'update_project'])->middleware(['api.ability:write']);

View File

@@ -8,13 +8,13 @@ services:
activepieces:
image: "ghcr.io/activepieces/activepieces:latest"
environment:
- SERVICE_FQDN_ACTIVEPIECES
- SERVICE_URL_ACTIVEPIECES
- AP_API_KEY=$SERVICE_PASSWORD_64_APIKEY
- AP_ENCRYPTION_KEY=$SERVICE_PASSWORD_ENCRYPTIONKEY
- AP_ENGINE_EXECUTABLE_PATH=${AP_ENGINE_EXECUTABLE_PATH:-dist/packages/engine/main.js}
- AP_ENVIRONMENT=${AP_ENVIRONMENT:-prod}
- AP_EXECUTION_MODE=${AP_EXECUTION_MODE:-UNSANDBOXED}
- AP_FRONTEND_URL=${SERVICE_FQDN_ACTIVEPIECES}
- AP_FRONTEND_URL=${SERVICE_URL_ACTIVEPIECES}
- AP_JWT_SECRET=$SERVICE_PASSWORD_64_JWT
- AP_POSTGRES_DATABASE=${POSTGRES_DB:-activepieces}
- AP_POSTGRES_HOST=${POSTGRES_HOST:-postgres}

View File

@@ -8,7 +8,7 @@ services:
actual_server:
image: actualbudget/actual-server:latest
environment:
- SERVICE_FQDN_ACTUAL_5006
- SERVICE_URL_ACTUAL_5006
- ACTUAL_LOGIN_METHOD=password
volumes:
- actual_data:/data

View File

@@ -20,13 +20,13 @@ services:
options:
max-size: 1000m
environment:
- SERVICE_FQDN_AFFINE_3010
- SERVICE_URL_AFFINE_3010
- AFFINE_CONFIG_PATH=/root/.affine/config
- REDIS_SERVER_HOST=redis
- DATABASE_URL=postgres://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@postgres:5432/${POSTGRES_DB:-affine}
- NODE_ENV=production
- AFFINE_SERVER_HOST=$SERVICE_FQDN_AFFINE
- AFFINE_SERVER_EXTERNAL_URL=$SERVICE_FQDN_AFFINE
- AFFINE_SERVER_HOST=$SERVICE_URL_AFFINE
- AFFINE_SERVER_EXTERNAL_URL=$SERVICE_URL_AFFINE
- MAILER_HOST=${MAILER_HOST}
- MAILER_PORT=${MAILER_PORT}
- MAILER_USER=${MAILER_USER}

View File

@@ -8,7 +8,7 @@ services:
anything-llm:
image: mintplexlabs/anythingllm
environment:
- SERVICE_FQDN_ANYTHINGLLM_3001
- SERVICE_URL_ANYTHINGLLM_3001
- STORAGE_DIR=/app/server/storage
- DISABLE_TELEMETRY=${DISABLE_TELEMETRY:-true}
- PASSWORDLOWERCASE=${PASSWORDLOWERCASE:-1}

View File

@@ -8,7 +8,7 @@ services:
apprise-api:
image: linuxserver/apprise-api:latest
environment:
- SERVICE_FQDN_APPRISE_8000
- SERVICE_URL_APPRISE_8000
- PUID=1000
- PGID=1000
- TZ=UTC

View File

@@ -7,7 +7,7 @@ services:
appsmith:
image: index.docker.io/appsmith/appsmith-ce:latest
environment:
- SERVICE_FQDN_APPSMITH
- SERVICE_URL_APPSMITH
- APPSMITH_MAIL_ENABLED=${APPSMITH_MAIL_ENABLED:-false}
- APPSMITH_DISABLE_TELEMETRY=${APPSMITH_DISABLE_TELEMETRY:-false}
- APPSMITH_DISABLE_INTERCOM=${APPSMITH_DISABLE_INTERCOM:-true}

View File

@@ -20,7 +20,7 @@ services:
- appwrite-mariadb
- appwrite-redis
environment:
- SERVICE_FQDN_APPWRITE=/
- SERVICE_URL_APPWRITE=/
- _APP_ENV=${_APP_ENV:-production}
- _APP_WORKER_PER_CORE=${_APP_WORKER_PER_CORE:-6}
- _APP_LOCALE=${_APP_LOCALE:-en}
@@ -128,7 +128,7 @@ services:
image: appwrite/console:6.0.13
container_name: appwrite-console
environment:
- SERVICE_FQDN_APPWRITE=/console
- SERVICE_URL_APPWRITE=/console
appwrite-realtime:
image: appwrite/appwrite:1.7.4
@@ -138,7 +138,7 @@ services:
- appwrite-mariadb
- appwrite-redis
environment:
- SERVICE_FQDN_APPWRITE=/v1/realtime
- SERVICE_URL_APPWRITE=/v1/realtime
- _APP_ENV=${_APP_ENV:-production}
- _APP_WORKER_PER_CORE=${_APP_WORKER_PER_CORE:-6}
- _APP_OPTIONS_ABUSE=${_APP_OPTIONS_ABUSE:-enabled}

View File

@@ -8,7 +8,7 @@ services:
argilla:
image: "argilla/argilla-server:v2.2.0"
environment:
- SERVICE_FQDN_ARGILLA_6900
- SERVICE_URL_ARGILLA_6900
- ARGILLA_HOME_PATH=/var/lib/argilla
- ARGILLA_ELASTICSEARCH=http://elasticsearch:9200
- ARGILLA_DATABASE_URL=postgresql+asyncpg://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@postgres:5432/${POSTGRES_DB}

View File

@@ -8,7 +8,7 @@ services:
audiobookshelf:
image: ghcr.io/advplyr/audiobookshelf:latest
environment:
- SERVICE_FQDN_AUDIOBOOKSHELF_80
- SERVICE_URL_AUDIOBOOKSHELF_80
- TZ=${TIMEZONE:-America/Toronto}
volumes:
- audiobookshelf-audiobooks:/audiobooks

View File

@@ -10,7 +10,7 @@ services:
restart: unless-stopped
command: server
environment:
- SERVICE_FQDN_AUTHENTIKSERVER_9000
- SERVICE_URL_AUTHENTIKSERVER_9000
- AUTHENTIK_REDIS__HOST=${REDIS_HOST:-redis}
- AUTHENTIK_POSTGRESQL__HOST=${POSTGRES_HOST:-postgresql}
- AUTHENTIK_POSTGRESQL__USER=${SERVICE_USER_POSTGRESQL}

View File

@@ -54,8 +54,8 @@ services:
image: bytemark/smtp:latest
platform: linux/amd64
environment:
- SERVICE_FQDN_SMTP
- RELAY_HOST=$SERVICE_FQDN_SMTP
- SERVICE_URL_SMTP
- RELAY_HOST=$SERVICE_URL_SMTP
- RELAY_PORT=${RELAY_PORT:-587}
- RELAY_USERNAME=$SERVICE_EMAIL_SMTP
- RELAY_PASSWORD=$SERVICE_PASSWORD_SMTP
@@ -75,7 +75,7 @@ services:
minio:
condition: service_healthy
environment:
- SERVICE_FQDN_AZIMUTT_4000
- SERVICE_URL_AZIMUTT_4000
- SENTRY=false
- PHX_SERVER=true
- PHX_HOST=$SERVICE_URL_AZIMUTT
@@ -92,7 +92,7 @@ services:
- S3_KEY_ID=${S3_KEY_ID}
- S3_KEY_SECRET=${S3_KEY_SECRET}
- EMAIL_ADAPTER=${EMAIL_ADAPTER:-smtp}
- SMTP_RELAY=$SERVICE_FQDN_SMTP
- SMTP_RELAY=$SERVICE_URL_SMTP
- SMTP_USERNAME=$SERVICE_EMAIL_SMTP
- SMTP_PASSWORD=$SERVICE_PASSWORD_SMTP
- SMTP_PORT=${SMTP_PORT:-587}

View File

@@ -7,11 +7,11 @@ services:
babybuddy:
image: lscr.io/linuxserver/babybuddy:latest
environment:
- SERVICE_FQDN_BABYBUDDY
- SERVICE_URL_BABYBUDDY
- PUID=1000
- PGID=1000
- TZ=Europe/Madrid
- CSRF_TRUSTED_ORIGINS=$SERVICE_FQDN_BABYBUDDY
- CSRF_TRUSTED_ORIGINS=$SERVICE_URL_BABYBUDDY
volumes:
- babybuddy-config:/config
healthcheck:

View File

@@ -10,7 +10,7 @@ services:
beszel:
image: henrygd/beszel:latest
environment:
- SERVICE_FQDN_BESZEL_8090
- SERVICE_URL_BESZEL_8090
volumes:
- beszel_data:/beszel_data

View File

@@ -1,34 +1,64 @@
# ignore: true
# documentation: https://github.com/bluesky-social/pds
# slogan: A social network for the decentralized web
# tags: pds, bluesky, social, network, decentralized
# logo:
# slogan: Bluesky PDS (Personal Data Server)
# tags: bluesky, pds, platform
# logo: svgs/bluesky.svg
# port: 3000
services:
pds:
image: ghcr.io/bluesky-social/pds:0.4
image: 'ghcr.io/bluesky-social/pds:latest'
volumes:
- pds-data:/pds
- ./pds-data:/pds
environment:
- SERVICE_FQDN_PDS_3000
- PDS_JWT_SECRET=${SERVICE_BASE64_PDS}
- PDS_ADMIN_PASSWORD=${SERVICE_PASSWORD_PDS}
- PDS_ADMIN_EMAIL=${PDS_ADMIN_EMAIL:-admin@example.com}
- PDS_DATADIR=${PDS_DATADIR:-/pds}
- PDS_BLOBSTORE_DISK_LOCATION=${PDS_DATADIR:-/pds}/blocks
- PDS_BLOB_UPLOAD_LIMIT=${PDS_BLOB_UPLOAD_LIMIT:-52428800}
- SERVICE_URL_PDS_3000
- PDS_HOSTNAME=${SERVICE_URL_PDS}
- PDS_DID_PLC_URL=https://plc.directory
- PDS_BSKY_APP_VIEW_URL=https://api.bsky.app
- PDS_BSKY_APP_VIEW_DID=did:web:api.bsky.app
- PDS_REPORT_SERVICE_URL=https://mod.bsky.app
- PDS_REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac
- PDS_CRAWLERS=https://bsky.network
- PDS_JWT_SECRET=${SERVICE_PASSWORD_JWT_SECRET}
- PDS_ADMIN_PASSWORD=${SERVICE_PASSWORD_ADMIN}
- PDS_ADMIN_EMAIL=${SERVICE_EMAIL_ADMIN}
- PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX}
- PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}
- PDS_BLOBSTORE_DISK_LOCATION=${PDS_DATA_DIRECTORY:-/pds}/blocks
- PDS_BLOB_UPLOAD_LIMIT=${PDS_BLOB_UPLOAD_LIMIT:-52428800}
- PDS_DID_PLC_URL=${PDS_DID_PLC_URL:-https://plc.directory}
- PDS_BSKY_APP_VIEW_URL=${PDS_BSKY_APP_VIEW_URL:-https://api.bsky.app}
- PDS_BSKY_APP_VIEW_DID=${PDS_BSKY_APP_VIEW_DID:-did:web:api.bsky.app}
- PDS_REPORT_SERVICE_URL=${PDS_REPORT_SERVICE_URL:-https://mod.bsky.app/xrpc/com.atproto.moderation.createReport}
- PDS_REPORT_SERVICE_DID=${PDS_REPORT_SERVICE_DID:-did:plc:ar7c4by46qjdydhdevvrndac}
- PDS_CRAWLERS=${PDS_CRAWLERS:-https://bsky.network}
- LOG_ENABLED=${LOG_ENABLED:-true}
- PDS_EMAIL_SMTP_URL=${PDS_EMAIL_SMTP_URL:-smtp://localhost:8025}
- PDS_EMAIL_FROM_ADDRESS=${PDS_EMAIL_FROM_ADDRESS:-admin@example.com}
- PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${SERVICE_HEX_32_ROTATIONKEY}
command: >
sh -c '
echo "Installing curl, bash, and pdsadmin..."
apk add --no-cache curl bash && \
curl -o /usr/local/bin/pdsadmin.sh https://raw.githubusercontent.com/bluesky-social/pds/main/pdsadmin.sh && \
chmod +x /usr/local/bin/pdsadmin.sh && \
ln -sf /usr/local/bin/pdsadmin.sh /usr/local/bin/pdsadmin
echo "Generating /pds/pds.env..."
printf "%s\n" \
"SERVICE_FQDN_PDS_3000=$${SERVICE_FQDN_PDS_3000}" \
"PDS_HOSTNAME=$${PDS_HOSTNAME}" \
"PDS_JWT_SECRET=$${PDS_JWT_SECRET}" \
"PDS_ADMIN_PASSWORD=$${PDS_ADMIN_PASSWORD}" \
"PDS_ADMIN_EMAIL=$${PDS_ADMIN_EMAIL}" \
"PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=$${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX}" \
"PDS_DATA_DIRECTORY=$${PDS_DATA_DIRECTORY}" \
"PDS_BLOBSTORE_DISK_LOCATION=$${PDS_DATA_DIRECTORY}/blocks" \
"PDS_BLOB_UPLOAD_LIMIT=$${PDS_BLOB_UPLOAD_LIMIT}" \
"PDS_DID_PLC_URL=$${PDS_DID_PLC_URL}" \
"PDS_BSKY_APP_VIEW_URL=$${PDS_BSKY_APP_VIEW_URL}" \
"PDS_BSKY_APP_VIEW_DID=$${PDS_BSKY_APP_VIEW_DID}" \
"PDS_REPORT_SERVICE_URL=$${PDS_REPORT_SERVICE_URL}" \
"PDS_REPORT_SERVICE_DID=$${PDS_REPORT_SERVICE_DID}" \
"PDS_CRAWLERS=$${PDS_CRAWLERS}" \
"LOG_ENABLED=$${LOG_ENABLED}" \
> /pds/pds.env
echo "Launching PDS..."
exec node --enable-source-maps index.js
'
healthcheck:
test: ["CMD", "wget", "--spider", "http://127.0.0.1:3000/xrpc/_health"]
interval: 2s

View File

@@ -8,8 +8,8 @@ services:
bookstack:
image: lscr.io/linuxserver/bookstack:latest
environment:
- SERVICE_FQDN_BOOKSTACK_80
- APP_URL=${SERVICE_FQDN_BOOKSTACK}
- SERVICE_URL_BOOKSTACK_80
- APP_URL=${SERVICE_URL_BOOKSTACK}
- APP_KEY=${SERVICE_PASSWORD_APPKEY}
- PUID=1000
- PGID=1000

View File

@@ -8,7 +8,7 @@ services:
browserless:
image: ghcr.io/browserless/chromium
environment:
- SERVICE_FQDN_BROWSERLESS_3000
- SERVICE_URL_BROWSERLESS_3000
- TOKEN=$SERVICE_PASSWORD_BROWSERLESS
expose:
- 3000

View File

@@ -7,7 +7,7 @@ services:
budge:
image: lscr.io/linuxserver/budge:latest
environment:
- SERVICE_FQDN_BUDGE
- SERVICE_URL_BUDGE
- PUID=1000
- PGID=1000
- TZ=Europe/Madrid

View File

@@ -82,7 +82,7 @@ services:
proxy-service:
image: budibase/proxy
environment:
- SERVICE_FQDN_BUDIBASE_10000
- SERVICE_URL_BUDIBASE_10000
- PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10
- PROXY_RATE_LIMIT_API_PER_SECOND=20
- APPS_UPSTREAM_URL=http://app-service:4002

View File

@@ -31,8 +31,8 @@ services:
environment:
- SECRET_KEY=$SERVICE_PASSWORD_64_BUGSINK
- CREATE_SUPERUSER=admin:$SERVICE_PASSWORD_BUGSINK
- SERVICE_FQDN_BUGSINK_8000
- BASE_URL=$SERVICE_FQDN_BUGSINK_8000
- SERVICE_URL_BUGSINK_8000
- BASE_URL=$SERVICE_URL_BUGSINK_8000
- DATABASE_URL=mysql://${SERVICE_USER_BUGSINK}:$SERVICE_PASSWORD_BUGSINK@mysql:3306/${MYSQL_DATABASE:-bugsink}
depends_on:
mysql:

View File

@@ -12,14 +12,14 @@ services:
# Some variables still uses Calcom previous name, Calendso
#
# Full list https://github.com/calcom/cal.com/blob/main/.env.example
- SERVICE_FQDN_CALCOM_3000
- SERVICE_URL_CALCOM_3000
- NEXT_PUBLIC_LICENSE_CONSENT=agree
- NODE_ENV=production
- NEXT_PUBLIC_WEBAPP_URL=${SERVICE_FQDN_CALCOM}
- NEXT_PUBLIC_API_V2_URL=${SERVICE_FQDN_CALCOM}/api/v2
- NEXT_PUBLIC_WEBAPP_URL=${SERVICE_URL_CALCOM}
- NEXT_PUBLIC_API_V2_URL=${SERVICE_URL_CALCOM}/api/v2
# https://next-auth.js.org/configuration/options#nextauth_url
# From https://github.com/calcom/docker?tab=readme-ov-file#important-run-time-variables, it should be ${NEXT_PUBLIC_WEBAPP_URL}/api/auth
- NEXTAUTH_URL=${SERVICE_FQDN_CALCOM}/api/auth
- NEXTAUTH_URL=${SERVICE_URL_CALCOM}/api/auth
# It is highly recommended that the NEXTAUTH_SECRET must be overridden and very unique
# Use `openssl rand -base64 32` to generate a key
- NEXTAUTH_SECRET=${SERVICE_BASE64_CALCOMSECRET}
@@ -47,7 +47,7 @@ services:
- EMAIL_SERVER_PASSWORD=${EMAIL_SERVER_PASSWORD}
- NEXT_PUBLIC_APP_NAME="Cal.com"
# More info on ALLOWED_HOSTNAMES https://github.com/calcom/cal.com/issues/12201
- ALLOWED_HOSTNAMES=["${SERVICE_FQDN_CALCOM}"]
- ALLOWED_HOSTNAMES=["${SERVICE_URL_CALCOM}"]
depends_on:
- postgresql
postgresql:

View File

@@ -8,7 +8,7 @@ services:
calibre-web:
image: lscr.io/linuxserver/calibre-web:latest
environment:
- SERVICE_FQDN_CALIBRE_8083
- SERVICE_URL_CALIBRE_8083
- PUID=1000
- PGID=1000
- TZ=${TZ:-Etc/UTC}

View File

@@ -10,12 +10,12 @@ services:
volumes:
- castopod-media:/var/www/castopod/public/media
environment:
- SERVICE_FQDN_CASTOPOD_8000
- SERVICE_URL_CASTOPOD_8000
- MYSQL_DATABASE=castopod
- MYSQL_USER=$SERVICE_USER_MYSQL
- MYSQL_PASSWORD=$SERVICE_PASSWORD_MYSQL
- CP_DISABLE_HTTPS=${CP_DISABLE_HTTPS:-1}
- CP_BASEURL=$SERVICE_FQDN_CASTOPOD
- CP_BASEURL=$SERVICE_URL_CASTOPOD
- CP_ANALYTICS_SALT=$SERVICE_REALBASE64_64_SALT
- CP_CACHE_HANDLER=redis
- CP_REDIS_HOST=redis

View File

@@ -10,8 +10,8 @@ services:
volumes:
- changedetection-data:/datastore
environment:
- SERVICE_FQDN_CHANGEDETECTION_5000
- BASE_URL=${SERVICE_FQDN_CHANGEDETECTION}
- SERVICE_URL_CHANGEDETECTION_5000
- BASE_URL=${SERVICE_URL_CHANGEDETECTION}
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- PLAYWRIGHT_DRIVER_URL=${PLAYWRIGHT_DRIVER_URL:-ws://browser-sockpuppet-chrome:3000}

View File

@@ -8,13 +8,13 @@ services:
chaskiq:
image: chaskiq/chaskiq:latest
environment:
- SERVICE_FQDN_CHASKIQ_3000
- SERVICE_URL_CHASKIQ_3000
- REDIS_URL=redis://redis:6379/
- DATABASE_URL=postgres://$SERVICE_USER_POSTGRES:$SERVICE_PASSWORD_POSTGRES@postgresql:5432/${POSTGRES_DB:-chaskiq}
- POSTGRES_USER=$SERVICE_USER_POSTGRES
- SERVICE_URL=${SERVICE_URL_CHASKIQ}
- HOST=${SERVICE_FQDN_CHASKIQ_3000}
- ASSET_HOST=${SERVICE_FQDN_CHASKIQ_3000}
- HOST=${SERVICE_URL_CHASKIQ_3000}
- ASSET_HOST=${SERVICE_URL_CHASKIQ_3000}
- WS=wss://${SERVICE_URL_CHASKIQ}/cable
- SNS_CONFIGURATION_SET=metrics
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
@@ -80,8 +80,8 @@ services:
- REDIS_URL=redis://redis:6379/
- DATABASE_URL=postgres://$SERVICE_USER_POSTGRES:$SERVICE_PASSWORD_POSTGRES@postgresql:5432/${POSTGRES_DB:-chaskiq}
- POSTGRES_USER=$SERVICE_USER_POSTGRES
- HOST=${SERVICE_FQDN_CHASKIQ_3000}
- ASSET_HOST=${SERVICE_FQDN_CHASKIQ_3000}
- HOST=${SERVICE_URL_CHASKIQ_3000}
- ASSET_HOST=${SERVICE_URL_CHASKIQ_3000}
- WS=wss://${SERVICE_URL_CHASKIQ}/cable
- SNS_CONFIGURATION_SET=metrics
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}

View File

@@ -11,9 +11,9 @@ services:
- postgres
- redis
environment:
- SERVICE_FQDN_CHATWOOT_3000
- SERVICE_URL_CHATWOOT_3000
- SECRET_KEY_BASE=$SERVICE_PASSWORD_CHATWOOT
- FRONTEND_URL=${SERVICE_FQDN_CHATWOOT}
- FRONTEND_URL=${SERVICE_URL_CHATWOOT}
- DEFAULT_LOCALE=${CHATWOOT_DEFAULT_LOCALE}
- FORCE_SSL=${FORCE_SSL:-false}
- ENABLE_ACCOUNT_SIGNUP=${ENABLE_ACCOUNT_SIGNUP:-false}
@@ -54,7 +54,7 @@ services:
- redis
environment:
- SECRET_KEY_BASE=$SERVICE_PASSWORD_CHATWOOT
- FRONTEND_URL=${SERVICE_FQDN_CHATWOOT}
- FRONTEND_URL=${SERVICE_URL_CHATWOOT}
- DEFAULT_LOCALE=${CHATWOOT_DEFAULT_LOCALE}
- FORCE_SSL=${FORCE_SSL:-false}
- ENABLE_ACCOUNT_SIGNUP=${ENABLE_ACCOUNT_SIGNUP:-false}

View File

@@ -8,14 +8,14 @@ services:
client:
image: bluewaveuptime/uptime_client:latest
environment:
- SERVICE_FQDN_CHECKMATE_80
- UPTIME_APP_API_BASE_URL=${SERVICE_FQDN_CHECKMATESERVER_5000}/api/v1
- SERVICE_URL_CHECKMATE_80
- UPTIME_APP_API_BASE_URL=${SERVICE_URL_CHECKMATESERVER_5000}/api/v1
depends_on:
- server
server:
image: bluewaveuptime/uptime_server:latest
environment:
- SERVICE_FQDN_CHECKMATESERVER_5000
- SERVICE_URL_CHECKMATESERVER_5000
- JWT_SECRET=${SERVICE_PASSWORD_64_JWT}
- REFRESH_TOKEN_SECRET=${SERVICE_PASSWORD_64_REFRESH}
- SYSTEM_EMAIL_ADDRESS=${SYSTEM_EMAIL_ADDRESS:-test@example.com}

View File

@@ -10,7 +10,7 @@ services:
volumes:
- chroma-data:/data
environment:
- SERVICE_FQDN_CHROMA_8000
- SERVICE_URL_CHROMA_8000
- IS_PERSISTENT=TRUE
- PERSIST_DIRECTORY=/data
healthcheck:

View File

@@ -9,7 +9,7 @@ services:
volumes:
- classicpress-files:/var/www/html
environment:
- SERVICE_FQDN_CLASSICPRESS
- SERVICE_URL_CLASSICPRESS
- CLASSICPRESS_DB_HOST=mariadb
- CLASSICPRESS_DB_USER=$SERVICE_USER_CLASSICPRESS
- CLASSICPRESS_DB_PASSWORD=$SERVICE_PASSWORD_CLASSICPRESS

View File

@@ -9,7 +9,7 @@ services:
volumes:
- classicpress-files:/var/www/html
environment:
- SERVICE_FQDN_CLASSICPRESS
- SERVICE_URL_CLASSICPRESS
- CLASSICPRESS_DB_HOST=mysql
- CLASSICPRESS_DB_USER=$SERVICE_USER_CLASSICPRESS
- CLASSICPRESS_DB_PASSWORD=$SERVICE_PASSWORD_CLASSICPRESS

View File

@@ -9,7 +9,7 @@ services:
volumes:
- classicpress-files:/var/www/html
environment:
- SERVICE_FQDN_CLASSICPRESS
- SERVICE_URL_CLASSICPRESS
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1"]
interval: 2s

View File

@@ -10,7 +10,7 @@ services:
volumes:
- cloudbeaver-data:/opt/cloudbeaver/workspace
environment:
- SERVICE_FQDN_CLOUDBEAVER_8978
- SERVICE_URL_CLOUDBEAVER_8978
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8978/"]
interval: 5s

View File

@@ -8,7 +8,7 @@ services:
code-server:
image: lscr.io/linuxserver/code-server:latest
environment:
- SERVICE_FQDN_CODESERVER_8443
- SERVICE_URL_CODESERVER_8443
- PUID=1000
- PGID=1000
- TZ=Europe/Madrid

View File

@@ -9,10 +9,10 @@ services:
coder:
image: ghcr.io/coder/coder:latest
environment:
- SERVICE_FQDN_CODER_7080
- SERVICE_URL_CODER_7080
- CODER_PG_CONNECTION_URL=postgresql://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@coder-database/${POSTGRES_DB:-coder-db}?sslmode=disable
- CODER_HTTP_ADDRESS=0.0.0.0:7080
- CODER_ACCESS_URL=${SERVICE_FQDN_CODER}
- CODER_ACCESS_URL=${SERVICE_URL_CODER}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
depends_on:

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