Fixes config file editing

main
Yasen Pramatarov 2025-04-11 16:55:08 +03:00
parent 9d3bb9ef04
commit d253d87515
6 changed files with 312 additions and 158 deletions

View File

@ -0,0 +1,47 @@
<?php
/**
* API Response Handler
* Provides a consistent way to send JSON responses from controllers
*/
class ApiResponse {
/**
* Send a success response
* @param mixed $data Optional data to include in response
* @param string $message Optional success message
* @param int $status HTTP status code
*/
public static function success($data = null, $message = '', $status = 200) {
self::send([
'success' => true,
'data' => $data,
'message' => $message
], $status);
}
/**
* Send an error response
* @param string $message Error message
* @param mixed $errors Optional error details
* @param int $status HTTP status code
*/
public static function error($message, $errors = null, $status = 400) {
self::send([
'success' => false,
'error' => $message,
'errors' => $errors
], $status);
}
/**
* Send the actual JSON response
* @param array $data Response data
* @param int $status HTTP status code
*/
private static function send($data, $status) {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
}

View File

@ -3,7 +3,7 @@
/** /**
* class Config * class Config
* *
* Handles editing and fetching ot the config files. * Handles editing and fetching of the config files.
*/ */
class Config { class Config {
@ -13,41 +13,146 @@ class Config {
* @param array $updatedConfig Key-value pairs of config options to update. * @param array $updatedConfig Key-value pairs of config options to update.
* @param string $config_file Path to the config file. * @param string $config_file Path to the config file.
* *
* @return mixed Returns true on success, or an error message on failure. * @return array Returns an array with 'success' and 'updated' keys on success, or 'success' and 'error' keys on failure.
*/ */
public function editConfigFile($updatedConfig, $config_file) { public function editConfigFile($updatedConfig, $config_file) {
// first we get a fresh config file contents as text global $logObject, $user_id;
$config_contents = file_get_contents($config_file); $allLogs = [];
if (!$config_contents) { $updated = [];
return "Failed to read the config file \"$config_file\".";
try {
if (!is_array($updatedConfig)) {
throw new Exception("Invalid config data: expected array");
} }
// loop through the variables and updated them if (!file_exists($config_file) || !is_writable($config_file)) {
foreach ($updatedConfig as $key => $newValue) { throw new Exception("Config file does not exist or is not writable: $config_file");
// we look for 'option' => value }
// option is always in single quotes
// value is without quotes, because it could be true/false
$pattern = "/(['\"]{$key}['\"]\s*=>\s*)([^,]+),/";
// prepare the value, make booleans w/out single quotes // First we get a fresh config file contents as text
if ($newValue === 'true') { $config_contents = file_get_contents($config_file);
if ($config_contents === false) {
throw new Exception("Failed to read the config file: $config_file");
}
$lines = explode("\n", $config_contents);
// We loop through the variables and update them
foreach ($updatedConfig as $key => $newValue) {
if (strpos($key, '[') !== false) {
preg_match_all('/([^\[\]]+)/', $key, $matches);
if (empty($matches[1])) continue;
$parts = $matches[1];
$currentPath = [];
$found = false;
$inTargetArray = false;
foreach ($lines as $i => $line) {
$line = rtrim($line);
if (preg_match("/^\\s*\\]/", $line)) {
if (!empty($currentPath)) {
if ($inTargetArray && end($currentPath) === $parts[0]) {
$inTargetArray = false;
}
array_pop($currentPath);
}
continue;
}
if (preg_match("/^\\s*['\"]([^'\"]+)['\"]\\s*=>/", $line, $matches)) {
$key = $matches[1];
if (strpos($line, '[') !== false) {
$currentPath[] = $key;
if ($key === $parts[0]) {
$inTargetArray = true;
}
} else if ($key === end($parts) && $inTargetArray) {
$pathMatches = true;
$expectedPath = array_slice($parts, 0, -1);
if (count($currentPath) === count($expectedPath)) {
for ($j = 0; $j < count($expectedPath); $j++) {
if ($currentPath[$j] !== $expectedPath[$j]) {
$pathMatches = false;
break;
}
}
if ($pathMatches) {
if ($newValue === 'true' || $newValue === '1') {
$replacementValue = 'true'; $replacementValue = 'true';
} elseif ($newValue === 'false') { } elseif ($newValue === 'false' || $newValue === '0') {
$replacementValue = 'false'; $replacementValue = 'false';
} else { } else {
$replacementValue = var_export($newValue, true); $replacementValue = var_export($newValue, true);
} }
// value replacing if (preg_match("/^(\\s*['\"]" . preg_quote($key, '/') . "['\"]\\s*=>\\s*).*?(,?)\\s*$/", $line, $matches)) {
$config_contents = preg_replace($pattern, "$1{$replacementValue},", $config_contents); $lines[$i] = $matches[1] . $replacementValue . $matches[2];
$updated[] = implode('.', array_merge($currentPath, [$key]));
$found = true;
}
}
}
}
}
} }
// write the new config file if (!$found) {
if (!file_put_contents($config_file, $config_contents)) { $allLogs[] = "Failed to update: $key";
return "Failed to write the config file \"$config_file\"."; }
} else {
if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $key)) {
throw new Exception("Invalid config key format: $key");
} }
return true; if ($newValue === 'true' || $newValue === '1') {
$replacementValue = 'true';
} elseif ($newValue === 'false' || $newValue === '0') {
$replacementValue = 'false';
} else {
$replacementValue = var_export($newValue, true);
} }
$found = false;
foreach ($lines as $i => $line) {
if (preg_match("/^(\\s*['\"]" . preg_quote($key, '/') . "['\"]\\s*=>\\s*).*?(,?)\\s*$/", $line, $matches)) {
$lines[$i] = $matches[1] . $replacementValue . $matches[2];
$updated[] = $key;
$found = true;
break;
}
}
if (!$found) {
$allLogs[] = "Failed to update: $key";
}
}
}
// We write the new config file
$new_contents = implode("\n", $lines);
if (file_put_contents($config_file, $new_contents) === false) {
throw new Exception("Failed to write the config file: $config_file");
}
if (!empty($allLogs)) {
$logObject->insertLog($user_id, implode("\n", $allLogs), 'system');
}
return [
'success' => true,
'updated' => $updated
];
} catch (Exception $e) {
$logObject->insertLog($user_id, "Config update error: " . $e->getMessage(), 'system');
return [
'success' => false,
'error' => $e->getMessage()
];
}
}
} }

