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

@@ -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;
$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.");
}
}
}
$originalDomains[$originalServiceName] = $value;
}
$this->application->docker_compose_domains = json_encode($originalDomains);
foreach ($originalDomains as $serviceName => $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')) {
check_domain_usage(resource: $this->application);
$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

@@ -75,7 +75,7 @@ class Index extends Component
}
} catch (\Exception $e) {
// Log the error
logger()->error('Stripe API error: ' . $e->getMessage());
logger()->error('Stripe API error: '.$e->getMessage());
// Set a flag to show an error message to the user
$this->addError('stripe', 'Could not retrieve subscription information. Please try again later.');
} finally {

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,60 +13,102 @@ class EmailChannel
public function send(SendsEmail $notifiable, Notification $notification): void
{
$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();
}
$mailMessage = $notification->toMail($notifiable);
try {
// Get team and validate membership before proceeding
$team = data_get($notifiable, 'id');
$members = Team::find($team)->members;
if ($isResendEnabled) {
$resend = Resend::client($settings->resend_api_key);
$from = "{$settings->smtp_from_name} <{$settings->smtp_from_address}>";
$resend->emails->send([
'from' => $from,
'to' => $recipients,
'subject' => $mailMessage->subject,
'html' => (string) $mailMessage->render(),
$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) {
$resend = Resend::client($settings->resend_api_key);
$from = "{$settings->smtp_from_name} <{$settings->smtp_from_address}>";
$resend->emails->send([
'from' => $from,
'to' => $recipients,
'subject' => $mailMessage->subject,
'html' => (string) $mailMessage->render(),
]);
} elseif ($isSmtpEnabled) {
$encryption = match (strtolower($settings->smtp_encryption)) {
'starttls' => null,
'tls' => 'tls',
'none' => null,
default => null,
};
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
$settings->smtp_host,
$settings->smtp_port,
$encryption
);
$transport->setUsername($settings->smtp_username ?? '');
$transport->setPassword($settings->smtp_password ?? '');
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
$fromEmail = $settings->smtp_from_address ?? 'noreply@localhost';
$fromName = $settings->smtp_from_name ?? 'System';
$from = new \Symfony\Component\Mime\Address($fromEmail, $fromName);
$email = (new \Symfony\Component\Mime\Email)
->from($from)
->to(...$recipients)
->subject($mailMessage->subject)
->html((string) $mailMessage->render());
$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(),
]);
} elseif ($isSmtpEnabled) {
$encryption = match (strtolower($settings->smtp_encryption)) {
'starttls' => null,
'tls' => 'tls',
'none' => null,
default => null,
};
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
$settings->smtp_host,
$settings->smtp_port,
$encryption
);
$transport->setUsername($settings->smtp_username ?? '');
$transport->setPassword($settings->smtp_password ?? '');
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
$fromEmail = $settings->smtp_from_address ?? 'noreply@localhost';
$fromName = $settings->smtp_from_name ?? 'System';
$from = new \Symfony\Component\Mime\Address($fromEmail, $fromName);
$email = (new \Symfony\Component\Mime\Email)
->from($from)
->to(...$recipients)
->subject($mailMessage->subject)
->html((string) $mailMessage->render());
$mailer->send($email);
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