Harden database backup imports (#10796)

This commit is contained in:
Andras Bacsai 2026-06-29 10:36:31 +02:00 committed by GitHub
commit 9f36a8df31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 562 additions and 77 deletions

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Support\DatabaseBackupFileValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
@ -16,24 +17,7 @@ class UploadController extends BaseController
private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB
private const ALLOWED_EXTENSIONS = [
'sql',
'sql.gz',
'gz',
'zip',
'tar',
'tar.gz',
'tgz',
'dump',
'bak',
'bson',
'bson.gz',
'archive',
'archive.gz',
'bz2',
'xz',
'dmp',
];
private const ALLOWED_EXTENSIONS = DatabaseBackupFileValidator::ALLOWED_EXTENSIONS;
public function upload(Request $request)
{
@ -85,10 +69,7 @@ public function upload(Request $request)
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
{
$originalName = $file->getClientOriginalName();
$size = $file->getSize();
if (! self::hasAllowedExtension($originalName) || $size === false || $size > self::MAX_BYTES) {
if (! DatabaseBackupFileValidator::isUploadAllowed($file, self::MAX_BYTES)) {
@unlink($file->getPathname());
return response()->json([
@ -108,24 +89,7 @@ protected function saveFile(UploadedFile $file, string $resourceIdentifier)
private static function hasAllowedExtension(string $name): bool
{
$lower = strtolower($name);
$suffixes = array_map(fn ($ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
usort($suffixes, fn ($a, $b) => strlen($b) <=> strlen($a));
foreach ($suffixes as $suffix) {
if (! str_ends_with($lower, $suffix)) {
continue;
}
$stem = substr($lower, 0, -strlen($suffix));
if ($stem !== '' && ! str_ends_with($stem, '.')) {
return true;
}
return false;
}
return false;
return DatabaseBackupFileValidator::hasAllowedExtension($name);
}
private static function formatMaxSize(): string

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Destination;
use App\Models\StandaloneDocker;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
@ -35,7 +36,7 @@ public function mount(string $destination_uuid)
$this->destination = $destination;
$this->syncData();
} catch (\Illuminate\Auth\Access\AuthorizationException) {
} catch (AuthorizationException) {
abort(403);
} catch (\Throwable $e) {
return handleError($e, $this);

View file

@ -2,6 +2,7 @@
namespace App\Livewire\Project\Database;
use App\Jobs\DatabaseBackupJob;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Exception;
@ -195,7 +196,7 @@ public function backupNow()
try {
$this->authorize('manageBackups', $this->backup->database);
\App\Jobs\DatabaseBackupJob::dispatch($this->backup);
DatabaseBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
} catch (\Throwable $e) {
return handleError($e, $this);

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
@ -97,7 +98,7 @@ public function deleteBackup($executionId, $password, $selectedActions = [])
return;
}
$server = $execution->scheduledDatabaseBackup->database->getMorphClass() === \App\Models\ServiceDatabase::class
$server = $execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class
? $execution->scheduledDatabaseBackup->database->service->destination->server
: $execution->scheduledDatabaseBackup->database->destination->server;
@ -204,7 +205,7 @@ public function server()
if ($this->database) {
$server = null;
if ($this->database instanceof \App\Models\ServiceDatabase) {
if ($this->database instanceof ServiceDatabase) {
$server = $this->database->service->destination->server;
} elseif ($this->database->destination && $this->database->destination->server) {
$server = $this->database->destination->server;

View file

@ -14,6 +14,7 @@
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Support\DatabaseBackupFileValidator;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Storage;
@ -451,10 +452,20 @@ public function runImport(string $password = ''): bool|string
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
// Reject malicious PostgreSQL payloads before transferring the file anywhere.
if ($this->isPostgresqlRestore() && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($path)) {
Storage::delete($backupFileName);
$this->dispatch('error', 'The uploaded backup contains disallowed PostgreSQL restore directives (COPY ... PROGRAM or psql shell commands) and was rejected.');
return true;
}
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
@ -465,6 +476,7 @@ public function runImport(string $password = ''): bool|string
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
@ -721,6 +733,7 @@ public function restoreFromS3(string $password = ''): bool|string
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
$this->addRestoreSafetyCheckCommand($commands, $containerTmpPath);
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
@ -765,6 +778,69 @@ public function restoreFromS3(string $password = ''): bool|string
return true;
}
public function buildRestoreSafetyCheckCommand(string $tmpPath): ?string
{
$script = $this->buildPostgresRestoreScanScript($tmpPath);
if ($script === null) {
return null;
}
return "docker exec {$this->container} sh -c ".escapeshellarg($script);
}
/**
* Build the POSIX shell snippet that aborts (exit 1) when a PostgreSQL
* backup contains directives leading to OS command execution.
*
* Hardened against bypasses:
* - decompresses gzip backups before scanning,
* - strips `--` line comments and flattens newlines so multi-line and
* comment-separated payloads (e.g. `FROM/**/PROGRAM`) are caught,
* - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects.
*/
public function buildPostgresRestoreScanScript(string $tmpPath): ?string
{
if (! $this->isPostgresqlRestore()) {
return null;
}
$escapedTmpPath = escapeshellarg($tmpPath);
// Token separator PostgreSQL treats as whitespace: real whitespace or a
// /* ... */ block comment (used to split keywords like FROM/**/PROGRAM).
$sep = '([[:space:]]|/\\*[^*]*\\*/)';
$pattern = implode('|', [
"copy{$sep}+[^;]*(from|to){$sep}+program",
'(^|[[:space:]])\\\\!',
"(^|[[:space:]])\\\\(o|g){$sep}*\\|",
]);
$escapedPattern = escapeshellarg($pattern);
return "if (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi";
}
private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void
{
$command = $this->buildRestoreSafetyCheckCommand($tmpPath);
if ($command !== null) {
$commands[] = $command;
}
}
private function isPostgresqlRestore(): bool
{
$morphClass = $this->resource->getMorphClass();
if ($morphClass === ServiceDatabase::class) {
return str_contains($this->resource->databaseType(), 'postgres');
}
return $morphClass === StandalonePostgresql::class || $morphClass === 'postgresql';
}
public function buildRestoreCommand(string $tmpPath): string
{
$escapedTmpPath = escapeshellarg($tmpPath);

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -34,7 +35,7 @@ public function mount(): void
$this->setSelectedBackup($this->selectedBackupId, true);
}
$this->parameters = get_route_parameters();
if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
if ($this->database->getMorphClass() === ServiceDatabase::class) {
$this->type = 'service-database';
} else {
$this->type = 'database';

View file

@ -4,6 +4,7 @@
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -25,7 +26,7 @@ public function mount()
}
try {
$this->authorize('view', $this->storage);
} catch (\Illuminate\Auth\Access\AuthorizationException) {
} catch (AuthorizationException) {
return $this->redirectRoute('storage.index', navigate: true);
}
$this->currentRoute = request()->route()->getName();

View file

@ -69,6 +69,6 @@ public function manageBackups(User $user, ServiceDatabase $serviceDatabase): boo
*/
public function uploadBackup(User $user, ServiceDatabase $serviceDatabase): bool
{
return Gate::allows('uploadBackup', $serviceDatabase->service);
return $user->can('uploadBackup', $serviceDatabase->service);
}
}

View file

@ -0,0 +1,209 @@
<?php
namespace App\Support;
use Illuminate\Http\UploadedFile;
class DatabaseBackupFileValidator
{
public const ALLOWED_EXTENSIONS = [
'sql',
'sql.gz',
'gz',
'zip',
'tar',
'tar.gz',
'tgz',
'dump',
'bak',
'bson',
'bson.gz',
'archive',
'archive.gz',
'bz2',
'xz',
'dmp',
];
private const DANGEROUS_EXTENSIONS = [
'asp',
'aspx',
'bat',
'bash',
'cgi',
'cmd',
'com',
'exe',
'htm',
'html',
'jar',
'js',
'jsp',
'php',
'php3',
'php4',
'php5',
'phtml',
'pl',
'ps1',
'py',
'rb',
'sh',
];
public static function hasAllowedExtension(string $name): bool
{
return self::extensionFor($name) !== null;
}
public static function isUploadAllowed(UploadedFile $file, int $maxBytes): bool
{
$originalName = $file->getClientOriginalName();
$size = $file->getSize();
if ($size === false || $size > $maxBytes) {
return false;
}
$extension = self::extensionFor($originalName);
if ($extension === null) {
return false;
}
return self::contentMatchesExtension($file->getPathname(), $extension);
}
/**
* Scan a stored backup file (decompressing gzip on the fly) for PostgreSQL
* restore directives that lead to OS command execution.
*/
public static function fileContainsPostgresqlProgramExecution(string $path): bool
{
$contents = self::readPossiblyGzippedText($path);
if ($contents === null) {
return false;
}
return self::containsPostgresqlProgramExecution($contents);
}
public static function containsPostgresqlProgramExecution(string $sql): bool
{
$withoutComments = self::stripSqlComments($sql);
if (preg_match('/^\s*\\\\(?:!|copy\b.*\bprogram\b)/mi', $withoutComments) === 1) {
return true;
}
return preg_match('/\bcopy\b[\s\S]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
}
private static function extensionFor(string $name): ?string
{
$lower = strtolower($name);
$suffixes = array_map(fn (string $ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
usort($suffixes, fn (string $a, string $b) => strlen($b) <=> strlen($a));
foreach ($suffixes as $suffix) {
if (! str_ends_with($lower, $suffix)) {
continue;
}
$stem = substr($lower, 0, -strlen($suffix));
if ($stem === '' || str_ends_with($stem, '.')) {
return null;
}
$parts = array_filter(explode('.', $stem));
if (array_intersect($parts, self::DANGEROUS_EXTENSIONS) !== []) {
return null;
}
return ltrim($suffix, '.');
}
return null;
}
private static function contentMatchesExtension(string $path, string $extension): bool
{
$sample = (string) file_get_contents($path, false, null, 0, 4096);
return match ($extension) {
'sql' => self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample),
'sql.gz', 'gz', 'tar.gz', 'tgz', 'bson.gz', 'archive.gz' => str_starts_with($sample, "\x1f\x8b"),
'zip' => str_starts_with($sample, "PK\x03\x04") || str_starts_with($sample, "PK\x05\x06") || str_starts_with($sample, "PK\x07\x08"),
'tar' => substr($sample, 257, 5) === 'ustar',
'bz2' => str_starts_with($sample, 'BZh'),
'xz' => str_starts_with($sample, "\xfd7zXZ\x00"),
'dump', 'bak', 'archive', 'dmp' => str_starts_with($sample, 'PGDMP')
|| (self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample)),
'bson' => self::looksLikeBson($path, $sample),
default => false,
};
}
private static function readPossiblyGzippedText(string $path): ?string
{
// Cap the scan so a huge legitimate dump cannot exhaust memory; the
// remote pre-restore scanner inspects the full file as a second layer.
$maxBytes = 50 * 1024 * 1024;
$handle = @fopen($path, 'rb');
if ($handle === false) {
return null;
}
$magic = (string) fread($handle, 2);
fclose($handle);
if ($magic === "\x1f\x8b") {
$gz = @gzopen($path, 'rb');
if ($gz === false) {
return null;
}
$data = '';
while (! gzeof($gz) && strlen($data) < $maxBytes) {
$chunk = gzread($gz, 1024 * 1024);
if ($chunk === false || $chunk === '') {
break;
}
$data .= $chunk;
}
gzclose($gz);
return $data;
}
return (string) file_get_contents($path, false, null, 0, $maxBytes);
}
private static function looksLikeText(string $sample): bool
{
if ($sample === '' || str_contains($sample, "\0")) {
return false;
}
return mb_check_encoding($sample, 'UTF-8') || mb_check_encoding($sample, 'ASCII');
}
private static function looksLikeBson(string $path, string $sample): bool
{
if (strlen($sample) < 5) {
return false;
}
$documentLength = unpack('V', substr($sample, 0, 4))[1] ?? 0;
$fileSize = filesize($path) ?: 0;
return $documentLength >= 5 && $documentLength <= $fileSize;
}
private static function stripSqlComments(string $sql): string
{
$sql = preg_replace('/\/\*[\s\S]*?\*\//', ' ', $sql) ?? $sql;
return preg_replace('/--[^\r\n]*/', ' ', $sql) ?? $sql;
}
}

View file

@ -787,7 +787,7 @@
"convex": {
"documentation": "https://github.com/get-convex/convex-backend/blob/main/self-hosted/README.md?utm_source=coolify.io",
"slogan": "Convex is the open-source reactive database for app developers.",
"compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOmE5YTc2MGNhMTAzOTllZDQyZTFiNGJiODdjNzg1MzlhMjM1NDg4YzcnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0JBQ0tFTkRfMzIxMAogICAgICAtICdJTlNUQU5DRV9OQU1FPSR7SU5TVEFOQ0VfTkFNRTotc2VsZi1ob3N0ZWQtY29udmV4fScKICAgICAgLSAnSU5TVEFOQ0VfU0VDUkVUPSR7U0VSVklDRV9IRVhfNjRfU0VDUkVUfScKICAgICAgLSAnQ09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY9JHtDT05WRVhfUkVMRUFTRV9WRVJTSU9OX0RFVjotfScKICAgICAgLSAnQUNUSU9OU19VU0VSX1RJTUVPVVRfU0VDUz0ke0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M6LX0nCiAgICAgIC0gJ0NPTlZFWF9DTE9VRF9PUklHSU49JHtTRVJWSUNFX1VSTF9EQVNIQk9BUkR9JwogICAgICAtICdDT05WRVhfU0lURV9PUklHSU49JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDphOWE3NjBjYTEwMzk5ZWQ0MmUxYjRiYjg3Yzc4NTM5YTIzNTQ4OGM3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfREFTSEJPQVJEXzY3OTEKICAgICAgLSAnTkVYVF9QVUJMSUNfREVQTE9ZTUVOVF9VUkw9JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgIGRlcGVuZHNfb246CiAgICAgIGJhY2tlbmQ6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjY3OTEvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=",
"compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOmE5YTc2MGNhMTAzOTllZDQyZTFiNGJiODdjNzg1MzlhMjM1NDg4YzcnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0JBQ0tFTkRfMzIxMAogICAgICAtIFNFUlZJQ0VfVVJMX1NJVEVfMzIxMQogICAgICAtICdJTlNUQU5DRV9OQU1FPSR7SU5TVEFOQ0VfTkFNRTotc2VsZi1ob3N0ZWQtY29udmV4fScKICAgICAgLSAnSU5TVEFOQ0VfU0VDUkVUPSR7U0VSVklDRV9IRVhfNjRfU0VDUkVUfScKICAgICAgLSAnQ09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY9JHtDT05WRVhfUkVMRUFTRV9WRVJTSU9OX0RFVjotfScKICAgICAgLSAnQUNUSU9OU19VU0VSX1RJTUVPVVRfU0VDUz0ke0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M6LX0nCiAgICAgIC0gJ0NPTlZFWF9DTE9VRF9PUklHSU49JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgICAgLSAnQ09OVkVYX1NJVEVfT1JJR0lOPSR7U0VSVklDRV9VUkxfU0lURX0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD0ke0RBVEFCQVNFX1VSTDotfScKICAgICAgLSAnRElTQUJMRV9CRUFDT049JHtESVNBQkxFX0JFQUNPTjo/ZmFsc2V9JwogICAgICAtICdSRURBQ1RfTE9HU19UT19DTElFTlQ9JHtSRURBQ1RfTE9HU19UT19DTElFTlQ6P2ZhbHNlfScKICAgICAgLSAnRE9fTk9UX1JFUVVJUkVfU1NMPSR7RE9fTk9UX1JFUVVJUkVfU1NMOj90cnVlfScKICAgICAgLSAnUE9TVEdSRVNfVVJMPSR7UE9TVEdSRVNfVVJMOi19JwogICAgICAtICdNWVNRTF9VUkw9JHtNWVNRTF9VUkw6LX0nCiAgICAgIC0gJ1JVU1RfTE9HPSR7UlVTVF9MT0c6LWluZm99JwogICAgICAtICdSVVNUX0JBQ0tUUkFDRT0ke1JVU1RfQkFDS1RSQUNFOi19JwogICAgICAtICdBV1NfUkVHSU9OPSR7QVdTX1JFR0lPTjotfScKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtBV1NfQUNDRVNTX0tFWV9JRDotfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7QVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOi19JwogICAgICAtICdBV1NfU0VTU0lPTl9UT0tFTj0ke0FXU19TRVNTSU9OX1RPS0VOOi19JwogICAgICAtICdBV1NfUzNfRk9SQ0VfUEFUSF9TVFlMRT0ke0FXU19TM19GT1JDRV9QQVRIX1NUWUxFOi19JwogICAgICAtICdBV1NfUzNfRElTQUJMRV9TU0U9JHtBV1NfUzNfRElTQUJMRV9TU0U6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX0NIRUNLU1VNUz0ke0FXU19TM19ESVNBQkxFX0NIRUNLU1VNUzotfScKICAgICAgLSAnUzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRVhQT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ9JHtTM19TVE9SQUdFX1NOQVBTSE9UX0lNUE9SVFNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUPSR7UzNfU1RPUkFHRV9NT0RVTEVTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9GSUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TRUFSQ0hfQlVDS0VUPSR7UzNfU1RPUkFHRV9TRUFSQ0hfQlVDS0VUOi19JwogICAgICAtICdTM19FTkRQT0lOVF9VUkw9JHtTM19FTkRQT0lOVF9VUkw6LX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ2N1cmwgLWYgaHR0cDovLzEyNy4wLjAuMTozMjEwL3ZlcnNpb24nCiAgICAgIGludGVydmFsOiA1cwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogIGRhc2hib2FyZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1kYXNoYm9hcmQ6YTlhNzYwY2ExMDM5OWVkNDJlMWI0YmI4N2M3ODUzOWEyMzU0ODhjNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0RBU0hCT0FSRF82NzkxCiAgICAgIC0gJ05FWFRfUFVCTElDX0RFUExPWU1FTlRfVVJMPSR7U0VSVklDRV9VUkxfQkFDS0VORH0nCiAgICBkZXBlbmRzX29uOgogICAgICBiYWNrZW5kOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ2N1cmwgLWYgaHR0cDovLzEyNy4wLjAuMTo2NzkxLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK",
"tags": [
"database",
"reactive",
@ -803,7 +803,7 @@
"category": "backend",
"logo": "svgs/convex.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-05-09T19:26:30+05:30",
"template_last_updated_at": "2026-06-12T10:45:52+02:00",
"port": "6791"
},
"cryptgeon": {
@ -1769,7 +1769,7 @@
"gitea-runner": {
"documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io",
"slogan": "Gitea Actions runner for docker",
"compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC43JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
"compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC44JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
"tags": [
"gitea",
"actions",
@ -1779,7 +1779,7 @@
"category": "devtools",
"logo": "svgs/gitea.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-06-01T07:54:27-05:00"
"template_last_updated_at": "2026-06-06T00:11:24+02:00"
},
"gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io",
@ -2346,6 +2346,24 @@
"template_last_updated_at": "2025-12-15T17:56:33+01:00",
"port": "8080"
},
"inngest": {
"documentation": "https://www.inngest.com/docs/self-hosting?utm_source=coolify.io",
"slogan": "Durable workflow engine for background jobs, queues and scheduled tasks.",
"compose": "c2VydmljZXM6CiAgaW5uZ2VzdDoKICAgIGltYWdlOiAnaW5uZ2VzdC9pbm5nZXN0OnYxLjI3LjAnCiAgICBjb21tYW5kOiAnaW5uZ2VzdCBzdGFydCAtLWhvc3QgMC4wLjAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0lOTkdFU1RfODI4OAogICAgICAtICdJTk5HRVNUX0VWRU5UX0tFWT0ke1NFUlZJQ0VfSEVYXzMyX0VWRU5US0VZfScKICAgICAgLSAnSU5OR0VTVF9TSUdOSU5HX0tFWT0ke1NFUlZJQ0VfSEVYXzMyX1NJR05JTkdLRVl9JwogICAgICAtICdJTk5HRVNUX1BPU1RHUkVTX1VSST1wb3N0Z3JlczovL2lubmdlc3Q6JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyL2lubmdlc3QnCiAgICAgIC0gJ0lOTkdFU1RfUkVESVNfVVJJPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaW5uZ2VzdAogICAgICAgIC0gYWxwaGEKICAgICAgICAtIGRvY3RvcgogICAgICAgIC0gaGVhbHRoY2hlY2sKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDQwcwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTcnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19EQj1pbm5nZXN0CiAgICAgIC0gUE9TVEdSRVNfVVNFUj1pbm5nZXN0CiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSBpbm5nZXN0IC1kIGlubmdlc3QnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1hcHBlbmRvbmx5IHllcycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiA1CiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAo=",
"tags": [
"queue",
"workflow",
"jobs",
"events",
"background",
"automation"
],
"category": "automation",
"logo": "svgs/inngest.png",
"minversion": "0.0.0",
"template_last_updated_at": "2026-06-10T13:46:21+05:30",
"port": "8288"
},
"invoice-ninja": {
"documentation": "https://invoiceninja.github.io/selfhost.html?utm_source=coolify.io",
"slogan": "The leading open-source invoicing platform",

View file

@ -787,7 +787,7 @@
"convex": {
"documentation": "https://github.com/get-convex/convex-backend/blob/main/self-hosted/README.md?utm_source=coolify.io",
"slogan": "Convex is the open-source reactive database for app developers.",
"compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOmE5YTc2MGNhMTAzOTllZDQyZTFiNGJiODdjNzg1MzlhMjM1NDg4YzcnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9CQUNLRU5EXzMyMTAKICAgICAgLSAnSU5TVEFOQ0VfTkFNRT0ke0lOU1RBTkNFX05BTUU6LXNlbGYtaG9zdGVkLWNvbnZleH0nCiAgICAgIC0gJ0lOU1RBTkNFX1NFQ1JFVD0ke1NFUlZJQ0VfSEVYXzY0X1NFQ1JFVH0nCiAgICAgIC0gJ0NPTlZFWF9SRUxFQVNFX1ZFUlNJT05fREVWPSR7Q09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY6LX0nCiAgICAgIC0gJ0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M9JHtBQ1RJT05TX1VTRVJfVElNRU9VVF9TRUNTOi19JwogICAgICAtICdDT05WRVhfQ0xPVURfT1JJR0lOPSR7U0VSVklDRV9GUUROX0RBU0hCT0FSRH0nCiAgICAgIC0gJ0NPTlZFWF9TSVRFX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDphOWE3NjBjYTEwMzk5ZWQ0MmUxYjRiYjg3Yzc4NTM5YTIzNTQ4OGM3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0RBU0hCT0FSRF82NzkxCiAgICAgIC0gJ05FWFRfUFVCTElDX0RFUExPWU1FTlRfVVJMPSR7U0VSVklDRV9GUUROX0JBQ0tFTkR9JwogICAgZGVwZW5kc19vbjoKICAgICAgYmFja2VuZDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIC1mIGh0dHA6Ly8xMjcuMC4wLjE6Njc5MS8nCiAgICAgIGludGVydmFsOiA1cwogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==",
"compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOmE5YTc2MGNhMTAzOTllZDQyZTFiNGJiODdjNzg1MzlhMjM1NDg4YzcnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9CQUNLRU5EXzMyMTAKICAgICAgLSBTRVJWSUNFX0ZRRE5fU0lURV8zMjExCiAgICAgIC0gJ0lOU1RBTkNFX05BTUU9JHtJTlNUQU5DRV9OQU1FOi1zZWxmLWhvc3RlZC1jb252ZXh9JwogICAgICAtICdJTlNUQU5DRV9TRUNSRVQ9JHtTRVJWSUNFX0hFWF82NF9TRUNSRVR9JwogICAgICAtICdDT05WRVhfUkVMRUFTRV9WRVJTSU9OX0RFVj0ke0NPTlZFWF9SRUxFQVNFX1ZFUlNJT05fREVWOi19JwogICAgICAtICdBQ1RJT05TX1VTRVJfVElNRU9VVF9TRUNTPSR7QUNUSU9OU19VU0VSX1RJTUVPVVRfU0VDUzotfScKICAgICAgLSAnQ09OVkVYX0NMT1VEX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9CQUNLRU5EfScKICAgICAgLSAnQ09OVkVYX1NJVEVfT1JJR0lOPSR7U0VSVklDRV9GUUROX1NJVEV9JwogICAgICAtICdEQVRBQkFTRV9VUkw9JHtEQVRBQkFTRV9VUkw6LX0nCiAgICAgIC0gJ0RJU0FCTEVfQkVBQ09OPSR7RElTQUJMRV9CRUFDT046P2ZhbHNlfScKICAgICAgLSAnUkVEQUNUX0xPR1NfVE9fQ0xJRU5UPSR7UkVEQUNUX0xPR1NfVE9fQ0xJRU5UOj9mYWxzZX0nCiAgICAgIC0gJ0RPX05PVF9SRVFVSVJFX1NTTD0ke0RPX05PVF9SRVFVSVJFX1NTTDo/dHJ1ZX0nCiAgICAgIC0gJ1BPU1RHUkVTX1VSTD0ke1BPU1RHUkVTX1VSTDotfScKICAgICAgLSAnTVlTUUxfVVJMPSR7TVlTUUxfVVJMOi19JwogICAgICAtICdSVVNUX0xPRz0ke1JVU1RfTE9HOi1pbmZvfScKICAgICAgLSAnUlVTVF9CQUNLVFJBQ0U9JHtSVVNUX0JBQ0tUUkFDRTotfScKICAgICAgLSAnQVdTX1JFR0lPTj0ke0FXU19SRUdJT046LX0nCiAgICAgIC0gJ0FXU19BQ0NFU1NfS0VZX0lEPSR7QVdTX0FDQ0VTU19LRVlfSUQ6LX0nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke0FXU19TRUNSRVRfQUNDRVNTX0tFWTotfScKICAgICAgLSAnQVdTX1NFU1NJT05fVE9LRU49JHtBV1NfU0VTU0lPTl9UT0tFTjotfScKICAgICAgLSAnQVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU9JHtBV1NfUzNfRk9SQ0VfUEFUSF9TVFlMRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfU1NFPSR7QVdTX1MzX0RJU0FCTEVfU1NFOi19JwogICAgICAtICdBV1NfUzNfRElTQUJMRV9DSEVDS1NVTVM9JHtBV1NfUzNfRElTQUJMRV9DSEVDS1NVTVM6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfRVhQT1JUU19CVUNLRVQ9JHtTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NOQVBTSE9UX0lNUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9NT0RVTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUPSR7UzNfU1RPUkFHRV9GSUxFU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfU0VBUkNIX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU0VBUkNIX0JVQ0tFVDotfScKICAgICAgLSAnUzNfRU5EUE9JTlRfVVJMPSR7UzNfRU5EUE9JTlRfVVJMOi19JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIC1mIGh0dHA6Ly8xMjcuMC4wLjE6MzIxMC92ZXJzaW9uJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICBkYXNoYm9hcmQ6CiAgICBpbWFnZTogJ2doY3IuaW8vZ2V0LWNvbnZleC9jb252ZXgtZGFzaGJvYXJkOmE5YTc2MGNhMTAzOTllZDQyZTFiNGJiODdjNzg1MzlhMjM1NDg4YzcnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fREFTSEJPQVJEXzY3OTEKICAgICAgLSAnTkVYVF9QVUJMSUNfREVQTE9ZTUVOVF9VUkw9JHtTRVJWSUNFX0ZRRE5fQkFDS0VORH0nCiAgICBkZXBlbmRzX29uOgogICAgICBiYWNrZW5kOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ2N1cmwgLWYgaHR0cDovLzEyNy4wLjAuMTo2NzkxLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK",
"tags": [
"database",
"reactive",
@ -803,7 +803,7 @@
"category": "backend",
"logo": "svgs/convex.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-05-09T19:26:30+05:30",
"template_last_updated_at": "2026-06-12T10:45:52+02:00",
"port": "6791"
},
"cryptgeon": {
@ -1769,7 +1769,7 @@
"gitea-runner": {
"documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io",
"slogan": "Gitea Actions runner for docker",
"compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC43JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
"compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC44JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
"tags": [
"gitea",
"actions",
@ -1779,7 +1779,7 @@
"category": "devtools",
"logo": "svgs/gitea.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-06-01T07:54:27-05:00"
"template_last_updated_at": "2026-06-06T00:11:24+02:00"
},
"gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io",
@ -2346,6 +2346,24 @@
"template_last_updated_at": "2025-12-15T17:56:33+01:00",
"port": "8080"
},
"inngest": {
"documentation": "https://www.inngest.com/docs/self-hosting?utm_source=coolify.io",
"slogan": "Durable workflow engine for background jobs, queues and scheduled tasks.",
"compose": "c2VydmljZXM6CiAgaW5uZ2VzdDoKICAgIGltYWdlOiAnaW5uZ2VzdC9pbm5nZXN0OnYxLjI3LjAnCiAgICBjb21tYW5kOiAnaW5uZ2VzdCBzdGFydCAtLWhvc3QgMC4wLjAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9JTk5HRVNUXzgyODgKICAgICAgLSAnSU5OR0VTVF9FVkVOVF9LRVk9JHtTRVJWSUNFX0hFWF8zMl9FVkVOVEtFWX0nCiAgICAgIC0gJ0lOTkdFU1RfU0lHTklOR19LRVk9JHtTRVJWSUNFX0hFWF8zMl9TSUdOSU5HS0VZfScKICAgICAgLSAnSU5OR0VTVF9QT1NUR1JFU19VUkk9cG9zdGdyZXM6Ly9pbm5nZXN0OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi9pbm5nZXN0JwogICAgICAtICdJTk5HRVNUX1JFRElTX1VSST1yZWRpczovL3JlZGlzOjYzNzknCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGlubmdlc3QKICAgICAgICAtIGFscGhhCiAgICAgICAgLSBkb2N0b3IKICAgICAgICAtIGhlYWx0aGNoZWNrCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiA0MHMKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfREI9aW5uZ2VzdAogICAgICAtIFBPU1RHUkVTX1VTRVI9aW5uZ2VzdAogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgaW5uZ2VzdCAtZCBpbm5nZXN0JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogNQogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQK",
"tags": [
"queue",
"workflow",
"jobs",
"events",
"background",
"automation"
],
"category": "automation",
"logo": "svgs/inngest.png",
"minversion": "0.0.0",
"template_last_updated_at": "2026-06-10T13:46:21+05:30",
"port": "8288"
},
"invoice-ninja": {
"documentation": "https://invoiceninja.github.io/selfhost.html?utm_source=coolify.io",
"slogan": "The leading open-source invoicing platform",

View file

@ -323,5 +323,5 @@
session(['currentTeam' => $this->team]);
expect(fn () => $this->team->update(['name' => 'Hacked']))
->toThrow(\Exception::class, 'You are not allowed to update this team.');
->toThrow(Exception::class, 'You are not allowed to update this team.');
});

View file

@ -13,12 +13,19 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Once;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->withoutVite();
Once::flush();
config(['app.maintenance.driver' => 'file']);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
@ -159,7 +166,8 @@
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
$response = $this->withHeader('Accept', 'application/json')
->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
$response->assertForbidden();

View file

@ -1,6 +1,28 @@
<?php
use App\Http\Controllers\UploadController;
use App\Livewire\Project\Database\ImportForm;
use App\Models\StandalonePostgresql;
use App\Support\DatabaseBackupFileValidator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Process;
function writeScanPayload(string $content, bool $gzip = false): string
{
$path = tempnam(sys_get_temp_dir(), 'coolify-scan-payload-');
file_put_contents($path, $gzip ? gzencode($content) : $content);
return $path;
}
/**
* Execute the real shell scanner snippet (as it runs inside the container)
* against a local payload file and return true when the restore is blocked.
*/
function scannerBlocks(string $script): bool
{
return Process::run(['sh', '-c', $script])->exitCode() === 1;
}
function invokeHasAllowedExtension(string $name): bool
{
@ -10,6 +32,28 @@ function invokeHasAllowedExtension(string $name): bool
return $method->invoke(null, $name);
}
function backupValidationImportFormWithResource(string $modelClass): ImportForm
{
$component = new class extends ImportForm
{
public $resource;
};
$database = Mockery::mock($modelClass);
$database->shouldReceive('getMorphClass')->andReturn($modelClass);
$component->resource = $database;
return $component;
}
function makeTemporaryUpload(string $name, string $content): UploadedFile
{
$path = tempnam(sys_get_temp_dir(), 'coolify-upload-test-');
file_put_contents($path, $content);
return new UploadedFile($path, $name, null, null, true);
}
test('hasAllowedExtension accepts supported extensions', function (string $name) {
expect(invokeHasAllowedExtension($name))->toBeTrue();
})->with([
@ -46,6 +90,140 @@ function invokeHasAllowedExtension(string $name): bool
'misleading double ext' => ['shell.php.sql-evil'],
]);
test('hasAllowedExtension rejects dangerous double extensions', function (string $name) {
expect(invokeHasAllowedExtension($name))->toBeFalse();
})->with([
'php sql' => ['evil.php.sql'],
'php gzip' => ['evil.php.gz'],
'shell tar' => ['evil.sh.tar'],
'php tar gzip' => ['shell.php.tar.gz'],
'exe zip' => ['cmd.exe.zip'],
'jsp sql' => ['evil.jsp.sql'],
]);
test('backup validator rejects content that does not match the backup extension', function () {
$file = makeTemporaryUpload('payload.sql.gz', 'not actually gzip');
expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse();
});
test('backup validator accepts valid plain sql and gzip backup content', function () {
$plainSql = makeTemporaryUpload('backup.sql', "CREATE TABLE users (id integer);\n");
$gzipSql = makeTemporaryUpload('backup.sql.gz', gzencode("CREATE TABLE users (id integer);\n"));
expect(DatabaseBackupFileValidator::isUploadAllowed($plainSql, 10 * 1024 * 1024))->toBeTrue()
->and(DatabaseBackupFileValidator::isUploadAllowed($gzipSql, 10 * 1024 * 1024))->toBeTrue();
});
test('postgresql backup safety scanner detects program execution payloads', function (string $payload) {
expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($payload))->toBeTrue();
})->with([
'copy from program' => ["COPY pwned FROM PROGRAM 'id';"],
'copy to program' => ["COPY pwned TO PROGRAM 'cat > /tmp/out';"],
'copy with block comment' => ["COPY pwned FROM/**/PROGRAM 'id';"],
'psql shell command' => ["\\! id\n"],
'psql copy program' => ["\\copy pwned from program 'id'\n"],
]);
test('postgresql backup safety scanner allows ordinary sql dumps', function () {
$dump = <<<'SQL'
-- PostgreSQL database dump
CREATE TABLE users (id integer, name text);
COPY users (id, name) FROM stdin;
1 Taylor
\.
SQL;
expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($dump))->toBeFalse();
});
test('postgresql restore commands include a safety check before execution', function () {
$component = new class extends ImportForm
{
public function __get($property)
{
if ($property === 'resource') {
return new class
{
public function getMorphClass(): string
{
return StandalonePostgresql::class;
}
};
}
return parent::__get($property);
}
};
$component->container = 'postgres-test';
$command = $component->buildRestoreSafetyCheckCommand('/tmp/restore_test');
expect($command)
->toContain('docker exec postgres-test')
->toContain('COPY ... PROGRAM')
->toContain('/tmp/restore_test')
->toContain('grep -Eiq');
});
test('non postgresql restore commands do not include a safety check', function () {
$component = backupValidationImportFormWithResource('App\Models\StandaloneMysql');
$component->container = 'mysql-test';
expect($component->buildRestoreSafetyCheckCommand('/tmp/restore_test'))->toBeNull();
});
test('file scanner detects program execution payloads inside gzipped backups', function () {
$gzPayload = writeScanPayload("CREATE TABLE x();\nCOPY x FROM/**/PROGRAM 'id';\n", gzip: true);
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzPayload))->toBeTrue();
});
test('file scanner allows ordinary gzipped dumps', function () {
$gzClean = writeScanPayload("CREATE TABLE x();\nCOPY x FROM stdin;\n1\\.\n", gzip: true);
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzClean))->toBeFalse();
});
test('backup validator rejects plaintext .dump containing program execution', function () {
$file = makeTemporaryUpload('evil.dump', "COPY x FROM PROGRAM 'id';\n");
expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse();
});
test('remote postgresql scanner blocks bypass payloads', function (string $content, bool $gzip) {
$component = backupValidationImportFormWithResource(StandalonePostgresql::class);
$component->container = 'postgres-test';
$payload = writeScanPayload($content, $gzip);
$script = $component->buildPostgresRestoreScanScript($payload);
expect(scannerBlocks($script))->toBeTrue();
})->with([
'psql shell escape' => ["\\! id\n", false],
'copy from program' => ["COPY x FROM PROGRAM 'id';\n", false],
'copy with block comment' => ["COPY x FROM/**/PROGRAM 'id';\n", false],
'copy split across lines' => ["COPY x FROM\nPROGRAM 'id';\n", false],
'copy to program' => ["COPY x TO PROGRAM 'cat > /tmp/x';\n", false],
'psql pipe redirect' => ["\\o | id\n", false],
'gzipped comment bypass' => ["COPY x FROM/**/PROGRAM 'id';\n", true],
]);
test('remote postgresql scanner allows legitimate restores', function (string $content, bool $gzip) {
$component = backupValidationImportFormWithResource(StandalonePostgresql::class);
$component->container = 'postgres-test';
$payload = writeScanPayload($content, $gzip);
$script = $component->buildPostgresRestoreScanScript($payload);
expect(scannerBlocks($script))->toBeFalse();
})->with([
'commented out payload' => ["-- COPY x FROM PROGRAM 'id'\nSELECT 1;\n", false],
'copy from stdin' => ["COPY users FROM stdin;\n1\tTaylor\n\\.\n", false],
'plain select' => ["SELECT * FROM users;\n", false],
'gzipped clean dump' => ["CREATE TABLE users (id int);\n", true],
]);
test('MAX_BYTES constant is 10 GiB', function () {
$constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES');
expect($constant)->toBe(10 * 1024 * 1024 * 1024);

View file

@ -1,11 +1,15 @@
<?php
use App\Jobs\CheckTraefikVersionForServerJob;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Server\TraefikVersionOutdated;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
@ -14,7 +18,7 @@
});
it('detects servers table has detected_traefik_version column', function () {
expect(\Illuminate\Support\Facades\Schema::hasColumn('servers', 'detected_traefik_version'))->toBeTrue();
expect(Schema::hasColumn('servers', 'detected_traefik_version'))->toBeTrue();
});
it('server model casts detected_traefik_version as string', function () {
@ -137,7 +141,7 @@
});
it('traefik version check command exists', function () {
$commands = \Illuminate\Support\Facades\Artisan::all();
$commands = Artisan::all();
expect($commands)->toHaveKey('traefik:check-version');
});
@ -181,16 +185,16 @@
});
it('server check job exists and has correct structure', function () {
expect(class_exists(\App\Jobs\CheckTraefikVersionForServerJob::class))->toBeTrue();
expect(class_exists(CheckTraefikVersionForServerJob::class))->toBeTrue();
// Verify CheckTraefikVersionForServerJob has required properties
$reflection = new \ReflectionClass(\App\Jobs\CheckTraefikVersionForServerJob::class);
$reflection = new ReflectionClass(CheckTraefikVersionForServerJob::class);
expect($reflection->hasProperty('tries'))->toBeTrue();
expect($reflection->hasProperty('timeout'))->toBeTrue();
// Verify it implements ShouldQueue
$interfaces = class_implements(\App\Jobs\CheckTraefikVersionForServerJob::class);
expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldQueue::class);
$interfaces = class_implements(CheckTraefikVersionForServerJob::class);
expect($interfaces)->toContain(ShouldQueue::class);
});
it('sends immediate notifications when outdated traefik is detected', function () {

View file

@ -42,7 +42,7 @@
});
test('marks activity as error on permanent failure', function () {
$exception = new \RuntimeException('SSH connection failed');
$exception = new RuntimeException('SSH connection failed');
$this->job->failed($exception);

View file

@ -1,5 +1,6 @@
<?php
use App\Enums\ProxyTypes;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
@ -35,7 +36,7 @@ function makeServerProxyRunning(Server $server): void
'is_usable' => true,
]);
$server->proxy->status = 'running';
$server->proxy->type = \App\Enums\ProxyTypes::TRAEFIK->value;
$server->proxy->type = ProxyTypes::TRAEFIK->value;
$server->save();
$server->refresh();
}
@ -75,7 +76,7 @@ function makeServerProxyRunning(Server $server): void
[$user, $team, $server] = setupProxyUser('member');
$server->proxy->status = 'exited';
$server->proxy->type = \App\Enums\ProxyTypes::TRAEFIK->value;
$server->proxy->type = ProxyTypes::TRAEFIK->value;
$server->save();
$server->refresh();

View file

@ -1,5 +1,7 @@
<?php
use App\Rules\ValidIpOrCidr;
test('IP allowlist with single IPs', function () {
$testCases = [
['ip' => '192.168.1.100', 'allowlist' => ['192.168.1.100'], 'expected' => true],
@ -200,7 +202,7 @@
});
test('ValidIpOrCidr validation rule', function () {
$rule = new \App\Rules\ValidIpOrCidr;
$rule = new ValidIpOrCidr;
// Helper function to test validation
$validate = function ($value) use ($rule) {
@ -248,7 +250,7 @@
});
test('ValidIpOrCidr validation rule error messages', function () {
$rule = new \App\Rules\ValidIpOrCidr;
$rule = new ValidIpOrCidr;
// Helper function to get error message
$getError = function ($value) use ($rule) {

View file

@ -2,9 +2,10 @@
use App\Http\Middleware\TrustHosts;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
uses(RefreshDatabase::class);
beforeEach(function () {
// Clear cache before each test to ensure isolation
@ -84,7 +85,7 @@
it('handles exception during InstanceSettings fetch', function () {
// Drop the instance_settings table to simulate installation
\Schema::dropIfExists('instance_settings');
Schema::dropIfExists('instance_settings');
$middleware = new TrustHosts($this->app);

View file

@ -2,6 +2,7 @@
use App\Models\Server;
use Illuminate\Support\Once;
use Tests\TestCase;
/*
|--------------------------------------------------------------------------
@ -13,7 +14,7 @@
| need to change it using the "uses()" function to bind a different classes or traits.
|
*/
uses(Tests\TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser');
uses(TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser');
/*
|--------------------------------------------------------------------------

View file

@ -182,7 +182,7 @@
$escaped = escapeshellarg($maliciousPassword);
// Single quotes in the value get escaped as '\''
expect($escaped)->toBe("'pass'\\'''; whoami; echo '\\'''");
expect($escaped)->toBe("'pass'\\''; whoami; echo '\\'''");
$command = "docker exec container mariadb-dump -u root -p$escaped db";
// Verify the command doesn't contain an unescaped semicolon outside quotes
expect($command)->toContain("-p'pass'");

View file

@ -34,7 +34,7 @@ function importFormWithResource(string $modelClass): ImportForm
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('gunzip -cf /tmp/test.dump');
expect($result)->toContain("gunzip -cf '/tmp/test.dump'");
expect($result)->toContain('psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}');
});
@ -46,7 +46,7 @@ function importFormWithResource(string $modelClass): ImportForm
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mysql -u $MYSQL_USER');
expect($result)->toContain('< /tmp/test.dump');
expect($result)->toContain("< '/tmp/test.dump'");
});
test('buildRestoreCommand handles MariaDB without dumpAll', function () {
@ -57,7 +57,7 @@ function importFormWithResource(string $modelClass): ImportForm
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mariadb -u $MARIADB_USER');
expect($result)->toContain('< /tmp/test.dump');
expect($result)->toContain("< '/tmp/test.dump'");
});
test('buildRestoreCommand always appends the MongoDB archive path', function (bool $dumpAll) {
@ -68,5 +68,5 @@ function importFormWithResource(string $modelClass): ImportForm
$result = $component->buildRestoreCommand('/tmp/test.dump');
expect($result)->toContain('mongorestore');
expect($result)->toContain('--archive=/tmp/test.dump');
expect($result)->toContain("--archive='/tmp/test.dump'");
})->with([false, true]);