View File

@ -13,7 +13,7 @@ return [
// site name used in emails and in the inteerface // site name used in emails and in the inteerface
'site_name' => 'Jilo Web', 'site_name' => 'Jilo Web',
// set to false to disable new registrations // set to false to disable new registrations
'registration_enabled' => '1', 'registration_enabled' => true,
// will be displayed on login screen // will be displayed on login screen
'login_message' => '', 'login_message' => '',

View File

@ -4,6 +4,7 @@ require_once __DIR__ . '/../helpers/security.php';
require_once __DIR__ . '/../helpers/logs.php'; require_once __DIR__ . '/../helpers/logs.php';
function applyCsrfMiddleware() { function applyCsrfMiddleware() {
global $logObject;
$security = SecurityHelper::getInstance(); $security = SecurityHelper::getInstance();
// Skip CSRF check for GET requests // Skip CSRF check for GET requests
@ -21,7 +22,8 @@ function applyCsrfMiddleware() {
// Check CSRF token for all other POST requests // Check CSRF token for all other POST requests
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = $_POST['csrf_token'] ?? ''; // Check for token in POST data or headers
$token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!$security->verifyCsrfToken($token)) { if (!$security->verifyCsrfToken($token)) {
// Log CSRF attempt // Log CSRF attempt
$ipAddress = getUserIP(); $ipAddress = getUserIP();
@ -31,7 +33,7 @@ function applyCsrfMiddleware() {
$_GET['page'] ?? 'unknown', $_GET['page'] ?? 'unknown',
$_SESSION['username'] ?? 'anonymous' $_SESSION['username'] ?? 'anonymous'
); );
$logObject->insertLog(0, $logMessage, 'system'); $logObject->insertLog(null, $logMessage, 'system');
// Return error message // Return error message
http_response_code(403); http_response_code(403);

View File

@ -10,14 +10,28 @@
include '../app/helpers/feedback.php'; include '../app/helpers/feedback.php';
require '../app/classes/config.php'; require '../app/classes/config.php';
$configObject = new Config(); require '../app/classes/api_response.php';
require '../app/includes/rate_limit_middleware.php'; // Initialize required objects
$userObject = new User($dbWeb);
$logObject = new Log($dbWeb);
$configObject = new Config();
// For AJAX requests // For AJAX requests
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) && $isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest'; strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest';
// Ensure config file path is set
if (!isset($config_file) || empty($config_file)) {
if ($isAjax) {
ApiResponse::error('Config file path not set');
} else {
Feedback::flash('ERROR', 'DEFAULT', 'Config file path not set');
header('Location: ' . htmlspecialchars($app_root));
}
exit;
}
// Check if file is writable // Check if file is writable
$isWritable = is_writable($config_file); $isWritable = is_writable($config_file);
$configMessage = ''; $configMessage = '';
@ -26,7 +40,19 @@ if (!$isWritable) {
} }
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Check if user has permission to edit config
if (!$userObject->hasRight($user_id, 'edit config file')) {
$logObject->insertLog($user_id, "Unauthorized: User \"$currentUser\" tried to edit config file. IP: $user_IP", 'system');
if ($isAjax) {
ApiResponse::error('Forbidden: You do not have permission to edit the config file', null, 403);
} else {
include '../app/templates/error-unauthorized.php';
}
exit;
}
// Apply rate limiting // Apply rate limiting
require '../app/includes/rate_limit_middleware.php';
checkRateLimit($dbWeb, 'config', $user_id); checkRateLimit($dbWeb, 'config', $user_id);
// Ensure no output before this point // Ensure no output before this point
@ -34,50 +60,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// For AJAX requests, get JSON data // For AJAX requests, get JSON data
if ($isAjax) { if ($isAjax) {
header('Content-Type: application/json');
// Get raw input // Get raw input
$jsonData = file_get_contents('php://input'); $jsonData = file_get_contents('php://input');
if ($jsonData === false) {
$logObject->insertLog($user_id, "Failed to read request data for config update", 'system');
ApiResponse::error('Failed to read request data');
}
// Try to parse JSON
$postData = json_decode($jsonData, true); $postData = json_decode($jsonData, true);
if (json_last_error() !== JSON_ERROR_NONE) { if (json_last_error() !== JSON_ERROR_NONE) {
$error = json_last_error_msg(); $error = json_last_error_msg();
ApiResponse::error('Invalid JSON data received: ' . $error);
Feedback::flash('ERROR', 'DEFAULT', 'Invalid JSON data received: ' . $error, true);
echo json_encode([
'success' => false,
'message' => 'Invalid JSON data received: ' . $error
]);
exit;
} }
// Try to update config file // Try to update config file
$result = $configObject->editConfigFile($postData, $config_file); $result = $configObject->editConfigFile($postData, $config_file);
if ($result === true) { if ($result['success']) {
$messageData = Feedback::getMessageData('NOTICE', 'DEFAULT', 'Config file updated successfully', true); ApiResponse::success($result['updated'], 'Config file updated successfully');
echo json_encode([
'success' => true,
'message' => 'Config file updated successfully',
'messageData' => $messageData
]);
} else { } else {
$messageData = Feedback::getMessageData('ERROR', 'DEFAULT', "Error updating config file: $result", true); ApiResponse::error($result['error']);
echo json_encode([
'success' => false,
'message' => "Error updating config file: $result",
'messageData' => $messageData
]);
} }
exit;
} }
// Handle non-AJAX POST // Handle non-AJAX POST
$result = $configObject->editConfigFile($_POST, $config_file); $result = $configObject->editConfigFile($_POST, $config_file);
if ($result === true) { if ($result['success']) {
Feedback::flash('NOTICE', 'DEFAULT', 'Config file updated successfully', true); Feedback::flash('NOTICE', 'DEFAULT', 'Config file updated successfully', true);
} else { } else {
Feedback::flash('ERROR', 'DEFAULT', "Error updating config file: $result", true); Feedback::flash('ERROR', 'DEFAULT', "Error updating config file: " . $result['error'], true);
} }
header('Location: ' . htmlspecialchars($app_root) . '?page=config'); header('Location: ' . htmlspecialchars($app_root) . '?page=config');

View File

@ -3,8 +3,8 @@
<div class="container-fluid mt-2"> <div class="container-fluid mt-2">
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
<h2 class="mb-0">Configuration</h2> <h2>Configuration</h2>
<small>Jilo Web configuration file: <em><?= htmlspecialchars($localConfigPath) ?></em></small> <small><?= htmlspecialchars($config['site_name']) ?> configuration file: <em><?= htmlspecialchars($localConfigPath) ?></em></small>
<?php if ($configMessage) { ?> <?php if ($configMessage) { ?>
<?= $configMessage ?> <?= $configMessage ?>
<?php } ?> <?php } ?>
@ -15,7 +15,7 @@
<div class="card-header bg-light d-flex justify-content-between align-items-center py-3"> <div class="card-header bg-light d-flex justify-content-between align-items-center py-3">
<h5 class="card-title mb-0"> <h5 class="card-title mb-0">
<i class="fas fa-wrench me-2 text-secondary"></i> <i class="fas fa-wrench me-2 text-secondary"></i>
Jilo Web app configuration <?= htmlspecialchars($config['site_name']) ?> app configuration
</h5> </h5>
<?php if ($userObject->hasRight($user_id, 'edit config file')) { ?> <?php if ($userObject->hasRight($user_id, 'edit config file')) { ?>
<div> <div>
@ -37,6 +37,8 @@
<div class="card-body p-4"> <div class="card-body p-4">
<form id="configForm"> <form id="configForm">
<?php <?php
include 'csrf_token.php';
function renderConfigItem($key, $value, $path = '') { function renderConfigItem($key, $value, $path = '') {
$fullPath = $path ? $path . '[' . $key . ']' : $key; $fullPath = $path ? $path . '[' . $key . ']' : $key;
// Only capitalize first letter, not every word // Only capitalize first letter, not every word
@ -78,17 +80,19 @@ function renderConfigItem($key, $value, $path = '') {
</div> </div>
<?php } elseif ($key === 'environment') { ?> <?php } elseif ($key === 'environment') { ?>
<select class="form-select form-select-sm" name="<?= htmlspecialchars($fullPath) ?>"> <select class="form-select form-select-sm" name="<?= htmlspecialchars($fullPath) ?>">
<option value="development" <?= $value === 'development' ? 'selected' : '' ?>>Development</option> <option value="development" <?= $value === 'development' ? 'selected' : '' ?>>development</option>
<option value="production" <?= $value === 'production' ? 'selected' : '' ?>>Production</option> <option value="production" <?= $value === 'production' ? 'selected' : '' ?>>production</option>
</select> </select>
<?php } else { ?> <?php } else { ?>
<input type="text" class="form-control form-control-sm" name="<?= htmlspecialchars($fullPath) ?>" value="<?= htmlspecialchars($value ?? '') ?>"> <input type="text" class="form-control form-control-sm" name="<?= htmlspecialchars($fullPath) ?>" value="<?= htmlspecialchars($value) ?>">
<?php } ?> <?php } ?>
</div> </div>
</div> </div>
</div> </div>
<?php } <?php
} }
}
foreach ($config as $key => $value) { foreach ($config as $key => $value) {
renderConfigItem($key, $value); renderConfigItem($key, $value);
} ?> } ?>
@ -125,20 +129,17 @@ $(function() {
// Handle text inputs // Handle text inputs
$('#configForm input[type="text"]').each(function() { $('#configForm input[type="text"]').each(function() {
const name = $(this).attr('name'); data[$(this).attr('name')] = $(this).val();
data[name] = $(this).val();
}); });
// Handle checkboxes // Handle checkboxes
$('#configForm input[type="checkbox"]').each(function() { $('#configForm input[type="checkbox"]').each(function() {
const name = $(this).attr('name'); data[$(this).attr('name')] = $(this).prop('checked') ? '1' : '0';
data[name] = $(this).prop('checked') ? '1' : '0';
}); });
// Handle selects // Handle selects
$('#configForm select').each(function() { $('#configForm select').each(function() {
const name = $(this).attr('name'); data[$(this).attr('name')] = $(this).val();
data[name] = $(this).val();
}); });
$.ajax({ $.ajax({
@ -147,44 +148,30 @@ $(function() {
data: JSON.stringify(data), data: JSON.stringify(data),
contentType: 'application/json', contentType: 'application/json',
headers: { headers: {
'X-Requested-With': 'XMLHttpRequest' 'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': $('input[name="csrf_token"]').val()
}, },
success: function(response) { success: function(response) {
// Show message first
if (response.messageData) {
JsMessages.success(response.messageData['message']);
}
// Only update UI if save was successful
if (response.success) { if (response.success) {
// Update view mode values JsMessages.success(response.message || 'Config file updated successfully');
Object.entries(data).forEach(([key, value]) => {
const $item = $(`[name="${key}"]`).closest('.config-item'); $('#configForm input[type="text"], #configForm input[type="checkbox"], #configForm select').each(function() {
const $input = $(this);
const $item = $input.closest('.config-item');
const $viewMode = $item.find('.view-mode'); const $viewMode = $item.find('.view-mode');
if ($item.length) { if ($item.length) {
if ($item.find('input[type="checkbox"]').length) { let value;
// Boolean value if ($input.is('[type="checkbox"]')) {
value = $input.prop('checked') ? '1' : '0';
const isEnabled = value === '1'; const isEnabled = value === '1';
$viewMode.html(` $viewMode.html(`<span class="badge ${isEnabled ? 'bg-success' : 'bg-secondary'}">${isEnabled ? 'Enabled' : 'Disabled'}</span>`);
<span class="badge ${isEnabled ? 'bg-success' : 'bg-secondary'}"> } else if ($input.is('select')) {
${isEnabled ? 'Enabled' : 'Disabled'} value = $input.val();
</span> $viewMode.html(`<span class="badge ${value === 'production' ? 'bg-danger' : 'bg-info'}">${value}</span>`);
`);
} else if ($item.find('select').length) {
// Environment value
$viewMode.html(`
<span class="badge ${value === 'production' ? 'bg-danger' : 'bg-info'}">
${value}
</span>
`);
} else { } else {
// Text value value = $input.val();
if (value === '') { $viewMode.html(value === '' ? '<span class="text-muted fst-italic">blank</span>' : `<span class="text-body">${value}</span>`);
$viewMode.html('<span class="text-muted fst-italic">blank</span>');
} else {
$viewMode.html(`<span class="text-body">${value}</span>`);
}
} }
} }
}); });
@ -193,6 +180,8 @@ $(function() {
$('.edit-controls').addClass('d-none'); $('.edit-controls').addClass('d-none');
$('.view-mode').show(); $('.view-mode').show();
$('.edit-mode').addClass('d-none'); $('.edit-mode').addClass('d-none');
} else {
JsMessages.error(response.error || 'Error saving config');
} }
}, },
error: function(xhr, status, error) { error: function(xhr, status, error) {