mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 18:23:40 +00:00
Merge remote-tracking branch 'origin/next' into fix/docker-compose-domains-api-9211
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -176,6 +177,7 @@ class CloudProviderTokensController extends Controller
|
||||
if (is_null($token)) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
return response()->json($this->removeSensitiveData($token));
|
||||
}
|
||||
@@ -242,9 +244,10 @@ class CloudProviderTokensController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [CloudProviderToken::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -286,6 +289,13 @@ class CloudProviderTokensController extends Controller
|
||||
'name' => $body['name'],
|
||||
]);
|
||||
|
||||
auditLog('api.cloud_token.created', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $cloudProviderToken->uuid,
|
||||
'cloud_token_name' => $cloudProviderToken->name,
|
||||
'provider' => $cloudProviderToken->provider,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $cloudProviderToken->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -355,7 +365,7 @@ class CloudProviderTokensController extends Controller
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -386,9 +396,18 @@ class CloudProviderTokensController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $token);
|
||||
|
||||
$token->update(array_intersect_key($body, array_flip($allowedFields)));
|
||||
|
||||
auditLog('api.cloud_token.updated', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $token->uuid,
|
||||
'cloud_token_name' => $token->name,
|
||||
'provider' => $token->provider,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $token->uuid,
|
||||
]);
|
||||
@@ -459,13 +478,24 @@ class CloudProviderTokensController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $token);
|
||||
|
||||
if ($token->hasServers()) {
|
||||
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
|
||||
}
|
||||
|
||||
$tokenUuid = $token->uuid;
|
||||
$tokenName = $token->name;
|
||||
$tokenProvider = $token->provider;
|
||||
$token->delete();
|
||||
|
||||
auditLog('api.cloud_token.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $tokenUuid,
|
||||
'cloud_token_name' => $tokenName,
|
||||
'provider' => $tokenProvider,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Cloud provider token deleted.']);
|
||||
}
|
||||
|
||||
@@ -519,9 +549,18 @@ class CloudProviderTokensController extends Controller
|
||||
if (! $cloudToken) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $cloudToken);
|
||||
|
||||
$validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token);
|
||||
|
||||
auditLog('api.cloud_token.validated', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $cloudToken->uuid,
|
||||
'cloud_token_name' => $cloudToken->name,
|
||||
'provider' => $cloudToken->provider,
|
||||
'valid' => $validation['valid'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'valid' => $validation['valid'],
|
||||
'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'],
|
||||
|
||||
@@ -299,6 +299,11 @@ class DatabasesController extends Controller
|
||||
'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'],
|
||||
'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'],
|
||||
'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'],
|
||||
'health_check_enabled' => ['type' => 'boolean', 'description' => 'Enable the database healthcheck probe.', 'default' => true],
|
||||
'health_check_interval' => ['type' => 'integer', 'description' => 'Healthcheck interval in seconds.', 'minimum' => 1, 'default' => 15],
|
||||
'health_check_timeout' => ['type' => 'integer', 'description' => 'Healthcheck timeout in seconds.', 'minimum' => 1, 'default' => 5],
|
||||
'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.', 'minimum' => 1, 'default' => 5],
|
||||
'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.', 'minimum' => 0, 'default' => 5],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -379,9 +384,9 @@ class DatabasesController extends Controller
|
||||
case 'standalone-postgresql':
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'postgres_user' => 'string',
|
||||
'postgres_password' => 'string',
|
||||
'postgres_db' => 'string',
|
||||
'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'postgres_initdb_args' => 'string',
|
||||
'postgres_host_auth_method' => 'string',
|
||||
'postgres_conf' => 'string',
|
||||
@@ -410,20 +415,20 @@ class DatabasesController extends Controller
|
||||
case 'standalone-clickhouse':
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'clickhouse_admin_user' => 'string',
|
||||
'clickhouse_admin_password' => 'string',
|
||||
'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
]);
|
||||
break;
|
||||
case 'standalone-dragonfly':
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'dragonfly_password' => 'string',
|
||||
'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
]);
|
||||
break;
|
||||
case 'standalone-redis':
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'redis_password' => 'string',
|
||||
'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'redis_conf' => 'string',
|
||||
]);
|
||||
if ($request->has('redis_conf')) {
|
||||
@@ -450,7 +455,7 @@ class DatabasesController extends Controller
|
||||
case 'standalone-keydb':
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'keydb_password' => 'string',
|
||||
'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'keydb_conf' => 'string',
|
||||
]);
|
||||
if ($request->has('keydb_conf')) {
|
||||
@@ -478,10 +483,10 @@ class DatabasesController extends Controller
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'mariadb_conf' => 'string',
|
||||
'mariadb_root_password' => 'string',
|
||||
'mariadb_user' => 'string',
|
||||
'mariadb_password' => 'string',
|
||||
'mariadb_database' => 'string',
|
||||
'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
]);
|
||||
if ($request->has('mariadb_conf')) {
|
||||
if (! isBase64Encoded($request->mariadb_conf)) {
|
||||
@@ -508,9 +513,9 @@ class DatabasesController extends Controller
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'mongo_conf' => 'string',
|
||||
'mongo_initdb_root_username' => 'string',
|
||||
'mongo_initdb_root_password' => 'string',
|
||||
'mongo_initdb_database' => 'string',
|
||||
'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
]);
|
||||
if ($request->has('mongo_conf')) {
|
||||
if (! isBase64Encoded($request->mongo_conf)) {
|
||||
@@ -537,10 +542,10 @@ class DatabasesController extends Controller
|
||||
case 'standalone-mysql':
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'mysql_root_password' => 'string',
|
||||
'mysql_password' => 'string',
|
||||
'mysql_user' => 'string',
|
||||
'mysql_database' => 'string',
|
||||
'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'mysql_conf' => 'string',
|
||||
]);
|
||||
if ($request->has('mysql_conf')) {
|
||||
@@ -565,9 +570,17 @@ class DatabasesController extends Controller
|
||||
}
|
||||
break;
|
||||
}
|
||||
$allowedFields = array_merge($allowedFields, ['health_check_enabled', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period']);
|
||||
$healthCheckValidator = customApiValidator($request->all(), [
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_interval' => 'integer|min:1',
|
||||
'health_check_timeout' => 'integer|min:1',
|
||||
'health_check_retries' => 'integer|min:1',
|
||||
'health_check_start_period' => 'integer|min:0',
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if ($validator->fails() || $healthCheckValidator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors()->merge($healthCheckValidator->errors());
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
@@ -596,6 +609,14 @@ class DatabasesController extends Controller
|
||||
StopDatabaseProxy::dispatch($database);
|
||||
}
|
||||
|
||||
auditLog('api.database.updated', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $database->type(),
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Database updated.',
|
||||
]);
|
||||
@@ -639,10 +660,11 @@ class DatabasesController extends Controller
|
||||
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
|
||||
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
|
||||
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
|
||||
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'],
|
||||
'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'],
|
||||
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
|
||||
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
|
||||
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'],
|
||||
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -679,7 +701,7 @@ class DatabasesController extends Controller
|
||||
)]
|
||||
public function create_backup(Request $request)
|
||||
{
|
||||
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
|
||||
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -702,10 +724,11 @@ class DatabasesController extends Controller
|
||||
'databases_to_backup' => 'string|nullable',
|
||||
'database_backup_retention_amount_locally' => 'integer|min:0',
|
||||
'database_backup_retention_days_locally' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_locally' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_locally' => 'numeric|min:0',
|
||||
'database_backup_retention_amount_s3' => 'integer|min:0',
|
||||
'database_backup_retention_days_s3' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_s3' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_s3' => 'numeric|min:0',
|
||||
'timeout' => 'integer|min:60|max:36000',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -745,7 +768,7 @@ class DatabasesController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('s3_storage_uuid')) {
|
||||
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
$existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
if (! $existsInTeam) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -772,7 +795,7 @@ class DatabasesController extends Controller
|
||||
|
||||
// Convert s3_storage_uuid to s3_storage_id
|
||||
if (isset($backupData['s3_storage_uuid'])) {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
$s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
if ($s3Storage) {
|
||||
$backupData['s3_storage_id'] = $s3Storage->id;
|
||||
} elseif ($request->boolean('save_s3')) {
|
||||
@@ -824,6 +847,15 @@ class DatabasesController extends Controller
|
||||
dispatch(new DatabaseBackupJob($backupConfig));
|
||||
}
|
||||
|
||||
auditLog('api.database.backup_created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'backup_uuid' => $backupConfig->uuid,
|
||||
'frequency' => $backupConfig->frequency,
|
||||
'save_s3' => (bool) $backupConfig->save_s3,
|
||||
'backup_now' => (bool) $request->backup_now,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $backupConfig->uuid,
|
||||
'message' => 'Backup configuration created successfully.',
|
||||
@@ -876,10 +908,11 @@ class DatabasesController extends Controller
|
||||
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
|
||||
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
|
||||
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
|
||||
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
|
||||
'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'],
|
||||
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
|
||||
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
|
||||
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
|
||||
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -909,7 +942,7 @@ class DatabasesController extends Controller
|
||||
)]
|
||||
public function update_backup(Request $request)
|
||||
{
|
||||
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
|
||||
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -927,13 +960,14 @@ class DatabasesController extends Controller
|
||||
'dump_all' => 'boolean',
|
||||
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
|
||||
'databases_to_backup' => 'string|nullable',
|
||||
'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly',
|
||||
'frequency' => 'string',
|
||||
'database_backup_retention_amount_locally' => 'integer|min:0',
|
||||
'database_backup_retention_days_locally' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_locally' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_locally' => 'numeric|min:0',
|
||||
'database_backup_retention_amount_s3' => 'integer|min:0',
|
||||
'database_backup_retention_days_s3' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_s3' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_s3' => 'numeric|min:0',
|
||||
'timeout' => 'integer|min:60|max:36000',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
@@ -960,6 +994,17 @@ class DatabasesController extends Controller
|
||||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
// Validate frequency is a valid cron expression
|
||||
if ($request->filled('frequency')) {
|
||||
$isValid = validate_cron_expression($request->frequency);
|
||||
if (! $isValid) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -967,7 +1012,7 @@ class DatabasesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
if ($request->filled('s3_storage_uuid')) {
|
||||
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
$existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
if (! $existsInTeam) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -1000,7 +1045,7 @@ class DatabasesController extends Controller
|
||||
|
||||
// Convert s3_storage_uuid to s3_storage_id
|
||||
if (isset($backupData['s3_storage_uuid'])) {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
$s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
if ($s3Storage) {
|
||||
$backupData['s3_storage_id'] = $s3Storage->id;
|
||||
} elseif ($request->boolean('save_s3')) {
|
||||
@@ -1030,6 +1075,14 @@ class DatabasesController extends Controller
|
||||
dispatch(new DatabaseBackupJob($backupConfig));
|
||||
}
|
||||
|
||||
auditLog('api.database.backup_updated', [
|
||||
'team_id' => $teamId,
|
||||
'backup_uuid' => $backupConfig->uuid,
|
||||
'database_id' => $backupConfig->database_id,
|
||||
'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))),
|
||||
'backup_now' => (bool) $request->backup_now,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Database backup configuration updated',
|
||||
]);
|
||||
@@ -1709,9 +1762,9 @@ class DatabasesController extends Controller
|
||||
if ($type === NewDatabaseTypes::POSTGRESQL) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'postgres_user' => 'string',
|
||||
'postgres_password' => 'string',
|
||||
'postgres_db' => 'string',
|
||||
'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'postgres_initdb_args' => 'string',
|
||||
'postgres_host_auth_method' => 'string',
|
||||
'postgres_conf' => 'string',
|
||||
@@ -1751,7 +1804,7 @@ class DatabasesController extends Controller
|
||||
}
|
||||
$request->offsetSet('postgres_conf', $postgresConf);
|
||||
}
|
||||
$database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_postgresql($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
@@ -1764,12 +1817,25 @@ class DatabasesController extends Controller
|
||||
$payload['external_db_url'] = $database->external_db_url;
|
||||
}
|
||||
|
||||
auditLog('api.database.created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $type->value,
|
||||
'server_uuid' => $serverUuid,
|
||||
'is_public' => (bool) $database->is_public,
|
||||
'instant_deploy' => (bool) $instantDeploy,
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::MARIADB) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'clickhouse_admin_user' => 'string',
|
||||
'clickhouse_admin_password' => 'string',
|
||||
'mariadb_conf' => 'string',
|
||||
'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
@@ -1806,7 +1872,7 @@ class DatabasesController extends Controller
|
||||
}
|
||||
$request->offsetSet('mariadb_conf', $mariadbConf);
|
||||
}
|
||||
$database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_mariadb($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
@@ -1820,14 +1886,24 @@ class DatabasesController extends Controller
|
||||
$payload['external_db_url'] = $database->external_db_url;
|
||||
}
|
||||
|
||||
auditLog('api.database.created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $type->value,
|
||||
'server_uuid' => $serverUuid,
|
||||
'is_public' => (bool) $database->is_public,
|
||||
'instant_deploy' => (bool) $instantDeploy,
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::MYSQL) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'mysql_root_password' => 'string',
|
||||
'mysql_password' => 'string',
|
||||
'mysql_user' => 'string',
|
||||
'mysql_database' => 'string',
|
||||
'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'mysql_conf' => 'string',
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -1865,7 +1941,7 @@ class DatabasesController extends Controller
|
||||
}
|
||||
$request->offsetSet('mysql_conf', $mysqlConf);
|
||||
}
|
||||
$database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_mysql($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
@@ -1879,11 +1955,21 @@ class DatabasesController extends Controller
|
||||
$payload['external_db_url'] = $database->external_db_url;
|
||||
}
|
||||
|
||||
auditLog('api.database.created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $type->value,
|
||||
'server_uuid' => $serverUuid,
|
||||
'is_public' => (bool) $database->is_public,
|
||||
'instant_deploy' => (bool) $instantDeploy,
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::REDIS) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'redis_password' => 'string',
|
||||
'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'redis_conf' => 'string',
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -1921,7 +2007,7 @@ class DatabasesController extends Controller
|
||||
}
|
||||
$request->offsetSet('redis_conf', $redisConf);
|
||||
}
|
||||
$database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_redis($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
@@ -1935,11 +2021,21 @@ class DatabasesController extends Controller
|
||||
$payload['external_db_url'] = $database->external_db_url;
|
||||
}
|
||||
|
||||
auditLog('api.database.created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $type->value,
|
||||
'server_uuid' => $serverUuid,
|
||||
'is_public' => (bool) $database->is_public,
|
||||
'instant_deploy' => (bool) $instantDeploy,
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'dragonfly_password' => 'string',
|
||||
'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -1958,7 +2054,7 @@ class DatabasesController extends Controller
|
||||
}
|
||||
|
||||
removeUnnecessaryFieldsFromRequest($request);
|
||||
$database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_dragonfly($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
@@ -1969,7 +2065,7 @@ class DatabasesController extends Controller
|
||||
} elseif ($type === NewDatabaseTypes::KEYDB) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'keydb_password' => 'string',
|
||||
'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'keydb_conf' => 'string',
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -2007,7 +2103,7 @@ class DatabasesController extends Controller
|
||||
}
|
||||
$request->offsetSet('keydb_conf', $keydbConf);
|
||||
}
|
||||
$database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_keydb($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
@@ -2021,12 +2117,22 @@ class DatabasesController extends Controller
|
||||
$payload['external_db_url'] = $database->external_db_url;
|
||||
}
|
||||
|
||||
auditLog('api.database.created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $type->value,
|
||||
'server_uuid' => $serverUuid,
|
||||
'is_public' => (bool) $database->is_public,
|
||||
'instant_deploy' => (bool) $instantDeploy,
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'clickhouse_admin_user' => 'string',
|
||||
'clickhouse_admin_password' => 'string',
|
||||
'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
@@ -2043,7 +2149,7 @@ class DatabasesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
removeUnnecessaryFieldsFromRequest($request);
|
||||
$database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_clickhouse($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
@@ -2057,14 +2163,24 @@ class DatabasesController extends Controller
|
||||
$payload['external_db_url'] = $database->external_db_url;
|
||||
}
|
||||
|
||||
auditLog('api.database.created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $type->value,
|
||||
'server_uuid' => $serverUuid,
|
||||
'is_public' => (bool) $database->is_public,
|
||||
'instant_deploy' => (bool) $instantDeploy,
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::MONGODB) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'mongo_conf' => 'string',
|
||||
'mongo_initdb_root_username' => 'string',
|
||||
'mongo_initdb_root_password' => 'string',
|
||||
'mongo_initdb_database' => 'string',
|
||||
'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
|
||||
'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
@@ -2101,7 +2217,7 @@ class DatabasesController extends Controller
|
||||
}
|
||||
$request->offsetSet('mongo_conf', $mongoConf);
|
||||
}
|
||||
$database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields));
|
||||
$database = create_standalone_mongodb($environment->id, $destination, $request->only($allowedFields));
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
@@ -2115,6 +2231,16 @@ class DatabasesController extends Controller
|
||||
$payload['external_db_url'] = $database->external_db_url;
|
||||
}
|
||||
|
||||
auditLog('api.database.created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $type->value,
|
||||
'server_uuid' => $serverUuid,
|
||||
'is_public' => (bool) $database->is_public,
|
||||
'instant_deploy' => (bool) $instantDeploy,
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -2199,6 +2325,13 @@ class DatabasesController extends Controller
|
||||
dockerCleanup: $request->boolean('docker_cleanup', true)
|
||||
);
|
||||
|
||||
auditLog('api.database.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $database->type(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Database deletion request queued.',
|
||||
]);
|
||||
@@ -2311,13 +2444,21 @@ class DatabasesController extends Controller
|
||||
$backup->delete();
|
||||
DB::commit();
|
||||
|
||||
auditLog('api.database.backup_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'backup_uuid' => $request->scheduled_backup_uuid,
|
||||
'delete_s3' => $deleteS3,
|
||||
'executions_deleted' => $executions->count(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Backup configuration and all executions deleted.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to delete backup.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2433,11 +2574,19 @@ class DatabasesController extends Controller
|
||||
|
||||
$execution->delete();
|
||||
|
||||
auditLog('api.database.backup_execution_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'backup_uuid' => $request->scheduled_backup_uuid,
|
||||
'execution_uuid' => $request->execution_uuid,
|
||||
'delete_s3' => $deleteS3,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Backup execution deleted.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to delete backup execution.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2615,6 +2764,13 @@ class DatabasesController extends Controller
|
||||
}
|
||||
StartDatabase::dispatch($database);
|
||||
|
||||
auditLog('api.database.started', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $database->type(),
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'message' => 'Database starting request queued.',
|
||||
@@ -2706,6 +2862,14 @@ class DatabasesController extends Controller
|
||||
$dockerCleanup = $request->boolean('docker_cleanup', true);
|
||||
StopDatabase::dispatch($database, $dockerCleanup);
|
||||
|
||||
auditLog('api.database.stopped', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $database->type(),
|
||||
'docker_cleanup' => $dockerCleanup,
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'message' => 'Database stopping request queued.',
|
||||
@@ -2783,6 +2947,13 @@ class DatabasesController extends Controller
|
||||
|
||||
RestartDatabase::dispatch($database);
|
||||
|
||||
auditLog('api.database.restarted', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $database->type(),
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'message' => 'Database restarting request queued.',
|
||||
@@ -2962,8 +3133,12 @@ class DatabasesController extends Controller
|
||||
|
||||
$this->authorize('manageEnvironment', $database);
|
||||
|
||||
if ($request->has('key')) {
|
||||
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -2999,6 +3174,13 @@ class DatabasesController extends Controller
|
||||
}
|
||||
$env->save();
|
||||
|
||||
auditLog('api.database.env_updated', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'env_uuid' => $env->uuid,
|
||||
'env_key' => $env->key,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -3103,8 +3285,12 @@ class DatabasesController extends Controller
|
||||
|
||||
$updatedEnvs = collect();
|
||||
foreach ($bulk_data as $item) {
|
||||
if (array_key_exists('key', $item)) {
|
||||
$item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($item, [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -3127,6 +3313,12 @@ class DatabasesController extends Controller
|
||||
$updatedEnvs->push($this->removeSensitiveEnvData($env));
|
||||
}
|
||||
|
||||
auditLog('api.database.env_bulk_upserted', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'env_count' => $updatedEnvs->count(),
|
||||
]);
|
||||
|
||||
return response()->json($updatedEnvs)->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -3215,8 +3407,12 @@ class DatabasesController extends Controller
|
||||
|
||||
$this->authorize('manageEnvironment', $database);
|
||||
|
||||
if ($request->has('key')) {
|
||||
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -3248,6 +3444,13 @@ class DatabasesController extends Controller
|
||||
'comment' => $request->comment ?? null,
|
||||
]);
|
||||
|
||||
auditLog('api.database.env_created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'env_uuid' => $env->uuid,
|
||||
'env_key' => $env->key,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -3333,8 +3536,17 @@ class DatabasesController extends Controller
|
||||
return response()->json(['message' => 'Environment variable not found.'], 404);
|
||||
}
|
||||
|
||||
$envKey = $env->key;
|
||||
$envUuid = $env->uuid;
|
||||
$env->forceDelete();
|
||||
|
||||
auditLog('api.database.env_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'env_uuid' => $envUuid,
|
||||
'env_key' => $envKey,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Environment variable deleted.']);
|
||||
}
|
||||
|
||||
@@ -3481,13 +3693,14 @@ class DatabasesController extends Controller
|
||||
'type' => 'required|string|in:persistent,file',
|
||||
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => 'string|nullable',
|
||||
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'content' => 'string|nullable',
|
||||
'is_directory' => 'boolean',
|
||||
'is_host_file' => 'boolean',
|
||||
'fs_path' => 'string',
|
||||
]);
|
||||
|
||||
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
|
||||
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
@@ -3511,7 +3724,7 @@ class DatabasesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
|
||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
|
||||
if (! empty($typeSpecificInvalidFields)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -3542,6 +3755,14 @@ class DatabasesController extends Controller
|
||||
}
|
||||
|
||||
$isDirectory = $request->boolean('is_directory', false);
|
||||
$isHostFile = $request->boolean('is_host_file', false);
|
||||
|
||||
if ($isDirectory && $isHostFile) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($isDirectory) {
|
||||
if (! $request->fs_path) {
|
||||
@@ -3564,12 +3785,50 @@ class DatabasesController extends Controller
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => get_class($database),
|
||||
]);
|
||||
} elseif ($isHostFile) {
|
||||
if (! $request->fs_path) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->filled('content')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['content' => 'Content is not valid for host file mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
'content' => null,
|
||||
'is_directory' => false,
|
||||
'is_host_file' => true,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => get_class($database),
|
||||
]);
|
||||
} else {
|
||||
$mountPath = str($request->mount_path)->trim()->start('/')->value();
|
||||
|
||||
validateShellSafePath($mountPath, 'file storage path');
|
||||
|
||||
$fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath;
|
||||
try {
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||
$fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
@@ -3581,6 +3840,15 @@ class DatabasesController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
auditLog('api.database.storage_created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'storage_uuid' => $storage->uuid ?? null,
|
||||
'storage_id' => $storage->id,
|
||||
'storage_type' => $request->type,
|
||||
'mount_path' => $storage->mount_path,
|
||||
]);
|
||||
|
||||
return response()->json($storage, 201);
|
||||
}
|
||||
|
||||
@@ -3679,7 +3947,7 @@ class DatabasesController extends Controller
|
||||
'is_preview_suffix_enabled' => 'boolean',
|
||||
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
|
||||
'mount_path' => 'string',
|
||||
'host_path' => 'string|nullable',
|
||||
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'content' => 'string|nullable',
|
||||
]);
|
||||
|
||||
@@ -3779,6 +4047,15 @@ class DatabasesController extends Controller
|
||||
|
||||
$storage->save();
|
||||
|
||||
auditLog('api.database.storage_updated', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'storage_uuid' => $storage->uuid ?? null,
|
||||
'storage_id' => $storage->id,
|
||||
'storage_type' => $request->type,
|
||||
'mount_path' => $storage->mount_path ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($storage);
|
||||
}
|
||||
|
||||
@@ -3852,8 +4129,18 @@ class DatabasesController extends Controller
|
||||
$storage->deleteStorageOnServer();
|
||||
}
|
||||
|
||||
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
|
||||
$storageMountPath = $storage->mount_path ?? null;
|
||||
$storage->delete();
|
||||
|
||||
auditLog('api.database.storage_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'storage_uuid' => $storageUuid,
|
||||
'storage_type' => $storageType,
|
||||
'mount_path' => $storageMountPath,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Storage deleted.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ use App\Models\Tag;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class DeployController extends Controller
|
||||
{
|
||||
@@ -281,6 +280,14 @@ class DeployController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
auditLog('api.deployment.cancelled', [
|
||||
'team_id' => $teamId,
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
'application_id' => $application?->id,
|
||||
'application_uuid' => $application?->uuid,
|
||||
'server_id' => $deployment->server_id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Deployment cancelled successfully.',
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
@@ -503,7 +510,7 @@ class DeployController extends Controller
|
||||
if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') {
|
||||
return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null];
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$deployment_uuid = new_public_id();
|
||||
$result = queue_application_deployment(
|
||||
application: $resource,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
@@ -518,6 +525,14 @@ class DeployController extends Controller
|
||||
$message = $result['message'];
|
||||
} else {
|
||||
$message = "Application {$resource->name} deployment queued.";
|
||||
auditLog('api.deployment.triggered', [
|
||||
'resource_type' => 'application',
|
||||
'application_uuid' => $resource->uuid,
|
||||
'application_name' => $resource->name,
|
||||
'deployment_uuid' => $deployment_uuid?->toString(),
|
||||
'force_rebuild' => $force,
|
||||
'pull_request_id' => $pr,
|
||||
]);
|
||||
}
|
||||
break;
|
||||
case Service::class:
|
||||
@@ -529,6 +544,10 @@ class DeployController extends Controller
|
||||
}
|
||||
StartService::run($resource);
|
||||
$message = "Service {$resource->name} started. It could take a while, be patient.";
|
||||
auditLog('api.service.deployed', [
|
||||
'service_uuid' => $resource->uuid,
|
||||
'service_name' => $resource->name,
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
// Database resource - check authorization
|
||||
@@ -543,6 +562,11 @@ class DeployController extends Controller
|
||||
$resource->save();
|
||||
|
||||
$message = "Database {$resource->name} started.";
|
||||
auditLog('api.database.started', [
|
||||
'database_uuid' => $resource->uuid,
|
||||
'database_name' => $resource->name,
|
||||
'database_type' => $resource->getMorphClass(),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DestinationsController extends Controller
|
||||
{
|
||||
private function transform(StandaloneDocker|SwarmDocker $destination): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $destination->uuid,
|
||||
'name' => $destination->name,
|
||||
'network' => $destination->network,
|
||||
'type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone',
|
||||
'server_uuid' => $destination->server?->uuid,
|
||||
'created_at' => $destination->created_at,
|
||||
'updated_at' => $destination->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the calling token's team id, or return an invalid-token response.
|
||||
*/
|
||||
private function teamIdOrAbort(): int|JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
/**
|
||||
* StandaloneDocker / SwarmDocker scoped to a team via their parent server.
|
||||
* Uses whereHas instead of the model's ownedByCurrentTeamAPI() scope so the
|
||||
* controller works on Coolify versions that pre-date that scope being added
|
||||
* to the destination models (e.g. 4.0.0-beta.470).
|
||||
*/
|
||||
private function teamScopedDockers(int $teamId): array
|
||||
{
|
||||
return [
|
||||
'standalone' => StandaloneDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
|
||||
'swarm' => SwarmDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
|
||||
];
|
||||
}
|
||||
|
||||
private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDocker|SwarmDocker
|
||||
{
|
||||
return StandaloneDocker::with('server:id,uuid,team_id,ip,user,port,private_key_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->first()
|
||||
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$sets = $this->teamScopedDockers($teamId);
|
||||
|
||||
return response()->json(
|
||||
$sets['standalone']->concat($sets['swarm'])
|
||||
->map(fn ($destination) => $this->transform($destination))
|
||||
->values()
|
||||
);
|
||||
}
|
||||
|
||||
public function index_by_server(Request $request, string $server_uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$server = Server::with(['standaloneDockers.server:id,uuid', 'swarmDockers.server:id,uuid'])
|
||||
->whereTeamId($teamId)
|
||||
->whereUuid($server_uuid)
|
||||
->firstOrFail();
|
||||
$list = $server->standaloneDockers->concat($server->swarmDockers);
|
||||
|
||||
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
|
||||
}
|
||||
|
||||
public function show(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$destination = $this->findDestinationForTeam($teamId, $uuid);
|
||||
|
||||
return response()->json($this->transform($destination));
|
||||
}
|
||||
|
||||
public function create(Request $request, string $server_uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail();
|
||||
|
||||
$allowed = ['name', 'network', 'type'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'nullable|string|max:255',
|
||||
'network' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'],
|
||||
'type' => 'nullable|in:standalone,swarm',
|
||||
]);
|
||||
$extra = array_diff(array_keys($request->all()), $allowed);
|
||||
if ($validator->fails() || ! empty($extra)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extra)) {
|
||||
foreach ($extra as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
$expectedType = $server->isSwarm() ? 'swarm' : 'standalone';
|
||||
$type = $request->input('type', $expectedType);
|
||||
if ($type !== $expectedType) {
|
||||
return response()->json(['message' => "Destination type must be {$expectedType} for this server."], 422);
|
||||
}
|
||||
|
||||
$name = $request->input('name') ?: ($server->name.'-'.$request->input('network'));
|
||||
$class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class;
|
||||
|
||||
$this->authorize('create', $class);
|
||||
|
||||
$exists = $class::where('server_id', $server->id)->where('network', $request->input('network'))->exists();
|
||||
if ($exists) {
|
||||
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
|
||||
}
|
||||
|
||||
try {
|
||||
$destination = $class::create([
|
||||
'name' => $name,
|
||||
'network' => $request->input('network'),
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
if ($this->isUniqueConstraintViolation($exception)) {
|
||||
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
auditLog('api.destination.created', [
|
||||
'team_id' => $teamId,
|
||||
'destination_uuid' => $destination->uuid,
|
||||
'destination_name' => $destination->name,
|
||||
'destination_type' => $type,
|
||||
'server_uuid' => $server->uuid,
|
||||
]);
|
||||
|
||||
return response()->json($this->transform($destination->load('server:id,uuid')), 201);
|
||||
}
|
||||
|
||||
private function isUniqueConstraintViolation(QueryException $exception): bool
|
||||
{
|
||||
$sqlState = $exception->errorInfo[0] ?? null;
|
||||
$driverCode = (string) ($exception->errorInfo[1] ?? $exception->getCode());
|
||||
|
||||
return in_array($sqlState, ['23000', '23505'], true)
|
||||
|| in_array($driverCode, ['19', '1062', '2067'], true);
|
||||
}
|
||||
|
||||
public function delete(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$destination = $this->findDestinationForTeam($teamId, $uuid);
|
||||
|
||||
$this->authorize('delete', $destination);
|
||||
|
||||
// Guard against deleting destinations with attached resources. attachedTo()
|
||||
// is recent on the destination models; fall back to a manual check for
|
||||
// older Coolify versions (e.g. 4.0.0-beta.470).
|
||||
if (method_exists($destination, 'attachedTo')) {
|
||||
if ($destination->attachedTo()) {
|
||||
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
|
||||
}
|
||||
} else {
|
||||
$hasAttached = $destination->applications()->exists()
|
||||
|| $destination->postgresqls()->exists()
|
||||
|| (method_exists($destination, 'mysqls') && $destination->mysqls()->exists())
|
||||
|| (method_exists($destination, 'mariadbs') && $destination->mariadbs()->exists())
|
||||
|| (method_exists($destination, 'mongodbs') && $destination->mongodbs()->exists())
|
||||
|| (method_exists($destination, 'redis') && $destination->redis()->exists())
|
||||
|| (method_exists($destination, 'keydbs') && $destination->keydbs()->exists())
|
||||
|| (method_exists($destination, 'dragonflies') && $destination->dragonflies()->exists())
|
||||
|| (method_exists($destination, 'clickhouses') && $destination->clickhouses()->exists())
|
||||
|| (method_exists($destination, 'services') && $destination->services()->exists());
|
||||
if ($hasAttached) {
|
||||
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
|
||||
}
|
||||
}
|
||||
if ($destination instanceof StandaloneDocker) {
|
||||
app(RemoveStandaloneDockerNetwork::class)->handle($destination);
|
||||
}
|
||||
|
||||
$destinationUuid = $destination->uuid;
|
||||
$destinationName = $destination->name;
|
||||
$destinationType = $destination instanceof SwarmDocker ? 'swarm' : 'standalone';
|
||||
$serverUuid = $destination->server?->uuid;
|
||||
|
||||
$destination->delete();
|
||||
|
||||
auditLog('api.destination.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'destination_uuid' => $destinationUuid,
|
||||
'destination_name' => $destinationName,
|
||||
'destination_type' => $destinationType,
|
||||
'server_uuid' => $serverUuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Deleted.']);
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,7 @@ class GithubController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [GithubApp::class]);
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
@@ -271,6 +272,12 @@ class GithubController extends Controller
|
||||
|
||||
$githubApp = GithubApp::create($payload);
|
||||
|
||||
auditLog('api.github_app.created', [
|
||||
'team_id' => $teamId,
|
||||
'github_app_uuid' => $githubApp->uuid,
|
||||
'github_app_name' => $githubApp->name,
|
||||
]);
|
||||
|
||||
return response()->json($githubApp, 201);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
@@ -558,6 +565,7 @@ class GithubController extends Controller
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
$this->authorize('update', $githubApp);
|
||||
|
||||
// Define allowed fields for update
|
||||
$allowedFields = [
|
||||
@@ -650,6 +658,13 @@ class GithubController extends Controller
|
||||
// Update the GitHub app
|
||||
$githubApp->update($payload);
|
||||
|
||||
auditLog('api.github_app.updated', [
|
||||
'team_id' => $teamId,
|
||||
'github_app_uuid' => $githubApp->uuid,
|
||||
'github_app_name' => $githubApp->name,
|
||||
'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitHub app updated successfully',
|
||||
'data' => $githubApp,
|
||||
@@ -724,6 +739,7 @@ class GithubController extends Controller
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
$this->authorize('delete', $githubApp);
|
||||
|
||||
// Check if the GitHub app is being used by any applications
|
||||
if ($githubApp->applications->isNotEmpty()) {
|
||||
@@ -734,8 +750,16 @@ class GithubController extends Controller
|
||||
], 409);
|
||||
}
|
||||
|
||||
$deletedUuid = $githubApp->uuid;
|
||||
$deletedName = $githubApp->name;
|
||||
$githubApp->delete();
|
||||
|
||||
auditLog('api.github_app.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'github_app_uuid' => $deletedUuid,
|
||||
'github_app_name' => $deletedName,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitHub app deleted successfully',
|
||||
]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Controller;
|
||||
@@ -12,6 +13,7 @@ use App\Models\Team;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\HetznerService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
@@ -114,6 +116,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
@@ -121,7 +124,7 @@ class HetznerController extends Controller
|
||||
|
||||
return response()->json($locations);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +238,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
@@ -242,7 +246,7 @@ class HetznerController extends Controller
|
||||
|
||||
return response()->json($serverTypes);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +338,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
@@ -354,7 +359,7 @@ class HetznerController extends Controller
|
||||
|
||||
return response()->json(array_values($filtered));
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,6 +448,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
@@ -450,7 +456,7 @@ class HetznerController extends Controller
|
||||
|
||||
return response()->json($sshKeys);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,9 +554,10 @@ class HetznerController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [Server::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -618,6 +625,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
// Validate private key
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
@@ -717,9 +725,17 @@ class HetznerController extends Controller
|
||||
|
||||
// Validate server if requested
|
||||
if ($request->instant_validate) {
|
||||
\App\Actions\Server\ValidateServer::dispatch($server);
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.hetzner_server.created', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'hetzner_server_id' => $hetznerServer['id'],
|
||||
'ip' => $ipAddress,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
'hetzner_server_id' => $hetznerServer['id'],
|
||||
@@ -733,7 +749,7 @@ class HetznerController extends Controller
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to create Hetzner server.'], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +85,15 @@ class OtherController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to enable the API.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_api_enabled' => true]);
|
||||
|
||||
auditLog('api.instance.enabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'API enabled.'], 200);
|
||||
}
|
||||
|
||||
@@ -137,21 +141,141 @@ class OtherController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to disable the API.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_api_enabled' => false]);
|
||||
|
||||
auditLog('api.instance.disabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'API disabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Enable MCP Server',
|
||||
description: 'Enable the MCP server endpoint at /mcp (only with root permissions).',
|
||||
path: '/mcp/enable',
|
||||
operationId: 'enable-mcp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'MCP server enabled.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'You are not allowed to enable the MCP server.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function enable_mcp(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_mcp_server_enabled' => true]);
|
||||
|
||||
auditLog('api.mcp.enabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'MCP server enabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Disable MCP Server',
|
||||
description: 'Disable the MCP server endpoint at /mcp (only with root permissions).',
|
||||
path: '/mcp/disable',
|
||||
operationId: 'disable-mcp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'MCP server disabled.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'You are not allowed to disable the MCP server.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function disable_mcp(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_mcp_server_enabled' => false]);
|
||||
|
||||
auditLog('api.mcp.disabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'MCP server disabled.'], 200);
|
||||
}
|
||||
|
||||
public function feedback(Request $request)
|
||||
{
|
||||
$content = $request->input('content');
|
||||
$data = $request->validate([
|
||||
'content' => ['required', 'string', 'min:10', 'max:2000'],
|
||||
]);
|
||||
|
||||
$webhook_url = config('constants.webhooks.feedback_discord_webhook');
|
||||
if ($webhook_url) {
|
||||
Http::post($webhook_url, [
|
||||
'content' => $content,
|
||||
Http::timeout(5)->post($webhook_url, [
|
||||
'content' => $data['content'],
|
||||
'allowed_mentions' => ['parse' => []],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $project);
|
||||
|
||||
$project->load(['environments']);
|
||||
|
||||
@@ -233,6 +234,7 @@ class ProjectController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [Project::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
@@ -258,12 +260,18 @@ class ProjectController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$project = Project::forceCreate([
|
||||
$project = Project::create([
|
||||
'name' => $request->name,
|
||||
'description' => $request->description,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
auditLog('api.project.created', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'project_name' => $project->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $project->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -379,9 +387,17 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $project);
|
||||
|
||||
$project->update($request->only($allowedFields));
|
||||
|
||||
auditLog('api.project.updated', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'project_name' => $project->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $project->uuid,
|
||||
'name' => $project->name,
|
||||
@@ -456,12 +472,21 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $project);
|
||||
if (! $project->isEmpty()) {
|
||||
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
|
||||
}
|
||||
|
||||
$projectUuid = $project->uuid;
|
||||
$projectName = $project->name;
|
||||
$project->delete();
|
||||
|
||||
auditLog('api.project.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $projectUuid,
|
||||
'project_name' => $projectName,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Project deleted.']);
|
||||
}
|
||||
|
||||
@@ -631,6 +656,7 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $project);
|
||||
|
||||
$existingEnvironment = $project->environments()->where('name', $request->name)->first();
|
||||
if ($existingEnvironment) {
|
||||
@@ -641,6 +667,13 @@ class ProjectController extends Controller
|
||||
'name' => $request->name,
|
||||
]);
|
||||
|
||||
auditLog('api.project.environment_created', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'environment_name' => $environment->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $environment->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -718,13 +751,23 @@ class ProjectController extends Controller
|
||||
if (! $environment) {
|
||||
return response()->json(['message' => 'Environment not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $environment);
|
||||
|
||||
if (! $environment->isEmpty()) {
|
||||
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
|
||||
}
|
||||
|
||||
$envUuid = $environment->uuid;
|
||||
$envName = $environment->name;
|
||||
$environment->delete();
|
||||
|
||||
auditLog('api.project.environment_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $envUuid,
|
||||
'environment_name' => $envName,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Environment deleted.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Application;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
@@ -33,7 +34,7 @@ class ScheduledTasksController extends Controller
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
|
||||
}
|
||||
|
||||
private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function listTasks(Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
@@ -44,12 +45,12 @@ class ScheduledTasksController extends Controller
|
||||
return response()->json($tasks);
|
||||
}
|
||||
|
||||
private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function createTask(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -105,15 +106,23 @@ class ScheduledTasksController extends Controller
|
||||
|
||||
$task->save();
|
||||
|
||||
auditLog('api.scheduled_task.created', [
|
||||
'team_id' => $teamId,
|
||||
'task_uuid' => $task->uuid,
|
||||
'task_name' => $task->name,
|
||||
'resource_type' => $resource instanceof Application ? 'application' : 'service',
|
||||
'resource_uuid' => $resource->uuid,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($task), 201);
|
||||
}
|
||||
|
||||
private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function updateTask(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -161,22 +170,43 @@ class ScheduledTasksController extends Controller
|
||||
|
||||
$task->update($request->only($allowedFields));
|
||||
|
||||
auditLog('api.scheduled_task.updated', [
|
||||
'team_id' => getTeamIdFromToken(),
|
||||
'task_uuid' => $task->uuid,
|
||||
'task_name' => $task->name,
|
||||
'resource_type' => $resource instanceof Application ? 'application' : 'service',
|
||||
'resource_uuid' => $resource->uuid,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($task), 200);
|
||||
}
|
||||
|
||||
private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function deleteTask(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
|
||||
if (! $deleted) {
|
||||
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
|
||||
if (! $task) {
|
||||
return response()->json(['message' => 'Scheduled task not found.'], 404);
|
||||
}
|
||||
|
||||
$taskUuid = $task->uuid;
|
||||
$taskName = $task->name;
|
||||
$task->delete();
|
||||
|
||||
auditLog('api.scheduled_task.deleted', [
|
||||
'team_id' => getTeamIdFromToken(),
|
||||
'task_uuid' => $taskUuid,
|
||||
'task_name' => $taskName,
|
||||
'resource_type' => $resource instanceof Application ? 'application' : 'service',
|
||||
'resource_uuid' => $resource->uuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Scheduled task deleted.']);
|
||||
}
|
||||
|
||||
private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function getExecutions(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
@@ -238,7 +268,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -317,7 +347,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -404,7 +434,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -474,7 +504,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -542,7 +572,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function executions_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -601,7 +631,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -680,7 +710,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -767,7 +797,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -837,7 +867,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -905,7 +935,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function executions_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
|
||||
@@ -110,6 +110,7 @@ class SecurityController extends Controller
|
||||
'message' => 'Private Key not found.',
|
||||
], 404);
|
||||
}
|
||||
$this->authorize('view', $key);
|
||||
|
||||
return response()->json($this->removeSensitiveData($key));
|
||||
}
|
||||
@@ -176,6 +177,7 @@ class SecurityController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [PrivateKey::class]);
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
@@ -232,6 +234,13 @@ class SecurityController extends Controller
|
||||
'private_key' => $request->private_key,
|
||||
]);
|
||||
|
||||
auditLog('api.private_key.created', [
|
||||
'team_id' => $teamId,
|
||||
'private_key_uuid' => $key->uuid,
|
||||
'private_key_name' => $key->name,
|
||||
'fingerprint' => $fingerPrint,
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => $key->uuid,
|
||||
]))->setStatusCode(201);
|
||||
@@ -331,8 +340,16 @@ class SecurityController extends Controller
|
||||
'message' => 'Private Key not found.',
|
||||
], 404);
|
||||
}
|
||||
$this->authorize('update', $foundKey);
|
||||
$foundKey->update($request->only($allowedFields));
|
||||
|
||||
auditLog('api.private_key.updated', [
|
||||
'team_id' => $teamId,
|
||||
'private_key_uuid' => $foundKey->uuid,
|
||||
'private_key_name' => $foundKey->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => $foundKey->uuid,
|
||||
]))->setStatusCode(201);
|
||||
@@ -407,6 +424,7 @@ class SecurityController extends Controller
|
||||
if (is_null($key)) {
|
||||
return response()->json(['message' => 'Private Key not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $key);
|
||||
|
||||
if ($key->isInUse()) {
|
||||
return response()->json([
|
||||
@@ -415,8 +433,16 @@ class SecurityController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$keyUuid = $key->uuid;
|
||||
$keyName = $key->name;
|
||||
$key->forceDelete();
|
||||
|
||||
auditLog('api.private_key.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'private_key_uuid' => $keyUuid,
|
||||
'private_key_name' => $keyName,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Private Key deleted.',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class SentinelController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle a Sentinel agent metrics push.
|
||||
*
|
||||
* Sentinel pushes its full container list on a fixed interval (default 60s),
|
||||
* even when nothing changed. To avoid dispatching one PushServerUpdateJob per
|
||||
* server per minute, the job is only dispatched when the container state hash
|
||||
* changes, or when the force window has elapsed.
|
||||
*/
|
||||
public function push(Request $request)
|
||||
{
|
||||
$token = $request->header('Authorization');
|
||||
if (! $token) {
|
||||
auditLogWebhookFailure('sentinel', 'token_missing');
|
||||
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
$naked_token = str_replace('Bearer ', '', $token);
|
||||
try {
|
||||
$decrypted = decrypt($naked_token);
|
||||
$decrypted_token = json_decode($decrypted, true);
|
||||
} catch (Exception $e) {
|
||||
auditLogWebhookFailure('sentinel', 'decrypt_failed');
|
||||
|
||||
return response()->json(['message' => 'Invalid token'], 401);
|
||||
}
|
||||
$server_uuid = data_get($decrypted_token, 'server_uuid');
|
||||
if (! $server_uuid) {
|
||||
auditLogWebhookFailure('sentinel', 'invalid_token_payload');
|
||||
|
||||
return response()->json(['message' => 'Invalid token'], 401);
|
||||
}
|
||||
$server = Server::where('uuid', $server_uuid)->first();
|
||||
if (! $server) {
|
||||
auditLogWebhookFailure('sentinel', 'server_not_found', [
|
||||
'server_uuid' => $server_uuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Server not found'], 404);
|
||||
}
|
||||
|
||||
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
||||
auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
if ($server->isFunctional() === false) {
|
||||
auditLogWebhookFailure('sentinel', 'server_not_functional', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Server is not functional'], 401);
|
||||
}
|
||||
|
||||
if ($server->settings->sentinel_token !== $naked_token) {
|
||||
auditLogWebhookFailure('sentinel', 'token_mismatch', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
$validator = Validator::make($request->all(), [
|
||||
'containers' => ['present', 'array'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(serializeApiResponse([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
]), 422);
|
||||
}
|
||||
|
||||
$data = $request->all();
|
||||
|
||||
// Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping.
|
||||
$server->sentinelHeartbeat();
|
||||
|
||||
if ($this->shouldDispatchUpdate($server, $data)) {
|
||||
PushServerUpdateJob::dispatch($server, $data);
|
||||
|
||||
auditLog('sentinel.metrics_pushed', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'ok'], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether PushServerUpdateJob should be dispatched for this push.
|
||||
*
|
||||
* Dispatches when: first push (no cached hash), the container state changed,
|
||||
* or the force window elapsed.
|
||||
*/
|
||||
private function shouldDispatchUpdate(Server $server, array $data): bool
|
||||
{
|
||||
$hash = $this->containerStateHash($data);
|
||||
$hashKey = "sentinel:push-hash:{$server->id}";
|
||||
$forceKey = "sentinel:push-force:{$server->id}";
|
||||
$lockKey = "sentinel:push-lock:{$server->id}";
|
||||
|
||||
try {
|
||||
return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool {
|
||||
$cachedHash = Cache::get($hashKey);
|
||||
$forceActive = Cache::has($forceKey);
|
||||
|
||||
$shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
|
||||
|
||||
if ($shouldDispatch) {
|
||||
// Day-long TTL bounds memory if a server stops pushing entirely.
|
||||
Cache::put($hashKey, $hash, now()->addDay());
|
||||
Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300));
|
||||
}
|
||||
|
||||
return $shouldDispatch;
|
||||
});
|
||||
} catch (LockTimeoutException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable hash of container state.
|
||||
*
|
||||
* Covers [name, state] only — metrics, filesystem_usage_root, and
|
||||
* health_status are excluded on purpose. Disk % churns constantly, and
|
||||
* health checks can flap between starting/healthy/unhealthy while the
|
||||
* container lifecycle state remains unchanged. Both would otherwise defeat
|
||||
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
|
||||
* The force window still refreshes full state periodically. Sorted by name
|
||||
* so container ordering from Sentinel does not affect the hash.
|
||||
*/
|
||||
private function containerStateHash(array $data): string
|
||||
{
|
||||
$containers = collect(data_get($data, 'containers', []))
|
||||
->map(fn ($c) => [
|
||||
'name' => data_get($c, 'name'),
|
||||
'state' => data_get($c, 'state'),
|
||||
])
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return hash('xxh128', json_encode($containers));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server as ModelsServer;
|
||||
use App\Rules\ValidServerIp;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Stringable;
|
||||
@@ -146,6 +148,7 @@ class ServersController extends Controller
|
||||
if (is_null($server)) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $server);
|
||||
if ($with_resources) {
|
||||
$server['resources'] = $server->definedResources()->map(function ($resource) {
|
||||
$payload = [
|
||||
@@ -475,9 +478,10 @@ class ServersController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [ModelsServer::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
@@ -486,10 +490,12 @@ class ServersController extends Controller
|
||||
'ip' => ['string', 'required', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
'private_key_uuid' => 'string|required',
|
||||
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'user' => ValidationPatterns::serverUsernameRules(required: false),
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
], [
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -564,6 +570,14 @@ class ServersController extends Controller
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.server.created', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'ip' => $server->ip,
|
||||
'is_build_server' => (bool) $request->is_build_server,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -598,6 +612,12 @@ class ServersController extends Controller
|
||||
'is_build_server' => ['type' => 'boolean', 'description' => 'Is build server.'],
|
||||
'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'],
|
||||
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'],
|
||||
'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'],
|
||||
'dynamic_timeout' => ['type' => 'integer', 'description' => 'Deployment timeout in seconds.'],
|
||||
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
|
||||
'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
|
||||
'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
|
||||
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -634,7 +654,7 @@ class ServersController extends Controller
|
||||
)]
|
||||
public function update_server(Request $request)
|
||||
{
|
||||
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type'];
|
||||
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -642,7 +662,7 @@ class ServersController extends Controller
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
@@ -651,10 +671,18 @@ class ServersController extends Controller
|
||||
'ip' => ['string', 'nullable', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
'private_key_uuid' => 'string|nullable',
|
||||
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'user' => ValidationPatterns::serverUsernameRules(required: false),
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
'concurrent_builds' => 'integer|min:1',
|
||||
'dynamic_timeout' => 'integer|min:1',
|
||||
'deployment_queue_limit' => 'integer|min:1',
|
||||
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
|
||||
'server_disk_usage_check_frequency' => 'string',
|
||||
'connection_timeout' => 'integer|min:1|max:300',
|
||||
], [
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -675,26 +703,58 @@ class ServersController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $server);
|
||||
if ($request->proxy_type) {
|
||||
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
|
||||
return str($proxyType->value)->lower();
|
||||
});
|
||||
if ($validProxyTypes->contains(str($request->proxy_type)->lower())) {
|
||||
$server->changeProxy($request->proxy_type, async: true);
|
||||
} else {
|
||||
if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) {
|
||||
return response()->json(['message' => 'Invalid proxy type.'], 422);
|
||||
}
|
||||
}
|
||||
$server->update($request->only(['name', 'description', 'ip', 'port', 'user']));
|
||||
if ($request->is_build_server) {
|
||||
$updateFields = $request->only(['name', 'description', 'ip', 'port', 'user']);
|
||||
if ($request->filled('private_key_uuid')) {
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
$updateFields['private_key_id'] = $privateKey->id;
|
||||
}
|
||||
|
||||
if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['server_disk_usage_check_frequency' => ['Invalid Cron / Human expression for Disk Usage Check Frequency.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$server->update($updateFields);
|
||||
if ($request->has('is_build_server')) {
|
||||
$server->settings()->update([
|
||||
'is_build_server' => $request->is_build_server,
|
||||
'is_build_server' => $request->boolean('is_build_server'),
|
||||
]);
|
||||
}
|
||||
|
||||
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
|
||||
if (! empty($advancedSettings)) {
|
||||
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
|
||||
}
|
||||
|
||||
if ($request->proxy_type) {
|
||||
$server->changeProxy($request->proxy_type, async: true);
|
||||
}
|
||||
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.server.updated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -768,6 +828,7 @@ class ServersController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $server);
|
||||
|
||||
$force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
@@ -784,6 +845,9 @@ class ServersController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$deletedUuid = $server->uuid;
|
||||
$deletedName = $server->name;
|
||||
$deletedIp = $server->ip;
|
||||
$server->delete();
|
||||
DeleteServer::dispatch(
|
||||
$server->id,
|
||||
@@ -793,6 +857,14 @@ class ServersController extends Controller
|
||||
$server->team_id
|
||||
);
|
||||
|
||||
auditLog('api.server.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $deletedUuid,
|
||||
'server_name' => $deletedName,
|
||||
'ip' => $deletedIp,
|
||||
'force' => $force,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Server deleted.']);
|
||||
}
|
||||
|
||||
@@ -856,8 +928,15 @@ class ServersController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $server);
|
||||
ValidateServer::dispatch($server);
|
||||
|
||||
auditLog('api.server.validated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Validation started.'], 201);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ class ServicesController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
if ($service->is_shown_once ?? false) {
|
||||
$service->makeHidden(['value', 'real_value']);
|
||||
}
|
||||
|
||||
return serializeApiResponse($service);
|
||||
}
|
||||
|
||||
@@ -56,19 +60,10 @@ class ServicesController extends Controller
|
||||
return str($urlValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter();
|
||||
});
|
||||
|
||||
$urls = $urls->map(function ($url) use (&$errors) {
|
||||
if (! filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
$errors[] = "Invalid URL: {$url}";
|
||||
|
||||
return $url;
|
||||
}
|
||||
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
|
||||
if (! in_array(strtolower($scheme), ['http', 'https'])) {
|
||||
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
|
||||
}
|
||||
|
||||
return $url;
|
||||
});
|
||||
$errors = ValidationPatterns::validateApplicationDomains($urls->implode(','));
|
||||
$urls = collect(ValidationPatterns::applicationDomainList(
|
||||
ValidationPatterns::normalizeApplicationDomains($urls->implode(','))
|
||||
));
|
||||
|
||||
$duplicates = $urls->duplicates()->unique()->values();
|
||||
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
|
||||
@@ -97,10 +92,10 @@ class ServicesController extends Controller
|
||||
}
|
||||
|
||||
if (filled($containerUrls)) {
|
||||
$containerUrls = str($containerUrls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
|
||||
$containerUrls = str($containerUrls)->explode(',')->map(fn ($url) => str(trim($url))->lower());
|
||||
$containerUrls = ValidationPatterns::normalizeApplicationDomains($containerUrls);
|
||||
$containerUrlCollection = collect(ValidationPatterns::applicationDomainList($containerUrls));
|
||||
|
||||
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $application->uuid);
|
||||
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrlCollection, $teamId, $application->uuid);
|
||||
if (isset($result['error'])) {
|
||||
$errors[] = $result['error'];
|
||||
|
||||
@@ -112,8 +107,6 @@ class ServicesController extends Controller
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$containerUrls = $containerUrls->filter(fn ($u) => filled($u))->unique()->implode(',');
|
||||
} else {
|
||||
$containerUrls = null;
|
||||
}
|
||||
@@ -221,7 +214,7 @@ class ServicesController extends Controller
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
|
||||
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'],
|
||||
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -432,7 +425,7 @@ class ServicesController extends Controller
|
||||
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
|
||||
data_set($servicePayload, 'connect_to_docker_network', true);
|
||||
}
|
||||
$service = Service::forceCreate($servicePayload);
|
||||
$service = Service::create($servicePayload);
|
||||
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
|
||||
$service->description = $request->description;
|
||||
if ($request->has('is_container_label_escape_enabled')) {
|
||||
@@ -486,6 +479,14 @@ class ServicesController extends Controller
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
||||
auditLog('api.service.created', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
'service_type' => $oneClickServiceName ?? null,
|
||||
'instant_deploy' => (bool) $instantDeploy,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $service->uuid,
|
||||
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
|
||||
@@ -650,6 +651,14 @@ class ServicesController extends Controller
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
||||
auditLog('api.service.created', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
'service_type' => 'docker_compose',
|
||||
'instant_deploy' => (bool) $instantDeploy,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $service->uuid,
|
||||
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
|
||||
@@ -792,6 +801,12 @@ class ServicesController extends Controller
|
||||
dockerCleanup: $request->boolean('docker_cleanup', true)
|
||||
);
|
||||
|
||||
auditLog('api.service.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Service deletion request queued.',
|
||||
]);
|
||||
@@ -843,7 +858,7 @@ class ServicesController extends Controller
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
|
||||
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'],
|
||||
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1046,6 +1061,13 @@ class ServicesController extends Controller
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
||||
auditLog('api.service.updated', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $service->uuid,
|
||||
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
|
||||
@@ -1218,8 +1240,12 @@ class ServicesController extends Controller
|
||||
|
||||
$this->authorize('manageEnvironment', $service);
|
||||
|
||||
if ($request->has('key')) {
|
||||
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -1255,6 +1281,13 @@ class ServicesController extends Controller
|
||||
}
|
||||
$env->save();
|
||||
|
||||
auditLog('api.service.env_updated', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'env_uuid' => $env->uuid,
|
||||
'env_key' => $env->key,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -1360,8 +1393,12 @@ class ServicesController extends Controller
|
||||
|
||||
$updatedEnvs = collect();
|
||||
foreach ($bulk_data as $item) {
|
||||
if (array_key_exists('key', $item)) {
|
||||
$item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($item, [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -1384,6 +1421,12 @@ class ServicesController extends Controller
|
||||
$updatedEnvs->push($this->removeSensitiveData($env));
|
||||
}
|
||||
|
||||
auditLog('api.service.env_bulk_upserted', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'env_count' => $updatedEnvs->count(),
|
||||
]);
|
||||
|
||||
return response()->json($updatedEnvs)->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -1473,8 +1516,12 @@ class ServicesController extends Controller
|
||||
|
||||
$this->authorize('manageEnvironment', $service);
|
||||
|
||||
if ($request->has('key')) {
|
||||
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -1506,6 +1553,13 @@ class ServicesController extends Controller
|
||||
'comment' => $request->comment ?? null,
|
||||
]);
|
||||
|
||||
auditLog('api.service.env_created', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'env_uuid' => $env->uuid,
|
||||
'env_key' => $env->key,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -1591,8 +1645,17 @@ class ServicesController extends Controller
|
||||
return response()->json(['message' => 'Environment variable not found.'], 404);
|
||||
}
|
||||
|
||||
$envKey = $env->key;
|
||||
$envUuid = $env->uuid;
|
||||
$env->forceDelete();
|
||||
|
||||
auditLog('api.service.env_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'env_uuid' => $envUuid,
|
||||
'env_key' => $envKey,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Environment variable deleted.']);
|
||||
}
|
||||
|
||||
@@ -1668,6 +1731,12 @@ class ServicesController extends Controller
|
||||
}
|
||||
StartService::dispatch($service);
|
||||
|
||||
auditLog('api.service.deployed', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'message' => 'Service starting request queued.',
|
||||
@@ -1759,6 +1828,13 @@ class ServicesController extends Controller
|
||||
$dockerCleanup = $request->boolean('docker_cleanup', true);
|
||||
StopService::dispatch($service, false, $dockerCleanup);
|
||||
|
||||
auditLog('api.service.stopped', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
'docker_cleanup' => $dockerCleanup,
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'message' => 'Service stopping request queued.',
|
||||
@@ -1846,6 +1922,13 @@ class ServicesController extends Controller
|
||||
$pullLatest = $request->boolean('latest');
|
||||
RestartService::dispatch($service, $pullLatest);
|
||||
|
||||
auditLog('api.service.restarted', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
'pull_latest' => $pullLatest,
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'message' => 'Service restarting request queued.',
|
||||
@@ -2018,13 +2101,14 @@ class ServicesController extends Controller
|
||||
'resource_uuid' => 'required|string',
|
||||
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => 'string|nullable',
|
||||
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'content' => 'string|nullable',
|
||||
'is_directory' => 'boolean',
|
||||
'is_host_file' => 'boolean',
|
||||
'fs_path' => 'string',
|
||||
]);
|
||||
|
||||
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
|
||||
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
@@ -2056,7 +2140,7 @@ class ServicesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
|
||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
|
||||
if (! empty($typeSpecificInvalidFields)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -2087,6 +2171,14 @@ class ServicesController extends Controller
|
||||
}
|
||||
|
||||
$isDirectory = $request->boolean('is_directory', false);
|
||||
$isHostFile = $request->boolean('is_host_file', false);
|
||||
|
||||
if ($isDirectory && $isHostFile) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($isDirectory) {
|
||||
if (! $request->fs_path) {
|
||||
@@ -2109,12 +2201,50 @@ class ServicesController extends Controller
|
||||
'resource_id' => $subResource->id,
|
||||
'resource_type' => get_class($subResource),
|
||||
]);
|
||||
} elseif ($isHostFile) {
|
||||
if (! $request->fs_path) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->filled('content')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['content' => 'Content is not valid for host file mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
'content' => null,
|
||||
'is_directory' => false,
|
||||
'is_host_file' => true,
|
||||
'resource_id' => $subResource->id,
|
||||
'resource_type' => get_class($subResource),
|
||||
]);
|
||||
} else {
|
||||
$mountPath = str($request->mount_path)->trim()->start('/')->value();
|
||||
|
||||
validateShellSafePath($mountPath, 'file storage path');
|
||||
|
||||
$fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath;
|
||||
try {
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||
$fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
@@ -2126,6 +2256,15 @@ class ServicesController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
auditLog('api.service.storage_created', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'storage_uuid' => $storage->uuid ?? null,
|
||||
'storage_id' => $storage->id,
|
||||
'storage_type' => $request->type,
|
||||
'mount_path' => $storage->mount_path,
|
||||
]);
|
||||
|
||||
return response()->json($storage, 201);
|
||||
}
|
||||
|
||||
@@ -2227,7 +2366,7 @@ class ServicesController extends Controller
|
||||
'is_preview_suffix_enabled' => 'boolean',
|
||||
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
|
||||
'mount_path' => 'string',
|
||||
'host_path' => 'string|nullable',
|
||||
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'content' => 'string|nullable',
|
||||
]);
|
||||
|
||||
@@ -2354,6 +2493,15 @@ class ServicesController extends Controller
|
||||
|
||||
$storage->save();
|
||||
|
||||
auditLog('api.service.storage_updated', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'storage_uuid' => $storage->uuid ?? null,
|
||||
'storage_id' => $storage->id,
|
||||
'storage_type' => $request->type,
|
||||
'mount_path' => $storage->mount_path ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($storage);
|
||||
}
|
||||
|
||||
@@ -2454,8 +2602,18 @@ class ServicesController extends Controller
|
||||
$storage->deleteStorageOnServer();
|
||||
}
|
||||
|
||||
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
|
||||
$storageMountPath = $storage->mount_path ?? null;
|
||||
$storage->delete();
|
||||
|
||||
auditLog('api.service.storage_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'storage_uuid' => $storageUuid,
|
||||
'storage_type' => $storageType,
|
||||
'mount_path' => $storageMountPath,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Storage deleted.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ class TeamController extends Controller
|
||||
if (is_null($team)) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $team);
|
||||
$team = $this->removeSensitiveData($team);
|
||||
|
||||
return response()->json(
|
||||
@@ -168,6 +169,7 @@ class TeamController extends Controller
|
||||
if (is_null($team)) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $team);
|
||||
$members = $team->members;
|
||||
$members->makeHidden([
|
||||
'pivot',
|
||||
|
||||
@@ -6,8 +6,9 @@ use App\Events\TestEvent;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
@@ -39,9 +40,29 @@ class Controller extends BaseController
|
||||
return view('auth.verify-email');
|
||||
}
|
||||
|
||||
public function email_verify(EmailVerificationRequest $request)
|
||||
public function email_verify(Request $request)
|
||||
{
|
||||
$request->fulfill();
|
||||
if (! $request->hasValidSignature()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! hash_equals((string) $request->route('id'), (string) $user->getKey())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! hash_equals((string) $request->route('hash'), hash('sha256', $user->getEmailForVerification()))) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
$user->markEmailAsVerified();
|
||||
event(new Verified($user));
|
||||
}
|
||||
|
||||
return redirect(RouteServiceProvider::HOME);
|
||||
}
|
||||
@@ -77,27 +98,50 @@ class Controller extends BaseController
|
||||
public function link()
|
||||
{
|
||||
$token = request()->get('token');
|
||||
if ($token) {
|
||||
$decrypted = Crypt::decryptString($token);
|
||||
$email = str($decrypted)->before('@@@');
|
||||
$password = str($decrypted)->after('@@@');
|
||||
if (is_string($token) && $token !== '') {
|
||||
try {
|
||||
$decrypted = Crypt::decryptString($token);
|
||||
} catch (DecryptException) {
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
}
|
||||
|
||||
if (! str_contains($decrypted, '@@@')) {
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
}
|
||||
|
||||
$payload = explode('@@@', $decrypted, 3);
|
||||
if (count($payload) === 3) {
|
||||
[$email, $invitationUuid, $password] = $payload;
|
||||
} else {
|
||||
[$email, $password] = $payload;
|
||||
$invitationUuid = null;
|
||||
}
|
||||
|
||||
$email = Str::lower($email);
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$invitation = TeamInvitation::query()
|
||||
->where('email', $email)
|
||||
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
|
||||
->first();
|
||||
if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) {
|
||||
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||
}
|
||||
|
||||
if (Hash::check($password, $user->password)) {
|
||||
$invitation = TeamInvitation::whereEmail($email);
|
||||
if ($invitation->exists()) {
|
||||
$team = $invitation->first()->team;
|
||||
$user->teams()->attach($team->id, ['role' => $invitation->first()->role]);
|
||||
$invitation->delete();
|
||||
} else {
|
||||
$team = $user->teams()->first();
|
||||
}
|
||||
if (is_null(data_get($user, 'email_verified_at'))) {
|
||||
$user->email_verified_at = now();
|
||||
$user->save();
|
||||
$team = $invitation->team;
|
||||
if (! $user->teams()->where('team_id', $team->id)->exists()) {
|
||||
$user->teams()->attach($team->id, ['role' => $invitation->role]);
|
||||
}
|
||||
$invitation->delete();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
])->save();
|
||||
|
||||
Auth::login($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
@@ -108,6 +152,19 @@ class Controller extends BaseController
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
}
|
||||
|
||||
private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool
|
||||
{
|
||||
$query = parse_url($invitation->link, PHP_URL_QUERY);
|
||||
if (! is_string($query)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
parse_str($query, $parameters);
|
||||
$storedToken = $parameters['token'] ?? null;
|
||||
|
||||
return is_string($storedToken) && hash_equals($storedToken, $token);
|
||||
}
|
||||
|
||||
public function showInvitation()
|
||||
{
|
||||
$invitationUuid = request()->route('uuid');
|
||||
|
||||
@@ -19,7 +19,12 @@ class OauthController extends Controller
|
||||
{
|
||||
try {
|
||||
$oauthUser = get_socialite_provider($provider)->user();
|
||||
$user = User::whereEmail($oauthUser->email)->first();
|
||||
$email = trim((string) $oauthUser->email);
|
||||
if ($email === '') {
|
||||
abort(403, 'OAuth provider did not return an email address');
|
||||
}
|
||||
$email = strtolower($email);
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (! $user) {
|
||||
$settings = instanceSettings();
|
||||
if (! $settings->is_registration_enabled) {
|
||||
@@ -28,7 +33,7 @@ class OauthController extends Controller
|
||||
|
||||
$user = User::create([
|
||||
'name' => $oauthUser->name,
|
||||
'email' => $oauthUser->email,
|
||||
'email' => $email,
|
||||
]);
|
||||
}
|
||||
Auth::login($user);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Support\DatabaseBackupFileValidator;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
@@ -11,6 +13,12 @@ use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
|
||||
|
||||
class UploadController extends BaseController
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB
|
||||
|
||||
private const ALLOWED_EXTENSIONS = DatabaseBackupFileValidator::ALLOWED_EXTENSIONS;
|
||||
|
||||
public function upload(Request $request)
|
||||
{
|
||||
$databaseIdentifier = request()->route('databaseUuid');
|
||||
@@ -18,6 +26,24 @@ class UploadController extends BaseController
|
||||
if (is_null($resource)) {
|
||||
return response()->json(['error' => 'You do not have permission for this database'], 500);
|
||||
}
|
||||
|
||||
$this->authorize('uploadBackup', $resource);
|
||||
|
||||
$chunk = $request->file('file');
|
||||
$originalName = $chunk instanceof UploadedFile ? $chunk->getClientOriginalName() : null;
|
||||
if (blank($originalName) || ! self::hasAllowedExtension($originalName)) {
|
||||
return response()->json([
|
||||
'error' => 'Unsupported file type. Allowed extensions: '.implode(', ', self::ALLOWED_EXTENSIONS),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$declaredTotalSize = (int) $request->input('dzTotalFilesize', 0);
|
||||
if ($declaredTotalSize > self::MAX_BYTES) {
|
||||
return response()->json([
|
||||
'error' => 'File exceeds maximum allowed size of '.self::formatMaxSize().'.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
|
||||
|
||||
if ($receiver->isUploaded() === false) {
|
||||
@@ -40,29 +66,17 @@ class UploadController extends BaseController
|
||||
'status' => true,
|
||||
]);
|
||||
}
|
||||
// protected function saveFileToS3($file)
|
||||
// {
|
||||
// $fileName = $this->createFilename($file);
|
||||
|
||||
// $disk = Storage::disk('s3');
|
||||
// // It's better to use streaming Streaming (laravel 5.4+)
|
||||
// $disk->putFileAs('photos', $file, $fileName);
|
||||
|
||||
// // for older laravel
|
||||
// // $disk->put($fileName, file_get_contents($file), 'public');
|
||||
// $mime = str_replace('/', '-', $file->getMimeType());
|
||||
|
||||
// // We need to delete the file when uploaded to s3
|
||||
// unlink($file->getPathname());
|
||||
|
||||
// return response()->json([
|
||||
// 'path' => $disk->url($fileName),
|
||||
// 'name' => $fileName,
|
||||
// 'mime_type' => $mime
|
||||
// ]);
|
||||
// }
|
||||
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
|
||||
{
|
||||
if (! DatabaseBackupFileValidator::isUploadAllowed($file, self::MAX_BYTES)) {
|
||||
@unlink($file->getPathname());
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Uploaded file failed validation.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$mime = str_replace('/', '-', $file->getMimeType());
|
||||
$filePath = "upload/{$resourceIdentifier}";
|
||||
$finalPath = storage_path('app/'.$filePath);
|
||||
@@ -73,13 +87,13 @@ class UploadController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createFilename(UploadedFile $file)
|
||||
private static function hasAllowedExtension(string $name): bool
|
||||
{
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension
|
||||
return DatabaseBackupFileValidator::hasAllowedExtension($name);
|
||||
}
|
||||
|
||||
$filename .= '_'.md5(time()).'.'.$extension;
|
||||
|
||||
return $filename;
|
||||
private static function formatMaxSize(): string
|
||||
{
|
||||
return (self::MAX_BYTES / (1024 * 1024 * 1024)).' GiB';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,18 @@ namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Bitbucket extends Controller
|
||||
{
|
||||
use DetectsSkipDeployCommits;
|
||||
use MatchesManualWebhookApplications;
|
||||
|
||||
public function manual(Request $request)
|
||||
{
|
||||
try {
|
||||
@@ -31,6 +35,16 @@ class Bitbucket extends Controller
|
||||
$branch = data_get($payload, 'push.changes.0.new.name');
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$commit = data_get($payload, 'push.changes.0.new.target.hash');
|
||||
// Bitbucket webhooks ship up to 5 commits per change. Larger pushes
|
||||
// are evaluated only on the visible 5.
|
||||
$skip_deploy_commits = self::shouldSkipDeploy(
|
||||
collect(data_get($payload, 'push.changes', []))
|
||||
->flatMap(fn ($change) => data_get($change, 'commits', []))
|
||||
->pluck('message')
|
||||
->filter()
|
||||
->values()
|
||||
->all()
|
||||
);
|
||||
|
||||
if (! $branch) {
|
||||
return response([
|
||||
@@ -45,10 +59,18 @@ class Bitbucket extends Controller
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$pull_request_id = data_get($payload, 'pullrequest.id');
|
||||
$pull_request_html_url = data_get($payload, 'pullrequest.links.html.href');
|
||||
$pull_request_title = data_get($payload, 'pullrequest.title');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
|
||||
$commit = data_get($payload, 'pullrequest.source.commit.hash');
|
||||
}
|
||||
$applications = Application::where('git_repository', 'like', "%$full_name%");
|
||||
$applications = $applications->where('git_branch', $branch)->get();
|
||||
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
||||
if ($full_name === null) {
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
'message' => 'Nothing to do. Invalid repository.',
|
||||
]);
|
||||
}
|
||||
$applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name);
|
||||
if ($applications->isEmpty()) {
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
@@ -57,16 +79,41 @@ class Bitbucket extends Controller
|
||||
}
|
||||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
|
||||
if (empty($webhook_secret)) {
|
||||
auditLogWebhookFailure('bitbucket', 'webhook_secret_missing', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_bitbucket_event,
|
||||
]);
|
||||
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||
|
||||
continue;
|
||||
}
|
||||
$payload = $request->getContent();
|
||||
|
||||
[$algo, $hash] = explode('=', $x_bitbucket_token, 2);
|
||||
$payloadHash = hash_hmac($algo, $payload, $webhook_secret);
|
||||
if (! hash_equals($hash, $payloadHash) && ! isDev()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid signature.',
|
||||
$parts = explode('=', $x_bitbucket_token, 2);
|
||||
if (count($parts) !== 2 || $parts[0] !== 'sha256') {
|
||||
auditLogWebhookFailure('bitbucket', 'malformed_signature', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_bitbucket_event,
|
||||
]);
|
||||
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||
|
||||
continue;
|
||||
}
|
||||
$hash = $parts[1];
|
||||
$payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
|
||||
if (! hash_equals($hash, $payloadHash) && ! isDev()) {
|
||||
auditLogWebhookFailure('bitbucket', 'invalid_signature', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_bitbucket_event,
|
||||
]);
|
||||
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -82,7 +129,18 @@ class Bitbucket extends Controller
|
||||
}
|
||||
if ($x_bitbucket_event === 'repo:push') {
|
||||
if ($application->isDeployable()) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
if ($skip_deploy_commits ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new_public_id();
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
@@ -99,6 +157,15 @@ class Bitbucket extends Controller
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
} else {
|
||||
auditLog('webhook.deployment.queued', [
|
||||
'provider' => 'bitbucket',
|
||||
'mode' => 'manual',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $deployment_uuid->toString(),
|
||||
'commit' => $commit,
|
||||
'repository' => $full_name ?? null,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
@@ -115,11 +182,20 @@ class Bitbucket extends Controller
|
||||
}
|
||||
if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') {
|
||||
if ($application->isPRDeployable()) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
if ($skip_deploy_pr ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new_public_id();
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$pr_app = ApplicationPreview::forceCreate([
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'bitbucket',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
@@ -128,7 +204,7 @@ class Bitbucket extends Controller
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
} else {
|
||||
$pr_app = ApplicationPreview::forceCreate([
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'bitbucket',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Webhook\Concerns;
|
||||
|
||||
trait DetectsSkipDeployCommits
|
||||
{
|
||||
/**
|
||||
* Returns true if there is at least one non-empty message and every message
|
||||
* contains [skip cd] or [skip ci] (case-insensitive).
|
||||
*
|
||||
* Accepts commit messages from a push payload. Null/empty entries are
|
||||
* filtered before evaluation.
|
||||
*
|
||||
* @param array<int, string|null> $messages
|
||||
*/
|
||||
public static function shouldSkipDeploy(array $messages): bool
|
||||
{
|
||||
$messages = array_values(array_filter($messages, fn ($m) => filled($m)));
|
||||
|
||||
if (empty($messages)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$lower = strtolower((string) $message);
|
||||
if (! str_contains($lower, '[skip cd]') && ! str_contains($lower, '[skip ci]')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if at least one non-empty message contains [skip cd] or
|
||||
* [skip ci]. Used for PR/MR title + latest-commit signals where any one
|
||||
* marker should trigger the skip.
|
||||
*
|
||||
* @param array<int, string|null> $messages
|
||||
*/
|
||||
public static function shouldSkipDeployAny(array $messages): bool
|
||||
{
|
||||
foreach ($messages as $message) {
|
||||
if (! filled($message)) {
|
||||
continue;
|
||||
}
|
||||
$lower = strtolower((string) $message);
|
||||
if (str_contains($lower, '[skip cd]') || str_contains($lower, '[skip ci]')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Webhook\Concerns;
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait MatchesManualWebhookApplications
|
||||
{
|
||||
protected function manualWebhookRepositoryFullName(mixed $fullName): ?string
|
||||
{
|
||||
if (! is_string($fullName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fullName = trim($fullName, " \t\n\r\0\x0B/");
|
||||
|
||||
if ($fullName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! preg_match('/\A[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\z/', $fullName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->normalizeManualWebhookRepositoryPath($fullName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Application>
|
||||
*/
|
||||
protected function manualWebhookApplications(Builder $query, string $fullName): Collection
|
||||
{
|
||||
return $query->get()
|
||||
->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName))
|
||||
->values();
|
||||
}
|
||||
|
||||
protected function manualWebhookRepositoryMatches(?string $gitRepository, string $fullName): bool
|
||||
{
|
||||
$repositoryPath = $this->canonicalManualWebhookRepository($gitRepository);
|
||||
|
||||
if ($repositoryPath === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Git hosts (GitHub, GitLab, Gitea, Bitbucket) treat owner/repo names
|
||||
// case-insensitively, so compare the canonical paths case-insensitively.
|
||||
return hash_equals(mb_strtolower($fullName), mb_strtolower($repositoryPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, message: string}
|
||||
*/
|
||||
protected function unauthenticatedManualWebhookFailurePayload(): array
|
||||
{
|
||||
return [
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid signature.',
|
||||
];
|
||||
}
|
||||
|
||||
protected function canonicalManualWebhookRepository(?string $gitRepository): ?string
|
||||
{
|
||||
if (! is_string($gitRepository)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$gitRepository = trim($gitRepository);
|
||||
|
||||
if ($gitRepository === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = null;
|
||||
$parts = parse_url($gitRepository);
|
||||
|
||||
if (is_array($parts) && isset($parts['scheme'])) {
|
||||
$path = data_get($parts, 'path');
|
||||
} elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) {
|
||||
$path = Str::after($gitRepository, ':');
|
||||
// scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo".
|
||||
// Strip the leading numeric port segment so the path matches the webhook
|
||||
// payload's owner/repo, consistent with convertGitUrl() in shared.php.
|
||||
$path = preg_replace('#^\d+/#', '', $path) ?? $path;
|
||||
} else {
|
||||
$path = $gitRepository;
|
||||
}
|
||||
|
||||
if (! is_string($path) || $path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->normalizeManualWebhookRepositoryPath($path);
|
||||
}
|
||||
|
||||
protected function normalizeManualWebhookRepositoryPath(string $path): string
|
||||
{
|
||||
$path = trim($path);
|
||||
$path = strtok($path, '?#') ?: $path;
|
||||
$path = trim($path, '/');
|
||||
$path = preg_replace('/\.git\z/i', '', $path) ?? $path;
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,19 @@ namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Gitea extends Controller
|
||||
{
|
||||
use DetectsSkipDeployCommits;
|
||||
use MatchesManualWebhookApplications;
|
||||
|
||||
public function manual(Request $request)
|
||||
{
|
||||
try {
|
||||
@@ -40,40 +44,60 @@ class Gitea extends Controller
|
||||
$removed_files = data_get($payload, 'commits.*.removed');
|
||||
$modified_files = data_get($payload, 'commits.*.modified');
|
||||
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
|
||||
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
|
||||
}
|
||||
if ($x_gitea_event === 'pull_request') {
|
||||
$action = data_get($payload, 'action');
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$pull_request_id = data_get($payload, 'number');
|
||||
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
|
||||
$pull_request_title = data_get($payload, 'pull_request.title');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
|
||||
$branch = data_get($payload, 'pull_request.head.ref');
|
||||
$base_branch = data_get($payload, 'pull_request.base.ref');
|
||||
}
|
||||
if (! $branch) {
|
||||
return response('Nothing to do. No branch found in the request.');
|
||||
}
|
||||
$applications = Application::where('git_repository', 'like', "%$full_name%");
|
||||
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
||||
if ($full_name === null) {
|
||||
return response('Nothing to do. Invalid repository.');
|
||||
}
|
||||
$applications = Application::query();
|
||||
if ($x_gitea_event === 'push') {
|
||||
$applications = $applications->where('git_branch', $branch)->get();
|
||||
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
|
||||
if ($applications->isEmpty()) {
|
||||
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
|
||||
}
|
||||
}
|
||||
if ($x_gitea_event === 'pull_request') {
|
||||
$applications = $applications->where('git_branch', $base_branch)->get();
|
||||
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
|
||||
if ($applications->isEmpty()) {
|
||||
return response("Nothing to do. No applications found with branch '$base_branch'.");
|
||||
}
|
||||
}
|
||||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
|
||||
if (empty($webhook_secret)) {
|
||||
auditLogWebhookFailure('gitea', 'webhook_secret_missing', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_gitea_event,
|
||||
]);
|
||||
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||
|
||||
continue;
|
||||
}
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid signature.',
|
||||
auditLogWebhookFailure('gitea', 'invalid_signature', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_gitea_event,
|
||||
]);
|
||||
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -91,7 +115,18 @@ class Gitea extends Controller
|
||||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || blank($application->watch_paths)) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
if ($skip_deploy_commits ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new_public_id();
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
@@ -108,6 +143,15 @@ class Gitea extends Controller
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
} else {
|
||||
auditLog('webhook.deployment.queued', [
|
||||
'provider' => 'gitea',
|
||||
'mode' => 'manual',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $deployment_uuid->toString(),
|
||||
'commit' => data_get($payload, 'after'),
|
||||
'repository' => $full_name ?? null,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'status' => 'success',
|
||||
'message' => 'Deployment queued.',
|
||||
@@ -140,11 +184,20 @@ class Gitea extends Controller
|
||||
if ($x_gitea_event === 'pull_request') {
|
||||
if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') {
|
||||
if ($application->isPRDeployable()) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
if ($skip_deploy_pr ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new_public_id();
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$pr_app = ApplicationPreview::forceCreate([
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'gitea',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
@@ -153,7 +206,7 @@ class Gitea extends Controller
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
} else {
|
||||
$pr_app = ApplicationPreview::forceCreate([
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'gitea',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
|
||||
@@ -3,19 +3,26 @@
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||
use App\Jobs\GithubAppPermissionJob;
|
||||
use App\Jobs\ProcessGithubPullRequestWebhook;
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
use Exception;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Github extends Controller
|
||||
{
|
||||
use DetectsSkipDeployCommits;
|
||||
use MatchesManualWebhookApplications;
|
||||
|
||||
public function manual(Request $request)
|
||||
{
|
||||
try {
|
||||
@@ -43,17 +50,20 @@ class Github extends Controller
|
||||
$removed_files = data_get($payload, 'commits.*.removed');
|
||||
$modified_files = data_get($payload, 'commits.*.modified');
|
||||
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
|
||||
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
$action = data_get($payload, 'action');
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$pull_request_id = data_get($payload, 'number');
|
||||
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
|
||||
$pull_request_title = data_get($payload, 'pull_request.title');
|
||||
$branch = data_get($payload, 'pull_request.head.ref');
|
||||
$base_branch = data_get($payload, 'pull_request.base.ref');
|
||||
$before_sha = data_get($payload, 'before');
|
||||
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
|
||||
$author_association = data_get($payload, 'pull_request.author_association');
|
||||
$is_fork_pull_request = $this->isForkPullRequest($payload);
|
||||
}
|
||||
if (! in_array($x_github_event, ['push', 'pull_request'])) {
|
||||
return response("Nothing to do. Event '$x_github_event' is not supported.");
|
||||
@@ -61,15 +71,19 @@ class Github extends Controller
|
||||
if (! $branch) {
|
||||
return response('Nothing to do. No branch found in the request.');
|
||||
}
|
||||
$applications = Application::where('git_repository', 'like', "%$full_name%");
|
||||
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
||||
if ($full_name === null) {
|
||||
return response('Nothing to do. Invalid repository.');
|
||||
}
|
||||
$applications = Application::query();
|
||||
if ($x_github_event === 'push') {
|
||||
$applications = $applications->where('git_branch', $branch)->get();
|
||||
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
|
||||
if ($applications->isEmpty()) {
|
||||
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
|
||||
}
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
$applications = $applications->where('git_branch', $base_branch)->get();
|
||||
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
|
||||
if ($applications->isEmpty()) {
|
||||
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
|
||||
}
|
||||
@@ -81,13 +95,26 @@ class Github extends Controller
|
||||
foreach ($applicationsByServer as $serverId => $serverApplications) {
|
||||
foreach ($serverApplications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
|
||||
if (empty($webhook_secret)) {
|
||||
auditLogWebhookFailure('github', 'webhook_secret_missing', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'mode' => 'manual',
|
||||
]);
|
||||
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||
|
||||
continue;
|
||||
}
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid signature.',
|
||||
auditLogWebhookFailure('github', 'invalid_signature', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'mode' => 'manual',
|
||||
]);
|
||||
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -105,7 +132,18 @@ class Github extends Controller
|
||||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || blank($application->watch_paths)) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
if ($skip_deploy_commits ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new_public_id();
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
@@ -122,6 +160,15 @@ class Github extends Controller
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
} else {
|
||||
auditLog('webhook.deployment.queued', [
|
||||
'provider' => 'github',
|
||||
'mode' => 'manual',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
'commit' => data_get($payload, 'after'),
|
||||
'repository' => $full_name ?? null,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
@@ -171,11 +218,13 @@ class Github extends Controller
|
||||
action: $action,
|
||||
pullRequestId: $pull_request_id,
|
||||
pullRequestHtmlUrl: $pull_request_html_url,
|
||||
pullRequestTitle: $pull_request_title ?? null,
|
||||
beforeSha: $before_sha,
|
||||
afterSha: $after_sha,
|
||||
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
|
||||
authorAssociation: $author_association,
|
||||
fullName: $full_name,
|
||||
isForkPullRequest: $is_fork_pull_request ?? false,
|
||||
);
|
||||
|
||||
$return_payloads->push([
|
||||
@@ -212,9 +261,26 @@ class Github extends Controller
|
||||
return response('Nothing to do. No GitHub App found.');
|
||||
}
|
||||
$webhook_secret = data_get($github_app, 'webhook_secret');
|
||||
if (empty($webhook_secret)) {
|
||||
auditLogWebhookFailure('github', 'webhook_secret_missing', [
|
||||
'mode' => 'app',
|
||||
'github_app_id' => $github_app->id,
|
||||
'github_app_name' => $github_app->name,
|
||||
'installation_target_id' => $x_github_hook_installation_target_id,
|
||||
]);
|
||||
|
||||
return response('Invalid signature.');
|
||||
}
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (config('app.env') !== 'local') {
|
||||
if (! hash_equals($x_hub_signature_256, $hmac)) {
|
||||
auditLogWebhookFailure('github', 'invalid_signature', [
|
||||
'mode' => 'app',
|
||||
'github_app_id' => $github_app->id,
|
||||
'github_app_name' => $github_app->name,
|
||||
'installation_target_id' => $x_github_hook_installation_target_id,
|
||||
]);
|
||||
|
||||
return response('Invalid signature.');
|
||||
}
|
||||
}
|
||||
@@ -237,17 +303,20 @@ class Github extends Controller
|
||||
$removed_files = data_get($payload, 'commits.*.removed');
|
||||
$modified_files = data_get($payload, 'commits.*.modified');
|
||||
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
|
||||
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
$action = data_get($payload, 'action');
|
||||
$id = data_get($payload, 'repository.id');
|
||||
$pull_request_id = data_get($payload, 'number');
|
||||
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
|
||||
$pull_request_title = data_get($payload, 'pull_request.title');
|
||||
$branch = data_get($payload, 'pull_request.head.ref');
|
||||
$base_branch = data_get($payload, 'pull_request.base.ref');
|
||||
$before_sha = data_get($payload, 'before');
|
||||
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
|
||||
$author_association = data_get($payload, 'pull_request.author_association');
|
||||
$is_fork_pull_request = $this->isForkPullRequest($payload);
|
||||
}
|
||||
if (! in_array($x_github_event, ['push', 'pull_request'])) {
|
||||
return response("Nothing to do. Event '$x_github_event' is not supported.");
|
||||
@@ -291,7 +360,18 @@ class Github extends Controller
|
||||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || blank($application->watch_paths)) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
if ($skip_deploy_commits ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new_public_id();
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
@@ -302,6 +382,17 @@ class Github extends Controller
|
||||
if ($result['status'] === 'queue_full') {
|
||||
return response($result['message'], 429)->header('Retry-After', 60);
|
||||
}
|
||||
if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
|
||||
auditLog('webhook.deployment.queued', [
|
||||
'provider' => 'github',
|
||||
'mode' => 'app',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
'commit' => data_get($payload, 'after'),
|
||||
'github_app_id' => $github_app->id,
|
||||
]);
|
||||
}
|
||||
$return_payloads->push([
|
||||
'status' => $result['status'],
|
||||
'message' => $result['message'],
|
||||
@@ -351,11 +442,13 @@ class Github extends Controller
|
||||
action: $action,
|
||||
pullRequestId: $pull_request_id,
|
||||
pullRequestHtmlUrl: $pull_request_html_url,
|
||||
pullRequestTitle: $pull_request_title ?? null,
|
||||
beforeSha: $before_sha,
|
||||
afterSha: $after_sha,
|
||||
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
|
||||
authorAssociation: $author_association,
|
||||
fullName: $full_name,
|
||||
isForkPullRequest: $is_fork_pull_request ?? false,
|
||||
);
|
||||
|
||||
$return_payloads->push([
|
||||
@@ -373,55 +466,203 @@ class Github extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a pull_request webhook payload originates from a fork.
|
||||
*
|
||||
* GitHub's `author_association` is not a reliable trust signal (it grants
|
||||
* CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
|
||||
* detection is gated on whether the PR crosses repository boundaries.
|
||||
*
|
||||
* The repository id comparison is the canonical signal; the `head.repo.fork`
|
||||
* flag and a case-insensitive full_name comparison are fallbacks for payloads
|
||||
* where the ids are unavailable (e.g. a deleted head repository).
|
||||
*/
|
||||
private function isForkPullRequest(mixed $payload): bool
|
||||
{
|
||||
$headRepoId = data_get($payload, 'pull_request.head.repo.id');
|
||||
$baseRepoId = data_get($payload, 'pull_request.base.repo.id');
|
||||
|
||||
if ($headRepoId !== null && $baseRepoId !== null) {
|
||||
return (string) $headRepoId !== (string) $baseRepoId;
|
||||
}
|
||||
|
||||
if (data_get($payload, 'pull_request.head.repo.fork') === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name');
|
||||
$baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name');
|
||||
|
||||
if (is_string($headRepoFullName) && is_string($baseRepoFullName)) {
|
||||
return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function redirect(Request $request)
|
||||
{
|
||||
try {
|
||||
$code = $request->get('code');
|
||||
$state = $request->get('state');
|
||||
$github_app = GithubApp::where('uuid', $state)->firstOrFail();
|
||||
$api_url = data_get($github_app, 'api_url');
|
||||
$data = Http::withBody(null)->accept('application/vnd.github+json')->post("$api_url/app-manifests/$code/conversions")->throw()->json();
|
||||
$id = data_get($data, 'id');
|
||||
$slug = data_get($data, 'slug');
|
||||
$client_id = data_get($data, 'client_id');
|
||||
$client_secret = data_get($data, 'client_secret');
|
||||
$private_key = data_get($data, 'pem');
|
||||
$webhook_secret = data_get($data, 'webhook_secret');
|
||||
$private_key = PrivateKey::create([
|
||||
'name' => "github-app-{$slug}",
|
||||
'private_key' => $private_key,
|
||||
'team_id' => $github_app->team_id,
|
||||
'is_git_related' => true,
|
||||
]);
|
||||
$github_app->name = $slug;
|
||||
$github_app->app_id = $id;
|
||||
$github_app->client_id = $client_id;
|
||||
$github_app->client_secret = $client_secret;
|
||||
$github_app->webhook_secret = $webhook_secret;
|
||||
$github_app->private_key_id = $private_key->id;
|
||||
$github_app->save();
|
||||
$code = (string) $request->query('code', '');
|
||||
abort_if(blank($code), 422, 'Missing GitHub App manifest code.');
|
||||
|
||||
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
||||
} catch (Exception $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
$github_app = $this->consumeGithubAppSetupState(
|
||||
request: $request,
|
||||
state: (string) $request->query('state', ''),
|
||||
action: 'manifest',
|
||||
);
|
||||
|
||||
abort_if($this->githubAppHasManifestCredentials($github_app), 403, 'GitHub App credentials are already configured.');
|
||||
|
||||
$api_url = data_get($github_app, 'api_url');
|
||||
$data = Http::withBody(null)
|
||||
->accept('application/vnd.github+json')
|
||||
->timeout(10)
|
||||
->connectTimeout(5)
|
||||
->post("$api_url/app-manifests/$code/conversions")
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$id = data_get($data, 'id');
|
||||
$slug = data_get($data, 'slug');
|
||||
$client_id = data_get($data, 'client_id');
|
||||
$client_secret = data_get($data, 'client_secret');
|
||||
$private_key = data_get($data, 'pem');
|
||||
$webhook_secret = data_get($data, 'webhook_secret');
|
||||
|
||||
abort_if(blank($id) || blank($slug) || blank($client_id) || blank($client_secret) || blank($private_key) || blank($webhook_secret), 422, 'GitHub App manifest conversion response is incomplete.');
|
||||
|
||||
$private_key = PrivateKey::create([
|
||||
'name' => "github-app-{$slug}",
|
||||
'private_key' => $private_key,
|
||||
'team_id' => $github_app->team_id,
|
||||
'is_git_related' => true,
|
||||
]);
|
||||
$github_app->name = $slug;
|
||||
$github_app->app_id = $id;
|
||||
$github_app->client_id = $client_id;
|
||||
$github_app->client_secret = $client_secret;
|
||||
$github_app->webhook_secret = $webhook_secret;
|
||||
$github_app->private_key_id = $private_key->id;
|
||||
$github_app->save();
|
||||
|
||||
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
||||
}
|
||||
|
||||
public function install(Request $request)
|
||||
{
|
||||
try {
|
||||
$installation_id = $request->get('installation_id');
|
||||
$source = $request->get('source');
|
||||
$setup_action = $request->get('setup_action');
|
||||
$github_app = GithubApp::where('uuid', $source)->firstOrFail();
|
||||
if ($setup_action === 'install') {
|
||||
$github_app->installation_id = $installation_id;
|
||||
$github_app->save();
|
||||
}
|
||||
$setup_action = (string) $request->query('setup_action', '');
|
||||
abort_unless(in_array($setup_action, ['install', 'update'], true), 422, 'Invalid GitHub App setup action.');
|
||||
|
||||
$installation_id = (string) $request->query('installation_id', '');
|
||||
abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
|
||||
|
||||
if ($setup_action === 'update') {
|
||||
return $this->redirectAfterGithubAppInstallationUpdate($installation_id);
|
||||
}
|
||||
|
||||
$github_app = $this->consumeGithubAppSetupState(
|
||||
request: $request,
|
||||
state: (string) $request->query('state', ''),
|
||||
action: 'install',
|
||||
);
|
||||
|
||||
abort_unless(
|
||||
$this->githubInstallationBelongsToApp($github_app, $installation_id),
|
||||
403,
|
||||
'GitHub App installation could not be verified.'
|
||||
);
|
||||
|
||||
$github_app->installation_id = $installation_id;
|
||||
$github_app->save();
|
||||
|
||||
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
||||
}
|
||||
|
||||
private function redirectAfterGithubAppInstallationUpdate(string $installation_id): RedirectResponse
|
||||
{
|
||||
$github_app = GithubApp::ownedByCurrentTeam()
|
||||
->where('installation_id', $installation_id)
|
||||
->first();
|
||||
|
||||
if ($github_app) {
|
||||
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
||||
} catch (Exception $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
|
||||
return redirect()->route('source.all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the given installation id actually belongs to this GitHub App.
|
||||
*
|
||||
* The installation id arrives as an untrusted query parameter on an
|
||||
* unauthenticated-reachable GET callback, so it must be confirmed against
|
||||
* the GitHub API using the App's own credentials before it is persisted.
|
||||
*/
|
||||
private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool
|
||||
{
|
||||
if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$jwt = generateGithubJwt($github_app);
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer $jwt",
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
])
|
||||
->timeout(10)
|
||||
->connectTimeout(5)
|
||||
->get("{$github_app->api_url}/app/installations/{$installation_id}");
|
||||
|
||||
return $response->successful()
|
||||
&& (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
|
||||
{
|
||||
if (blank($state)) {
|
||||
$this->rejectInvalidGithubAppSetupState($request);
|
||||
}
|
||||
|
||||
$payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
|
||||
if (! is_array($payload) || data_get($payload, 'action') !== $action) {
|
||||
$this->rejectInvalidGithubAppSetupState($request);
|
||||
}
|
||||
|
||||
$team_id = $request->user()?->currentTeam()?->id;
|
||||
abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403);
|
||||
|
||||
return GithubApp::whereKey(data_get($payload, 'github_app_id'))
|
||||
->where('team_id', data_get($payload, 'team_id'))
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function rejectInvalidGithubAppSetupState(Request $request): never
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
throw new HttpResponseException(
|
||||
redirect()
|
||||
->route('source.all')
|
||||
);
|
||||
}
|
||||
|
||||
private function githubAppSetupStateCacheKey(string $state): string
|
||||
{
|
||||
return 'github-app-setup-state:'.hash('sha256', $state);
|
||||
}
|
||||
|
||||
private function githubAppHasManifestCredentials(GithubApp $github_app): bool
|
||||
{
|
||||
return filled($github_app->app_id)
|
||||
|| filled($github_app->client_id)
|
||||
|| filled($github_app->client_secret)
|
||||
|| filled($github_app->webhook_secret)
|
||||
|| filled($github_app->private_key_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,19 @@ namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Gitlab extends Controller
|
||||
{
|
||||
use DetectsSkipDeployCommits;
|
||||
use MatchesManualWebhookApplications;
|
||||
|
||||
public function manual(Request $request)
|
||||
{
|
||||
try {
|
||||
@@ -32,6 +36,9 @@ class Gitlab extends Controller
|
||||
}
|
||||
|
||||
if (empty($x_gitlab_token)) {
|
||||
auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
|
||||
'event' => $x_gitlab_event,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid signature.',
|
||||
@@ -58,6 +65,7 @@ class Gitlab extends Controller
|
||||
$removed_files = data_get($payload, 'commits.*.removed');
|
||||
$modified_files = data_get($payload, 'commits.*.modified');
|
||||
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
|
||||
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
|
||||
}
|
||||
if ($x_gitlab_event === 'merge_request') {
|
||||
$action = data_get($payload, 'object_attributes.action');
|
||||
@@ -66,6 +74,9 @@ class Gitlab extends Controller
|
||||
$full_name = data_get($payload, 'project.path_with_namespace');
|
||||
$pull_request_id = data_get($payload, 'object_attributes.iid');
|
||||
$pull_request_html_url = data_get($payload, 'object_attributes.url');
|
||||
$pull_request_title = data_get($payload, 'object_attributes.title');
|
||||
$latest_commit_message = data_get($payload, 'object_attributes.last_commit.message');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]);
|
||||
if (! $branch) {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
@@ -75,9 +86,18 @@ class Gitlab extends Controller
|
||||
return response($return_payloads);
|
||||
}
|
||||
}
|
||||
$applications = Application::where('git_repository', 'like', "%$full_name%");
|
||||
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
||||
if ($full_name === null) {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Nothing to do. Invalid repository.',
|
||||
]);
|
||||
|
||||
return response($return_payloads);
|
||||
}
|
||||
$applications = Application::query();
|
||||
if ($x_gitlab_event === 'push') {
|
||||
$applications = $applications->where('git_branch', $branch)->get();
|
||||
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
|
||||
if ($applications->isEmpty()) {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
@@ -88,7 +108,7 @@ class Gitlab extends Controller
|
||||
}
|
||||
}
|
||||
if ($x_gitlab_event === 'merge_request') {
|
||||
$applications = $applications->where('git_branch', $base_branch)->get();
|
||||
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
|
||||
if ($applications->isEmpty()) {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
@@ -100,12 +120,25 @@ class Gitlab extends Controller
|
||||
}
|
||||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
|
||||
if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid signature.',
|
||||
if (empty($webhook_secret)) {
|
||||
auditLogWebhookFailure('gitlab', 'webhook_secret_missing', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_gitlab_event,
|
||||
]);
|
||||
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
|
||||
auditLogWebhookFailure('gitlab', 'invalid_signature', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_gitlab_event,
|
||||
]);
|
||||
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -123,7 +156,18 @@ class Gitlab extends Controller
|
||||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || blank($application->watch_paths)) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
if ($skip_deploy_commits ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new_public_id();
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
@@ -141,6 +185,15 @@ class Gitlab extends Controller
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
} else {
|
||||
auditLog('webhook.deployment.queued', [
|
||||
'provider' => 'gitlab',
|
||||
'mode' => 'manual',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $deployment_uuid->toString(),
|
||||
'commit' => data_get($payload, 'after'),
|
||||
'repository' => $full_name ?? null,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'status' => 'success',
|
||||
'message' => 'Deployment queued.',
|
||||
@@ -173,11 +226,20 @@ class Gitlab extends Controller
|
||||
if ($x_gitlab_event === 'merge_request') {
|
||||
if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') {
|
||||
if ($application->isPRDeployable()) {
|
||||
$deployment_uuid = new Cuid2;
|
||||
if ($skip_deploy_pr ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'PR title or latest commit contains [skip cd] or [skip ci]. Skipping preview deployment.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new_public_id();
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$pr_app = ApplicationPreview::forceCreate([
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'gitlab',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
@@ -186,7 +248,7 @@ class Gitlab extends Controller
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
} else {
|
||||
$pr_app = ApplicationPreview::forceCreate([
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'gitlab',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
||||
use App\Jobs\StripeProcessJob;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Stripe\Exception\SignatureVerificationException;
|
||||
use Stripe\Webhook;
|
||||
|
||||
class Stripe extends Controller
|
||||
{
|
||||
@@ -14,7 +16,7 @@ class Stripe extends Controller
|
||||
try {
|
||||
$webhookSecret = config('subscription.stripe_webhook_secret');
|
||||
$signature = $request->header('Stripe-Signature');
|
||||
$event = \Stripe\Webhook::constructEvent(
|
||||
$event = Webhook::constructEvent(
|
||||
$request->getContent(),
|
||||
$signature,
|
||||
$webhookSecret
|
||||
@@ -22,6 +24,12 @@ class Stripe extends Controller
|
||||
StripeProcessJob::dispatch($event);
|
||||
|
||||
return response('Webhook received. Cool cool cool cool cool.', 200);
|
||||
} catch (SignatureVerificationException $e) {
|
||||
auditLogWebhookFailure('stripe', 'invalid_signature', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response($e->getMessage(), 400);
|
||||
} catch (Exception $e) {
|
||||
return response($e->getMessage(), 400);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user