Add system status displays for Minio and Redis, and enhance backup configuration
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class backup_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_records (
|
||||
backup_uuid VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
description TEXT NULL,
|
||||
source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
schema_version INT UNSIGNED NOT NULL DEFAULT 2,
|
||||
storage_bucket VARCHAR(191) NOT NULL DEFAULT 'backups',
|
||||
storage_prefix VARCHAR(255) NOT NULL,
|
||||
manifest_key VARCHAR(255) NULL,
|
||||
manifest_sha256 CHAR(64) NULL,
|
||||
encryption_key_id VARCHAR(191) NULL,
|
||||
component_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
object_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
total_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
requested_by_user_id INT NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
verified_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
last_error TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_records_status_created (status, created_at),
|
||||
KEY idx_backup_records_verified (verified_at),
|
||||
KEY idx_backup_records_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_components (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
backup_uuid VARCHAR(64) NOT NULL,
|
||||
component_type VARCHAR(32) NOT NULL,
|
||||
logical_name VARCHAR(191) NOT NULL,
|
||||
source_bucket VARCHAR(191) NULL,
|
||||
source_prefix VARCHAR(255) NULL,
|
||||
storage_key VARCHAR(255) NULL,
|
||||
manifest_key VARCHAR(255) NULL,
|
||||
object_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
byte_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
content_sha256 CHAR(64) NULL,
|
||||
encrypted_sha256 CHAR(64) NULL,
|
||||
encryption_key_id VARCHAR(191) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_components_backup (backup_uuid),
|
||||
KEY idx_backup_components_status (status),
|
||||
KEY idx_backup_components_type_name (component_type, logical_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
job_type VARCHAR(32) NOT NULL,
|
||||
backup_uuid VARCHAR(64) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
progress_message VARCHAR(255) NULL,
|
||||
payload_json LONGTEXT NULL,
|
||||
result_json LONGTEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
locked_at DATETIME NULL,
|
||||
lock_owner VARCHAR(191) NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
error_message TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_jobs_status_created (status, created_at),
|
||||
KEY idx_backup_jobs_backup (backup_uuid),
|
||||
KEY idx_backup_jobs_type_status (job_type, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_restore_audit (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
restore_job_id BIGINT UNSIGNED NULL,
|
||||
preview_job_id BIGINT UNSIGNED NULL,
|
||||
backup_uuid VARCHAR(64) NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
target_environment VARCHAR(64) NOT NULL DEFAULT 'production',
|
||||
confirmation_fingerprint CHAR(64) NULL,
|
||||
reason TEXT NULL,
|
||||
ip_address VARCHAR(64) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
pre_restore_backup_uuid VARCHAR(64) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
error_message TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_restore_audit_backup (backup_uuid),
|
||||
KEY idx_backup_restore_audit_job (restore_job_id),
|
||||
KEY idx_backup_restore_audit_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,11 @@ class coolify_api_client
|
||||
return $this->request('GET', '/services');
|
||||
}
|
||||
|
||||
public function listApplications(): array
|
||||
{
|
||||
return $this->request('GET', '/applications');
|
||||
}
|
||||
|
||||
public function listGithubApps(): array
|
||||
{
|
||||
return $this->request('GET', '/github-apps');
|
||||
@@ -169,6 +174,11 @@ class coolify_api_client
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
|
||||
}
|
||||
|
||||
public function stopService(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/stop');
|
||||
}
|
||||
|
||||
public function stopApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
|
||||
@@ -179,6 +189,11 @@ class coolify_api_client
|
||||
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function deleteApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('DELETE', '/applications/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function listDeployments(): array
|
||||
{
|
||||
return $this->request('GET', '/deployments');
|
||||
|
||||
@@ -106,6 +106,31 @@ class cron_scheduler
|
||||
];
|
||||
}
|
||||
|
||||
public function markExpiredRunningRuns(): int
|
||||
{
|
||||
$this->ensureReady();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$message = 'Task lock expired before completion.';
|
||||
|
||||
$this->query(
|
||||
"UPDATE cron_task_runs r
|
||||
INNER JOIN cron_task_state s ON s.task_id = r.task_id AND s.current_run_id = r.id
|
||||
SET r.status = 'timed_out',
|
||||
r.completed_at = COALESCE(s.locked_until, " . $this->sql($now) . "),
|
||||
r.error_message = COALESCE(r.error_message, " . $this->sql($message) . "),
|
||||
s.current_run_id = NULL,
|
||||
s.locked_until = NULL,
|
||||
s.lock_owner = NULL,
|
||||
s.last_status = 'timed_out',
|
||||
s.last_error = " . $this->sql($message) . "
|
||||
WHERE r.status = 'running'
|
||||
AND s.locked_until IS NOT NULL
|
||||
AND s.locked_until < " . $this->sql($now)
|
||||
);
|
||||
|
||||
return $this->affectedRows();
|
||||
}
|
||||
|
||||
public function runTask(
|
||||
string $task_id_or_legacy_name,
|
||||
string $source = 'manual',
|
||||
|
||||
@@ -61,6 +61,37 @@ class cron_schema_bootstrap
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS cron_worker_state (
|
||||
worker_id VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
hostname VARCHAR(191) NULL,
|
||||
pid INT UNSIGNED NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'coolify_worker',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'starting',
|
||||
release_channel_id BIGINT UNSIGNED NULL,
|
||||
release_target_id BIGINT UNSIGNED NULL,
|
||||
coolify_resource_uuid VARCHAR(128) NULL,
|
||||
coolify_resource_type VARCHAR(32) NULL,
|
||||
commit_sha VARCHAR(64) NULL,
|
||||
poll_seconds INT UNSIGNED NOT NULL DEFAULT 15,
|
||||
last_run_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_stale_run_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
started_at DATETIME NULL,
|
||||
last_heartbeat_at DATETIME NULL,
|
||||
last_loop_started_at DATETIME NULL,
|
||||
last_loop_finished_at DATETIME NULL,
|
||||
stopped_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_cron_worker_state_heartbeat (last_heartbeat_at),
|
||||
KEY idx_cron_worker_state_status (status),
|
||||
KEY idx_cron_worker_state_release_target (release_target_id),
|
||||
KEY idx_cron_worker_state_coolify_resource (coolify_resource_uuid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Throwable;
|
||||
|
||||
class cron_worker
|
||||
{
|
||||
private cron_scheduler $scheduler;
|
||||
private string $worker_id;
|
||||
private string $name;
|
||||
private string $source;
|
||||
private int $poll_seconds;
|
||||
private int $heartbeat_seconds;
|
||||
private int $max_runtime_seconds;
|
||||
private bool $should_stop = false;
|
||||
private int $last_heartbeat = 0;
|
||||
|
||||
public function __construct(?cron_scheduler $scheduler = null, array $options = [])
|
||||
{
|
||||
$this->scheduler = $scheduler ?? new cron_scheduler();
|
||||
$this->name = $this->stringOption($options, 'name', 'CRON_WORKER_NAME', 'cron-worker');
|
||||
$this->worker_id = $this->stringOption($options, 'worker_id', 'CRON_WORKER_ID', $this->name);
|
||||
$this->source = $this->stringOption($options, 'source', 'CRON_WORKER_SOURCE', 'coolify_worker');
|
||||
$this->poll_seconds = $this->intOption($options, 'poll_seconds', 'CRON_WORKER_POLL_SECONDS', 15, 1, 300);
|
||||
$this->heartbeat_seconds = $this->intOption($options, 'heartbeat_seconds', 'CRON_WORKER_HEARTBEAT_SECONDS', 30, 5, 300);
|
||||
$this->max_runtime_seconds = $this->intOption($options, 'max_runtime_seconds', 'CRON_WORKER_MAX_RUNTIME_SECONDS', 0, 0, 86400);
|
||||
}
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
if (!$this->boolOption('CRON_WORKER_ENABLED', true)) {
|
||||
$this->heartbeat('disabled', 0, 0, null, true);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
$started = time();
|
||||
$this->heartbeat('starting', 0, 0, null, true);
|
||||
|
||||
while (!$this->should_stop) {
|
||||
$result = $this->tick();
|
||||
$this->writeStatusLine($result);
|
||||
|
||||
if ($this->max_runtime_seconds > 0 && time() - $started >= $this->max_runtime_seconds) {
|
||||
$this->should_stop = true;
|
||||
break;
|
||||
}
|
||||
|
||||
$this->sleepUntilNextPoll();
|
||||
}
|
||||
|
||||
$this->heartbeat('stopped', 0, 0, null, true, true);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function tick(): array
|
||||
{
|
||||
$this->heartbeat('running');
|
||||
$loopStartedAt = date('Y-m-d H:i:s');
|
||||
$staleRuns = 0;
|
||||
$ran = ['count' => 0, 'ran' => []];
|
||||
$error = null;
|
||||
$status = 'running';
|
||||
|
||||
try {
|
||||
$staleRuns = $this->scheduler->markExpiredRunningRuns();
|
||||
$ran = $this->scheduler->runDue($this->source);
|
||||
} catch (Throwable $throwable) {
|
||||
$status = 'failed';
|
||||
$error = $throwable->getMessage();
|
||||
}
|
||||
|
||||
$this->heartbeat($status, (int)($ran['count'] ?? 0), $staleRuns, $error, true, false, $loopStartedAt);
|
||||
|
||||
return [
|
||||
'worker_id' => $this->worker_id,
|
||||
'status' => $status,
|
||||
'ran' => (int)($ran['count'] ?? 0),
|
||||
'stale_runs' => $staleRuns,
|
||||
'error' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
public function listWorkers(): array
|
||||
{
|
||||
cron_schema_bootstrap::ensureTables();
|
||||
$rows = $this->fetchAll('SELECT * FROM cron_worker_state ORDER BY last_heartbeat_at DESC, worker_id');
|
||||
$workers = [];
|
||||
foreach ($rows as $row) {
|
||||
$workers[] = $this->publicWorker($row);
|
||||
}
|
||||
|
||||
return [
|
||||
'workers' => $workers,
|
||||
'summary' => [
|
||||
'total' => count($workers),
|
||||
'running' => count(array_filter($workers, static fn(array $worker): bool => ($worker['status'] ?? '') === 'running')),
|
||||
'stale' => count(array_filter($workers, static fn(array $worker): bool => (bool)($worker['stale'] ?? false))),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function registerSignalHandlers(): void
|
||||
{
|
||||
if (!function_exists('pcntl_signal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (function_exists('pcntl_async_signals')) {
|
||||
pcntl_async_signals(true);
|
||||
}
|
||||
|
||||
pcntl_signal(SIGTERM, function (): void {
|
||||
$this->should_stop = true;
|
||||
});
|
||||
pcntl_signal(SIGINT, function (): void {
|
||||
$this->should_stop = true;
|
||||
});
|
||||
}
|
||||
|
||||
private function sleepUntilNextPoll(): void
|
||||
{
|
||||
$remaining = $this->poll_seconds;
|
||||
while ($remaining > 0 && !$this->should_stop) {
|
||||
$sleep = min(1, $remaining);
|
||||
sleep($sleep);
|
||||
$remaining -= $sleep;
|
||||
if (time() - $this->last_heartbeat >= $this->heartbeat_seconds) {
|
||||
$this->heartbeat('running');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function heartbeat(
|
||||
string $status,
|
||||
int $runCount = 0,
|
||||
int $staleRunCount = 0,
|
||||
?string $error = null,
|
||||
bool $force = false,
|
||||
bool $stopped = false,
|
||||
?string $loopStartedAt = null
|
||||
): void {
|
||||
if (!$force && time() - $this->last_heartbeat < $this->heartbeat_seconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
cron_schema_bootstrap::ensureTables();
|
||||
$this->last_heartbeat = time();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$workerId = $this->sql($this->worker_id);
|
||||
$name = $this->sql($this->name);
|
||||
$hostname = $this->nullableSql(gethostname() ?: null);
|
||||
$pid = getmypid() ?: 0;
|
||||
$source = $this->sql($this->source);
|
||||
$statusSql = $this->sql($status);
|
||||
$releaseChannelId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_CHANNEL_ID'));
|
||||
$releaseTargetId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_TARGET_ID'));
|
||||
$resourceUuid = $this->nullableSql($this->env('COOLIFY_RESOURCE_UUID') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_UUID'));
|
||||
$resourceType = $this->nullableSql($this->env('COOLIFY_RESOURCE_TYPE') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_TYPE') ?: 'application');
|
||||
$commitSha = $this->nullableSql($this->commitSha());
|
||||
$errorSql = $this->nullableSql($error);
|
||||
$loopStarted = $this->nullableSql($loopStartedAt);
|
||||
$stoppedAt = $stopped ? $this->sql($now) : 'NULL';
|
||||
|
||||
$this->query(
|
||||
"INSERT INTO cron_worker_state (
|
||||
worker_id, name, hostname, pid, source, status, release_channel_id, release_target_id,
|
||||
coolify_resource_uuid, coolify_resource_type, commit_sha, poll_seconds, last_run_count,
|
||||
last_stale_run_count, last_error, started_at, last_heartbeat_at, last_loop_started_at,
|
||||
last_loop_finished_at, stopped_at
|
||||
) VALUES (
|
||||
$workerId, $name, $hostname, $pid, $source, $statusSql, $releaseChannelId, $releaseTargetId,
|
||||
$resourceUuid, $resourceType, $commitSha, $this->poll_seconds, $runCount,
|
||||
$staleRunCount, $errorSql, $this->sql($now), $this->sql($now), $loopStarted,
|
||||
$this->sql($now), $stoppedAt
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
hostname = VALUES(hostname),
|
||||
pid = VALUES(pid),
|
||||
source = VALUES(source),
|
||||
status = VALUES(status),
|
||||
release_channel_id = VALUES(release_channel_id),
|
||||
release_target_id = VALUES(release_target_id),
|
||||
coolify_resource_uuid = VALUES(coolify_resource_uuid),
|
||||
coolify_resource_type = VALUES(coolify_resource_type),
|
||||
commit_sha = VALUES(commit_sha),
|
||||
poll_seconds = VALUES(poll_seconds),
|
||||
last_run_count = VALUES(last_run_count),
|
||||
last_stale_run_count = VALUES(last_stale_run_count),
|
||||
last_error = VALUES(last_error),
|
||||
last_heartbeat_at = VALUES(last_heartbeat_at),
|
||||
last_loop_started_at = COALESCE(VALUES(last_loop_started_at), last_loop_started_at),
|
||||
last_loop_finished_at = VALUES(last_loop_finished_at),
|
||||
stopped_at = VALUES(stopped_at)"
|
||||
);
|
||||
}
|
||||
|
||||
private function publicWorker(array $row): array
|
||||
{
|
||||
$heartbeatAt = (string)($row['last_heartbeat_at'] ?? '');
|
||||
$heartbeatTs = strtotime($heartbeatAt);
|
||||
$threshold = max(60, ((int)($row['poll_seconds'] ?? 15) * 4) + 30);
|
||||
$age = $heartbeatTs !== false ? max(0, time() - $heartbeatTs) : null;
|
||||
|
||||
return [
|
||||
'worker_id' => (string)($row['worker_id'] ?? ''),
|
||||
'name' => (string)($row['name'] ?? ''),
|
||||
'hostname' => $row['hostname'] ?? null,
|
||||
'pid' => isset($row['pid']) ? (int)$row['pid'] : null,
|
||||
'source' => (string)($row['source'] ?? ''),
|
||||
'status' => (string)($row['status'] ?? 'unknown'),
|
||||
'release_channel_id' => isset($row['release_channel_id']) ? (int)$row['release_channel_id'] : null,
|
||||
'release_target_id' => isset($row['release_target_id']) ? (int)$row['release_target_id'] : null,
|
||||
'coolify_resource_uuid' => $row['coolify_resource_uuid'] ?? null,
|
||||
'coolify_resource_type' => $row['coolify_resource_type'] ?? null,
|
||||
'commit_sha' => $row['commit_sha'] ?? null,
|
||||
'poll_seconds' => (int)($row['poll_seconds'] ?? 0),
|
||||
'last_run_count' => (int)($row['last_run_count'] ?? 0),
|
||||
'last_stale_run_count' => (int)($row['last_stale_run_count'] ?? 0),
|
||||
'last_error' => $row['last_error'] ?? null,
|
||||
'started_at' => $row['started_at'] ?? null,
|
||||
'last_heartbeat_at' => $heartbeatAt !== '' ? $heartbeatAt : null,
|
||||
'last_heartbeat_age_seconds' => $age,
|
||||
'last_loop_started_at' => $row['last_loop_started_at'] ?? null,
|
||||
'last_loop_finished_at' => $row['last_loop_finished_at'] ?? null,
|
||||
'stopped_at' => $row['stopped_at'] ?? null,
|
||||
'stale' => $age === null || $age > $threshold,
|
||||
'stale_after_seconds' => $threshold,
|
||||
];
|
||||
}
|
||||
|
||||
private function writeStatusLine(array $result): void
|
||||
{
|
||||
echo '[' . date('Y-m-d H:i:s') . '][CRON_WORKER] '
|
||||
. json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
. PHP_EOL;
|
||||
}
|
||||
|
||||
private function stringOption(array $options, string $key, string $env, string $default): string
|
||||
{
|
||||
$value = trim((string)($options[$key] ?? $this->env($env) ?? ''));
|
||||
return $value !== '' ? $value : $default;
|
||||
}
|
||||
|
||||
private function intOption(array $options, string $key, string $env, int $default, int $min, int $max): int
|
||||
{
|
||||
$value = (int)($options[$key] ?? $this->env($env) ?? $default);
|
||||
return max($min, min($max, $value));
|
||||
}
|
||||
|
||||
private function boolOption(string $env, bool $default): bool
|
||||
{
|
||||
$value = $this->env($env);
|
||||
if ($value === null || trim($value) === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function commitSha(): string
|
||||
{
|
||||
foreach (['CRON_WORKER_COMMIT_SHA', 'API_COMMIT_SHA', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA'] as $key) {
|
||||
$value = trim((string)($this->env($key) ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function env(string $key): ?string
|
||||
{
|
||||
$value = getenv($key);
|
||||
if ($value !== false) {
|
||||
return (string)$value;
|
||||
}
|
||||
|
||||
return isset($_SERVER[$key]) ? (string)$_SERVER[$key] : null;
|
||||
}
|
||||
|
||||
private function nullableInt(?string $value): string
|
||||
{
|
||||
$value = trim((string)$value);
|
||||
if ($value === '' || filter_var($value, FILTER_VALIDATE_INT) === false) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
return (string)max(0, (int)$value);
|
||||
}
|
||||
|
||||
private function nullableSql(?string $value): string
|
||||
{
|
||||
$value = $value !== null ? trim($value) : '';
|
||||
return $value === '' ? 'NULL' : $this->sql($value);
|
||||
}
|
||||
|
||||
private function fetchAll(string $sql): array
|
||||
{
|
||||
$result = $this->query($sql);
|
||||
if ($result === false || $result === true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $result->fetch_all(MYSQLI_ASSOC);
|
||||
}
|
||||
|
||||
private function query(string $sql): \mysqli_result|bool
|
||||
{
|
||||
global $db;
|
||||
return $db->query($sql);
|
||||
}
|
||||
|
||||
private function sql(string $value): string
|
||||
{
|
||||
global $db;
|
||||
return "'" . $db->escape_string($value) . "'";
|
||||
}
|
||||
}
|
||||
@@ -177,15 +177,19 @@ class db
|
||||
return $this->database;
|
||||
}
|
||||
|
||||
public function getPort(): int
|
||||
{
|
||||
return $this->port;
|
||||
}
|
||||
|
||||
public function getSslMode(): string
|
||||
{
|
||||
return $this->ssl_mode;
|
||||
}
|
||||
|
||||
public function backupDatabase(string $path): bool
|
||||
{
|
||||
// Save the database to the path
|
||||
// Build a safe mysqldump command with configurable SSL (MariaDB-compatible flags)
|
||||
$mode = strtoupper(trim($this->ssl_mode));
|
||||
// Map ssl_mode to MariaDB client flags
|
||||
// DISABLED => --skip-ssl (no TLS)
|
||||
// PREFERRED => (no flag; client decides)
|
||||
// REQUIRED/VERIFY_* => --ssl (enable TLS without strict verification unless CA materials provided)
|
||||
$sslFlag = '';
|
||||
switch ($mode) {
|
||||
case 'DISABLED':
|
||||
@@ -201,17 +205,42 @@ class db
|
||||
$sslFlag = '--ssl';
|
||||
break;
|
||||
}
|
||||
|
||||
$host = escapeshellarg($this->host);
|
||||
$user = escapeshellarg($this->user);
|
||||
$pass = escapeshellarg($this->password);
|
||||
$db = escapeshellarg($this->database);
|
||||
$port = (int)$this->port;
|
||||
$outfile = escapeshellarg($path);
|
||||
$sslPart = $sslFlag !== '' ? ($sslFlag . ' ') : '';
|
||||
$command = "mysqldump {$sslPart}-h $host -P $port -u $user --password=$pass $db > $outfile 2>&1";
|
||||
exec($command, $output, $return);
|
||||
// Check if the command was successful
|
||||
return $return === 0;
|
||||
$command = "mysqldump {$sslPart}--single-transaction --quick --routines --triggers --events --hex-blob -h $host -P $port -u $user $db";
|
||||
|
||||
$directory = dirname($path);
|
||||
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$environment = array_merge(getenv() ?: [], $_ENV);
|
||||
$environment['MYSQL_PWD'] = $this->password;
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['file', $path, 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$process = proc_open($command, $descriptors, $pipes, null, $environment);
|
||||
if (!is_resource($process)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[2]);
|
||||
$return = proc_close($process);
|
||||
|
||||
if ($return !== 0 && is_string($stderr) && $stderr !== '') {
|
||||
@file_put_contents($path . '.error.log', $stderr);
|
||||
}
|
||||
|
||||
return $return === 0 && is_file($path) && filesize($path) !== false;
|
||||
}
|
||||
|
||||
public function getView(string $view): array
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class security_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS security_firewall_rules (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
action VARCHAR(16) NOT NULL,
|
||||
target_type VARCHAR(32) NOT NULL,
|
||||
target_value VARCHAR(255) NOT NULL,
|
||||
route_pattern VARCHAR(255) NULL,
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
reason TEXT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
expires_at DATETIME NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
created_by INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_security_firewall_rules_active (enabled, deleted_at, expires_at),
|
||||
INDEX idx_security_firewall_rules_target (target_type, target_value),
|
||||
INDEX idx_security_firewall_rules_priority (priority)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS security_policy_rules (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
rule_key VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
threshold_count INT NOT NULL,
|
||||
window_seconds INT NOT NULL,
|
||||
mode VARCHAR(16) NOT NULL DEFAULT 'observe',
|
||||
exempt_permission_nodes_json LONGTEXT NULL,
|
||||
updated_by INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_security_policy_rules_key (rule_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS security_policy_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
rule_key VARCHAR(64) NOT NULL,
|
||||
subject_type VARCHAR(32) NOT NULL,
|
||||
subject_key VARCHAR(191) NOT NULL,
|
||||
route_path VARCHAR(255) NULL,
|
||||
route_template VARCHAR(255) NULL,
|
||||
method VARCHAR(16) NULL,
|
||||
source_ip VARCHAR(64) NULL,
|
||||
customer_number INT NULL,
|
||||
user_id INT NULL,
|
||||
subuser_id INT NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_security_policy_events_window (rule_key, subject_type, subject_key, created_at),
|
||||
INDEX idx_security_policy_events_created (created_at),
|
||||
INDEX idx_security_policy_events_customer (customer_number, created_at),
|
||||
INDEX idx_security_policy_events_ip (source_ip, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS security_incidents (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
incident_key VARCHAR(191) NOT NULL,
|
||||
type VARCHAR(64) NOT NULL,
|
||||
severity VARCHAR(16) NOT NULL DEFAULT 'medium',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'open',
|
||||
title VARCHAR(255) NOT NULL,
|
||||
source_ip VARCHAR(64) NULL,
|
||||
customer_number INT NULL,
|
||||
user_id INT NULL,
|
||||
subuser_id INT NULL,
|
||||
route_path VARCHAR(255) NULL,
|
||||
route_template VARCHAR(255) NULL,
|
||||
method VARCHAR(16) NULL,
|
||||
related_rule_id BIGINT UNSIGNED NULL,
|
||||
related_firewall_rule_id BIGINT UNSIGNED NULL,
|
||||
occurrence_count INT NOT NULL DEFAULT 1,
|
||||
metadata_json LONGTEXT NULL,
|
||||
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_by INT NULL,
|
||||
resolved_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_security_incidents_key (incident_key),
|
||||
INDEX idx_security_incidents_status_seen (status, last_seen_at),
|
||||
INDEX idx_security_incidents_type_seen (type, last_seen_at),
|
||||
INDEX idx_security_incidents_customer_seen (customer_number, last_seen_at),
|
||||
INDEX idx_security_incidents_ip_seen (source_ip, last_seen_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS security_incident_notes (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
incident_id BIGINT UNSIGNED NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
created_by INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_security_incident_notes_incident (incident_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $query) {
|
||||
$db->query($query);
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
public static function tablesExist(): bool
|
||||
{
|
||||
global $db;
|
||||
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) AS count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = '{$database}'
|
||||
AND table_name IN (
|
||||
'security_firewall_rules',
|
||||
'security_policy_rules',
|
||||
'security_policy_events',
|
||||
'security_incidents',
|
||||
'security_incident_notes'
|
||||
)"
|
||||
);
|
||||
$row = $result ? $result->fetch_assoc() : ['count' => 0];
|
||||
return (int)($row['count'] ?? 0) === 5;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ class selfserve_schema_bootstrap
|
||||
department_id INT NOT NULL,
|
||||
machine_type_id INT NULL,
|
||||
customer_number INT NULL,
|
||||
subuser_id INT NULL,
|
||||
vehicle_id INT NULL,
|
||||
vehicle_type_id INT NULL,
|
||||
reg VARCHAR(255) NOT NULL,
|
||||
@@ -73,6 +74,8 @@ class selfserve_schema_bootstrap
|
||||
INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg),
|
||||
INDEX idx_selfserve_wash_sessions_status (status),
|
||||
INDEX idx_selfserve_wash_sessions_customer (customer_number),
|
||||
INDEX idx_selfserve_wash_sessions_subuser_active (subuser_id, completed_at),
|
||||
INDEX idx_selfserve_wash_sessions_customer_subuser_active (customer_number, subuser_id, completed_at),
|
||||
INDEX idx_selfserve_wash_sessions_created_at (created_at),
|
||||
INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at),
|
||||
INDEX idx_selfserve_wash_sessions_order (order_id)
|
||||
@@ -216,11 +219,26 @@ class selfserve_schema_bootstrap
|
||||
'dynamic_images_vehicle_type',
|
||||
'ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'selfserve_wash_sessions',
|
||||
'subuser_id',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD COLUMN subuser_id INT NULL AFTER customer_number'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'selfserve_wash_sessions',
|
||||
'wash_started_at',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_subuser_active',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_subuser_active (subuser_id, completed_at)'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_customer_subuser_active',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_customer_subuser_active (customer_number, subuser_id, completed_at)'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_department_completed',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class subusers_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::ensureColumn(
|
||||
'subuser_grants',
|
||||
'assigned_vehicle_id',
|
||||
'INT NULL AFTER `subuser`'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'subuser_grants',
|
||||
'idx_subuser_grants_assigned_vehicle_id',
|
||||
'`assigned_vehicle_id`'
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$columnSql = $db->escape_string($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$columnSql'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||
}
|
||||
|
||||
private static function ensureIndex(string $table, string $index, string $columns): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
|
||||
if ($table === '' || $index === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexSql = $db->escape_string($index);
|
||||
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,6 @@ class superuser_system_status_service
|
||||
$dependencies['database']['status'] ?? 'down',
|
||||
$dependencies['redis']['status'] ?? 'down',
|
||||
$dependencies['minio']['status'] ?? 'down',
|
||||
$dependencies['database']['replication']['status'] ?? 'not_configured',
|
||||
$dependencies['redis']['replication']['status'] ?? 'not_configured',
|
||||
$dependencies['minio']['replication']['status'] ?? 'not_configured',
|
||||
];
|
||||
foreach ($modules as $module) {
|
||||
if (($module['enabled'] ?? false) === true) {
|
||||
@@ -298,16 +295,6 @@ class superuser_system_status_service
|
||||
$database = $this->probeDatabase();
|
||||
$redis = $this->probeRedis();
|
||||
$minio = $this->probeMinio();
|
||||
try {
|
||||
$replicationManager = new replication_manager();
|
||||
$database['replication'] = $replicationManager->dependencyReplication('database');
|
||||
$redis['replication'] = $replicationManager->dependencyReplication('redis');
|
||||
$minio['replication'] = $replicationManager->dependencyReplication('minio');
|
||||
} catch (Throwable $throwable) {
|
||||
$database['replication'] = $this->replicationStatusFallback('database', $throwable);
|
||||
$redis['replication'] = $this->replicationStatusFallback('redis', $throwable);
|
||||
$minio['replication'] = $this->replicationStatusFallback('minio', $throwable);
|
||||
}
|
||||
|
||||
if (($redis['status'] ?? '') === 'down') {
|
||||
$this->pushWarning(
|
||||
@@ -333,19 +320,6 @@ class superuser_system_status_service
|
||||
];
|
||||
}
|
||||
|
||||
private function replicationStatusFallback(string $kind, Throwable $throwable): array
|
||||
{
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'min_percent' => 0.0,
|
||||
'average_percent' => 0.0,
|
||||
'replicas' => [],
|
||||
'blockers' => [
|
||||
'Replication status for ' . $kind . ' could not be loaded: ' . $throwable->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function probeCpu(array &$warnings): array
|
||||
{
|
||||
$checkedAt = date('c');
|
||||
@@ -990,12 +964,50 @@ class superuser_system_status_service
|
||||
|
||||
try {
|
||||
$this->validateBackupsStore();
|
||||
$health = $this->backupHealthSummary();
|
||||
|
||||
if (empty($health['encryption']['available'])) {
|
||||
$error = (string)($health['encryption']['error'] ?? 'Backup encryption key is missing.');
|
||||
return [
|
||||
'status' => 'down',
|
||||
'status_reason' => 'Backup encryption is not ready: ' . $error,
|
||||
'status_reason_key' => 'backup_encryption_key_missing',
|
||||
'status_reason_params' => ['error' => $error],
|
||||
'checked_at' => $checkedAt,
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($health['latest_verified_backup'])) {
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'status_reason' => 'No verified backup is available for restore.',
|
||||
'status_reason_key' => 'backup_no_verified_backup',
|
||||
'status_reason_params' => [],
|
||||
'checked_at' => $checkedAt,
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($health['fresh'])) {
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'status_reason' => 'Latest verified backup is stale.',
|
||||
'status_reason_key' => 'backup_latest_verified_stale',
|
||||
'status_reason_params' => ['age_seconds' => (string)($health['latest_verified_age_seconds'] ?? '')],
|
||||
'checked_at' => $checkedAt,
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'status_reason' => 'Backup store connectivity confirmed.',
|
||||
'status_reason_key' => 'backup_connectivity_confirmed',
|
||||
'status_reason_params' => [],
|
||||
'status_reason_params' => [
|
||||
'latest_verified_age_seconds' => (string)($health['latest_verified_age_seconds'] ?? ''),
|
||||
'encryption_key_id' => (string)($health['encryption']['key_id'] ?? ''),
|
||||
],
|
||||
'checked_at' => $checkedAt,
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
@@ -1539,6 +1551,11 @@ class superuser_system_status_service
|
||||
new backup_store();
|
||||
}
|
||||
|
||||
protected function backupHealthSummary(): array
|
||||
{
|
||||
return (new backup_store())->healthSummary();
|
||||
}
|
||||
|
||||
protected function bootstrapSelfserveSchema(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
|
||||
@@ -102,6 +102,10 @@ if ($args[1] === 'run') {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running the cron script\n";
|
||||
require_once 'cron/Cron.php';
|
||||
break;
|
||||
case 'cron-worker':
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON_WORKER] Starting cron worker\n";
|
||||
(new \classes\cron_worker())->run();
|
||||
break;
|
||||
default:
|
||||
echo "Invalid script name";
|
||||
break;
|
||||
|
||||
@@ -40,6 +40,8 @@ require_once __DIR__ . '/../classes/economic_transfer_executor.php';
|
||||
const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
||||
require_once __DIR__ . '/../classes/backup_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/backup_store.php';
|
||||
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
|
||||
require_once __DIR__ . '/../classes/cron_schedule.php';
|
||||
require_once __DIR__ . '/../classes/cron_task_definition.php';
|
||||
@@ -538,12 +540,34 @@ function backup(): void
|
||||
{
|
||||
try {
|
||||
$backup = new backup_store();
|
||||
$backup->createBackup();
|
||||
$backup->enqueueCreateBackup(null, null, null, 'scheduled');
|
||||
} catch (Exception $e) {
|
||||
warn('Backup failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function processBackupJobs(): array
|
||||
{
|
||||
try {
|
||||
return (new backup_store())->processPendingJobs(3);
|
||||
} catch (Exception $e) {
|
||||
warn('Backup job processing failed: ' . $e->getMessage());
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
function pruneBackupRetention(): array
|
||||
{
|
||||
try {
|
||||
$backup = new backup_store();
|
||||
$queued = $backup->enqueueRetentionPrune();
|
||||
return $backup->runJobById((int)$queued['job_id']);
|
||||
} catch (Exception $e) {
|
||||
warn('Backup retention prune failed: ' . $e->getMessage());
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
function SyncUserEconomicCustomerDiscounts(): void
|
||||
{
|
||||
$users_o = new users_o();
|
||||
|
||||
@@ -31,7 +31,7 @@ interface minio_backups_i
|
||||
* Create a backup, upload it to the Minio bucket and return the UUID
|
||||
* @return string The UUID of the backup
|
||||
*/
|
||||
public function createBackup(): string;
|
||||
public function createBackup($backup_name = null, $backup_description = null): string;
|
||||
|
||||
/**
|
||||
* Create the metadata for a backup
|
||||
|
||||
@@ -2,8 +2,22 @@
|
||||
|
||||
namespace backups;
|
||||
require_once WD . '/modules/backups/config/backups_enabled_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_retention_recent_hours_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_retention_daily_days_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_retention_weekly_weeks_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_retention_monthly_months_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_app_data_enabled_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_verification_required_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_restore_enabled_c.php';
|
||||
|
||||
use backups\config\backups_app_data_enabled_c;
|
||||
use backups\config\backups_enabled_c;
|
||||
use backups\config\backups_restore_enabled_c;
|
||||
use backups\config\backups_retention_daily_days_c;
|
||||
use backups\config\backups_retention_monthly_months_c;
|
||||
use backups\config\backups_retention_recent_hours_c;
|
||||
use backups\config\backups_retention_weekly_weeks_c;
|
||||
use backups\config\backups_verification_required_c;
|
||||
use traits\module_config_t;
|
||||
|
||||
class backups_c
|
||||
@@ -15,6 +29,13 @@ class backups_c
|
||||
* @var backups_enabled_c
|
||||
*/
|
||||
public backups_enabled_c $enabled;
|
||||
public backups_retention_recent_hours_c $retention_recent_hours;
|
||||
public backups_retention_daily_days_c $retention_daily_days;
|
||||
public backups_retention_weekly_weeks_c $retention_weekly_weeks;
|
||||
public backups_retention_monthly_months_c $retention_monthly_months;
|
||||
public backups_app_data_enabled_c $app_data_enabled;
|
||||
public backups_verification_required_c $verification_required;
|
||||
public backups_restore_enabled_c $restore_enabled;
|
||||
|
||||
|
||||
public function __construct()
|
||||
@@ -22,8 +43,22 @@ class backups_c
|
||||
$this->setupConfig('Backups');
|
||||
$this->allowUpdate([
|
||||
backups_enabled_c::class,
|
||||
backups_retention_recent_hours_c::class,
|
||||
backups_retention_daily_days_c::class,
|
||||
backups_retention_weekly_weeks_c::class,
|
||||
backups_retention_monthly_months_c::class,
|
||||
backups_app_data_enabled_c::class,
|
||||
backups_verification_required_c::class,
|
||||
backups_restore_enabled_c::class,
|
||||
]);
|
||||
$this->enabled = new backups_enabled_c();
|
||||
$this->retention_recent_hours = new backups_retention_recent_hours_c();
|
||||
$this->retention_daily_days = new backups_retention_daily_days_c();
|
||||
$this->retention_weekly_weeks = new backups_retention_weekly_weeks_c();
|
||||
$this->retention_monthly_months = new backups_retention_monthly_months_c();
|
||||
$this->app_data_enabled = new backups_app_data_enabled_c();
|
||||
$this->verification_required = new backups_verification_required_c();
|
||||
$this->restore_enabled = new backups_restore_enabled_c();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_app_data_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'app_data_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether app-owned object storage buckets are included in backups.',
|
||||
'1',
|
||||
false,
|
||||
'true'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_restore_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'restore_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether direct production restore execution is enabled for superusers.',
|
||||
'0',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_retention_daily_days_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'retention_daily_days',
|
||||
'int',
|
||||
true,
|
||||
null,
|
||||
'How many days of daily backup representatives are retained.',
|
||||
'30',
|
||||
false,
|
||||
'30'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_retention_monthly_months_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'retention_monthly_months',
|
||||
'int',
|
||||
true,
|
||||
null,
|
||||
'How many months of monthly backup representatives are retained.',
|
||||
'3',
|
||||
false,
|
||||
'3'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_retention_recent_hours_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'retention_recent_hours',
|
||||
'int',
|
||||
true,
|
||||
null,
|
||||
'How many hours of recent recovery points are protected before tiered pruning.',
|
||||
'48',
|
||||
false,
|
||||
'48'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_retention_weekly_weeks_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'retention_weekly_weeks',
|
||||
'int',
|
||||
true,
|
||||
null,
|
||||
'How many weeks of weekly backup representatives are retained.',
|
||||
'8',
|
||||
false,
|
||||
'8'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_verification_required_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'verification_required',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether a backup must pass verification before it is marked available for restore.',
|
||||
'1',
|
||||
false,
|
||||
'true'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,37 @@ return [
|
||||
[
|
||||
'id' => 'backups.create_backup',
|
||||
'legacy_name' => 'backup',
|
||||
'name' => 'Create backup',
|
||||
'description' => 'Creates the scheduled backup bundle through the configured backup store.',
|
||||
'name' => 'Enqueue backup',
|
||||
'description' => 'Queues the scheduled production recovery backup through the configured backup store.',
|
||||
'module' => 'backups',
|
||||
'handler' => 'backup',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 43200],
|
||||
'timeout_seconds' => 1800,
|
||||
'estimated_duration_ms' => 60000,
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 3600],
|
||||
'timeout_seconds' => 120,
|
||||
'estimated_duration_ms' => 5000,
|
||||
'priority' => 115,
|
||||
],
|
||||
[
|
||||
'id' => 'backups.process_jobs',
|
||||
'legacy_name' => null,
|
||||
'name' => 'Process backup jobs',
|
||||
'description' => 'Processes queued backup create, verify, restore, and prune jobs.',
|
||||
'module' => 'backups',
|
||||
'handler' => 'processBackupJobs',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 3600,
|
||||
'estimated_duration_ms' => 60000,
|
||||
'priority' => 116,
|
||||
],
|
||||
[
|
||||
'id' => 'backups.prune_retention',
|
||||
'legacy_name' => null,
|
||||
'name' => 'Prune backup retention',
|
||||
'description' => 'Queues tiered retention cleanup for old backup artifacts.',
|
||||
'module' => 'backups',
|
||||
'handler' => 'pruneBackupRetention',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 86400],
|
||||
'timeout_seconds' => 1800,
|
||||
'estimated_duration_ms' => 30000,
|
||||
'priority' => 117,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -213,6 +213,18 @@
|
||||
}
|
||||
},
|
||||
|
||||
"/department/timebookings/departments/public": {
|
||||
"get": {
|
||||
"tags": ["Public Time Bookings"],
|
||||
"summary": "List public departments with time bookings enabled",
|
||||
"description": "Returns only visible, active departments where `bookingsystem_time_based_enabled` is `true`.",
|
||||
"parameters": [ { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/search" }, { "$ref": "#/components/parameters/filters" }, { "$ref": "#/components/parameters/order" } ],
|
||||
"responses": {
|
||||
"200": { "description": "Departments", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopePublicTimeBookingDepartmentsList" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"/department/timebookings/opening-hours/public": {
|
||||
"get": {
|
||||
"tags": ["Public Time Bookings"],
|
||||
@@ -339,6 +351,12 @@
|
||||
{ "type": "object", "properties": { "data": { "$ref": "#/components/schemas/OpeningHours" } } }
|
||||
]
|
||||
},
|
||||
"EnvelopePublicTimeBookingDepartmentsList": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/components/schemas/Envelope" },
|
||||
{ "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/PublicTimeBookingDepartment" } } } }
|
||||
]
|
||||
},
|
||||
"EnvelopeBookingTypesList": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/components/schemas/Envelope" },
|
||||
@@ -396,6 +414,21 @@
|
||||
"required": ["link"]
|
||||
},
|
||||
|
||||
"PublicTimeBookingDepartment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"address": { "type": "string" },
|
||||
"longitude": { "type": "number", "format": "float" },
|
||||
"latitude": { "type": "number", "format": "float" },
|
||||
"order_priority": { "type": "integer" },
|
||||
"time_booking_enabled": { "type": "boolean" }
|
||||
},
|
||||
"required": ["id", "name", "description", "address", "longitude", "latitude", "order_priority", "time_booking_enabled"]
|
||||
},
|
||||
|
||||
"OpeningHours": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -34,6 +34,7 @@ require_once WD . '/objects/selfserve_wash_session_answers_o.php';
|
||||
require_once WD . '/objects/selfserve_wash_session_events_o.php';
|
||||
require_once WD . '/objects/selfserve_wash_session_tasks_o.php';
|
||||
require_once WD . '/objects/selfserve_wash_sessions_o.php';
|
||||
require_once WD . '/objects/subusers_o.php';
|
||||
|
||||
use classes\selfserve;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
@@ -63,6 +64,7 @@ use objects\selfserve_wash_session_answers_o;
|
||||
use objects\selfserve_wash_session_events_o;
|
||||
use objects\selfserve_wash_session_tasks_o;
|
||||
use objects\selfserve_wash_sessions_o;
|
||||
use objects\subusers_o;
|
||||
|
||||
class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
{
|
||||
@@ -76,7 +78,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array
|
||||
{
|
||||
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
||||
$actorSubuserId = $this->resolveActorSubuserId($options);
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
|
||||
|
||||
return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
|
||||
}
|
||||
@@ -89,7 +92,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
{
|
||||
$options['debug'] = true;
|
||||
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
||||
$actorSubuserId = $this->resolveActorSubuserId($options);
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
|
||||
$response = $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
|
||||
$response['simulator_version'] = 2;
|
||||
$response['dry_run'] = true;
|
||||
@@ -103,12 +107,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array
|
||||
{
|
||||
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||
$actorSubuserId = $this->resolveActorSubuserId($options);
|
||||
$mutationResult = $this->withSessionMutationLock(
|
||||
$laneId,
|
||||
$snapshot['reg'],
|
||||
$snapshot['customer_number'],
|
||||
function () use ($laneId, $snapshot, $options): array {
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
||||
$actorSubuserId,
|
||||
function () use ($laneId, $snapshot, $options, $actorSubuserId): array {
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
|
||||
$createSession = (bool)($options['create_session'] ?? true);
|
||||
|
||||
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
||||
@@ -139,10 +145,17 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$this->deriveBaseStatus($snapshot),
|
||||
(bool)$snapshot['allowed'],
|
||||
$this->buildSessionMetadata($snapshot),
|
||||
$actorSubuserId,
|
||||
);
|
||||
} else {
|
||||
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
|
||||
$session->customer_number->set($snapshot['customer_number']);
|
||||
if ($actorSubuserId !== null) {
|
||||
$currentSubuserId = $this->nullableInt($session->subuser_id->value());
|
||||
if ($currentSubuserId === null || $currentSubuserId === $actorSubuserId) {
|
||||
$session->subuser_id->set($actorSubuserId);
|
||||
}
|
||||
}
|
||||
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
|
||||
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
|
||||
$session->reg->set($snapshot['reg']);
|
||||
@@ -158,6 +171,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||
'allowed_services' => $snapshot['allowed_services'],
|
||||
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
|
||||
'subuser_id' => $actorSubuserId,
|
||||
]);
|
||||
|
||||
return [
|
||||
@@ -202,6 +216,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$lane = (new selfserve())->lane($laneId);
|
||||
$effectiveReg = $normalizedReg ?? (string)$session->reg->value();
|
||||
$customerNumber = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
|
||||
$subuserId = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
|
||||
|
||||
if ($lane->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) {
|
||||
$lane->setLaneStatus(selfserve_lane_status::OCCUPIED);
|
||||
@@ -227,11 +242,13 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'lane_id' => $laneId,
|
||||
'reg' => $effectiveReg,
|
||||
'customer_number' => $customerNumber,
|
||||
'subuser_id' => $subuserId,
|
||||
]);
|
||||
$actionContext = [
|
||||
'lane_id' => $laneId,
|
||||
'reg' => $effectiveReg,
|
||||
'customer_number' => $customerNumber,
|
||||
'subuser_id' => $subuserId,
|
||||
'session_id' => (int)$session->id,
|
||||
'source_payload' => $payload,
|
||||
];
|
||||
@@ -269,12 +286,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
|
||||
public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null): bool
|
||||
public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $subuserId = null): bool
|
||||
{
|
||||
$normalizedReg = $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg);
|
||||
$session = $normalizedReg !== null
|
||||
? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber)
|
||||
: $this->findLatestOpenSessionByLane($laneId, $customerNumber);
|
||||
? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber, $subuserId)
|
||||
: $this->findLatestOpenSessionByLane($laneId, $customerNumber, $subuserId);
|
||||
|
||||
return $session->exists() && (bool)$session->machine_start_triggered->value();
|
||||
}
|
||||
@@ -403,10 +420,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
];
|
||||
}, (new selfserve_wash_session_events_o())->listBySession($sessionId));
|
||||
|
||||
$sessionPayload = $session->asArray();
|
||||
$sessionPayload['subuser'] = $this->formatSessionSubuser($this->nullableInt($session->subuser_id->value()));
|
||||
|
||||
return [
|
||||
'session' => $session->asArray(),
|
||||
'session' => $sessionPayload,
|
||||
'lane' => $lane->exists() ? $lane->asArray() : null,
|
||||
'machine_type' => $machineType,
|
||||
'subuser' => $sessionPayload['subuser'],
|
||||
'questions' => $answers,
|
||||
'tasks' => $tasks,
|
||||
'events' => $events,
|
||||
@@ -430,15 +451,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true, ?int $subuserId = null): ?array
|
||||
{
|
||||
$session = $reg !== null
|
||||
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
||||
: $this->findLatestOpenSessionByLane($laneId, $customerNumber);
|
||||
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber, $subuserId)
|
||||
: $this->findLatestOpenSessionByLane($laneId, $customerNumber, $subuserId);
|
||||
|
||||
if (!$session->exists()) {
|
||||
return null;
|
||||
}
|
||||
$resolvedSubuserId = $subuserId ?? $this->nullableInt($session->subuser_id->value());
|
||||
|
||||
$this->fillMissingWashStartedAtFromLaneRuntime($session, $laneId);
|
||||
|
||||
@@ -452,6 +474,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'lane_id' => $laneId,
|
||||
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
||||
'customer_number' => $customerNumber ?? ($session->customer_number->value() === null ? null : (int)$session->customer_number->value()),
|
||||
'subuser_id' => $resolvedSubuserId,
|
||||
'order_id' => $orderId,
|
||||
]);
|
||||
|
||||
@@ -555,6 +578,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'vehicle' => $vehicleData,
|
||||
'reg' => $normalizedReg,
|
||||
'customer_number' => $resolvedCustomerNumber,
|
||||
'subuser_id' => $this->resolveActorSubuserId($options),
|
||||
'vehicle_type_id' => $vehicleTypeId,
|
||||
'answers' => [],
|
||||
'persisted_answers' => [],
|
||||
@@ -746,6 +770,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'vehicle' => $vehicleData,
|
||||
'reg' => $normalizedReg,
|
||||
'customer_number' => $resolvedCustomerNumber,
|
||||
'subuser_id' => $this->resolveActorSubuserId($options),
|
||||
'vehicle_type_id' => $vehicleTypeId,
|
||||
'answers' => $answers,
|
||||
'persisted_answers' => $persistedAnswers,
|
||||
@@ -854,6 +879,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'vehicle' => $snapshot['vehicle'],
|
||||
'reg' => $snapshot['reg'],
|
||||
'customer_number' => $snapshot['customer_number'],
|
||||
'subuser_id' => $snapshot['subuser_id'] ?? null,
|
||||
'vehicle_type_id' => $snapshot['vehicle_type_id'],
|
||||
'questions' => $snapshot['questions'],
|
||||
'tasks' => $snapshot['tasks'],
|
||||
@@ -876,6 +902,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'session' => null,
|
||||
'lane' => $snapshot['lane'],
|
||||
'machine_type' => $snapshot['machine_type'],
|
||||
'subuser_id' => $snapshot['subuser_id'] ?? null,
|
||||
'questions' => [],
|
||||
'tasks' => [],
|
||||
'events' => [],
|
||||
@@ -3259,13 +3286,47 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
(new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $options
|
||||
*/
|
||||
protected function resolveActorSubuserId(array $options): ?int
|
||||
{
|
||||
return $this->nullableInt($options['subuser_id'] ?? null);
|
||||
}
|
||||
|
||||
protected function formatSessionSubuser(?int $subuserId): ?array
|
||||
{
|
||||
if ($subuserId === null || $subuserId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$subuser = (new subusers_o())->select($subuserId);
|
||||
if (!$subuser->exists()) {
|
||||
return [
|
||||
'id' => $subuserId,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$subuser->id,
|
||||
'name' => $subuser->name->value() === null ? null : (string)$subuser->name->value(),
|
||||
'username' => $subuser->username->value() === null ? null : (string)$subuser->username->value(),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return [
|
||||
'id' => $subuserId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable():array<string,mixed> $callback
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, callable $callback): array
|
||||
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, ?int $subuserId, callable $callback): array
|
||||
{
|
||||
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber);
|
||||
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber, $subuserId);
|
||||
$lock = $this->acquireSessionMutationLock($lockKey);
|
||||
|
||||
try {
|
||||
@@ -3332,10 +3393,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
}
|
||||
}
|
||||
|
||||
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber): string
|
||||
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber, ?int $subuserId = null): string
|
||||
{
|
||||
return 'selfserve_session_mutation:' . (int)$laneId . ':' . sha1(
|
||||
selfserve::standardize_registration($reg) . ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
|
||||
selfserve::standardize_registration($reg)
|
||||
. ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
|
||||
. ':' . ($subuserId === null ? 'customer' : 'subuser:' . (int)$subuserId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3345,6 +3408,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'allowed_services' => $snapshot['allowed_services'],
|
||||
'machine_available' => (bool)$snapshot['machine_available'],
|
||||
'machine_wash_enabled' => (bool)($snapshot['machine_wash_enabled'] ?? true),
|
||||
'subuser_id' => $snapshot['subuser_id'] ?? null,
|
||||
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
||||
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
|
||||
@@ -3495,14 +3559,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $vehicleTypeId > 0 ? $vehicleTypeId : null;
|
||||
}
|
||||
|
||||
protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null): selfserve_wash_sessions_o
|
||||
protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null, ?int $subuserId = null): selfserve_wash_sessions_o
|
||||
{
|
||||
$session = new selfserve_wash_sessions_o();
|
||||
$session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber);
|
||||
$session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber, $subuserId);
|
||||
return $session;
|
||||
}
|
||||
|
||||
protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null): selfserve_wash_sessions_o
|
||||
protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null, ?int $subuserId = null): selfserve_wash_sessions_o
|
||||
{
|
||||
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere(
|
||||
[
|
||||
@@ -3510,6 +3574,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'completed_at' => null,
|
||||
'deleted_at' => null,
|
||||
...($customerNumber !== null ? ['customer_number' => $customerNumber] : []),
|
||||
...($subuserId !== null ? ['subuser_id' => $subuserId] : []),
|
||||
],
|
||||
['id', 'status']
|
||||
);
|
||||
|
||||
@@ -393,7 +393,7 @@ class departments_o extends db
|
||||
public function isModuleTimeBookingsEnabled(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
return (bool)$this->variables->getVariable('bookingsystem_time_based_enabled');
|
||||
return $this->variables->getVariable('bookingsystem_time_based_enabled') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ class selfserve_wash_sessions_o extends db
|
||||
public object_property $department_id;
|
||||
public object_property $machine_type_id;
|
||||
public object_property $customer_number;
|
||||
public object_property $subuser_id;
|
||||
public object_property $vehicle_id;
|
||||
public object_property $vehicle_type_id;
|
||||
public object_property $reg;
|
||||
@@ -56,13 +57,15 @@ class selfserve_wash_sessions_o extends db
|
||||
?int $vehicleTypeId,
|
||||
selfserve_wash_session_status $status,
|
||||
bool $allowed = false,
|
||||
?array $metadata = null
|
||||
?array $metadata = null,
|
||||
?int $subuserId = null
|
||||
): self {
|
||||
$this->id = self::add_object([
|
||||
'lane_id' => $laneId,
|
||||
'department_id' => $departmentId,
|
||||
'machine_type_id' => $machineTypeId,
|
||||
'customer_number' => $customerNumber,
|
||||
'subuser_id' => $subuserId !== null && $subuserId > 0 ? $subuserId : null,
|
||||
'vehicle_id' => $vehicleId,
|
||||
'vehicle_type_id' => $vehicleTypeId,
|
||||
'reg' => selfserve::standardize_registration($reg),
|
||||
@@ -82,6 +85,7 @@ class selfserve_wash_sessions_o extends db
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false);
|
||||
$this->customer_number = new object_property($this->table, $this->id, 'customer_number', 'int', false);
|
||||
$this->subuser_id = new object_property($this->table, $this->id, 'subuser_id', 'int', false);
|
||||
$this->vehicle_id = new object_property($this->table, $this->id, 'vehicle_id', 'int', false);
|
||||
$this->vehicle_type_id = new object_property($this->table, $this->id, 'vehicle_type_id', 'int', false);
|
||||
$this->reg = new object_property($this->table, $this->id, 'reg', 'string', false);
|
||||
@@ -252,7 +256,7 @@ class selfserve_wash_sessions_o extends db
|
||||
return isset($this->id) && (int)$this->id > 0 && $this->exists();
|
||||
}
|
||||
|
||||
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self
|
||||
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null, ?int $subuserId = null): self
|
||||
{
|
||||
$filters = [
|
||||
'lane_id' => $laneId,
|
||||
@@ -262,6 +266,9 @@ class selfserve_wash_sessions_o extends db
|
||||
if ($customerNumber !== null) {
|
||||
$filters['customer_number'] = $customerNumber;
|
||||
}
|
||||
if ($subuserId !== null) {
|
||||
$filters['subuser_id'] = $subuserId;
|
||||
}
|
||||
$rows = $this->getFieldsWhere($filters, ['id', 'status']);
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
@@ -275,7 +282,7 @@ class selfserve_wash_sessions_o extends db
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function selectLatestOpenByLaneAndReg(int $laneId, string $reg, ?int $customerNumber = null): self
|
||||
public function selectLatestOpenByLaneAndReg(int $laneId, string $reg, ?int $customerNumber = null, ?int $subuserId = null): self
|
||||
{
|
||||
$filters = [
|
||||
'lane_id' => $laneId,
|
||||
@@ -286,6 +293,9 @@ class selfserve_wash_sessions_o extends db
|
||||
if ($customerNumber !== null) {
|
||||
$filters['customer_number'] = $customerNumber;
|
||||
}
|
||||
if ($subuserId !== null) {
|
||||
$filters['subuser_id'] = $subuserId;
|
||||
}
|
||||
$rows = $this->getFieldsWhere($filters, ['id', 'status']);
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
@@ -322,6 +332,7 @@ class selfserve_wash_sessions_o extends db
|
||||
'department_id' => (int)$this->department_id->value(),
|
||||
'machine_type_id' => $this->machine_type_id->value() === null ? null : (int)$this->machine_type_id->value(),
|
||||
'customer_number' => $this->customer_number->value() === null ? null : (int)$this->customer_number->value(),
|
||||
'subuser_id' => $this->subuser_id->value() === null ? null : (int)$this->subuser_id->value(),
|
||||
'vehicle_id' => $this->vehicle_id->value() === null ? null : (int)$this->vehicle_id->value(),
|
||||
'vehicle_type_id' => $this->vehicle_type_id->value() === null ? null : (int)$this->vehicle_type_id->value(),
|
||||
'reg' => (string)$this->reg->value(),
|
||||
|
||||
@@ -15,6 +15,7 @@ class subuser_grants_o extends db
|
||||
|
||||
public object_property $billing_customer_number;
|
||||
public object_property $subuser;
|
||||
public object_property $assigned_vehicle_id;
|
||||
public object_property $enabled;
|
||||
public object_property $note;
|
||||
public object_property $permissions;
|
||||
@@ -94,6 +95,7 @@ class subuser_grants_o extends db
|
||||
{
|
||||
$this->billing_customer_number = new object_property($this->table, $this->id, 'billing_customer_number', 'int');
|
||||
$this->subuser = new object_property($this->table, $this->id, 'subuser', 'int');
|
||||
$this->assigned_vehicle_id = new object_property($this->table, $this->id, 'assigned_vehicle_id', 'int');
|
||||
$this->enabled = new object_property($this->table, $this->id, 'enabled', 'bool');
|
||||
$this->note = new object_property($this->table, $this->id, 'note', 'string');
|
||||
$this->permissions = new object_property($this->table, $this->id, 'permissions', 'json');
|
||||
@@ -109,6 +111,7 @@ class subuser_grants_o extends db
|
||||
'id' => (int)$this->id,
|
||||
'billing_customer_number' => (int)$this->billing_customer_number->value(),
|
||||
'subuser' => (int)$this->subuser->value(),
|
||||
'assigned_vehicle_id' => $this->assigned_vehicle_id->value() !== null ? (int)$this->assigned_vehicle_id->value() : null,
|
||||
'enabled' => (bool)$this->enabled->value(),
|
||||
'note' => $this->note->value(),
|
||||
'permissions' => self::normalizePermissionsValue($this->permissions->value()),
|
||||
|
||||
@@ -280,6 +280,21 @@ class subusers_o extends db
|
||||
}
|
||||
}
|
||||
|
||||
public function invalidateCurrentSetupToken(): void
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
$object_id = 'subuser_setup_token';
|
||||
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$this->id;
|
||||
$currentToken = $this->getCached($reverse_cache_key, $object_id);
|
||||
|
||||
if (is_string($currentToken) && $currentToken !== '') {
|
||||
$this->deleteCached('setup_token:' . $currentToken, $object_id);
|
||||
}
|
||||
|
||||
$this->deleteCached($reverse_cache_key, $object_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
|
||||
+1067
-90
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ use classes\economic;
|
||||
use classes\email;
|
||||
use classes\release_manager;
|
||||
use classes\recaptcha;
|
||||
use classes\security_policy_service;
|
||||
use classes\slack;
|
||||
use classes\totp;
|
||||
use classes\virkdata;
|
||||
@@ -20,6 +21,8 @@ use objects\subusers_o;
|
||||
use objects\passkeys_o;
|
||||
use traits\route_t;
|
||||
|
||||
require_once WD . '/classes/security_policy_service.php';
|
||||
|
||||
class authRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -119,6 +122,7 @@ class authRoute
|
||||
$user = (new users_o())->getUserByCustomerNumber($data['customer_number']);
|
||||
if (!$user->exists() || !$user->hasPassword()) {
|
||||
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']);
|
||||
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'missing_user_or_password']);
|
||||
$response->error('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
@@ -134,6 +138,7 @@ class authRoute
|
||||
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_SUCCESS', 'Customer number: ' . $data['customer_number']);
|
||||
} else {
|
||||
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']);
|
||||
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'invalid_password']);
|
||||
$response->error('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
@@ -346,6 +351,7 @@ class authRoute
|
||||
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_SUCCESS', 'Employee number: ' . $data['user_id']);
|
||||
} else {
|
||||
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Employee number: ' . $data['user_id']);
|
||||
$this->observeLoginFailure('employee', (string)$data['user_id'], ['reason' => 'invalid_credentials']);
|
||||
$response->error('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
@@ -850,6 +856,7 @@ class authRoute
|
||||
}
|
||||
if ($passkey === null) {
|
||||
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Unknown credential');
|
||||
$this->observeLoginFailure('passkey', $credentialId, ['reason' => 'unknown_credential', 'user_id_hint' => $user_id_hint]);
|
||||
$response->error('Invalid credential', 404);
|
||||
}
|
||||
|
||||
@@ -858,6 +865,7 @@ class authRoute
|
||||
$ok = $wa->verifyAssertion($credentialJson, $challenge_token, $passkey, $host);
|
||||
if (!$ok) {
|
||||
(new logs_o())->add('auth', 'global', 1, (int)$passkey->user_id->value(), 'AUTH_PASSKEY_VERIFY_FAILURE', 'Assertion verification failed');
|
||||
$this->observeLoginFailure('passkey', $credentialId, ['reason' => 'assertion_failed', 'user_id_hint' => $user_id_hint]);
|
||||
$response->error('Invalid passkey assertion', 401);
|
||||
}
|
||||
|
||||
@@ -869,6 +877,7 @@ class authRoute
|
||||
|| ($challengePrincipalType === 'user' && $is_subuser)
|
||||
) {
|
||||
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Credential principal mismatch');
|
||||
$this->observeLoginFailure('passkey', $credentialId, ['reason' => 'principal_mismatch', 'user_id_hint' => $user_id_hint]);
|
||||
$token_o->delete($challenge_token);
|
||||
$this->clearPasskeyChallengePrincipal($challenge_token);
|
||||
$response->error('Invalid credential', 401);
|
||||
@@ -1054,6 +1063,15 @@ class authRoute
|
||||
(new logs_o())->add('auth', 'global', 0, 0, $action, $message);
|
||||
}
|
||||
|
||||
private function observeLoginFailure(string $principalType, string|int $identifier, array $metadata = []): void
|
||||
{
|
||||
try {
|
||||
(new security_policy_service())->observeLoginFailure($principalType, $identifier, $metadata);
|
||||
} catch (\Throwable) {
|
||||
// Security observation must not change authentication responses.
|
||||
}
|
||||
}
|
||||
|
||||
private function appendRuntimeConfig(array $payload): array
|
||||
{
|
||||
$payload['runtime_config'] = array_replace_recursive(
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\cron_scheduler;
|
||||
use classes\cron_worker;
|
||||
use classes\release_manager;
|
||||
use objects\logs_o;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
@@ -36,6 +38,45 @@ class cronRoute
|
||||
'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/cron/workers', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_cron_view');
|
||||
$parameters = $this->getParametersAsArray();
|
||||
$workers = (new cron_worker())->listWorkers();
|
||||
try {
|
||||
$workers['deployment'] = (new release_manager())->cronWorkerStatus($parameters);
|
||||
} catch (Throwable $throwable) {
|
||||
$workers['deployment'] = [
|
||||
'ok' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
$response->success($workers);
|
||||
}, [
|
||||
'superuser_cron_view' => 'View cron worker deployment and heartbeat state',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/cron/workers/deploy', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_cron_manage');
|
||||
$this->requirePermission('superuser_coolify_manage');
|
||||
try {
|
||||
$result = (new release_manager())->deployCronWorker(
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
);
|
||||
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_WORKER_DEPLOY', 'Deployed cron worker');
|
||||
$response->success($result);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'superuser_cron_manage' => 'Deploy and configure cron workers',
|
||||
'superuser_coolify_manage' => 'Deploy Coolify-managed infrastructure resources',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/cron/run', function () {
|
||||
global $response;
|
||||
|
||||
|
||||
@@ -15,6 +15,30 @@ class customerTimeBookingsRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private static function requirePublicTimeBookingsDepartment(string $parameterName = 'id'): departments_o
|
||||
{
|
||||
global $response;
|
||||
|
||||
if (!self::isParametersSet([$parameterName])) {
|
||||
$response->error('Missing ' . $parameterName . ' parameter', 400);
|
||||
}
|
||||
self::requireType((int)self::getParameter($parameterName), self::type_int());
|
||||
self::requireMinValue((int)self::getParameter($parameterName), 1);
|
||||
self::requireSameLength(self::getParameter($parameterName), (int)self::getParameter($parameterName));
|
||||
|
||||
$department = new departments_o();
|
||||
$department->select((int)self::getParameter($parameterName));
|
||||
if (!$department->exists()) {
|
||||
$response->error('Department not found', 404);
|
||||
}
|
||||
|
||||
if (!$department->isModuleTimeBookingsEnabled()) {
|
||||
$response->error('Department time bookings are not enabled', 404);
|
||||
}
|
||||
|
||||
return $department;
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
global /** @var response $response */
|
||||
@@ -22,26 +46,60 @@ class customerTimeBookingsRoute
|
||||
$router, $response;
|
||||
|
||||
|
||||
/** Guest Time Bookings -> Departments -> GET */
|
||||
$this->get('/department/timebookings/departments/public', function () {
|
||||
global $response;
|
||||
|
||||
$departments = new departments_o();
|
||||
$response->success(
|
||||
$departments
|
||||
->setSearchableFields([
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'visible',
|
||||
'archived',
|
||||
'longitude',
|
||||
'latitude',
|
||||
])
|
||||
->listObjectsWithPaginationIfSet(
|
||||
function ($department): array {
|
||||
return [
|
||||
'id' => (int)$department['id'],
|
||||
'name' => (string)$department['name'],
|
||||
'description' => (string)$department['description'],
|
||||
'address' => (string)$department['description'],
|
||||
'longitude' => (float)$department['longitude'],
|
||||
'latitude' => (float)$department['latitude'],
|
||||
'order_priority' => (int)$department['order_priority'],
|
||||
'time_booking_enabled' => true,
|
||||
];
|
||||
},
|
||||
$departments->forceRestrictFilters([
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
]),
|
||||
[],
|
||||
"EXISTS (
|
||||
SELECT 1
|
||||
FROM department_variables time_booking_variables
|
||||
WHERE time_booking_variables.department_id = departments.id
|
||||
AND time_booking_variables.variable = 'bookingsystem_time_based_enabled'
|
||||
AND time_booking_variables.value = 'true'
|
||||
)"
|
||||
)
|
||||
);
|
||||
},
|
||||
[
|
||||
// No permissions required for this endpoint, as it is for guests
|
||||
]
|
||||
);
|
||||
|
||||
/** Guest Time Bookings -> Opening Hours -> GET */
|
||||
$this->get('/department/timebookings/opening-hours/public', function () {
|
||||
global $response;
|
||||
if (!self::isParametersSet(['id'])) {
|
||||
$response->error('Missing id parameter', 400);
|
||||
}
|
||||
self::requireType((int)self::getParameter('id'), self::type_int());
|
||||
self::requireMinValue((int)self::getParameter('id'), 1);
|
||||
self::requireSameLength(self::getParameter('id'), (int)self::getParameter('id'));
|
||||
// Check if the department exists
|
||||
$department = new \objects\departments_o();
|
||||
$department->select((int)self::getParameter('id'));
|
||||
if (!$department->exists()) {
|
||||
$response->error('Department not found', 404);
|
||||
}
|
||||
// Check if the department has time bookings enabled
|
||||
$variable = $department->isModuleTimeBookingsEnabled();
|
||||
if (!$variable) {
|
||||
$response->error('Department time bookings are not enabled', 404);
|
||||
}
|
||||
self::requirePublicTimeBookingsDepartment();
|
||||
|
||||
$department_time_bookings_opening_hours = new department_time_bookings_opening_hours_o();
|
||||
$department_time_bookings_opening_hours->selectByDepartment(
|
||||
(int)self::getParameter('id')
|
||||
@@ -79,23 +137,8 @@ class customerTimeBookingsRoute
|
||||
/** Guest Time Bookings -> Types -> GET */
|
||||
$this->get('/department/timebookings/types/public', function () {
|
||||
global $response;
|
||||
if (!self::isParametersSet(['id'])) {
|
||||
$response->error('Missing id parameter', 400);
|
||||
}
|
||||
self::requireType((int)self::getParameter('id'), self::type_int());
|
||||
self::requireMinValue((int)self::getParameter('id'), 1);
|
||||
self::requireSameLength(self::getParameter('id'), (int)self::getParameter('id'));
|
||||
// Check if the department exists
|
||||
$department = new \objects\departments_o();
|
||||
$department->select((int)self::getParameter('id'));
|
||||
if (!$department->exists()) {
|
||||
$response->error('Department not found', 404);
|
||||
}
|
||||
// Check if the department has time bookings enabled
|
||||
$variable = $department->isModuleTimeBookingsEnabled();
|
||||
if (!$variable) {
|
||||
$response->error('Department time bookings are not enabled', 404);
|
||||
}
|
||||
self::requirePublicTimeBookingsDepartment();
|
||||
|
||||
$department_time_bookings_types = new department_time_bookings_types_o();
|
||||
$booking_types_array = $department_time_bookings_types->getFieldsWhere(
|
||||
[
|
||||
@@ -134,23 +177,8 @@ class customerTimeBookingsRoute
|
||||
* https://api.truckwash.dk:4433/department/timebookings/entries/public?id=1&filters=created_at-date_from:2025-04-01,created_at-date_to:2025-05-30&order=created_at:desc
|
||||
*/
|
||||
global $response;
|
||||
if (!self::isParametersSet(['id'])) {
|
||||
$response->error('Missing id parameter', 400);
|
||||
}
|
||||
self::requireType((int)self::getParameter('id'), self::type_int());
|
||||
self::requireMinValue((int)self::getParameter('id'), 1);
|
||||
self::requireSameLength(self::getParameter('id'), (int)self::getParameter('id'));
|
||||
// Check if the department exists
|
||||
$department = new \objects\departments_o();
|
||||
$department->select((int)self::getParameter('id'));
|
||||
if (!$department->exists()) {
|
||||
$response->error('Department not found', 404);
|
||||
}
|
||||
// Check if the department has time bookings enabled
|
||||
$variable = $department->isModuleTimeBookingsEnabled();
|
||||
if (!$variable) {
|
||||
$response->error('Department time bookings are not enabled', 404);
|
||||
}
|
||||
$department = self::requirePublicTimeBookingsDepartment();
|
||||
|
||||
$department_time_bookings_entries = new department_time_bookings_entries_o();
|
||||
$booking_entries_array = $department_time_bookings_entries->listObjectsWithPaginationIfSet(
|
||||
function ($booking_entry): array {
|
||||
@@ -179,9 +207,7 @@ class customerTimeBookingsRoute
|
||||
$this->post('/department/timebookings/entries/public', function () {
|
||||
global $response;
|
||||
self::requireParameters(['department', 'type', 'start']);
|
||||
self::requireType((int)self::getParameter('department'), self::type_int());
|
||||
self::requireMinValue((int)self::getParameter('department'), 1);
|
||||
self::requireSameLength(self::getParameter('department'), (int)self::getParameter('department'));
|
||||
$department = self::requirePublicTimeBookingsDepartment('department');
|
||||
|
||||
// Get the type
|
||||
self::requireType((int)self::getParameter('type'), self::type_int());
|
||||
@@ -195,17 +221,6 @@ class customerTimeBookingsRoute
|
||||
self::requireSameLength(self::getParameter('start'), (string)self::getParameter('start'));
|
||||
self::requireDateFormat((string)self::getParameter('start'), 'Y-m-d H:i:s');
|
||||
|
||||
// Check if the department exists
|
||||
$department = new departments_o();
|
||||
$department->select((int)self::getParameter('department'));
|
||||
if (!$department->exists()) {
|
||||
$response->error('Department not found', 404);
|
||||
}
|
||||
// Check if the department has time bookings enabled
|
||||
$variable = $department->isModuleTimeBookingsEnabled();
|
||||
if (!$variable) {
|
||||
$response->error('Department time bookings are not enabled', 404);
|
||||
}
|
||||
// Check if the type exists
|
||||
$department_time_bookings_types = new department_time_bookings_types_o();
|
||||
$department_time_bookings_types->select((int)self::getParameter('type'));
|
||||
|
||||
@@ -7,6 +7,7 @@ use classes\backup_store;
|
||||
use classes\response;
|
||||
use classes\router;
|
||||
use objects\logs_o;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
class moduleBackupsRoute
|
||||
@@ -19,49 +20,144 @@ class moduleBackupsRoute
|
||||
/** @var router $router */
|
||||
$router, $response;
|
||||
|
||||
|
||||
/** Modules > Backups > GET */
|
||||
$this->get('/modules/backup/backups', function () {
|
||||
global $response;
|
||||
$this->requirePermission('modules_backup_list');
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user) {
|
||||
(new logs_o())->add('modules_backup', 'global', 1, $user->id, 'MODULES_BACKUP', 'Successfully fetched backup modules');
|
||||
$this->requireClassicSuperuserPermission('modules_backup_list');
|
||||
try {
|
||||
$response->success(
|
||||
(new backup_store())->listBackups()
|
||||
(new backup_store())->listBackups(
|
||||
(int)($this->fromQuery('limit') ?? 50),
|
||||
(int)($this->fromQuery('offset') ?? 0)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
(new logs_o())->add('modules_backup', 'global', 1, 0, 'MODULES_BACKUP', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
},
|
||||
[
|
||||
'modules_backup_list' => 'List all backup modules'
|
||||
]
|
||||
);
|
||||
}, [
|
||||
'modules_backup_list' => 'List backup records, components, and legacy metadata',
|
||||
]);
|
||||
|
||||
/** Modules > Backups > POST */
|
||||
$this->post('/modules/backup/backups', function () {
|
||||
global $response;
|
||||
$this->requirePermission('modules_backup_create');
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user) {
|
||||
(new logs_o())->add('modules_backup', 'global', 1, $user->id, 'MODULES_BACKUP', 'Successfully created backup module');
|
||||
// Check if the request contains a name, and description parameter
|
||||
$name = $this->fromRequest('name');
|
||||
$description = $this->fromRequest('description');
|
||||
// Create the backup
|
||||
$response->success(
|
||||
(new backup_store())->createBackup($name, $description)
|
||||
$this->requireClassicSuperuserPermission('modules_backup_create');
|
||||
$user_id = $this->actorUserId();
|
||||
try {
|
||||
$parameters = $this->getParametersAsArray();
|
||||
$result = (new backup_store())->enqueueCreateBackup(
|
||||
$parameters['name'] ?? null,
|
||||
$parameters['description'] ?? null,
|
||||
$user_id,
|
||||
'manual'
|
||||
);
|
||||
} else {
|
||||
(new logs_o())->add('modules_backup', 'global', 1, 0, 'MODULES_BACKUP', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
(new logs_o())->add('modules_backup', 'global', 1, $user_id ?? 0, 'MODULES_BACKUP', 'Queued backup: ' . $result['backup_uuid']);
|
||||
$response->success($result);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
},
|
||||
}, [
|
||||
'modules_backup_create' => 'Queue a backup job',
|
||||
]);
|
||||
|
||||
$this->get('/modules/backup/jobs/{id}', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_list');
|
||||
$job_id = (int)$this->fromRoute('id');
|
||||
$job = (new backup_store())->getJob($job_id);
|
||||
if ($job === null) {
|
||||
$response->error('Backup job not found.', 404);
|
||||
}
|
||||
$response->success($job);
|
||||
}, [
|
||||
'modules_backup_list' => 'View backup job status',
|
||||
]);
|
||||
|
||||
$this->post('/modules/backup/backups/{backup_uuid}/verify', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_verify');
|
||||
try {
|
||||
$backup_uuid = (string)$this->fromRoute('backup_uuid');
|
||||
$result = (new backup_store())->enqueueVerifyBackup($backup_uuid, $this->actorUserId());
|
||||
(new logs_o())->add('modules_backup', 'global', 1, $this->actorUserId() ?? 0, 'MODULES_BACKUP_VERIFY', 'Queued backup verification: ' . $backup_uuid);
|
||||
$response->success($result);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'modules_backup_verify' => 'Verify a backup',
|
||||
]);
|
||||
|
||||
$this->post('/modules/backup/backups/{backup_uuid}/restore/preview', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_restore');
|
||||
try {
|
||||
$backup_uuid = (string)$this->fromRoute('backup_uuid');
|
||||
$result = (new backup_store())->previewRestore($backup_uuid, $this->actorUserId());
|
||||
(new logs_o())->add('modules_backup', 'global', 1, $this->actorUserId() ?? 0, 'MODULES_BACKUP_RESTORE_PREVIEW', 'Previewed backup restore: ' . $backup_uuid);
|
||||
$response->success($result);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'modules_backup_restore' => 'Preview production restore from a backup',
|
||||
]);
|
||||
|
||||
$this->post('/modules/backup/backups/{backup_uuid}/restore', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_restore');
|
||||
try {
|
||||
$parameters = $this->getParametersAsArray();
|
||||
$backup_uuid = (string)$this->fromRoute('backup_uuid');
|
||||
$result = (new backup_store())->enqueueRestore(
|
||||
$backup_uuid,
|
||||
(int)($parameters['preview_id'] ?? 0),
|
||||
(string)($parameters['confirmation_phrase'] ?? ''),
|
||||
(string)($parameters['reason'] ?? ''),
|
||||
$this->actorUserId(),
|
||||
[
|
||||
'modules_backup_create' => 'Create a backup module'
|
||||
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
]
|
||||
);
|
||||
(new logs_o())->add('modules_backup', 'global', 1, $this->actorUserId() ?? 0, 'MODULES_BACKUP_RESTORE', 'Queued backup restore: ' . $backup_uuid);
|
||||
$response->success($result);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'modules_backup_restore' => 'Execute production restore from a backup',
|
||||
]);
|
||||
|
||||
$this->get('/modules/backup/restore-audit', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_restore');
|
||||
try {
|
||||
$response->success((new backup_store())->restoreAudit((int)($this->fromQuery('limit') ?? 50)));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'modules_backup_restore' => 'View backup restore audit log',
|
||||
]);
|
||||
}
|
||||
|
||||
private function requireClassicSuperuserPermission(string $permission): bool
|
||||
{
|
||||
global $response;
|
||||
|
||||
if ((new authentication())->get_subuser() !== false) {
|
||||
$response->error('Subuser sessions cannot manage backup disaster recovery.', 403);
|
||||
}
|
||||
|
||||
return $this->requirePermission($permission);
|
||||
}
|
||||
|
||||
private function actorUserId(): ?int
|
||||
{
|
||||
try {
|
||||
$user = (new authentication())->get_user();
|
||||
return $user !== false && isset($user->id) ? (int)$user->id : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use classes\email;
|
||||
use classes\order_bookings_counts_cache;
|
||||
use classes\order_bookings_list_cache;
|
||||
use classes\redis;
|
||||
use classes\security_policy_service;
|
||||
use Exception;
|
||||
use modules\subusers\helpers\subusers_permission_node_key;
|
||||
use objects\departments_o;
|
||||
@@ -18,6 +19,8 @@ use objects\products_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
require_once WD . '/classes/security_policy_service.php';
|
||||
|
||||
class orderBookingRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -84,6 +87,10 @@ class orderBookingRoute
|
||||
try {
|
||||
$order_bookings_o->add($data);
|
||||
$this->storeBookingIdempotencyResult($fingerprint, (int)$order_bookings_o->id);
|
||||
(new security_policy_service())->observeBookingCreated((int)$customer_number->customer_number->value(), [
|
||||
'booking_id' => (int)$order_bookings_o->id,
|
||||
'route' => '/order-bookings',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->clearBookingCreationSlot($fingerprint);
|
||||
throw $e;
|
||||
|
||||
@@ -104,6 +104,32 @@ class releaseManagerRoute
|
||||
'superuser_release_manager_deploy' => 'Run Release Manager tests with operation diagnostics',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/releases/coolify-cleanup/preview', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_deploy');
|
||||
try {
|
||||
$response->success((new release_manager())->previewCoolifyCleanup($this->requestPayload(), $this->actorUserId()));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_release_manager_deploy' => 'Preview Release Manager Coolify resource cleanup',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/releases/coolify-cleanup/apply', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_deploy');
|
||||
$this->requirePermission('superuser_coolify_manage');
|
||||
try {
|
||||
$response->success((new release_manager())->applyCoolifyCleanup($this->requestPayload(), $this->actorUserId()), 202);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_release_manager_deploy' => 'Apply Release Manager Coolify resource cleanup',
|
||||
'superuser_coolify_manage' => 'Stop or delete Coolify resources managed by Release Manager',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/releases/config', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_manage');
|
||||
|
||||
@@ -6,10 +6,12 @@ use classes\authentication;
|
||||
use classes\economic;
|
||||
use classes\gatewayapi;
|
||||
use classes\response;
|
||||
use classes\subusers_schema_bootstrap;
|
||||
use classes\subuser_permission_templates_service;
|
||||
use classes\virkdata;
|
||||
use Exception;
|
||||
use modules\virkdata\helpers\virkdata_response;
|
||||
use objects\customer_vehicles_o;
|
||||
use objects\logs_o;
|
||||
use objects\subuser_grants_o;
|
||||
use objects\subusers_o;
|
||||
@@ -26,6 +28,8 @@ class subusersRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private const DOGNVASK_PERMISSION_KEYS = ['SELFSERVE_LIST', 'SELFSERVE_ADD'];
|
||||
|
||||
private function getOwnPermissionForNode(subusers_permission_node_key $node)
|
||||
{
|
||||
return match ($node) {
|
||||
@@ -182,6 +186,100 @@ class subusersRoute
|
||||
return $name === '' || $name === 'Unknown Customer' ? null : $name;
|
||||
}
|
||||
|
||||
private function hasDognvaskAccess(array $permissions, bool $grantEnabled): bool
|
||||
{
|
||||
if (!$grantEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$permissions = subuser_grants_o::normalizePermissionsValue($permissions);
|
||||
foreach (self::DOGNVASK_PERMISSION_KEYS as $permission) {
|
||||
if (!in_array($permission, $permissions, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function normalizeAssignedVehicleId(mixed $value): ?int
|
||||
{
|
||||
global $response;
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
$id = $value;
|
||||
} else {
|
||||
$raw = trim((string)$value);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
if (!preg_match('/^[1-9][0-9]*$/', $raw)) {
|
||||
$response->error('Invalid assigned vehicle id', 400);
|
||||
}
|
||||
$id = (int)$raw;
|
||||
}
|
||||
|
||||
if ($id <= 0) {
|
||||
$response->error('Invalid assigned vehicle id', 400);
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function assignedVehiclePayload(?int $vehicleId, ?int $customerNumber = null, bool $strict = false): ?array
|
||||
{
|
||||
global $response;
|
||||
|
||||
if ($vehicleId === null || $vehicleId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$vehicle = (new customer_vehicles_o())->select($vehicleId);
|
||||
if (!$vehicle->exists()) {
|
||||
if ($strict) {
|
||||
$response->error('Assigned vehicle not found', 404);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$vehicle->getObjectProperties();
|
||||
if ($vehicle->deleted_at->value() !== null) {
|
||||
if ($strict) {
|
||||
$response->error('Assigned vehicle not found', 404);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$vehicleCustomerNumber = (int)$vehicle->customer_id->value();
|
||||
if ($customerNumber !== null && $vehicleCustomerNumber !== (int)$customerNumber) {
|
||||
if ($strict) {
|
||||
$response->error('Assigned vehicle must belong to the selected customer', 400);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$reg = strtoupper(trim((string)$vehicle->reg->value()));
|
||||
return [
|
||||
'id' => (int)$vehicle->id,
|
||||
'reg' => $reg !== '' ? $reg : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function validateAssignedVehicleIdForCustomer(mixed $value, int $customerNumber): ?int
|
||||
{
|
||||
$vehicleId = $this->normalizeAssignedVehicleId($value);
|
||||
if ($vehicleId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->assignedVehiclePayload($vehicleId, $customerNumber, true);
|
||||
return $vehicleId;
|
||||
}
|
||||
|
||||
private function assertSubuserIdentifiersAvailable(
|
||||
?int $phoneCountryCode,
|
||||
?int $phone,
|
||||
@@ -264,6 +362,170 @@ class subusersRoute
|
||||
return $frontendBaseUrl . '/complete-registration?token=' . rawurlencode($token);
|
||||
}
|
||||
|
||||
private function buildDirectSubuserLoginPath(string $sessionToken, int $customerNumber): string
|
||||
{
|
||||
return '/login/qr?token=' . rawurlencode($sessionToken)
|
||||
. '&type=subuser&customer_number=' . rawurlencode((string)$customerNumber);
|
||||
}
|
||||
|
||||
private function loadSubuserOrFail(int $subuserId): subusers_o
|
||||
{
|
||||
global $response;
|
||||
|
||||
$subuser = (new subusers_o())->select($subuserId);
|
||||
if (!$subuser->exists()) {
|
||||
$response->error('Subuser not found', 404);
|
||||
}
|
||||
$subuser->getObjectProperties();
|
||||
return $subuser;
|
||||
}
|
||||
|
||||
private function buildSubuserAccountPayload(subusers_o $subuser): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)$subuser->id,
|
||||
'username' => $subuser->username->value(),
|
||||
'name' => $subuser->name->value(),
|
||||
'email' => $subuser->email->value(),
|
||||
'phone_country_code' => $subuser->phone_country_code->value() !== null
|
||||
? (int)$subuser->phone_country_code->value()
|
||||
: null,
|
||||
'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null,
|
||||
'setup_required' => $subuser->requiresSetup(),
|
||||
'updated_at' => $subuser->updated_at->value() ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function parseSuperuserSubuserProfileUpdates(int $subuserId): array
|
||||
{
|
||||
global $response;
|
||||
|
||||
$updates = [];
|
||||
|
||||
if (self::isParametersSet(['name'])) {
|
||||
$name = $this->normalizeOptionalString(self::getParameter('name'));
|
||||
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
|
||||
$response->error('Name must be between 3 and 255 characters long', 400);
|
||||
}
|
||||
$updates['name'] = $name;
|
||||
}
|
||||
|
||||
if (self::isParametersSet(['username'])) {
|
||||
$username = $this->normalizeOptionalString(self::getParameter('username'));
|
||||
if ($username !== null && (strlen($username) < 3 || strlen($username) > 50)) {
|
||||
$response->error('Username must be between 3 and 50 characters long', 400);
|
||||
}
|
||||
$updates['username'] = $username;
|
||||
}
|
||||
|
||||
if (self::isParametersSet(['email'])) {
|
||||
$email = $this->normalizeOptionalString(self::getParameter('email'));
|
||||
if ($email !== null && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$response->error('Invalid email format', 400);
|
||||
}
|
||||
if ($email !== null && strlen($email) > 255) {
|
||||
$response->error('Email must be at most 255 characters long', 400);
|
||||
}
|
||||
$updates['email'] = $email;
|
||||
}
|
||||
|
||||
$hasPhoneCountryCode = self::isParametersSet(['phone_country_code']);
|
||||
$hasPhone = self::isParametersSet(['phone']);
|
||||
if ($hasPhoneCountryCode || $hasPhone) {
|
||||
if (!$hasPhoneCountryCode || !$hasPhone) {
|
||||
$response->error('Phone country code and phone must be provided together', 400);
|
||||
}
|
||||
|
||||
$phoneCountryCode = trim((string)self::getParameter('phone_country_code'));
|
||||
$phone = trim((string)self::getParameter('phone'));
|
||||
if (!preg_match('/^[0-9]{1,3}$/', $phoneCountryCode)) {
|
||||
$response->error('Phone country code must be 1-3 digits', 400);
|
||||
}
|
||||
if (!preg_match('/^[0-9]{4,15}$/', $phone)) {
|
||||
$response->error('Phone must be 4-15 digits', 400);
|
||||
}
|
||||
|
||||
$updates['phone_country_code'] = (int)$phoneCountryCode;
|
||||
$updates['phone'] = (int)$phone;
|
||||
}
|
||||
|
||||
if ($updates === []) {
|
||||
$response->error('No fields to update', 400);
|
||||
}
|
||||
|
||||
$this->assertSubuserIdentifiersAvailable(
|
||||
$updates['phone_country_code'] ?? null,
|
||||
$updates['phone'] ?? null,
|
||||
$updates['username'] ?? null,
|
||||
$updates['email'] ?? null,
|
||||
$subuserId
|
||||
);
|
||||
|
||||
return $updates;
|
||||
}
|
||||
|
||||
private function getEnabledGrantForSubuserAndCustomerOrFail(int $subuserId, int $customerNumber): subuser_grants_o
|
||||
{
|
||||
global $response;
|
||||
|
||||
if ($customerNumber <= 0) {
|
||||
$response->error('Customer number is required', 400);
|
||||
}
|
||||
|
||||
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true);
|
||||
if ($grant === null || !(bool)$grant->enabled->value() || $grant->deleted_at->value() !== null) {
|
||||
$response->error('Enabled subuser grant not found for selected customer', 404);
|
||||
}
|
||||
|
||||
return $grant;
|
||||
}
|
||||
|
||||
private function resolveDirectLoginCustomerNumber(subusers_o $subuser): int
|
||||
{
|
||||
global $response;
|
||||
|
||||
if (self::isParametersSet(['grant_id'])) {
|
||||
$grant = (new subuser_grants_o())->select((int)self::getParameter('grant_id'));
|
||||
if ($grant->exists()) {
|
||||
$grant->getObjectProperties();
|
||||
}
|
||||
if (
|
||||
!$grant->exists()
|
||||
|| (int)$grant->subuser->value() !== (int)$subuser->id
|
||||
|| !(bool)$grant->enabled->value()
|
||||
|| $grant->deleted_at->value() !== null
|
||||
) {
|
||||
$response->error('Enabled subuser grant not found', 404);
|
||||
}
|
||||
|
||||
return (int)$grant->billing_customer_number->value();
|
||||
}
|
||||
|
||||
if (self::isParametersSet(['customer_number'])) {
|
||||
$customerNumber = (int)self::getParameter('customer_number');
|
||||
$this->getEnabledGrantForSubuserAndCustomerOrFail((int)$subuser->id, $customerNumber);
|
||||
return $customerNumber;
|
||||
}
|
||||
|
||||
$grants = (new subuser_grants_o())->getFieldsWhere([
|
||||
'subuser' => (int)$subuser->id,
|
||||
'enabled' => 1,
|
||||
'deleted_at' => null,
|
||||
], ['billing_customer_number']);
|
||||
|
||||
$customerNumbers = array_values(array_unique(array_map(
|
||||
static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0),
|
||||
$grants
|
||||
)));
|
||||
$customerNumbers = array_values(array_filter($customerNumbers, static fn (int $customerNumber): bool => $customerNumber > 0));
|
||||
|
||||
if (count($customerNumbers) !== 1) {
|
||||
$response->error('Customer number or grant id is required for drivers with multiple customer grants', 400);
|
||||
}
|
||||
|
||||
return $customerNumbers[0];
|
||||
}
|
||||
|
||||
private function clientThrottleIp(): string
|
||||
{
|
||||
$remoteAddress = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
@@ -363,6 +625,10 @@ class subusersRoute
|
||||
$setupRequired = $subuser->requiresSetup();
|
||||
$grantEnabled = $grant ? (bool)$grant->enabled->value() : false;
|
||||
$inviteAccepted = !$setupRequired;
|
||||
$assignedVehicleId = $grant && $grant->assigned_vehicle_id->value() !== null
|
||||
? (int)$grant->assigned_vehicle_id->value()
|
||||
: null;
|
||||
$assignedVehicle = $this->assignedVehiclePayload($assignedVehicleId, $customerNumber);
|
||||
|
||||
$accessState = 'inactive';
|
||||
if ($grant !== null && $grantEnabled) {
|
||||
@@ -391,6 +657,10 @@ class subusersRoute
|
||||
'grant_id' => $grant ? (int)$grant->id : null,
|
||||
'grant_enabled' => $grantEnabled,
|
||||
'grant_note' => $grant ? $grant->note->value() : null,
|
||||
'assigned_vehicle_id' => $assignedVehicle['id'] ?? null,
|
||||
'assigned_vehicle_reg' => $assignedVehicle['reg'] ?? null,
|
||||
'assigned_vehicle' => $assignedVehicle,
|
||||
'dognvask_enabled' => $this->hasDognvaskAccess($grantPermissions, $grantEnabled),
|
||||
'grant_permissions' => $grantPermissions,
|
||||
'permissions' => $grantPermissions,
|
||||
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
|
||||
@@ -454,8 +724,8 @@ class subusersRoute
|
||||
'id' => 's.`id`',
|
||||
'created_at' => 's.`created_at`',
|
||||
'updated_at' => 'row_updated_at',
|
||||
'customer_number' => 'customer_number_sort',
|
||||
'grant_id' => 'grant_id_sort',
|
||||
'customer_number' => 'g.`billing_customer_number`',
|
||||
'grant_id' => 'g.`id`',
|
||||
'name' => 's.`name`',
|
||||
];
|
||||
|
||||
@@ -492,6 +762,15 @@ class subusersRoute
|
||||
$grantPermissions = subuser_grants_o::normalizePermissionsValue($row['grant_permissions'] ?? null);
|
||||
$templateService = new subuser_permission_templates_service();
|
||||
$grantEnabled = (bool)((int)($row['grant_enabled'] ?? 0));
|
||||
$assignedVehicleId = isset($row['assigned_vehicle_id']) && $row['assigned_vehicle_id'] !== null
|
||||
? (int)$row['assigned_vehicle_id']
|
||||
: null;
|
||||
$assignedVehicleReg = isset($row['assigned_vehicle_reg']) && trim((string)$row['assigned_vehicle_reg']) !== ''
|
||||
? strtoupper(trim((string)$row['assigned_vehicle_reg']))
|
||||
: null;
|
||||
$assignedVehicle = $assignedVehicleId !== null
|
||||
? ['id' => $assignedVehicleId, 'reg' => $assignedVehicleReg]
|
||||
: null;
|
||||
|
||||
$accessState = 'inactive';
|
||||
if (!empty($row['grant_id']) && $grantEnabled) {
|
||||
@@ -506,6 +785,10 @@ class subusersRoute
|
||||
'customer_name' => $row['customer_name'] ?: null,
|
||||
'grant_enabled' => $grantEnabled,
|
||||
'grant_note' => $row['grant_note'] ?? null,
|
||||
'assigned_vehicle_id' => $assignedVehicleId,
|
||||
'assigned_vehicle_reg' => $assignedVehicleReg,
|
||||
'assigned_vehicle' => $assignedVehicle,
|
||||
'dognvask_enabled' => $this->hasDognvaskAccess($grantPermissions, $grantEnabled),
|
||||
'grant_permissions' => $grantPermissions,
|
||||
'permissions' => $grantPermissions,
|
||||
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
|
||||
@@ -558,6 +841,10 @@ class subusersRoute
|
||||
'grant_id' => $primaryGrant['grant_id'] ?? null,
|
||||
'grant_enabled' => $primaryGrant['grant_enabled'] ?? false,
|
||||
'grant_note' => $primaryGrant['grant_note'] ?? null,
|
||||
'assigned_vehicle_id' => $primaryGrant['assigned_vehicle_id'] ?? null,
|
||||
'assigned_vehicle_reg' => $primaryGrant['assigned_vehicle_reg'] ?? null,
|
||||
'assigned_vehicle' => $primaryGrant['assigned_vehicle'] ?? null,
|
||||
'dognvask_enabled' => $primaryGrant['dognvask_enabled'] ?? false,
|
||||
'grant_permissions' => $primaryGrant['grant_permissions'] ?? [],
|
||||
'permissions' => $primaryGrant['permissions'] ?? [],
|
||||
'permission_template_key' => $primaryGrant['permission_template_key'] ?? subuser_permission_templates_service::TEMPLATE_DEACTIVATED,
|
||||
@@ -756,6 +1043,7 @@ class subusersRoute
|
||||
OR s.`email` LIKE ?
|
||||
OR CAST(s.`phone_country_code` AS CHAR) LIKE ?
|
||||
OR CAST(s.`phone` AS CHAR) LIKE ?
|
||||
OR cv.`reg` LIKE ?
|
||||
OR CAST(g.`billing_customer_number` AS CHAR) LIKE ?
|
||||
OR g.`note` LIKE ?
|
||||
OR EXISTS (
|
||||
@@ -766,7 +1054,7 @@ class subusersRoute
|
||||
)
|
||||
)";
|
||||
$search = '%' . $pagination['search'] . '%';
|
||||
for ($i = 0; $i < 9; $i++) {
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$params[] = $search;
|
||||
$types .= 's';
|
||||
}
|
||||
@@ -776,9 +1064,10 @@ class subusersRoute
|
||||
$fromSql = "
|
||||
FROM `subuser_grants` g
|
||||
INNER JOIN `subusers` s ON s.`id` = g.`subuser`
|
||||
LEFT JOIN `customer_vehicles` cv ON cv.`id` = g.`assigned_vehicle_id` AND cv.`deleted_at` IS NULL
|
||||
";
|
||||
|
||||
$countSql = "SELECT COUNT(DISTINCT s.`id`) AS `count` $fromSql $whereSql";
|
||||
$countSql = "SELECT COUNT(*) AS `count` $fromSql $whereSql";
|
||||
$countStatement = $db->conn->prepare($countSql);
|
||||
if ($countStatement === false) {
|
||||
throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error);
|
||||
@@ -802,23 +1091,18 @@ class subusersRoute
|
||||
s.`created_at`,
|
||||
s.`updated_at`,
|
||||
s.`suspended_at`,
|
||||
MAX(COALESCE(g.`updated_at`, s.`updated_at`)) AS `row_updated_at`,
|
||||
MIN(g.`billing_customer_number`) AS `customer_number_sort`,
|
||||
MAX(g.`id`) AS `grant_id_sort`
|
||||
COALESCE(g.`updated_at`, s.`updated_at`) AS `row_updated_at`,
|
||||
g.`id` AS `grant_id`,
|
||||
g.`billing_customer_number` AS `customer_number`,
|
||||
g.`enabled` AS `grant_enabled`,
|
||||
g.`note` AS `grant_note`,
|
||||
g.`permissions` AS `grant_permissions`,
|
||||
g.`assigned_vehicle_id`,
|
||||
cv.`reg` AS `assigned_vehicle_reg`,
|
||||
g.`created_at` AS `grant_created_at`,
|
||||
g.`updated_at` AS `grant_updated_at`
|
||||
$fromSql
|
||||
$whereSql
|
||||
GROUP BY
|
||||
s.`id`,
|
||||
s.`username`,
|
||||
s.`password`,
|
||||
s.`name`,
|
||||
s.`email`,
|
||||
s.`phone_country_code`,
|
||||
s.`phone`,
|
||||
s.`two_factor_enabled`,
|
||||
s.`created_at`,
|
||||
s.`updated_at`,
|
||||
s.`suspended_at`
|
||||
ORDER BY {$pagination['order_sql']} {$pagination['order_direction']}
|
||||
LIMIT ? OFFSET ?
|
||||
";
|
||||
@@ -834,68 +1118,15 @@ class subusersRoute
|
||||
$rows = $result->fetch_all(MYSQLI_ASSOC);
|
||||
$pageStatement->close();
|
||||
|
||||
$subuserIds = array_map(static fn (array $row): int => (int)$row['id'], $rows);
|
||||
$grantRowsBySubuserId = [];
|
||||
if ($subuserIds !== []) {
|
||||
$placeholders = implode(',', array_fill(0, count($subuserIds), '?'));
|
||||
$grantWhere = [
|
||||
'g.`deleted_at` IS NULL',
|
||||
'g.`subuser` IN (' . $placeholders . ')',
|
||||
];
|
||||
$grantParams = $subuserIds;
|
||||
$grantTypes = str_repeat('i', count($subuserIds));
|
||||
|
||||
if (!$includeNonEnabled) {
|
||||
$grantWhere[] = 'g.`enabled` = 1';
|
||||
}
|
||||
if ($customerNumber !== null) {
|
||||
$grantWhere[] = 'g.`billing_customer_number` = ?';
|
||||
$grantParams[] = $customerNumber;
|
||||
$grantTypes .= 'i';
|
||||
}
|
||||
|
||||
$grantSql = "
|
||||
SELECT
|
||||
g.`subuser` AS `subuser_id`,
|
||||
g.`id` AS `grant_id`,
|
||||
g.`billing_customer_number` AS `customer_number`,
|
||||
g.`enabled` AS `grant_enabled`,
|
||||
g.`note` AS `grant_note`,
|
||||
g.`permissions` AS `grant_permissions`,
|
||||
g.`created_at` AS `grant_created_at`,
|
||||
g.`updated_at` AS `grant_updated_at`
|
||||
FROM `subuser_grants` g
|
||||
WHERE " . implode(' AND ', $grantWhere) . "
|
||||
ORDER BY
|
||||
g.`subuser` ASC,
|
||||
g.`enabled` DESC,
|
||||
g.`billing_customer_number` ASC,
|
||||
COALESCE(g.`updated_at`, g.`created_at`) DESC,
|
||||
g.`id` DESC
|
||||
";
|
||||
$grantStatement = $db->conn->prepare($grantSql);
|
||||
if ($grantStatement === false) {
|
||||
throw new Exception('Failed to prepare subuser grant list query: ' . $db->conn->error);
|
||||
}
|
||||
$this->bindStatementParameters($grantStatement, $grantTypes, $grantParams);
|
||||
$grantStatement->execute();
|
||||
$grantResult = $grantStatement->get_result();
|
||||
$grantRows = $grantResult->fetch_all(MYSQLI_ASSOC);
|
||||
$grantStatement->close();
|
||||
|
||||
$customerNames = $this->resolveCustomerNames(array_map(
|
||||
static fn (array $grantRow): int => (int)($grantRow['customer_number'] ?? 0),
|
||||
$grantRows
|
||||
static fn (array $row): int => (int)($row['customer_number'] ?? 0),
|
||||
$rows
|
||||
));
|
||||
|
||||
foreach ($grantRows as $grantRow) {
|
||||
$subuserId = (int)$grantRow['subuser_id'];
|
||||
$customerNumberForGrant = (int)$grantRow['customer_number'];
|
||||
$grantRow['customer_name'] = $this->resolveCustomerName($customerNumberForGrant, $customerNames);
|
||||
$grantRowsBySubuserId[$subuserId] ??= [];
|
||||
$grantRowsBySubuserId[$subuserId][] = $grantRow;
|
||||
}
|
||||
}
|
||||
$rows = array_map(function (array $row) use ($customerNames): array {
|
||||
$customerNumberForGrant = (int)($row['customer_number'] ?? 0);
|
||||
$row['customer_name'] = $this->resolveCustomerName($customerNumberForGrant, $customerNames);
|
||||
return $row;
|
||||
}, $rows);
|
||||
|
||||
$response->paginate(
|
||||
(int)$pagination['page'],
|
||||
@@ -907,7 +1138,7 @@ class subusersRoute
|
||||
);
|
||||
|
||||
return array_map(
|
||||
fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row, $grantRowsBySubuserId[(int)$row['id']] ?? []),
|
||||
fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row, [$row]),
|
||||
$rows
|
||||
);
|
||||
}
|
||||
@@ -961,6 +1192,13 @@ class subusersRoute
|
||||
$updates['permissions'] = $this->parsePermissionsPayload(self::getParameter('permissions'), []);
|
||||
}
|
||||
|
||||
if (self::isParametersSet(['assigned_vehicle_id'])) {
|
||||
$updates['assigned_vehicle_id'] = $this->validateAssignedVehicleIdForCustomer(
|
||||
self::getParameter('assigned_vehicle_id'),
|
||||
$customerNumber
|
||||
);
|
||||
}
|
||||
|
||||
if ($updates === []) {
|
||||
$response->error('No fields to update', 400);
|
||||
}
|
||||
@@ -1109,6 +1347,8 @@ class subusersRoute
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
subusers_schema_bootstrap::ensureTables();
|
||||
|
||||
// =============================
|
||||
// Subuser Grant Management
|
||||
// =============================
|
||||
@@ -1182,6 +1422,7 @@ class subusersRoute
|
||||
'name' => $subuser->name->value(),
|
||||
'enabled' => $o->enabled,
|
||||
'note' => $o->note,
|
||||
'assigned_vehicle_id' => isset($o->assigned_vehicle_id) && $o->assigned_vehicle_id !== null ? (int)$o->assigned_vehicle_id : null,
|
||||
'permissions' => subuser_grants_o::normalizePermissionsValue($o->permissions ?? null),
|
||||
'created_at' => $o->created_at,
|
||||
'updated_at' => $o->updated_at,
|
||||
@@ -1228,8 +1469,18 @@ class subusersRoute
|
||||
$enabled = $templateAccess['enabled'];
|
||||
$permissions = $templateAccess['permissions'];
|
||||
}
|
||||
$assignedVehicleId = null;
|
||||
if (self::isParametersSet(['assigned_vehicle_id'])) {
|
||||
$assignedVehicleId = $this->validateAssignedVehicleIdForCustomer(
|
||||
self::getParameter('assigned_vehicle_id'),
|
||||
$customer_number
|
||||
);
|
||||
}
|
||||
try {
|
||||
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
|
||||
if ($assignedVehicleId !== null) {
|
||||
$grant->assigned_vehicle_id->set($assignedVehicleId);
|
||||
}
|
||||
$response->success(['grant' => $grant->asArray()]);
|
||||
} catch (Exception $e) {
|
||||
$response->error('Failed to add subuser grant', 500);
|
||||
@@ -1287,6 +1538,12 @@ class subusersRoute
|
||||
$permissions = $this->parsePermissionsPayload(self::getParameter('permissions'), []);
|
||||
$grant->permissions->set($permissions);
|
||||
}
|
||||
if (self::isParametersSet(['assigned_vehicle_id'])) {
|
||||
$grant->assigned_vehicle_id->set($this->validateAssignedVehicleIdForCustomer(
|
||||
self::getParameter('assigned_vehicle_id'),
|
||||
$targetCustomer
|
||||
));
|
||||
}
|
||||
$response->success($grant->asArray());
|
||||
},
|
||||
[
|
||||
@@ -1587,6 +1844,103 @@ class subusersRoute
|
||||
'list_subusers' => 'List all chauffeur access grants for superusers.',
|
||||
]);
|
||||
|
||||
$this->patch('/superuser/subusers/{subuser_id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('edit_subusers');
|
||||
$subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id'));
|
||||
$updates = $this->parseSuperuserSubuserProfileUpdates((int)$subuser->id);
|
||||
|
||||
try {
|
||||
$subuser->update($updates);
|
||||
} catch (Exception $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
|
||||
$subuser = $this->loadSubuserOrFail((int)$subuser->id);
|
||||
$authUser = (new authentication())->get_user();
|
||||
(new logs_o())->add(
|
||||
'subusers',
|
||||
'global',
|
||||
1,
|
||||
$authUser !== false ? (int)$authUser->id : 0,
|
||||
'SUPERUSER_SUBUSER_PROFILE_UPDATE',
|
||||
'Updated chauffeur profile: ' . (int)$subuser->id
|
||||
);
|
||||
|
||||
$response->success(['subuser' => $this->buildSubuserAccountPayload($subuser)]);
|
||||
}, [
|
||||
'edit_subusers' => 'Edit chauffeur account profile fields as a superuser.',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/subusers/{subuser_id}/password', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('edit_subusers');
|
||||
self::requireParameters(['password']);
|
||||
|
||||
$password = (string)self::getParameter('password');
|
||||
$this->requireSubuserPasswordPolicy($password);
|
||||
|
||||
$subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id'));
|
||||
try {
|
||||
$subuser->setPassword($password);
|
||||
$subuser->invalidateCurrentSetupToken();
|
||||
} catch (Exception $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
|
||||
$subuser = $this->loadSubuserOrFail((int)$subuser->id);
|
||||
$authUser = (new authentication())->get_user();
|
||||
(new logs_o())->add(
|
||||
'subusers',
|
||||
'global',
|
||||
1,
|
||||
$authUser !== false ? (int)$authUser->id : 0,
|
||||
'SUPERUSER_SUBUSER_PASSWORD_SET',
|
||||
'Set chauffeur password: ' . (int)$subuser->id
|
||||
);
|
||||
|
||||
$response->success(['subuser' => $this->buildSubuserAccountPayload($subuser)]);
|
||||
}, [
|
||||
'edit_subusers' => 'Set a chauffeur account password as a superuser.',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/subusers/{subuser_id}/login-link', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('edit_subusers');
|
||||
$this->requirePermission('SUPERUSER_INTIMIDATE');
|
||||
|
||||
$subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id'));
|
||||
$customerNumber = $this->resolveDirectLoginCustomerNumber($subuser);
|
||||
|
||||
try {
|
||||
$sessionToken = $subuser->generateSession();
|
||||
} catch (Exception $exception) {
|
||||
$response->error($exception->getMessage(), 500);
|
||||
}
|
||||
|
||||
$authUser = (new authentication())->get_user();
|
||||
(new logs_o())->add(
|
||||
'auth',
|
||||
'global',
|
||||
1,
|
||||
$authUser !== false ? (int)$authUser->id : 0,
|
||||
'SUPERUSER_SUBUSER_DIRECT_LOGIN_LINK',
|
||||
'Created chauffeur direct login link for subuser ' . (int)$subuser->id . ' and customer ' . $customerNumber
|
||||
);
|
||||
|
||||
$response->success([
|
||||
'subuser_id' => (int)$subuser->id,
|
||||
'customer_number' => $customerNumber,
|
||||
'login_path' => $this->buildDirectSubuserLoginPath($sessionToken, $customerNumber),
|
||||
]);
|
||||
}, [
|
||||
'edit_subusers' => 'Create chauffeur direct login links as a superuser.',
|
||||
'SUPERUSER_INTIMIDATE' => 'Create direct login sessions for chauffeur accounts.',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/users/{user_id}/subusers', function () {
|
||||
global $response;
|
||||
$this->requirePermission('list_subusers');
|
||||
|
||||
@@ -4,13 +4,14 @@ namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\replication_manager;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
class superuserReplicationRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private const RETIRED_MANAGEMENT_MESSAGE = 'Replication management has been retired. Use System -> Database, System -> Redis, and System -> MinIO for read-only status.';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/superuser/replication', function () {
|
||||
@@ -24,140 +25,71 @@ class superuserReplicationRoute
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/databases', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
$host = (new replication_manager())->addHost('database', $this->getParametersAsArray(), $this->actorUserId());
|
||||
$response->success($host, 201);
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Add and manage database replication host credentials',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/redis', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
$host = (new replication_manager())->addHost('redis', $this->getParametersAsArray(), $this->actorUserId());
|
||||
$response->success($host, 201);
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Add and manage Redis replication host credentials',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/minio', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
$host = (new replication_manager())->addHost('minio', $this->getParametersAsArray(), $this->actorUserId());
|
||||
$response->success($host, 201);
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Add and manage MinIO replication host credentials',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/compose-template', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
$response->success(replication_manager::composeTemplate($this->getParametersAsArray()));
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Generate Docker Compose templates for replication-ready database, Redis, and MinIO hosts',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/test-credentials', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
$parameters = $this->getParametersAsArray();
|
||||
$response->success((new replication_manager())->testCredentials(
|
||||
(string)($parameters['kind'] ?? ''),
|
||||
$parameters
|
||||
));
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Test database, Redis, and MinIO replication host credentials before saving them',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/{kind}/{id}/test', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
$response->success((new replication_manager())->testHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Validate database, Redis, and MinIO replication host connectivity and privileges',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/{kind}/{id}/provision', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
try {
|
||||
$result = (new replication_manager())->provisionHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->actorUserId(),
|
||||
true
|
||||
);
|
||||
if (($result['ok'] ?? false) !== true) {
|
||||
$response->error($result, 409);
|
||||
}
|
||||
$response->success($result);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Provision a database, Redis, or MinIO host as a replica of the current primary',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/{kind}/{id}/promote', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_promote');
|
||||
try {
|
||||
$response->success((new replication_manager())->promoteHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_promote' => 'Promote a healthy caught-up database, Redis, or MinIO replica to primary',
|
||||
]);
|
||||
|
||||
$this->patch('/superuser/replication/{kind}/{id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
try {
|
||||
$response->success((new replication_manager())->renameHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Rename database, Redis, and MinIO replication hosts',
|
||||
]);
|
||||
|
||||
$this->delete('/superuser/replication/{kind}/{id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_remove');
|
||||
try {
|
||||
$response->success((new replication_manager())->removeHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_remove' => 'Remove inactive prior hosts and unhealthy database, Redis, or MinIO replicas',
|
||||
]);
|
||||
@@ -180,21 +112,11 @@ class superuserReplicationRoute
|
||||
return $this->requirePermission($permission);
|
||||
}
|
||||
|
||||
private function routeId(): int
|
||||
private function rejectRetiredManagement(): void
|
||||
{
|
||||
$id = (int)$this->fromRoute('id');
|
||||
$this->requireParameterIntPositive($id, 'id');
|
||||
return $id;
|
||||
}
|
||||
global $response;
|
||||
|
||||
private function actorUserId(): ?int
|
||||
{
|
||||
try {
|
||||
$user = (new authentication())->get_user();
|
||||
return $user !== false && isset($user->id) ? (int)$user->id : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
$response->error(['message' => self::RETIRED_MANAGEMENT_MESSAGE], 410);
|
||||
}
|
||||
|
||||
private function toBool(mixed $value, bool $default): bool
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\security_policy_service;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
require_once WD . '/classes/security_policy_service.php';
|
||||
|
||||
class superuserSecurityRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/superuser/system/security/summary', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_view');
|
||||
$response->success((new security_policy_service())->summary());
|
||||
}, [
|
||||
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/system/security/settings', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_view');
|
||||
$response->success((new security_policy_service())->settings());
|
||||
}, [
|
||||
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
|
||||
'superuser_security_limits_exempt' => 'Exempt requests from observe-mode security limit incidents',
|
||||
]);
|
||||
|
||||
$this->patch('/superuser/system/security/settings', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_settings_manage');
|
||||
try {
|
||||
$response->success((new security_policy_service())->updateSettings(
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_settings_manage' => 'Configure observe-mode security thresholds and exemptions',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/system/security/firewall-rules', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_view');
|
||||
$response->success((new security_policy_service())->listFirewallRules($this->getParametersAsArray()));
|
||||
}, [
|
||||
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/system/security/firewall-rules', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_firewall_manage');
|
||||
try {
|
||||
$response->success((new security_policy_service())->createFirewallRule(
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
), 201);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_firewall_manage' => 'Create, update, and delete application firewall rules',
|
||||
]);
|
||||
|
||||
$this->patch('/superuser/system/security/firewall-rules/{id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_firewall_manage');
|
||||
try {
|
||||
$response->success((new security_policy_service())->updateFirewallRule(
|
||||
$this->routeId(),
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_firewall_manage' => 'Create, update, and delete application firewall rules',
|
||||
]);
|
||||
|
||||
$this->delete('/superuser/system/security/firewall-rules/{id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_firewall_manage');
|
||||
try {
|
||||
$response->success((new security_policy_service())->deleteFirewallRule(
|
||||
$this->routeId(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_firewall_manage' => 'Create, update, and delete application firewall rules',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/system/security/incidents', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_view');
|
||||
$response->success((new security_policy_service())->listIncidents($this->getParametersAsArray()));
|
||||
}, [
|
||||
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/system/security/incidents/{id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_view');
|
||||
try {
|
||||
$response->success((new security_policy_service())->incidentDetail($this->routeId()));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 404);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
|
||||
]);
|
||||
|
||||
$this->patch('/superuser/system/security/incidents/{id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_incidents_manage');
|
||||
try {
|
||||
$response->success((new security_policy_service())->updateIncident(
|
||||
$this->routeId(),
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_incidents_manage' => 'Acknowledge, resolve, reopen, and note security incidents',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/system/security/incidents/{id}/notes', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_incidents_manage');
|
||||
try {
|
||||
$parameters = $this->getParametersAsArray();
|
||||
$response->success((new security_policy_service())->addIncidentNote(
|
||||
$this->routeId(),
|
||||
(string)($parameters['note'] ?? ''),
|
||||
$this->actorUserId()
|
||||
), 201);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_incidents_manage' => 'Acknowledge, resolve, reopen, and note security incidents',
|
||||
]);
|
||||
}
|
||||
|
||||
private function requireClassicSuperuserPermission(string $permission): bool
|
||||
{
|
||||
global $response;
|
||||
|
||||
if ((new authentication())->get_subuser() !== false) {
|
||||
$response->error('Subuser sessions cannot manage security controls.', 403);
|
||||
}
|
||||
|
||||
return $this->requirePermission($permission);
|
||||
}
|
||||
|
||||
private function routeId(): int
|
||||
{
|
||||
$id = (int)$this->fromRoute('id');
|
||||
$this->requireParameterIntPositive($id, 'id');
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function actorUserId(): ?int
|
||||
{
|
||||
try {
|
||||
$user = (new authentication())->get_user();
|
||||
return $user !== false && isset($user->id) ? (int)$user->id : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\economic_v2_versioning_service;
|
||||
use classes\security_policy_service;
|
||||
use customers\economic_customer_mo;
|
||||
use objects\bookings_o;
|
||||
use objects\customer_vehicles_o;
|
||||
@@ -17,6 +18,8 @@ use objects\users_o;
|
||||
use traits\route_t;
|
||||
use modules\subusers\helpers\subusers_permission_node_key;
|
||||
|
||||
require_once WD . '/classes/security_policy_service.php';
|
||||
|
||||
class vehiclesRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -208,9 +211,23 @@ class vehiclesRoute
|
||||
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'ADD_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
|
||||
}
|
||||
|
||||
$this->observeVehicleCreated($customerNumber, (int)$vehicle->id, '/superuser/users/{user_id}/vehicles');
|
||||
|
||||
return $vehicle->asArray();
|
||||
}
|
||||
|
||||
private function observeVehicleCreated(int $customerNumber, int $vehicleId, string $route): void
|
||||
{
|
||||
try {
|
||||
(new security_policy_service())->observeVehicleCreated($customerNumber, [
|
||||
'vehicle_id' => $vehicleId,
|
||||
'route' => $route,
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// Security observation must not change vehicle creation responses.
|
||||
}
|
||||
}
|
||||
|
||||
private function updateScopedVehicle(customer_vehicles_o $vehicle, int $customerNumber): array
|
||||
{
|
||||
global $response;
|
||||
@@ -599,6 +616,7 @@ class vehiclesRoute
|
||||
}
|
||||
|
||||
(new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'ADD_VEHICLE', 'Successfully added vehicle');
|
||||
$this->observeVehicleCreated((int)$targetCustomer, (int)$vehicle->id, '/vehicles');
|
||||
$response->success($vehicle->asArray());
|
||||
},
|
||||
[
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('lists only visible active departments with public time bookings enabled', function (): void {
|
||||
api_test_covers('GET /department/timebookings/departments/public', 'happy');
|
||||
|
||||
$enabledDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Public Time Booking Enabled ' . uniqid('', false),
|
||||
'description' => 'Enabled booking department address',
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
'latitude' => 55.1,
|
||||
'longitude' => 12.1,
|
||||
'order_priority' => 7,
|
||||
]);
|
||||
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$enabledDepartment['id'], true);
|
||||
|
||||
$disabledDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Public Time Booking Disabled ' . uniqid('', false),
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
]);
|
||||
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$disabledDepartment['id'], false);
|
||||
|
||||
$missingVariableDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Public Time Booking Missing Variable ' . uniqid('', false),
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
]);
|
||||
|
||||
$legacyTruthyDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Public Time Booking Legacy Truthy ' . uniqid('', false),
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
]);
|
||||
api_fixtures()->setDepartmentVariable(
|
||||
(int)$legacyTruthyDepartment['id'],
|
||||
'bookingsystem_time_based_enabled',
|
||||
'1'
|
||||
);
|
||||
|
||||
$hiddenDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Public Time Booking Hidden ' . uniqid('', false),
|
||||
'visible' => 0,
|
||||
'archived' => 0,
|
||||
]);
|
||||
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$hiddenDepartment['id'], true);
|
||||
|
||||
$archivedDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Public Time Booking Archived ' . uniqid('', false),
|
||||
'visible' => 1,
|
||||
'archived' => 1,
|
||||
]);
|
||||
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$archivedDepartment['id'], true);
|
||||
|
||||
$response = api_client()->get('/department/timebookings/departments/public');
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$departmentsById = [];
|
||||
foreach ($response->data() as $department) {
|
||||
$departmentsById[(int)($department['id'] ?? 0)] = $department;
|
||||
}
|
||||
|
||||
expect($departmentsById)
|
||||
->toHaveKey((int)$enabledDepartment['id'])
|
||||
->not->toHaveKey((int)$disabledDepartment['id'])
|
||||
->not->toHaveKey((int)$missingVariableDepartment['id'])
|
||||
->not->toHaveKey((int)$legacyTruthyDepartment['id'])
|
||||
->not->toHaveKey((int)$hiddenDepartment['id'])
|
||||
->not->toHaveKey((int)$archivedDepartment['id']);
|
||||
|
||||
$returnedEnabledDepartment = $departmentsById[(int)$enabledDepartment['id']];
|
||||
expect($returnedEnabledDepartment)
|
||||
->toHaveKey('name')
|
||||
->toHaveKey('description', 'Enabled booking department address')
|
||||
->toHaveKey('address', 'Enabled booking department address')
|
||||
->toHaveKey('time_booking_enabled', true)
|
||||
->not->toHaveKey('slack_webhook')
|
||||
->not->toHaveKey('custom_pricing_only')
|
||||
->not->toHaveKey('variables')
|
||||
->not->toHaveKey('bookingsystem_time_based_enabled');
|
||||
});
|
||||
|
||||
it('returns an empty public time-booking department list when no matching department is enabled', function (): void {
|
||||
api_test_covers('GET /department/timebookings/departments/public', 'empty');
|
||||
|
||||
$uniqueName = 'Public Time Booking Empty ' . uniqid('', false);
|
||||
$disabledDepartment = api_fixtures()->createDepartment([
|
||||
'name' => $uniqueName,
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
]);
|
||||
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$disabledDepartment['id'], false);
|
||||
|
||||
$response = api_client()->get(
|
||||
'/department/timebookings/departments/public?search=' . rawurlencode($uniqueName)
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->data())->toBe([]);
|
||||
});
|
||||
|
||||
it('rejects public time-booking detail requests for disabled departments', function (): void {
|
||||
api_test_covers('GET /department/timebookings/types/public', 'disabled');
|
||||
|
||||
$disabledDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Disabled Public Time Booking Detail ' . uniqid('', false),
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
]);
|
||||
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$disabledDepartment['id'], false);
|
||||
|
||||
api_client()->get('/department/timebookings/types/public?id=' . (int)$disabledDepartment['id'])
|
||||
->assertStatus(404)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Department time bookings are not enabled');
|
||||
});
|
||||
@@ -218,10 +218,16 @@ it('lists chauffeur grants across customers for superusers', function (): void {
|
||||
]);
|
||||
$firstSubuser = api_fixtures()->createSubuser(['name' => 'Alpha Driver']);
|
||||
$secondSubuser = api_fixtures()->createSubuser(['name' => 'Beta Driver']);
|
||||
$firstVehicle = api_fixtures()->createVehicle([
|
||||
'customer_id' => (int)$firstCustomer['customer_number'],
|
||||
'type' => 1,
|
||||
'reg' => 'ab12345',
|
||||
]);
|
||||
$firstGrantId = api_fixtures()->grantSubuser(
|
||||
(int)$firstSubuser['id'],
|
||||
(int)$firstCustomer['customer_number'],
|
||||
['VEHICLES_LIST', 'SUBUSERS_LIST']
|
||||
['VEHICLES_LIST', 'SUBUSERS_LIST', 'SELFSERVE_LIST', 'SELFSERVE_ADD'],
|
||||
['assigned_vehicle_id' => (int)$firstVehicle['id']]
|
||||
);
|
||||
$secondGrantId = api_fixtures()->grantSubuser(
|
||||
(int)$secondSubuser['id'],
|
||||
@@ -247,23 +253,23 @@ it('lists chauffeur grants across customers for superusers', function (): void {
|
||||
&& in_array((int)($item['id'] ?? 0), [(int)$firstSubuser['id'], (int)$secondSubuser['id']], true)
|
||||
));
|
||||
|
||||
expect($rows)->toHaveCount(2);
|
||||
expect($rows)->toHaveCount(3);
|
||||
|
||||
$bySubuserId = [];
|
||||
$byGrantId = [];
|
||||
foreach ($rows as $row) {
|
||||
$bySubuserId[(int)$row['id']] = $row;
|
||||
$byGrantId[(int)$row['grant_id']] = $row;
|
||||
}
|
||||
|
||||
expect($bySubuserId[(int)$firstSubuser['id']]['grant_count'])->toBe(2);
|
||||
expect(array_column($bySubuserId[(int)$firstSubuser['id']]['grants'], 'grant_id'))
|
||||
->toContain($firstGrantId)
|
||||
->toContain($sharedGrantId);
|
||||
expect($bySubuserId[(int)$firstSubuser['id']]['customer_numbers'])
|
||||
->toContain((int)$firstCustomer['customer_number'])
|
||||
->toContain((int)$secondCustomer['customer_number']);
|
||||
expect($bySubuserId[(int)$secondSubuser['id']]['grant_count'])->toBe(1);
|
||||
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['grant_id'])->toBe($secondGrantId);
|
||||
expect($bySubuserId[(int)$secondSubuser['id']]['grants'][0]['customer_name'])->toBe('Fleet Customer Beta');
|
||||
expect($byGrantId[$firstGrantId]['id'])->toBe((int)$firstSubuser['id']);
|
||||
expect($byGrantId[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']);
|
||||
expect($byGrantId[$firstGrantId]['assigned_vehicle_id'])->toBe((int)$firstVehicle['id']);
|
||||
expect($byGrantId[$firstGrantId]['assigned_vehicle_reg'])->toBe('AB12345');
|
||||
expect($byGrantId[$firstGrantId]['dognvask_enabled'])->toBeTrue();
|
||||
expect($byGrantId[$sharedGrantId]['id'])->toBe((int)$firstSubuser['id']);
|
||||
expect($byGrantId[$sharedGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']);
|
||||
expect($byGrantId[$sharedGrantId]['dognvask_enabled'])->toBeFalse();
|
||||
expect($byGrantId[$secondGrantId]['id'])->toBe((int)$secondSubuser['id']);
|
||||
expect($byGrantId[$secondGrantId]['customer_name'])->toBe('Fleet Customer Beta');
|
||||
});
|
||||
|
||||
it('lets superusers invite chauffeurs for a selected customer', function (): void {
|
||||
@@ -483,6 +489,16 @@ it('edits only customer-matching chauffeur grants through the user-scoped route'
|
||||
$otherCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Patch Other']);
|
||||
$targetSubuser = api_fixtures()->createSubuser(['name' => 'Patch Target Driver']);
|
||||
$otherSubuser = api_fixtures()->createSubuser(['name' => 'Patch Other Driver']);
|
||||
$targetVehicle = api_fixtures()->createVehicle([
|
||||
'customer_id' => (int)$targetCustomer['customer_number'],
|
||||
'type' => 1,
|
||||
'reg' => 'scope123',
|
||||
]);
|
||||
$otherVehicle = api_fixtures()->createVehicle([
|
||||
'customer_id' => (int)$otherCustomer['customer_number'],
|
||||
'type' => 1,
|
||||
'reg' => 'other123',
|
||||
]);
|
||||
$targetGrantId = api_fixtures()->grantSubuser(
|
||||
(int)$targetSubuser['id'],
|
||||
(int)$targetCustomer['customer_number'],
|
||||
@@ -501,9 +517,16 @@ it('edits only customer-matching chauffeur grants through the user-scoped route'
|
||||
'enabled' => false,
|
||||
'note' => 'Scoped note',
|
||||
'permissions' => ['ORDERS_LIST'],
|
||||
'assigned_vehicle_id' => (int)$targetVehicle['id'],
|
||||
],
|
||||
$session['headers']
|
||||
);
|
||||
$mismatchedVehicle = api_client()->request(
|
||||
'PATCH',
|
||||
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $targetGrantId,
|
||||
['assigned_vehicle_id' => (int)$otherVehicle['id']],
|
||||
$session['headers']
|
||||
);
|
||||
$crossCustomer = api_client()->request(
|
||||
'PATCH',
|
||||
'/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $otherGrantId,
|
||||
@@ -515,6 +538,10 @@ it('edits only customer-matching chauffeur grants through the user-scoped route'
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
$mismatchedVehicle
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
$crossCustomer
|
||||
->assertStatus(404)
|
||||
->assertEnvelope()
|
||||
@@ -523,6 +550,123 @@ it('edits only customer-matching chauffeur grants through the user-scoped route'
|
||||
expect($update->data()['grant']['enabled'] ?? null)->toBeFalse();
|
||||
expect($update->data()['grant']['note'] ?? null)->toBe('Scoped note');
|
||||
expect($update->data()['grant']['permissions'] ?? null)->toBe(['ORDERS_LIST']);
|
||||
expect($update->data()['grant']['assigned_vehicle_id'] ?? null)->toBe((int)$targetVehicle['id']);
|
||||
expect($update->data()['subuser']['assigned_vehicle_reg'] ?? null)->toBe('SCOPE123');
|
||||
});
|
||||
|
||||
it('lets superusers edit chauffeur account details and set passwords', function (): void {
|
||||
api_test_covers('PATCH /superuser/subusers/{subuser_id}', 'happy');
|
||||
api_test_covers('POST /superuser/subusers/{subuser_id}/password', 'happy');
|
||||
|
||||
$session = api_fixtures()->createUserSession(['edit_subusers']);
|
||||
$subuser = api_fixtures()->createSubuser([
|
||||
'password_plaintext' => null,
|
||||
'name' => 'Admin Managed Driver',
|
||||
'email' => null,
|
||||
'phone_country_code' => 45,
|
||||
'phone' => 73123456,
|
||||
]);
|
||||
$subuserObject = (new \objects\subusers_o())->select((int)$subuser['id']);
|
||||
$subuserObject->getObjectProperties();
|
||||
$setupToken = $subuserObject->generateSetupToken();
|
||||
|
||||
try {
|
||||
$profile = api_client()->request(
|
||||
'PATCH',
|
||||
'/superuser/subusers/' . $subuser['id'],
|
||||
[
|
||||
'name' => 'Admin Updated Driver',
|
||||
'email' => 'admin.updated.driver@example.com',
|
||||
'phone_country_code' => 46,
|
||||
'phone' => 73123457,
|
||||
],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$password = api_client()->post(
|
||||
'/superuser/subusers/' . $subuser['id'] . '/password',
|
||||
['password' => 'ValidPass123'],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$profile
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
$password
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($profile->data()['subuser']['name'] ?? null)->toBe('Admin Updated Driver');
|
||||
expect($profile->data()['subuser']['email'] ?? null)->toBe('admin.updated.driver@example.com');
|
||||
expect($profile->data()['subuser']['phone_country_code'] ?? null)->toBe(46);
|
||||
expect($profile->data()['subuser']['phone'] ?? null)->toBe(73123457);
|
||||
expect($password->data()['subuser']['setup_required'] ?? null)->toBeFalse();
|
||||
expect((new \objects\subusers_o())->getSubuserBySetupToken($setupToken))->toBeNull();
|
||||
|
||||
$row = api_fixtures()->fetchRowById('subusers', (int)$subuser['id']);
|
||||
expect(password_verify('ValidPass123', (string)($row['password'] ?? '')))->toBeTrue();
|
||||
} finally {
|
||||
(new \objects\subusers_o())->invalidateSetupToken($setupToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('creates direct chauffeur login links scoped to a selected customer grant', function (): void {
|
||||
api_test_covers('POST /superuser/subusers/{subuser_id}/login-link', 'happy');
|
||||
api_test_covers('POST /superuser/subusers/{subuser_id}/login-link', 'failure');
|
||||
|
||||
$session = api_fixtures()->createUserSession(['edit_subusers', 'SUPERUSER_INTIMIDATE']);
|
||||
$firstCustomer = api_fixtures()->createUser(['display_name' => 'Direct Login Customer Alpha']);
|
||||
$secondCustomer = api_fixtures()->createUser(['display_name' => 'Direct Login Customer Beta']);
|
||||
$subuser = api_fixtures()->createSubuser(['name' => 'Direct Login Driver']);
|
||||
api_fixtures()->grantSubuser(
|
||||
(int)$subuser['id'],
|
||||
(int)$firstCustomer['customer_number'],
|
||||
['VEHICLES_LIST']
|
||||
);
|
||||
$secondGrantId = api_fixtures()->grantSubuser(
|
||||
(int)$subuser['id'],
|
||||
(int)$secondCustomer['customer_number'],
|
||||
['ORDERS_LIST']
|
||||
);
|
||||
|
||||
$ambiguous = api_client()->post(
|
||||
'/superuser/subusers/' . $subuser['id'] . '/login-link',
|
||||
[],
|
||||
$session['headers']
|
||||
);
|
||||
$direct = api_client()->post(
|
||||
'/superuser/subusers/' . $subuser['id'] . '/login-link',
|
||||
['grant_id' => $secondGrantId],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$ambiguous
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Customer number or grant id is required for drivers with multiple customer grants');
|
||||
$direct
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($direct->data()['customer_number'] ?? null)->toBe((int)$secondCustomer['customer_number']);
|
||||
expect($direct->data()['login_path'] ?? null)->toBeString();
|
||||
|
||||
$parts = parse_url((string)$direct->data()['login_path']);
|
||||
parse_str((string)($parts['query'] ?? ''), $query);
|
||||
|
||||
expect($parts['path'] ?? null)->toBe('/login/qr');
|
||||
expect($query['type'] ?? null)->toBe('subuser');
|
||||
expect((int)($query['customer_number'] ?? 0))->toBe((int)$secondCustomer['customer_number']);
|
||||
expect($query['token'] ?? null)->toBeString();
|
||||
|
||||
$resolved = (new \objects\subusers_o())->getSubuserBySessionToken((string)$query['token']);
|
||||
expect($resolved)->not->toBeNull();
|
||||
expect((int)$resolved->id)->toBe((int)$subuser['id']);
|
||||
(new \objects\subusers_o())->invalidateSessionToken((string)$query['token']);
|
||||
});
|
||||
|
||||
it('rejects mismatched customer numbers on user-scoped chauffeur invites', function (): void {
|
||||
|
||||
@@ -148,6 +148,42 @@ final class ApiFixtures
|
||||
return ['id' => $departmentId];
|
||||
}
|
||||
|
||||
public function setDepartmentVariable(int $departmentId, string $variable, mixed $value): void
|
||||
{
|
||||
if ($departmentId <= 0) {
|
||||
throw new RuntimeException('Department variable fixtures require a positive department id.');
|
||||
}
|
||||
|
||||
$variable = trim($variable);
|
||||
if ($variable === '') {
|
||||
throw new RuntimeException('Department variable fixtures require a variable name.');
|
||||
}
|
||||
|
||||
$conditions = [
|
||||
'department_id' => $departmentId,
|
||||
'variable' => $variable,
|
||||
];
|
||||
|
||||
$this->deleteWhereIfPossible('department_variables', $conditions);
|
||||
$this->cleanupDeleteWhere('department_variables', $conditions);
|
||||
|
||||
$variableId = $this->insertRowWithExistingColumns('department_variables', [
|
||||
'department_id' => $departmentId,
|
||||
'variable' => $variable,
|
||||
'value' => (string)$value,
|
||||
]);
|
||||
$this->cleanup->add(fn() => $this->deleteById('department_variables', $variableId));
|
||||
}
|
||||
|
||||
public function setDepartmentTimeBookingsEnabled(int $departmentId, bool $enabled): void
|
||||
{
|
||||
$this->setDepartmentVariable(
|
||||
$departmentId,
|
||||
'bookingsystem_time_based_enabled',
|
||||
$enabled ? 'true' : 'false'
|
||||
);
|
||||
}
|
||||
|
||||
public function setDepartmentSelfServeEnabled(int $departmentId, bool $enabled): void
|
||||
{
|
||||
if ($departmentId <= 0) {
|
||||
@@ -1029,16 +1065,17 @@ final class ApiFixtures
|
||||
];
|
||||
}
|
||||
|
||||
public function grantSubuser(int $subuserId, int $customerNumber, array $permissions): int
|
||||
public function grantSubuser(int $subuserId, int $customerNumber, array $permissions, array $attributes = []): int
|
||||
{
|
||||
$grantId = $this->insertRow('subuser_grants', [
|
||||
'billing_customer_number' => $customerNumber,
|
||||
'subuser' => $subuserId,
|
||||
'enabled' => 1,
|
||||
'note' => 'API test grant',
|
||||
'assigned_vehicle_id' => $attributes['assigned_vehicle_id'] ?? null,
|
||||
'enabled' => $attributes['enabled'] ?? 1,
|
||||
'note' => $attributes['note'] ?? 'API test grant',
|
||||
'permissions' => json_encode(array_values($permissions), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'created_at' => $this->now(),
|
||||
'updated_at' => $this->now(),
|
||||
'created_at' => $attributes['created_at'] ?? $this->now(),
|
||||
'updated_at' => $attributes['updated_at'] ?? $this->now(),
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
|
||||
@@ -95,6 +95,110 @@ CREATE TABLE IF NOT EXISTS `logs` (
|
||||
KEY `idx_logs_action` (`action`),
|
||||
KEY `idx_logs_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'security_firewall_rules' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `security_firewall_rules` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`action` VARCHAR(16) NOT NULL,
|
||||
`target_type` VARCHAR(32) NOT NULL,
|
||||
`target_value` VARCHAR(255) NOT NULL,
|
||||
`route_pattern` VARCHAR(255) NULL,
|
||||
`priority` INT NOT NULL DEFAULT 100,
|
||||
`reason` TEXT NULL,
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`expires_at` DATETIME NULL,
|
||||
`metadata_json` LONGTEXT NULL,
|
||||
`created_by` INT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_security_firewall_rules_active` (`enabled`, `deleted_at`, `expires_at`),
|
||||
KEY `idx_security_firewall_rules_target` (`target_type`, `target_value`),
|
||||
KEY `idx_security_firewall_rules_priority` (`priority`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'security_policy_rules' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `security_policy_rules` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`rule_key` VARCHAR(64) NOT NULL,
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`threshold_count` INT NOT NULL,
|
||||
`window_seconds` INT NOT NULL,
|
||||
`mode` VARCHAR(16) NOT NULL DEFAULT 'observe',
|
||||
`exempt_permission_nodes_json` LONGTEXT NULL,
|
||||
`updated_by` INT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_security_policy_rules_key` (`rule_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'security_policy_events' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `security_policy_events` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`rule_key` VARCHAR(64) NOT NULL,
|
||||
`subject_type` VARCHAR(32) NOT NULL,
|
||||
`subject_key` VARCHAR(191) NOT NULL,
|
||||
`route_path` VARCHAR(255) NULL,
|
||||
`route_template` VARCHAR(255) NULL,
|
||||
`method` VARCHAR(16) NULL,
|
||||
`source_ip` VARCHAR(64) NULL,
|
||||
`customer_number` INT NULL,
|
||||
`user_id` INT NULL,
|
||||
`subuser_id` INT NULL,
|
||||
`metadata_json` LONGTEXT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_security_policy_events_window` (`rule_key`, `subject_type`, `subject_key`, `created_at`),
|
||||
KEY `idx_security_policy_events_created` (`created_at`),
|
||||
KEY `idx_security_policy_events_customer` (`customer_number`, `created_at`),
|
||||
KEY `idx_security_policy_events_ip` (`source_ip`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'security_incidents' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `security_incidents` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`incident_key` VARCHAR(191) NOT NULL,
|
||||
`type` VARCHAR(64) NOT NULL,
|
||||
`severity` VARCHAR(16) NOT NULL DEFAULT 'medium',
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'open',
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`source_ip` VARCHAR(64) NULL,
|
||||
`customer_number` INT NULL,
|
||||
`user_id` INT NULL,
|
||||
`subuser_id` INT NULL,
|
||||
`route_path` VARCHAR(255) NULL,
|
||||
`route_template` VARCHAR(255) NULL,
|
||||
`method` VARCHAR(16) NULL,
|
||||
`related_rule_id` BIGINT UNSIGNED NULL,
|
||||
`related_firewall_rule_id` BIGINT UNSIGNED NULL,
|
||||
`occurrence_count` INT NOT NULL DEFAULT 1,
|
||||
`metadata_json` LONGTEXT NULL,
|
||||
`first_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`resolved_by` INT NULL,
|
||||
`resolved_at` DATETIME NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_security_incidents_key` (`incident_key`),
|
||||
KEY `idx_security_incidents_status_seen` (`status`, `last_seen_at`),
|
||||
KEY `idx_security_incidents_type_seen` (`type`, `last_seen_at`),
|
||||
KEY `idx_security_incidents_customer_seen` (`customer_number`, `last_seen_at`),
|
||||
KEY `idx_security_incidents_ip_seen` (`source_ip`, `last_seen_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'security_incident_notes' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `security_incident_notes` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`incident_id` BIGINT UNSIGNED NOT NULL,
|
||||
`note` TEXT NOT NULL,
|
||||
`created_by` INT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_security_incident_notes_incident` (`incident_id`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'departments' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `departments` (
|
||||
@@ -732,6 +836,7 @@ CREATE TABLE IF NOT EXISTS `subuser_grants` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`billing_customer_number` INT NOT NULL,
|
||||
`subuser` INT NOT NULL,
|
||||
`assigned_vehicle_id` INT NULL,
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`note` TEXT NULL,
|
||||
`permissions` LONGTEXT NULL,
|
||||
@@ -740,6 +845,7 @@ CREATE TABLE IF NOT EXISTS `subuser_grants` (
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_subuser_grants_subuser` (`subuser`),
|
||||
KEY `idx_subuser_grants_assigned_vehicle_id` (`assigned_vehicle_id`),
|
||||
KEY `idx_subuser_grants_billing_customer_number` (`billing_customer_number`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
it('defines durable backup schema tables for records, components, jobs, and restore audit', function (): void {
|
||||
$content = file_get_contents(app_path('classes/backup_schema_bootstrap.php'));
|
||||
|
||||
expect($content)->toContain('CREATE TABLE IF NOT EXISTS backup_records')
|
||||
->and($content)->toContain('CREATE TABLE IF NOT EXISTS backup_components')
|
||||
->and($content)->toContain('CREATE TABLE IF NOT EXISTS backup_jobs')
|
||||
->and($content)->toContain('CREATE TABLE IF NOT EXISTS backup_restore_audit')
|
||||
->and($content)->toContain('manifest_sha256')
|
||||
->and($content)->toContain('encryption_key_id');
|
||||
});
|
||||
|
||||
it('hardens database dumps by keeping passwords out of the mysqldump command', function (): void {
|
||||
$content = file_get_contents(app_path('classes/db.php'));
|
||||
|
||||
expect($content)->toContain('MYSQL_PWD')
|
||||
->and($content)->toContain('--single-transaction')
|
||||
->and($content)->toContain('--routines')
|
||||
->and($content)->toContain('--triggers')
|
||||
->and($content)->toContain('--events')
|
||||
->and($content)->not->toContain('--password=$pass');
|
||||
});
|
||||
|
||||
it('uses encrypted component backups and never stores raw environment dumps', function (): void {
|
||||
$content = file_get_contents(app_path('classes/backup_store.php'));
|
||||
|
||||
expect($content)->toContain('AES-256-GCM')
|
||||
->and($content)->toContain('BACKUP_ENCRYPTION_KEY_V1')
|
||||
->and($content)->toContain('required_runtime_config_keys')
|
||||
->and($content)->toContain('createObjectBucketComponent')
|
||||
->and($content)->not->toContain('json_encode($_ENV)')
|
||||
->and($content)->not->toContain("exec('zip -r");
|
||||
});
|
||||
|
||||
it('normalizes string boolean config values before destructive restore gates', function (): void {
|
||||
$content = file_get_contents(app_path('classes/backup_store.php'));
|
||||
|
||||
expect($content)->toContain("['1', 'true', 'yes', 'on']")
|
||||
->and($content)->toContain("['0', 'false', 'no', 'off', '']")
|
||||
->and($content)->toContain('Backup system is disabled in backup configuration.')
|
||||
->and($content)->toContain('Direct production restore is disabled in backup configuration.');
|
||||
});
|
||||
|
||||
it('wires backup job, verification, restore preview, restore, and audit routes', function (): void {
|
||||
$content = file_get_contents(app_path('routes/moduleBackupsRoute.php'));
|
||||
|
||||
expect($content)->toContain('/modules/backup/jobs/{id}')
|
||||
->and($content)->toContain('/modules/backup/backups/{backup_uuid}/verify')
|
||||
->and($content)->toContain('/modules/backup/backups/{backup_uuid}/restore/preview')
|
||||
->and($content)->toContain('/modules/backup/backups/{backup_uuid}/restore')
|
||||
->and($content)->toContain('/modules/backup/restore-audit')
|
||||
->and($content)->toContain('modules_backup_restore')
|
||||
->and($content)->toContain('Subuser sessions cannot manage backup disaster recovery.');
|
||||
});
|
||||
|
||||
it('registers hourly backup enqueue, worker, and retention prune cron tasks', function (): void {
|
||||
$content = file_get_contents(app_path('modules/backups/cron/tasks.php'));
|
||||
$cron = file_get_contents(app_path('cron/Cron.php'));
|
||||
|
||||
expect($content)->toContain("'seconds' => 3600")
|
||||
->and($content)->toContain('backups.process_jobs')
|
||||
->and($content)->toContain('backups.prune_retention')
|
||||
->and($cron)->toContain('processBackupJobs')
|
||||
->and($cron)->toContain('pruneBackupRetention');
|
||||
});
|
||||
|
||||
it('documents backup disaster recovery APIs and config variables in openapi', function (): void {
|
||||
$content = file_get_contents(app_path('openapi.yaml'));
|
||||
|
||||
expect($content)->toContain('/modules/backup/jobs/{id}:')
|
||||
->and($content)->toContain('/modules/backup/backups/{backup_uuid}/restore/preview:')
|
||||
->and($content)->toContain('BackupRestoreRequest')
|
||||
->and($content)->toContain('BackupRestoreAuditListResponse')
|
||||
->and($content)->toContain('retention_recent_hours')
|
||||
->and($content)->toContain('verification_required')
|
||||
->and($content)->toContain('restore_enabled');
|
||||
});
|
||||
@@ -7,9 +7,11 @@ it('discovers module-owned cron task definitions', function (): void {
|
||||
$registry = new cron_task_registry(app_path('modules'));
|
||||
$definitions = $registry->definitions();
|
||||
|
||||
expect($definitions)->toHaveCount(20);
|
||||
expect($definitions)->toHaveCount(22);
|
||||
expect(array_keys($definitions))->toContain(
|
||||
'system.sync_logs',
|
||||
'backups.process_jobs',
|
||||
'backups.prune_retention',
|
||||
'economic.transfer_queue',
|
||||
'dynamicimages.pre_render',
|
||||
'weatherapi.preload_department_responses',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
it('wires cron workers through schema, CLI, scheduler, and superuser routes', function (): void {
|
||||
$schema = file_get_contents(app_path('classes/cron_schema_bootstrap.php'));
|
||||
$worker = file_get_contents(app_path('classes/cron_worker.php'));
|
||||
$scheduler = file_get_contents(app_path('classes/cron_scheduler.php'));
|
||||
$cli = file_get_contents(app_path('cli.php'));
|
||||
$route = file_get_contents(app_path('routes/cronRoute.php'));
|
||||
$manager = file_get_contents(app_path('classes/release_manager.php'));
|
||||
|
||||
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS cron_worker_state');
|
||||
expect($schema)->toContain('last_heartbeat_at');
|
||||
expect($schema)->toContain('last_stale_run_count');
|
||||
|
||||
expect($worker)->toContain('CRON_WORKER_POLL_SECONDS');
|
||||
expect($worker)->toContain('CRON_WORKER_HEARTBEAT_SECONDS');
|
||||
expect($worker)->toContain('CRON_WORKER_RELEASE_TARGET_ID');
|
||||
expect($worker)->toContain('markExpiredRunningRuns');
|
||||
expect($worker)->toContain('runDue($this->source)');
|
||||
|
||||
expect($scheduler)->toContain('function markExpiredRunningRuns');
|
||||
expect($scheduler)->toContain("r.status = 'timed_out'");
|
||||
expect($scheduler)->toContain('s.current_run_id = NULL');
|
||||
|
||||
expect($cli)->toContain("case 'cron-worker'");
|
||||
expect($cli)->toContain('new \\classes\\cron_worker()');
|
||||
|
||||
expect($route)->toContain('/superuser/cron/workers');
|
||||
expect($route)->toContain('/superuser/cron/workers/deploy');
|
||||
expect($route)->toContain('superuser_cron_view');
|
||||
expect($route)->toContain('superuser_cron_manage');
|
||||
expect($route)->toContain('superuser_coolify_manage');
|
||||
|
||||
expect($manager)->toContain("private const CRON_WORKER_APP = 'cron'");
|
||||
expect($manager)->toContain("private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'");
|
||||
expect($manager)->toContain('deployCronWorkerAfterApiDeployment');
|
||||
expect($manager)->toContain('cron_worker_deploy_failed');
|
||||
expect($manager)->toContain('auto_deploy = 0');
|
||||
});
|
||||
@@ -375,6 +375,73 @@ it('uses the self-contained Coolify API Dockerfile for API applications', functi
|
||||
expect($payload)->not->toHaveKey('is_static');
|
||||
});
|
||||
|
||||
it('creates private Coolify application payloads for cron workers', function (): void {
|
||||
$manager = new release_manager();
|
||||
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');
|
||||
$payloadMethod->setAccessible(true);
|
||||
|
||||
$payload = $payloadMethod->invoke($manager, [
|
||||
'channel_slug' => 'internal',
|
||||
'app' => 'cron',
|
||||
'repository' => 'copenhagentruckwash/api',
|
||||
'branch' => 'master',
|
||||
'auto_deploy' => 0,
|
||||
], [
|
||||
'coolify_service_name' => 'release-internal-cron-worker',
|
||||
'coolify_project_uuid' => 'project-internal',
|
||||
'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
|
||||
'coolify_build_pack' => 'dockerfile',
|
||||
'coolify_deploy_now' => true,
|
||||
'coolify_start_command' => 'php index.php run cron-worker',
|
||||
], [
|
||||
'default_environment_name' => 'production',
|
||||
'default_server_uuid' => 'server-node3',
|
||||
]);
|
||||
|
||||
expect($payload['name'])->toBe('release-internal-cron-worker');
|
||||
expect($payload['build_pack'])->toBe('dockerfile');
|
||||
expect($payload['ports_exposes'])->toBe('80');
|
||||
expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-api');
|
||||
expect($payload['start_command'])->toBe('php index.php run cron-worker');
|
||||
expect($payload['is_auto_deploy_enabled'])->toBeFalse();
|
||||
expect($payload)->not->toHaveKey('domains');
|
||||
expect($payload)->not->toHaveKey('is_force_https_enabled');
|
||||
});
|
||||
|
||||
it('derives cron worker deployment context from the API target without public routing', function (): void {
|
||||
$manager = new release_manager();
|
||||
$contextMethod = new ReflectionMethod(release_manager::class, 'cronWorkerDeployContext');
|
||||
$contextMethod->setAccessible(true);
|
||||
|
||||
$context = $contextMethod->invoke($manager, [
|
||||
'id' => 17,
|
||||
'channel_id' => 3,
|
||||
'channel_slug' => 'internal',
|
||||
'deploy_context_json' => json_encode([
|
||||
'coolify_project_uuid' => 'project-internal',
|
||||
'coolify_environment_name' => 'production',
|
||||
'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
|
||||
'coolify_public_url' => 'https://api-v2.truckwash.io',
|
||||
'manual_endpoint_host' => 'manual.example.test',
|
||||
]),
|
||||
], null, '5555555555555555555555555555555555555555', 41);
|
||||
|
||||
expect($context['coolify_auto_create'])->toBeTrue();
|
||||
expect($context['coolify_resource_type'])->toBe('application');
|
||||
expect($context['coolify_build_pack'])->toBe('dockerfile');
|
||||
expect($context['coolify_dockerfile_location'])->toBe('/Dockerfile.coolify-api');
|
||||
expect($context['coolify_start_command'])->toBe('php index.php run cron-worker');
|
||||
expect($context['coolify_is_auto_deploy_enabled'])->toBeFalse();
|
||||
expect($context['coolify_enable_ssl'])->toBeFalse();
|
||||
expect($context['coolify_service_name'])->toBe('release-internal-cron-worker');
|
||||
expect($context['coolify_git_commit_sha'])->toBe('5555555555555555555555555555555555555555');
|
||||
expect($context['coolify_env']['CRON_WORKER_RELEASE_CHANNEL_ID'])->toBe('3');
|
||||
expect($context['coolify_env']['CRON_WORKER_RELEASE_TARGET_ID'])->toBe('41');
|
||||
expect($context['coolify_env']['CRON_WORKER_SOURCE'])->toBe('coolify_worker');
|
||||
expect($context)->not->toHaveKey('coolify_public_url');
|
||||
expect($context)->not->toHaveKey('manual_endpoint_host');
|
||||
});
|
||||
|
||||
it('builds explicit Coolify application route labels for release API targets', function (): void {
|
||||
$manager = new release_manager();
|
||||
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload');
|
||||
@@ -658,6 +725,57 @@ it('forces selected API commit into generated Coolify runtime env keys', functio
|
||||
expect($env['RELEASE_COMMIT_SHA'])->toBe($selectedCommit);
|
||||
});
|
||||
|
||||
it('builds cron worker runtime environment from API runtime keys and cron context', function (): void {
|
||||
$keys = ['CONFIG_DB_HOST', 'CRON_WORKER_NAME'];
|
||||
$previous = [];
|
||||
foreach ($keys as $key) {
|
||||
$previous[$key] = getenv($key);
|
||||
}
|
||||
|
||||
try {
|
||||
putenv('CONFIG_DB_HOST=db.example.test');
|
||||
$_ENV['CONFIG_DB_HOST'] = 'db.example.test';
|
||||
$_SERVER['CONFIG_DB_HOST'] = 'db.example.test';
|
||||
putenv('CRON_WORKER_NAME=ignored-runtime-name');
|
||||
$_ENV['CRON_WORKER_NAME'] = 'ignored-runtime-name';
|
||||
$_SERVER['CRON_WORKER_NAME'] = 'ignored-runtime-name';
|
||||
|
||||
$manager = new release_manager();
|
||||
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
|
||||
$runtimeEnv->setAccessible(true);
|
||||
$selectedCommit = '4444444444444444444444444444444444444444';
|
||||
|
||||
$env = $runtimeEnv->invoke($manager, [
|
||||
'app' => 'cron',
|
||||
'commit_sha' => $selectedCommit,
|
||||
], [
|
||||
'coolify_env' => [
|
||||
'CRON_WORKER_NAME' => 'release-internal-cron-worker',
|
||||
'CRON_WORKER_SOURCE' => 'coolify_worker',
|
||||
],
|
||||
]);
|
||||
|
||||
expect($env['USE_ENV'])->toBe('true');
|
||||
expect($env['CONFIG_DB_HOST'])->toBe('db.example.test');
|
||||
expect($env['CRON_WORKER_NAME'])->toBe('release-internal-cron-worker');
|
||||
expect($env['CRON_WORKER_SOURCE'])->toBe('coolify_worker');
|
||||
expect($env['CRON_WORKER_COMMIT_SHA'])->toBe($selectedCommit);
|
||||
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
|
||||
expect($env['COMMIT_SHA'])->toBe($selectedCommit);
|
||||
} finally {
|
||||
foreach ($previous as $key => $value) {
|
||||
if ($value === false) {
|
||||
putenv($key);
|
||||
unset($_ENV[$key], $_SERVER[$key]);
|
||||
} else {
|
||||
putenv($key . '=' . $value);
|
||||
$_ENV[$key] = $value;
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the selected deployment commit before stale Coolify context commits', function (): void {
|
||||
$manager = new release_manager();
|
||||
$gitCommitSha = new ReflectionMethod(release_manager::class, 'releaseCoolifyGitCommitSha');
|
||||
@@ -1444,6 +1562,32 @@ it('resolves release deployment endpoints from manual overrides, URLs, health ch
|
||||
'public_gateway_host' => 'gateway.example.test',
|
||||
]),
|
||||
]))->toBe('https://gateway.example.test/beta/frontend');
|
||||
|
||||
$cron = $endpoint->invoke($manager, [
|
||||
'app' => 'cron',
|
||||
'channel_slug' => 'beta',
|
||||
'deploy_context_json' => json_encode([
|
||||
'endpoint_mode' => 'auto',
|
||||
'public_gateway_host' => 'gateway.example.test',
|
||||
'coolify_public_url' => 'https://api-v2.truckwash.io',
|
||||
]),
|
||||
]);
|
||||
expect($cron)->toMatchArray([
|
||||
'status' => 'pending',
|
||||
'host' => null,
|
||||
'url' => null,
|
||||
'source' => 'private_worker',
|
||||
]);
|
||||
expect($publicUrl->invoke($manager, ['app' => 'cron', 'channel_slug' => 'beta'], [
|
||||
'public_gateway_host' => 'gateway.example.test',
|
||||
]))->toBeNull();
|
||||
expect($targetPublicBaseUrl->invoke($manager, [
|
||||
'app' => 'cron',
|
||||
'channel_slug' => 'beta',
|
||||
'deploy_context_json' => json_encode([
|
||||
'public_gateway_host' => 'gateway.example.test',
|
||||
]),
|
||||
]))->toBeNull();
|
||||
});
|
||||
|
||||
it('collects previous Coolify application UUIDs for stale route cleanup', function (): void {
|
||||
@@ -1634,3 +1778,56 @@ it('restricts release gate fetches to Truckwash release hosts and relative paths
|
||||
expect(fn() => $joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '//127.0.0.1/ping'))
|
||||
->toThrow(RuntimeException::class, 'relative');
|
||||
});
|
||||
|
||||
it('normalizes Coolify application list payloads for release cleanup previews', function (): void {
|
||||
$manager = new release_manager();
|
||||
$payloadRows = new ReflectionMethod(release_manager::class, 'payloadRows');
|
||||
|
||||
expect($payloadRows->invoke($manager, [
|
||||
'applications' => [
|
||||
['uuid' => 'app-1', 'name' => 'release-canary-api'],
|
||||
['uuid' => 'app-2', 'name' => 'release-old-frontend'],
|
||||
],
|
||||
]))->toBe([
|
||||
['uuid' => 'app-1', 'name' => 'release-canary-api'],
|
||||
['uuid' => 'app-2', 'name' => 'release-old-frontend'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps Coolify cleanup selection hashes tied to action and resource identity', function (): void {
|
||||
$manager = new release_manager();
|
||||
$hash = new ReflectionMethod(release_manager::class, 'coolifyCleanupSelectionHash');
|
||||
$phrase = new ReflectionMethod(release_manager::class, 'coolifyCleanupConfirmationPhrase');
|
||||
$matches = new ReflectionMethod(release_manager::class, 'coolifyCleanupReferenceMatchesPolicy');
|
||||
|
||||
$policy = [
|
||||
'instance_id' => 3,
|
||||
'channel_id' => 2,
|
||||
'channel_slug' => 'canary',
|
||||
'app' => 'api',
|
||||
'resource_type' => 'application',
|
||||
'action' => 'delete',
|
||||
];
|
||||
$candidate = [
|
||||
'instance_id' => 3,
|
||||
'type' => 'application',
|
||||
'uuid' => 'old-api-canary-app',
|
||||
'action' => 'delete',
|
||||
];
|
||||
$reference = [
|
||||
'apps' => ['api' => 'api'],
|
||||
'channels' => [2 => 2],
|
||||
'resource_types' => ['application' => 'application'],
|
||||
];
|
||||
|
||||
$deleteHash = $hash->invoke($manager, $policy, [$candidate]);
|
||||
$stopHash = $hash->invoke($manager, array_replace($policy, ['action' => 'stop']), [
|
||||
array_replace($candidate, ['action' => 'stop']),
|
||||
]);
|
||||
|
||||
expect($matches->invoke($manager, $reference, $policy))->toBeTrue()
|
||||
->and($matches->invoke($manager, $reference, array_replace($policy, ['app' => 'frontend'])))->toBeFalse()
|
||||
->and($matches->invoke($manager, $reference, array_replace($policy, ['channel_id' => 99])))->toBeFalse()
|
||||
->and($deleteHash)->not->toBe($stopHash)
|
||||
->and($phrase->invoke($manager, $deleteHash))->toBe('cleanup-coolify-' . substr($deleteHash, 0, 12));
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
it('registers superuser replication endpoints and permissions', function (): void {
|
||||
it('keeps the superuser replication read endpoint and retires mutation handlers', function (): void {
|
||||
$content = file_get_contents(app_path('routes/superuserReplicationRoute.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
@@ -18,17 +18,32 @@ it('registers superuser replication endpoints and permissions', function (): voi
|
||||
expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_manage')");
|
||||
expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_promote')");
|
||||
expect($content)->toContain("requireClassicSuperuserPermission('superuser_replication_remove')");
|
||||
expect($content)->toContain('RETIRED_MANAGEMENT_MESSAGE');
|
||||
expect($content)->toContain('private function rejectRetiredManagement(): void');
|
||||
expect($content)->toContain("\$response->error(['message' => self::RETIRED_MANAGEMENT_MESSAGE], 410);");
|
||||
expect($content)->not->toContain('->addHost(');
|
||||
expect($content)->not->toContain('->generateComposeTemplate(');
|
||||
expect($content)->not->toContain('->testUnsavedCredentials(');
|
||||
expect($content)->not->toContain('->testHost(');
|
||||
expect($content)->not->toContain('->provisionHost(');
|
||||
expect($content)->not->toContain('->promoteHost(');
|
||||
expect($content)->not->toContain('->renameHost(');
|
||||
expect($content)->not->toContain('->deleteHost(');
|
||||
});
|
||||
|
||||
it('documents replication management in openapi', function (): void {
|
||||
it('documents retired replication mutations in openapi', function (): void {
|
||||
$content = file_get_contents(app_path('openapi.yaml'));
|
||||
|
||||
expect($content)->toContain('/superuser/replication:');
|
||||
expect($content)->toContain('operationId: getSuperuserReplication');
|
||||
expect($content)->toContain('summary: Retired database replication host creation');
|
||||
expect($content)->toContain('operationId: generateSuperuserReplicationComposeTemplate');
|
||||
expect($content)->toContain('operationId: testSuperuserReplicationCredentials');
|
||||
expect($content)->toContain('operationId: addSuperuserMinioReplicationHost');
|
||||
expect($content)->toContain('operationId: renameSuperuserReplicationHost');
|
||||
expect(preg_match_all('/deprecated:\s+true/', $content))->toBeGreaterThanOrEqual(9);
|
||||
expect($content)->toContain("'410': { \$ref: '#/components/responses/Gone' }");
|
||||
expect($content)->toContain('Gone:');
|
||||
expect($content)->toContain('enum: [database, redis, minio]');
|
||||
expect($content)->toContain('space_headroom_percent');
|
||||
expect($content)->toContain('SuperuserReplicationStatus');
|
||||
@@ -37,6 +52,17 @@ it('documents replication management in openapi', function (): void {
|
||||
expect($content)->toContain('SuperuserReplicationComposeTemplateRequest');
|
||||
});
|
||||
|
||||
it('keeps superuser system status independent from replica health', function (): void {
|
||||
$content = file_get_contents(app_path('classes/superuser_system_status_service.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->not->toContain('dependencyReplication');
|
||||
expect($content)->not->toContain('replicationStatusFallback');
|
||||
expect($content)->not->toContain("\$dependencies['database']['replication']");
|
||||
expect($content)->not->toContain("\$dependencies['redis']['replication']");
|
||||
expect($content)->not->toContain("\$dependencies['minio']['replication']");
|
||||
});
|
||||
|
||||
it('rejects subuser sessions before checking replication permissions', function (): void {
|
||||
$content = file_get_contents(app_path('routes/superuserReplicationRoute.php'));
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
it('registers the superuser security endpoints and permissions', function (): void {
|
||||
$content = file_get_contents(app_path('routes/superuserSecurityRoute.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain('/superuser/system/security/summary');
|
||||
expect($content)->toContain('/superuser/system/security/settings');
|
||||
expect($content)->toContain('/superuser/system/security/firewall-rules');
|
||||
expect($content)->toContain('/superuser/system/security/firewall-rules/{id}');
|
||||
expect($content)->toContain('/superuser/system/security/incidents');
|
||||
expect($content)->toContain('/superuser/system/security/incidents/{id}');
|
||||
expect($content)->toContain('/superuser/system/security/incidents/{id}/notes');
|
||||
expect($content)->toContain("requireClassicSuperuserPermission('superuser_security_view')");
|
||||
expect($content)->toContain("requireClassicSuperuserPermission('superuser_security_settings_manage')");
|
||||
expect($content)->toContain("requireClassicSuperuserPermission('superuser_security_firewall_manage')");
|
||||
expect($content)->toContain("requireClassicSuperuserPermission('superuser_security_incidents_manage')");
|
||||
expect($content)->toContain("'superuser_security_limits_exempt' => 'Exempt requests from observe-mode security limit incidents'");
|
||||
});
|
||||
|
||||
it('keeps superuser security controls unavailable to subuser sessions', function (): void {
|
||||
$content = file_get_contents(app_path('routes/superuserSecurityRoute.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain('private function requireClassicSuperuserPermission(string $permission): bool');
|
||||
expect($content)->toContain('get_subuser() !== false');
|
||||
expect($content)->toContain("Subuser sessions cannot manage security controls.");
|
||||
expect($content)->toContain('return $this->requirePermission($permission);');
|
||||
expect($content)->not->toContain("requirePermission('superuser_security_");
|
||||
});
|
||||
|
||||
it('wires request and domain event observation into the backend', function (): void {
|
||||
$routeTrait = file_get_contents(app_path('traits/route_t.php'));
|
||||
$authRoute = file_get_contents(app_path('routes/authRoute.php'));
|
||||
$bookingRoute = file_get_contents(app_path('routes/orderBookingRoute.php'));
|
||||
$vehiclesRoute = file_get_contents(app_path('routes/vehiclesRoute.php'));
|
||||
|
||||
expect($routeTrait)->toContain('security_policy_service');
|
||||
expect($routeTrait)->toContain('inspectRequest($route, $method)');
|
||||
expect($authRoute)->toContain('observeLoginFailure(');
|
||||
expect($bookingRoute)->toContain('observeBookingCreated');
|
||||
expect($vehiclesRoute)->toContain('observeVehicleCreated');
|
||||
});
|
||||
|
||||
it('defines persistent security tables for runtime and api tests', function (): void {
|
||||
$runtimeSchema = file_get_contents(app_path('classes/security_schema_bootstrap.php'));
|
||||
$testSchema = file_get_contents(app_path('tests/Support/Api/ApiSchemaBootstrap.php'));
|
||||
|
||||
foreach ([
|
||||
'security_firewall_rules',
|
||||
'security_policy_rules',
|
||||
'security_policy_events',
|
||||
'security_incidents',
|
||||
'security_incident_notes',
|
||||
] as $table) {
|
||||
expect($runtimeSchema)->toContain($table);
|
||||
expect($testSchema)->toContain($table);
|
||||
}
|
||||
});
|
||||
|
||||
it('documents superuser security endpoints in openapi', function (): void {
|
||||
$content = file_get_contents(app_path('openapi.yaml'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain('/superuser/system/security/summary:');
|
||||
expect($content)->toContain('/superuser/system/security/settings:');
|
||||
expect($content)->toContain('/superuser/system/security/firewall-rules:');
|
||||
expect($content)->toContain('/superuser/system/security/firewall-rules/{id}:');
|
||||
expect($content)->toContain('/superuser/system/security/incidents:');
|
||||
expect($content)->toContain('/superuser/system/security/incidents/{id}:');
|
||||
expect($content)->toContain('/superuser/system/security/incidents/{id}/notes:');
|
||||
expect($content)->toContain('SuperuserSecuritySettingsUpdateRequest');
|
||||
expect($content)->toContain('SuperuserSecurityFirewallRuleMutation');
|
||||
expect($content)->toContain('SuperuserSecurityIncidentResponse');
|
||||
});
|
||||
@@ -28,6 +28,14 @@ class SuperuserSystemStatusServiceProbeDouble extends superuser_system_status_se
|
||||
public ?array $moduleConfigRowsOverride = null;
|
||||
public bool $backupStoreValidationShouldFail = false;
|
||||
public string $backupStoreFailureMessage = 'Backup bucket is missing.';
|
||||
public array $backupHealthSummary = [
|
||||
'latest_verified_backup' => ['backup_uuid' => 'verified-backup'],
|
||||
'latest_verified_age_seconds' => 60,
|
||||
'fresh' => true,
|
||||
'encryption' => ['available' => true, 'key_id' => 'test-key'],
|
||||
'verification_required' => true,
|
||||
'restore_enabled' => false,
|
||||
];
|
||||
public bool $selfserveBootstrapShouldFail = false;
|
||||
public string $selfserveBootstrapFailureMessage = 'Schema bootstrap failed.';
|
||||
public bool $selfserveMinuteProductExistsValue = true;
|
||||
@@ -167,6 +175,11 @@ class SuperuserSystemStatusServiceProbeDouble extends superuser_system_status_se
|
||||
}
|
||||
}
|
||||
|
||||
protected function backupHealthSummary(): array
|
||||
{
|
||||
return $this->backupHealthSummary;
|
||||
}
|
||||
|
||||
protected function bootstrapSelfserveSchema(): void
|
||||
{
|
||||
if ($this->selfserveBootstrapShouldFail) {
|
||||
@@ -525,6 +538,41 @@ it('reports backup probe failures from local validation', function (): void {
|
||||
expect($result['status_reason_key'])->toBe('backup_probe_failed');
|
||||
});
|
||||
|
||||
it('reports backups down when encryption key is missing', function (): void {
|
||||
$service = new SuperuserSystemStatusServiceProbeDouble();
|
||||
$service->backupHealthSummary['encryption'] = [
|
||||
'available' => false,
|
||||
'error' => 'BACKUP_ENCRYPTION_KEY_V1 is not configured.',
|
||||
];
|
||||
|
||||
$result = $service->probeBackupsModulePublic([]);
|
||||
|
||||
expect($result['status'])->toBe('down');
|
||||
expect($result['status_reason_key'])->toBe('backup_encryption_key_missing');
|
||||
});
|
||||
|
||||
it('reports backups degraded when no verified backup is available', function (): void {
|
||||
$service = new SuperuserSystemStatusServiceProbeDouble();
|
||||
$service->backupHealthSummary['latest_verified_backup'] = null;
|
||||
$service->backupHealthSummary['fresh'] = false;
|
||||
|
||||
$result = $service->probeBackupsModulePublic([]);
|
||||
|
||||
expect($result['status'])->toBe('degraded');
|
||||
expect($result['status_reason_key'])->toBe('backup_no_verified_backup');
|
||||
});
|
||||
|
||||
it('reports backups degraded when the latest verified backup is stale', function (): void {
|
||||
$service = new SuperuserSystemStatusServiceProbeDouble();
|
||||
$service->backupHealthSummary['fresh'] = false;
|
||||
$service->backupHealthSummary['latest_verified_age_seconds'] = 7200;
|
||||
|
||||
$result = $service->probeBackupsModulePublic([]);
|
||||
|
||||
expect($result['status'])->toBe('degraded');
|
||||
expect($result['status_reason_key'])->toBe('backup_latest_verified_stale');
|
||||
});
|
||||
|
||||
it('returns configured for shelly when no known device id is available for probing', function (): void {
|
||||
$service = new SuperuserSystemStatusServiceProbeDouble();
|
||||
|
||||
|
||||
@@ -679,9 +679,17 @@ trait route_t
|
||||
global $router;
|
||||
// Wrap the original callback so we can expose the matched route template to fromRoute()
|
||||
$self = $this;
|
||||
$wrapped = function () use ($callback, $route, $self) {
|
||||
$wrapped = function () use ($callback, $route, $method, $self) {
|
||||
// Set the current route template for parameter extraction
|
||||
$self->__current_route_template = $route;
|
||||
try {
|
||||
if (defined('WD') && is_file(WD . '/classes/security_policy_service.php')) {
|
||||
require_once WD . '/classes/security_policy_service.php';
|
||||
(new \classes\security_policy_service())->inspectRequest($route, $method);
|
||||
}
|
||||
} catch (\Throwable $throwable) {
|
||||
error_log('[security_policy_service] request inspection failed: ' . $throwable->getMessage());
|
||||
}
|
||||
// Execute the original callback
|
||||
$callback();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user