mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
Merge branch 'next' into feat/add-commit-author-to-notifications
This commit is contained in:
commit
41fe2e2016
16 changed files with 583 additions and 29 deletions
|
|
@ -1101,7 +1101,6 @@ class ApplicationsController extends Controller
|
|||
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
'docker_compose_location' => 'string',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
|
|
@ -1297,7 +1296,6 @@ class ApplicationsController extends Controller
|
|||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
'github_app_uuid' => 'string|required',
|
||||
'watch_paths' => 'string|nullable',
|
||||
'docker_compose_location' => 'string',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
|
|
@ -1525,7 +1523,6 @@ class ApplicationsController extends Controller
|
|||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
'private_key_uuid' => 'string|required',
|
||||
'watch_paths' => 'string|nullable',
|
||||
'docker_compose_location' => 'string',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
|
|
@ -2470,7 +2467,6 @@ class ApplicationsController extends Controller
|
|||
'description' => 'string|nullable',
|
||||
'static_image' => 'string',
|
||||
'watch_paths' => 'string|nullable',
|
||||
'docker_compose_location' => 'string',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
|
|
|
|||
|
|
@ -127,6 +127,10 @@ class DeployController extends Controller
|
|||
if (! $deployment) {
|
||||
return response()->json(['message' => 'Deployment not found.'], 404);
|
||||
}
|
||||
$application = $deployment->application;
|
||||
if (! $application || data_get($application->team(), 'id') !== $teamId) {
|
||||
return response()->json(['message' => 'Deployment not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json($this->removeSensitiveData($deployment));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
}
|
||||
if ($this->application->build_pack === 'dockerfile') {
|
||||
if (data_get($this->application, 'dockerfile_location')) {
|
||||
$this->dockerfile_location = $this->application->dockerfile_location;
|
||||
$this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -571,7 +571,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
private function deploy_docker_compose_buildpack()
|
||||
{
|
||||
if (data_get($this->application, 'docker_compose_location')) {
|
||||
$this->docker_compose_location = $this->application->docker_compose_location;
|
||||
$this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location');
|
||||
}
|
||||
if (data_get($this->application, 'docker_compose_custom_start_command')) {
|
||||
$this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command;
|
||||
|
|
@ -831,7 +831,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
$this->server = $this->build_server;
|
||||
}
|
||||
if (data_get($this->application, 'dockerfile_location')) {
|
||||
$this->dockerfile_location = $this->application->dockerfile_location;
|
||||
$this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
|
||||
}
|
||||
$this->prepare_builder_image();
|
||||
$this->check_git_if_build_needed();
|
||||
|
|
@ -3879,6 +3879,18 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||
return $composeFile;
|
||||
}
|
||||
|
||||
private function validatePathField(string $value, string $fieldName): string
|
||||
{
|
||||
if (! preg_match('/^\/[a-zA-Z0-9._\-\/]+$/', $value)) {
|
||||
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters.");
|
||||
}
|
||||
if (str_contains($value, '..')) {
|
||||
throw new \RuntimeException("Invalid {$fieldName}: path traversal detected.");
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function run_pre_deployment_command()
|
||||
{
|
||||
if (empty($this->application->pre_deployment_command)) {
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ class ScheduledJobManager implements ShouldQueue
|
|||
|
||||
Log::channel('scheduled')->info('ScheduledJobManager completed', [
|
||||
'execution_time' => $this->executionTime->toIso8601String(),
|
||||
'duration_ms' => Carbon::now()->diffInMilliseconds($this->executionTime),
|
||||
'duration_ms' => $this->executionTime->diffInMilliseconds(Carbon::now()),
|
||||
'dispatched' => $this->dispatchedCount,
|
||||
'skipped' => $this->skippedCount,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ class General extends Component
|
|||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $dockerfile = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])]
|
||||
public ?string $dockerfileLocation = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
|
|
@ -85,7 +85,7 @@ class General extends Component
|
|||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $dockerRegistryImageTag = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])]
|
||||
public ?string $dockerComposeLocation = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
|
|
|
|||
|
|
@ -163,10 +163,12 @@ class GithubPrivateRepository extends Component
|
|||
'selected_repository_owner' => $this->selected_repository_owner,
|
||||
'selected_repository_repo' => $this->selected_repository_repo,
|
||||
'selected_branch_name' => $this->selected_branch_name,
|
||||
'docker_compose_location' => $this->docker_compose_location,
|
||||
], [
|
||||
'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/',
|
||||
'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/',
|
||||
'selected_branch_name' => ['required', 'string', new ValidGitBranch],
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ class GithubPrivateRepositoryDeployKey extends Component
|
|||
'is_static' => 'required|boolean',
|
||||
'publish_directory' => 'nullable|string',
|
||||
'build_pack' => 'required|string',
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
];
|
||||
|
||||
protected function rules()
|
||||
|
|
@ -75,6 +76,7 @@ class GithubPrivateRepositoryDeployKey extends Component
|
|||
'is_static' => 'required|boolean',
|
||||
'publish_directory' => 'nullable|string',
|
||||
'build_pack' => 'required|string',
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ class PublicGitRepository extends Component
|
|||
'publish_directory' => 'nullable|string',
|
||||
'build_pack' => 'required|string',
|
||||
'base_directory' => 'nullable|string',
|
||||
'docker_compose_location' => 'nullable|string',
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
];
|
||||
|
||||
protected function rules()
|
||||
|
|
@ -82,7 +82,7 @@ class PublicGitRepository extends Component
|
|||
'publish_directory' => 'nullable|string',
|
||||
'build_pack' => 'required|string',
|
||||
'base_directory' => 'nullable|string',
|
||||
'docker_compose_location' => 'nullable|string',
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
'git_branch' => ['required', 'string', new ValidGitBranch],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,7 +191,8 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
|||
$this->getNotificationSettings('discord')?->isEnabled() ||
|
||||
$this->getNotificationSettings('slack')?->isEnabled() ||
|
||||
$this->getNotificationSettings('telegram')?->isEnabled() ||
|
||||
$this->getNotificationSettings('pushover')?->isEnabled();
|
||||
$this->getNotificationSettings('pushover')?->isEnabled() ||
|
||||
$this->getNotificationSettings('webhook')?->isEnabled();
|
||||
}
|
||||
|
||||
public function subscriptionEnded()
|
||||
|
|
|
|||
|
|
@ -132,8 +132,8 @@ function sharedDataApplications()
|
|||
'manual_webhook_secret_gitlab' => 'string|nullable',
|
||||
'manual_webhook_secret_bitbucket' => 'string|nullable',
|
||||
'manual_webhook_secret_gitea' => 'string|nullable',
|
||||
'dockerfile_location' => 'string|nullable',
|
||||
'docker_compose_location' => 'string',
|
||||
'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
'docker_compose' => 'string|nullable',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_custom_start_command' => 'string|nullable',
|
||||
|
|
|
|||
|
|
@ -1233,7 +1233,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: true,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
|
|
@ -1246,7 +1246,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
network: $network,
|
||||
uuid: $uuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: true,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
|
|
@ -1260,7 +1260,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: true,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
|
|
@ -1271,7 +1271,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
network: $network,
|
||||
uuid: $uuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: true,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class ApplicationSeeder extends Seeder
|
|||
'git_repository' => 'coollabsio/coolify-examples',
|
||||
'git_branch' => 'v4.x',
|
||||
'base_directory' => '/docker-compose',
|
||||
'docker_compose_location' => 'docker-compose-test.yaml',
|
||||
'docker_compose_location' => '/docker-compose-test.yaml',
|
||||
'build_pack' => 'dockercompose',
|
||||
'ports_exposes' => '80',
|
||||
'environment_id' => 1,
|
||||
|
|
|
|||
209
docker-compose-maxio.dev.yml
Normal file
209
docker-compose-maxio.dev.yml
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
services:
|
||||
coolify:
|
||||
image: coolify:dev
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/development/Dockerfile
|
||||
args:
|
||||
- USER_ID=${USERID:-1000}
|
||||
- GROUP_ID=${GROUPID:-1000}
|
||||
ports:
|
||||
- "${APP_PORT:-8000}:8080"
|
||||
environment:
|
||||
AUTORUN_ENABLED: false
|
||||
PUSHER_HOST: "${PUSHER_HOST}"
|
||||
PUSHER_PORT: "${PUSHER_PORT}"
|
||||
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
|
||||
PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}"
|
||||
PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
|
||||
PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
|
||||
healthcheck:
|
||||
test: curl -sf http://127.0.0.1:8080/api/health || exit 1
|
||||
interval: 5s
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
volumes:
|
||||
- .:/var/www/html/:cached
|
||||
- dev_backups_data:/var/www/html/storage/app/backups
|
||||
networks:
|
||||
- coolify
|
||||
postgres:
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "${FORWARD_DB_PORT:-5432}:5432"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_USER: "${DB_USERNAME:-coolify}"
|
||||
POSTGRES_PASSWORD: "${DB_PASSWORD:-password}"
|
||||
POSTGRES_DB: "${DB_DATABASE:-coolify}"
|
||||
POSTGRES_HOST_AUTH_METHOD: "trust"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
volumes:
|
||||
- dev_postgres_data:/var/lib/postgresql/data
|
||||
redis:
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "${FORWARD_REDIS_PORT:-6379}:6379"
|
||||
env_file:
|
||||
- .env
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
volumes:
|
||||
- dev_redis_data:/data
|
||||
soketi:
|
||||
image: coolify-realtime:dev
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/coolify-realtime/Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${FORWARD_SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
volumes:
|
||||
- ./storage:/var/www/html/storage
|
||||
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js
|
||||
environment:
|
||||
SOKETI_DEBUG: "false"
|
||||
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"
|
||||
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
|
||||
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"]
|
||||
vite:
|
||||
image: node:24-alpine
|
||||
pull_policy: always
|
||||
container_name: coolify-vite
|
||||
working_dir: /var/www/html
|
||||
environment:
|
||||
VITE_HOST: "${VITE_HOST:-localhost}"
|
||||
VITE_PORT: "${VITE_PORT:-5173}"
|
||||
ports:
|
||||
- "${VITE_PORT:-5173}:${VITE_PORT:-5173}"
|
||||
volumes:
|
||||
- .:/var/www/html/:cached
|
||||
command: sh -c "npm install && npm run dev"
|
||||
networks:
|
||||
- coolify
|
||||
testing-host:
|
||||
image: coolify-testing-host:dev
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/testing-host/Dockerfile
|
||||
init: true
|
||||
container_name: coolify-testing-host
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- dev_coolify_data:/data/coolify
|
||||
- dev_backups_data:/data/coolify/backups
|
||||
- dev_postgres_data:/data/coolify/_volumes/database
|
||||
- dev_redis_data:/data/coolify/_volumes/redis
|
||||
- dev_minio_data:/data/coolify/_volumes/minio
|
||||
networks:
|
||||
- coolify
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
pull_policy: always
|
||||
container_name: coolify-mail
|
||||
ports:
|
||||
- "${FORWARD_MAILPIT_PORT:-1025}:1025"
|
||||
- "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025"
|
||||
networks:
|
||||
- coolify
|
||||
# maxio:
|
||||
# image: ghcr.io/coollabsio/maxio
|
||||
# pull_policy: always
|
||||
# container_name: coolify-maxio
|
||||
# ports:
|
||||
# - "${FORWARD_MAXIO_PORT:-9000}:9000"
|
||||
# environment:
|
||||
# MAXIO_ACCESS_KEY: "${MAXIO_ACCESS_KEY:-maxioadmin}"
|
||||
# MAXIO_SECRET_KEY: "${MAXIO_SECRET_KEY:-maxioadmin}"
|
||||
# volumes:
|
||||
# - dev_maxio_data:/data
|
||||
# networks:
|
||||
# - coolify
|
||||
minio:
|
||||
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
|
||||
pull_policy: always
|
||||
container_name: coolify-minio
|
||||
command: server /data --console-address ":9001"
|
||||
ports:
|
||||
- "${FORWARD_MINIO_PORT:-9000}:9000"
|
||||
- "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001"
|
||||
environment:
|
||||
MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}"
|
||||
MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}"
|
||||
volumes:
|
||||
- dev_minio_data:/data
|
||||
- dev_maxio_data:/data
|
||||
networks:
|
||||
- coolify
|
||||
# maxio-init:
|
||||
# image: minio/mc:latest
|
||||
# pull_policy: always
|
||||
# container_name: coolify-maxio-init
|
||||
# restart: no
|
||||
# depends_on:
|
||||
# - maxio
|
||||
# entrypoint: >
|
||||
# /bin/sh -c "
|
||||
# echo 'Waiting for MaxIO to be ready...';
|
||||
# until mc alias set local http://coolify-maxio:9000 maxioadmin maxioadmin 2>/dev/null; do
|
||||
# echo 'MaxIO not ready yet, waiting...';
|
||||
# sleep 2;
|
||||
# done;
|
||||
# echo 'MaxIO is ready, creating bucket if needed...';
|
||||
# mc mb local/local --ignore-existing;
|
||||
# echo 'MaxIO initialization complete - bucket local is ready';
|
||||
# "
|
||||
# networks:
|
||||
# - coolify
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
pull_policy: always
|
||||
container_name: coolify-minio-init
|
||||
restart: no
|
||||
depends_on:
|
||||
- minio
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
echo 'Waiting for MinIO to be ready...';
|
||||
until mc alias set local http://coolify-minio:9000 minioadmin minioadmin 2>/dev/null; do
|
||||
echo 'MinIO not ready yet, waiting...';
|
||||
sleep 2;
|
||||
done;
|
||||
echo 'MinIO is ready, creating bucket if needed...';
|
||||
mc mb local/local --ignore-existing;
|
||||
echo 'MinIO initialization complete - bucket local is ready';
|
||||
"
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
volumes:
|
||||
dev_backups_data:
|
||||
dev_postgres_data:
|
||||
dev_redis_data:
|
||||
dev_coolify_data:
|
||||
dev_minio_data:
|
||||
dev_maxio_data:
|
||||
|
||||
networks:
|
||||
coolify:
|
||||
name: coolify
|
||||
external: false
|
||||
|
|
@ -121,9 +121,9 @@ Route::group([
|
|||
Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
||||
Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']);
|
||||
Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']);
|
||||
|
|
@ -152,9 +152,9 @@ Route::group([
|
|||
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
||||
Route::get('/services', [ServicesController::class, 'services'])->middleware(['api.ability:read']);
|
||||
Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']);
|
||||
|
|
@ -169,9 +169,9 @@ Route::group([
|
|||
Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
||||
Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']);
|
||||
Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
|
||||
|
|
|
|||
276
tests/Feature/CommandInjectionSecurityTest.php
Normal file
276
tests/Feature/CommandInjectionSecurityTest.php
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
|
||||
describe('deployment job path field validation', function () {
|
||||
test('rejects shell metacharacters in dockerfile_location', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, '/Dockerfile; echo pwned', 'dockerfile_location'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
});
|
||||
|
||||
test('rejects backtick injection', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, '/Dockerfile`whoami`', 'dockerfile_location'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
});
|
||||
|
||||
test('rejects dollar sign variable expansion', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, '/Dockerfile$(whoami)', 'dockerfile_location'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
});
|
||||
|
||||
test('rejects pipe injection', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, '/Dockerfile | cat /etc/passwd', 'dockerfile_location'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
});
|
||||
|
||||
test('rejects ampersand injection', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, '/Dockerfile && env', 'dockerfile_location'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
});
|
||||
|
||||
test('rejects path traversal', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, '/../../../etc/passwd', 'dockerfile_location'))
|
||||
->toThrow(RuntimeException::class, 'path traversal detected');
|
||||
});
|
||||
|
||||
test('allows valid simple path', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect($method->invoke($instance, '/Dockerfile', 'dockerfile_location'))
|
||||
->toBe('/Dockerfile');
|
||||
});
|
||||
|
||||
test('allows valid nested path with dots and hyphens', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect($method->invoke($instance, '/docker/Dockerfile.prod', 'dockerfile_location'))
|
||||
->toBe('/docker/Dockerfile.prod');
|
||||
});
|
||||
|
||||
test('allows valid compose file path', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect($method->invoke($instance, '/docker-compose.prod.yml', 'docker_compose_location'))
|
||||
->toBe('/docker-compose.prod.yml');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API validation rules for path fields', function () {
|
||||
test('dockerfile_location validation rejects shell metacharacters', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['dockerfile_location' => '/Dockerfile; echo pwned; #'],
|
||||
['dockerfile_location' => $rules['dockerfile_location']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('dockerfile_location validation allows valid paths', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['dockerfile_location' => '/docker/Dockerfile.prod'],
|
||||
['dockerfile_location' => $rules['dockerfile_location']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
test('docker_compose_location validation rejects shell metacharacters', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_location' => '/docker-compose.yml; env; #'],
|
||||
['docker_compose_location' => $rules['docker_compose_location']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('docker_compose_location validation allows valid paths', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_location' => '/docker/docker-compose.prod.yml'],
|
||||
['docker_compose_location' => $rules['docker_compose_location']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedDataApplications rules survive array_merge in controller', function () {
|
||||
test('docker_compose_location safe regex is not overridden by local rules', function () {
|
||||
$sharedRules = sharedDataApplications();
|
||||
|
||||
// Simulate what ApplicationsController does: array_merge(shared, local)
|
||||
// After our fix, local no longer contains docker_compose_location,
|
||||
// so the shared regex rule must survive
|
||||
$localRules = [
|
||||
'name' => 'string|max:255',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
];
|
||||
$merged = array_merge($sharedRules, $localRules);
|
||||
|
||||
// The merged rules for docker_compose_location should be the safe regex, not just 'string'
|
||||
expect($merged['docker_compose_location'])->toBeArray();
|
||||
expect($merged['docker_compose_location'])->toContain('regex:/^\/[a-zA-Z0-9._\-\/]+$/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('path fields require leading slash', function () {
|
||||
test('dockerfile_location without leading slash is rejected by API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['dockerfile_location' => 'Dockerfile'],
|
||||
['dockerfile_location' => $rules['dockerfile_location']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('docker_compose_location without leading slash is rejected by API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_location' => 'docker-compose.yaml'],
|
||||
['docker_compose_location' => $rules['docker_compose_location']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('deployment job rejects path without leading slash', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, 'docker-compose.yaml', 'docker_compose_location'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API route middleware for deploy actions', function () {
|
||||
test('application start route requires deploy ability', function () {
|
||||
$routes = app('router')->getRoutes();
|
||||
$route = $routes->getByAction('App\Http\Controllers\Api\ApplicationsController@action_deploy');
|
||||
|
||||
expect($route)->not->toBeNull();
|
||||
$middleware = $route->gatherMiddleware();
|
||||
expect($middleware)->toContain('api.ability:deploy');
|
||||
expect($middleware)->not->toContain('api.ability:write');
|
||||
});
|
||||
|
||||
test('application restart route requires deploy ability', function () {
|
||||
$routes = app('router')->getRoutes();
|
||||
$matchedRoute = null;
|
||||
foreach ($routes as $route) {
|
||||
if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'restart')) {
|
||||
$matchedRoute = $route;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect($matchedRoute)->not->toBeNull();
|
||||
$middleware = $matchedRoute->gatherMiddleware();
|
||||
expect($middleware)->toContain('api.ability:deploy');
|
||||
});
|
||||
|
||||
test('application stop route requires deploy ability', function () {
|
||||
$routes = app('router')->getRoutes();
|
||||
$matchedRoute = null;
|
||||
foreach ($routes as $route) {
|
||||
if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'stop')) {
|
||||
$matchedRoute = $route;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect($matchedRoute)->not->toBeNull();
|
||||
$middleware = $matchedRoute->gatherMiddleware();
|
||||
expect($middleware)->toContain('api.ability:deploy');
|
||||
});
|
||||
|
||||
test('database start route requires deploy ability', function () {
|
||||
$routes = app('router')->getRoutes();
|
||||
$matchedRoute = null;
|
||||
foreach ($routes as $route) {
|
||||
if (str_contains($route->uri(), 'databases') && str_contains($route->uri(), 'start')) {
|
||||
$matchedRoute = $route;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect($matchedRoute)->not->toBeNull();
|
||||
$middleware = $matchedRoute->gatherMiddleware();
|
||||
expect($middleware)->toContain('api.ability:deploy');
|
||||
});
|
||||
|
||||
test('service start route requires deploy ability', function () {
|
||||
$routes = app('router')->getRoutes();
|
||||
$matchedRoute = null;
|
||||
foreach ($routes as $route) {
|
||||
if (str_contains($route->uri(), 'services') && str_contains($route->uri(), 'start')) {
|
||||
$matchedRoute = $route;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect($matchedRoute)->not->toBeNull();
|
||||
$middleware = $matchedRoute->gatherMiddleware();
|
||||
expect($middleware)->toContain('api.ability:deploy');
|
||||
});
|
||||
});
|
||||
52
tests/Feature/TeamNotificationCheckTest.php
Normal file
52
tests/Feature/TeamNotificationCheckTest.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
});
|
||||
|
||||
describe('isAnyNotificationEnabled', function () {
|
||||
test('returns false when no notifications are enabled', function () {
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns true when email notifications are enabled', function () {
|
||||
$this->team->emailNotificationSettings->update(['smtp_enabled' => true]);
|
||||
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when discord notifications are enabled', function () {
|
||||
$this->team->discordNotificationSettings->update(['discord_enabled' => true]);
|
||||
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when slack notifications are enabled', function () {
|
||||
$this->team->slackNotificationSettings->update(['slack_enabled' => true]);
|
||||
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when telegram notifications are enabled', function () {
|
||||
$this->team->telegramNotificationSettings->update(['telegram_enabled' => true]);
|
||||
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when pushover notifications are enabled', function () {
|
||||
$this->team->pushoverNotificationSettings->update(['pushover_enabled' => true]);
|
||||
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when webhook notifications are enabled', function () {
|
||||
$this->team->webhookNotificationSettings->update(['webhook_enabled' => true]);
|
||||
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue