Merge branch 'master' into fronkdev
This commit is contained in:
@@ -13,476 +13,292 @@ class GenieACS {
|
||||
$this->baseurl = rtrim($baseurl, '/');
|
||||
$this->username = $username;
|
||||
$this->password = $password;
|
||||
|
||||
if (!$this->baseurl || !$this->username || !$this->password) {
|
||||
throw new Exception("Invalid Arguments");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate and retrieve JWT token
|
||||
* @return bool
|
||||
* @throws Exception
|
||||
*/
|
||||
private function _authenticate() {
|
||||
$session_key = "genieacs.{$this->baseurl}.jwt";
|
||||
$session = new mfConfig($session_key);
|
||||
|
||||
// Check if we have a valid cached token (valid for 1 hour)
|
||||
if ($session->value() && (time() - $session->edit) < 3600) {
|
||||
$this->jwt_token = $session->value();
|
||||
$this->log->debug("GenieACS: Using cached JWT token.");
|
||||
return true;
|
||||
}
|
||||
|
||||
$url = $this->baseurl . '/login';
|
||||
|
||||
$ctx_options = [
|
||||
$this->log->debug("GenieACS: Authenticating to get new JWT token.");
|
||||
$ctx = stream_context_create([
|
||||
"http" => [
|
||||
"ignore_errors" => true,
|
||||
"method" => "POST",
|
||||
"header" => [
|
||||
"Accept: application/json, text/*",
|
||||
"Content-Type: application/json; charset=UTF-8",
|
||||
],
|
||||
"content" => json_encode([
|
||||
"username" => $this->username,
|
||||
"password" => $this->password,
|
||||
]),
|
||||
"header" => ["Content-Type: application/json"],
|
||||
"content" => json_encode(["username" => $this->username, "password" => $this->password]),
|
||||
]
|
||||
];
|
||||
|
||||
$ctx = stream_context_create($ctx_options);
|
||||
$response = file_get_contents($url, false, $ctx);
|
||||
|
||||
// Extract JWT from response headers
|
||||
]);
|
||||
|
||||
$response = file_get_contents($this->baseurl . '/login', false, $ctx);
|
||||
|
||||
if (isset($http_response_header)) {
|
||||
foreach ($http_response_header as $header) {
|
||||
if (stripos($header, 'set-cookie') !== false && stripos($header, 'genieacs-ui-jwt=') !== false) {
|
||||
preg_match('/genieacs-ui-jwt=([^;]+)/', $header, $matches);
|
||||
if (isset($matches[1])) {
|
||||
$this->jwt_token = $matches[1];
|
||||
|
||||
// Cache the token
|
||||
$session->value($this->jwt_token);
|
||||
$session->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
if (preg_match('/genieacs-ui-jwt=([^;]+)/', $header, $matches)) {
|
||||
$this->jwt_token = $matches[1];
|
||||
$session->value($this->jwt_token);
|
||||
$session->save();
|
||||
$this->log->debug("GenieACS: Successfully retrieved and cached new JWT token.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Exception("Authentication failed - could not retrieve JWT token");
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GET request to the API
|
||||
* @param string $endpoint
|
||||
* @return array|null
|
||||
* @throws Exception
|
||||
*/
|
||||
private function _get($endpoint) {
|
||||
if (!$this->jwt_token) {
|
||||
$this->_authenticate();
|
||||
}
|
||||
|
||||
$url = $this->baseurl . $endpoint;
|
||||
|
||||
$ctx_options = [
|
||||
'http' => [
|
||||
'ignore_errors' => true,
|
||||
'method' => 'GET',
|
||||
'header' => [
|
||||
'Cookie: genieacs-ui-jwt=' . $this->jwt_token,
|
||||
'Accept: application/json',
|
||||
],
|
||||
]
|
||||
];
|
||||
|
||||
$ctx = stream_context_create($ctx_options);
|
||||
$response = file_get_contents($url, false, $ctx);
|
||||
|
||||
// Check if we got a 401 and need to re-authenticate
|
||||
if (isset($http_response_header)) {
|
||||
foreach ($http_response_header as $header) {
|
||||
if (stripos($header, 'HTTP/') === 0 && stripos($header, '401') !== false) {
|
||||
// Token expired, re-authenticate and retry
|
||||
$this->jwt_token = null;
|
||||
$this->_authenticate();
|
||||
return $this->_get($endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json_decode($response, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a POST request to the API
|
||||
* @param string $endpoint
|
||||
* @param array $data
|
||||
* @return array|null
|
||||
* @throws Exception
|
||||
*/
|
||||
private function _post($endpoint, $data) {
|
||||
if (!$this->jwt_token) {
|
||||
$this->_authenticate();
|
||||
}
|
||||
|
||||
$url = $this->baseurl . $endpoint;
|
||||
$jsonData = json_encode($data);
|
||||
|
||||
$ctx_options = [
|
||||
'http' => [
|
||||
'ignore_errors' => true,
|
||||
'method' => 'POST',
|
||||
'header' => [
|
||||
'Cookie: genieacs-ui-jwt=' . $this->jwt_token,
|
||||
'Accept: application/json',
|
||||
'Content-Type: application/json',
|
||||
'Content-Length: ' . strlen($jsonData)
|
||||
],
|
||||
'content' => $jsonData,
|
||||
]
|
||||
];
|
||||
|
||||
$ctx = stream_context_create($ctx_options);
|
||||
$response = file_get_contents($url, false, $ctx);
|
||||
|
||||
// Log for debugging
|
||||
error_log("GenieACS POST to $url: " . $jsonData);
|
||||
error_log("GenieACS response: " . ($response ?: 'empty'));
|
||||
|
||||
// Check if we got a 401 and need to re-authenticate
|
||||
if (isset($http_response_header)) {
|
||||
foreach ($http_response_header as $header) {
|
||||
error_log("GenieACS response header: " . $header);
|
||||
if (stripos($header, 'HTTP/') === 0 && stripos($header, '401') !== false) {
|
||||
// Token expired, re-authenticate and retry
|
||||
$this->jwt_token = null;
|
||||
$this->_authenticate();
|
||||
return $this->_post($endpoint, $data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If response is empty or false, it might still be successful (204 No Content)
|
||||
if ($response === false || $response === '') {
|
||||
// Check if status code indicates success
|
||||
if (isset($http_response_header)) {
|
||||
foreach ($http_response_header as $header) {
|
||||
if (stripos($header, 'HTTP/') === 0) {
|
||||
if (stripos($header, '200') !== false || stripos($header, '202') !== false || stripos($header, '204') !== false) {
|
||||
return ['success' => true];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json_decode($response, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all devices
|
||||
* @return array|null
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getDevices() {
|
||||
return $this->_get('/api/devices');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific device by ID
|
||||
* @param string $deviceId Device ID (will be URL-encoded automatically)
|
||||
* @return array|null
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getDevice($deviceId) {
|
||||
return $this->_get('/api/devices/' . rawurlencode($deviceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a task for a device
|
||||
* @param string $deviceId Device ID (will be URL-encoded automatically)
|
||||
* @param array $tasks Array of tasks to execute
|
||||
* @return array|null
|
||||
* @throws Exception
|
||||
*
|
||||
* Example:
|
||||
* $tasks = [
|
||||
* ["name" => "getParameterValues", "parameterNames" => ["InternetGatewayDevice.User.1.Username"]]
|
||||
* ];
|
||||
*/
|
||||
public function createTask($deviceId, $tasks) {
|
||||
return $this->_post('/api/devices/' . rawurlencode($deviceId) . '/tasks', $tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parameter values from a device
|
||||
* @param string $deviceId URL-encoded device ID
|
||||
* @param array $parameterNames Array of parameter names to retrieve
|
||||
* @return array|null
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getParameterValues($deviceId, $parameterNames) {
|
||||
$tasks = [
|
||||
[
|
||||
"name" => "getParameterValues",
|
||||
"parameterNames" => $parameterNames
|
||||
]
|
||||
];
|
||||
return $this->createTask($deviceId, $tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set parameter values on a device
|
||||
* @param string $deviceId URL-encoded device ID
|
||||
* @param array $parameterValues Array of parameter name => value pairs
|
||||
* @return array|null
|
||||
* @throws Exception
|
||||
*/
|
||||
public function setParameterValues($deviceId, $parameterValues) {
|
||||
// Convert associative array to GenieACS format: [["paramName", value, "type"], ...]
|
||||
$formattedParams = [];
|
||||
foreach ($parameterValues as $name => $value) {
|
||||
// Determine XSD type based on value type
|
||||
$xsdType = 'xsd:string'; // default
|
||||
if (is_bool($value)) {
|
||||
$xsdType = 'xsd:boolean';
|
||||
$value = $value ? true : false; // ensure proper boolean
|
||||
} elseif (is_int($value)) {
|
||||
$xsdType = 'xsd:int';
|
||||
} elseif (is_float($value)) {
|
||||
$xsdType = 'xsd:double';
|
||||
}
|
||||
|
||||
$formattedParams[] = [$name, $value, $xsdType];
|
||||
}
|
||||
|
||||
$tasks = [
|
||||
[
|
||||
"name" => "setParameterValues",
|
||||
"parameterValues" => $formattedParams
|
||||
]
|
||||
];
|
||||
return $this->createTask($deviceId, $tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh device information
|
||||
* @param string $deviceId URL-encoded device ID
|
||||
* @return array|null
|
||||
* @throws Exception
|
||||
*/
|
||||
public function refreshDevice($deviceId) {
|
||||
$tasks = [
|
||||
[
|
||||
"name" => "refreshObject",
|
||||
"objectName" => ""
|
||||
]
|
||||
];
|
||||
return $this->createTask($deviceId, $tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reboot a device
|
||||
* @param string $deviceId URL-encoded device ID
|
||||
* @return array|null
|
||||
* @throws Exception
|
||||
*/
|
||||
public function rebootDevice($deviceId) {
|
||||
$tasks = [
|
||||
[
|
||||
"name" => "reboot"
|
||||
]
|
||||
];
|
||||
return $this->createTask($deviceId, $tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory reset a device
|
||||
* @param string $deviceId URL-encoded device ID
|
||||
* @return array|null
|
||||
* @throws Exception
|
||||
*/
|
||||
public function factoryResetDevice($deviceId) {
|
||||
$tasks = [
|
||||
[
|
||||
"name" => "factoryReset"
|
||||
]
|
||||
];
|
||||
return $this->createTask($deviceId, $tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ping an IP address
|
||||
* @param string $ip IP address to ping
|
||||
* @return array|null
|
||||
* @throws Exception
|
||||
*
|
||||
* Returns: {
|
||||
* "packetsTransmitted": 3,
|
||||
* "packetsReceived": 3,
|
||||
* "packetLoss": 0,
|
||||
* "min": 2.674,
|
||||
* "avg": 3.054,
|
||||
* "max": 3.34,
|
||||
* "mdev": 0.28
|
||||
* }
|
||||
*/
|
||||
public function ping($ip) {
|
||||
return $this->_get('/api/ping/' . $ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from a device
|
||||
* @param string $deviceId URL-encoded device ID
|
||||
* @param string $fileType File type to download
|
||||
* @param string $fileName Optional file name
|
||||
* @return array|null
|
||||
* @throws Exception
|
||||
*/
|
||||
public function downloadFile($deviceId, $fileType, $fileName = null) {
|
||||
$task = [
|
||||
"name" => "download",
|
||||
"fileType" => $fileType
|
||||
];
|
||||
|
||||
if ($fileName) {
|
||||
$task["fileName"] = $fileName;
|
||||
}
|
||||
|
||||
return $this->createTask($deviceId, [$task]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse device data from API response to extract useful information
|
||||
* @param array $deviceData Raw device data from API
|
||||
* @return array Parsed device information
|
||||
*/
|
||||
public static function parseDeviceData($deviceData) {
|
||||
$parsed = [];
|
||||
|
||||
foreach ($deviceData as $key => $value) {
|
||||
// Extract simple values
|
||||
if (isset($value['value']) && is_array($value['value'])) {
|
||||
$parsed[$key] = $value['value'][0];
|
||||
}
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device ID from device data
|
||||
* @param array $deviceData Raw device data from API
|
||||
* @return string|null Device ID
|
||||
*/
|
||||
public static function getDeviceId($deviceData) {
|
||||
if (isset($deviceData['DeviceID.ID']['value'][0])) {
|
||||
return $deviceData['DeviceID.ID']['value'][0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MAC address from device data
|
||||
* @param array $deviceData Raw device data from API
|
||||
* @return string|null MAC address
|
||||
*/
|
||||
public static function getMacAddress($deviceData) {
|
||||
// Try WAN connection MAC address first
|
||||
if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress']['value'][0])) {
|
||||
return $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress']['value'][0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get external IP address from device data
|
||||
* @param array $deviceData Raw device data from API
|
||||
* @return string|null External IP address
|
||||
*/
|
||||
public static function getExternalIP($deviceData) {
|
||||
// Try to get from WAN IP Connection
|
||||
if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress']['value'][0])) {
|
||||
return $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress']['value'][0];
|
||||
}
|
||||
|
||||
// Try alternative connection
|
||||
if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress']['value'][0])) {
|
||||
return $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress']['value'][0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get management/local IP address from device data (private IP)
|
||||
* @param array $deviceData Raw device data from API
|
||||
* @return string|null Management IP address
|
||||
*/
|
||||
public static function getManagementIP($deviceData) {
|
||||
// Check both WAN connections and return the one with a private IP
|
||||
$ips = [];
|
||||
|
||||
if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress']['value'][0])) {
|
||||
$ips[] = $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress']['value'][0];
|
||||
}
|
||||
|
||||
if (isset($deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress']['value'][0])) {
|
||||
$ips[] = $deviceData['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress']['value'][0];
|
||||
}
|
||||
|
||||
// Return the first private IP found (10.x.x.x, 172.16-31.x.x, 192.168.x.x)
|
||||
foreach ($ips as $ip) {
|
||||
if (self::isPrivateIP($ip)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
|
||||
// If no private IP found, return first IP
|
||||
return $ips[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address is in a private range
|
||||
* @param string $ip IP address
|
||||
* @return bool True if private IP
|
||||
*/
|
||||
private static function isPrivateIP($ip) {
|
||||
$parts = explode('.', $ip);
|
||||
if (count($parts) !== 4) return false;
|
||||
|
||||
$first = (int)$parts[0];
|
||||
$second = (int)$parts[1];
|
||||
|
||||
// 10.0.0.0 - 10.255.255.255
|
||||
if ($first === 10) return true;
|
||||
|
||||
// 172.16.0.0 - 172.31.255.255
|
||||
if ($first === 172 && $second >= 16 && $second <= 31) return true;
|
||||
|
||||
// 192.168.0.0 - 192.168.255.255
|
||||
if ($first === 192 && $second === 168) return true;
|
||||
|
||||
$this->log->debug("GenieACS: Failed to retrieve JWT token.");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device manufacturer, model, and version info
|
||||
* @param array $deviceData Raw device data from API
|
||||
* @return array Device info
|
||||
*/
|
||||
private function _request($method, $endpoint, $data = null) {
|
||||
if (!$this->jwt_token && !$this->_authenticate()) {
|
||||
throw new Exception("GenieACS Authentication failed.");
|
||||
}
|
||||
|
||||
$this->log->debug("GenieACS: Making API request", ['method' => $method, 'endpoint' => $endpoint]);
|
||||
$opts = [
|
||||
'http' => [
|
||||
'ignore_errors' => true,
|
||||
'method' => $method,
|
||||
'header' => ['Cookie: genieacs-ui-jwt=' . $this->jwt_token, 'Content-Type: application/json'],
|
||||
]
|
||||
];
|
||||
if ($data) $opts['http']['content'] = json_encode($data);
|
||||
|
||||
$ctx = stream_context_create($opts);
|
||||
$response = @file_get_contents($this->baseurl . $endpoint, false, $ctx);
|
||||
|
||||
// Re-auth on 401
|
||||
if (isset($http_response_header)) {
|
||||
foreach ($http_response_header as $header) {
|
||||
if (strpos($header, '401') !== false) {
|
||||
$this->log->debug("GenieACS: 401 Unauthorized, re-authenticating.");
|
||||
$this->jwt_token = null;
|
||||
if ($this->_authenticate()) {
|
||||
return $this->_request($method, $endpoint, $data);
|
||||
} else {
|
||||
throw new Exception("GenieACS Re-authentication failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($response === false || $response === '') {
|
||||
// 200-204 check
|
||||
if (isset($http_response_header)) {
|
||||
foreach ($http_response_header as $header) {
|
||||
if (strpos($header, 'HTTP/') === 0 && (strpos($header, '200') !== false || strpos($header, '202') !== false || strpos($header, '204') !== false)) {
|
||||
return ['success' => true];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
// If request was GET /devices/ID, the response IS the device object.
|
||||
// If request was GET /devices, it is an array of objects.
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
public function getDevices() {
|
||||
return $this->_request('GET', '/api/devices');
|
||||
}
|
||||
|
||||
public function getDevice($deviceId) {
|
||||
return $this->_request('GET', '/api/devices/' . rawurlencode($deviceId));
|
||||
}
|
||||
|
||||
public function rebootDevice($deviceId) {
|
||||
return $this->_request('POST', '/api/devices/' . rawurlencode($deviceId) . '/tasks', [['name' => 'reboot']]);
|
||||
}
|
||||
|
||||
public function ping($ip) {
|
||||
return $this->_request('GET', '/api/ping/' . $ip);
|
||||
}
|
||||
|
||||
public function getParameterValues($deviceId, $parameterNames) {
|
||||
return $this->_request('POST', '/api/devices/' . rawurlencode($deviceId) . '/tasks', [[
|
||||
"name" => "getParameterValues", "parameterNames" => $parameterNames
|
||||
]]);
|
||||
}
|
||||
|
||||
public function setParameterValues($deviceId, $parameterValues) {
|
||||
$formattedParams = [];
|
||||
foreach ($parameterValues as $name => $value) {
|
||||
$type = 'xsd:string';
|
||||
if (is_bool($value)) { $type = 'xsd:boolean'; $value = $value ? true : false; }
|
||||
elseif (is_int($value)) $type = 'xsd:int';
|
||||
elseif (is_float($value)) $type = 'xsd:double';
|
||||
$formattedParams[] = [$name, $value, $type];
|
||||
}
|
||||
return $this->_request('POST', '/api/devices/' . rawurlencode($deviceId) . '/tasks', [[
|
||||
"name" => "setParameterValues", "parameterValues" => $formattedParams
|
||||
]]);
|
||||
}
|
||||
|
||||
public function getSpeedtestResult($deviceId) {
|
||||
$param = 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result';
|
||||
$this->getParameterValues($deviceId, [$param]);
|
||||
usleep(500000);
|
||||
$device = $this->getDevice($deviceId);
|
||||
return self::getParam($device, $param);
|
||||
}
|
||||
|
||||
public function createRemoteUser($deviceId, $forceRecreate = false) {
|
||||
$this->log->debug("GenieACS: createRemoteUser called", ['deviceId' => $deviceId, 'forceRecreate' => $forceRecreate]);
|
||||
$cacheKey = "remote_user_" . $deviceId;
|
||||
if (!$forceRecreate && $cached = $this->getCache($cacheKey)) {
|
||||
$this->log->debug("GenieACS: Using cached credentials");
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$password = $this->generatePassword(12);
|
||||
$timestamp = (string)time();
|
||||
|
||||
$userParamsToRefresh = [
|
||||
'InternetGatewayDevice.User.1.Enable',
|
||||
'InternetGatewayDevice.User.1.Password',
|
||||
'InternetGatewayDevice.User.1.RemoteAccessCapable',
|
||||
'InternetGatewayDevice.User.1.Username'
|
||||
];
|
||||
$this->getParameterValues($deviceId, $userParamsToRefresh);
|
||||
sleep(2);
|
||||
|
||||
$this->setParameterValues($deviceId, [
|
||||
'InternetGatewayDevice.User.1.Enable' => true,
|
||||
'InternetGatewayDevice.User.1.Password' => $password,
|
||||
'InternetGatewayDevice.User.1.RemoteAccessCapable' => true,
|
||||
'InternetGatewayDevice.User.1.Username' => $timestamp
|
||||
]);
|
||||
|
||||
// Poll for Username
|
||||
$username = null;
|
||||
$maxAttempts = 15;
|
||||
$paramName = 'InternetGatewayDevice.User.1.Username';
|
||||
|
||||
for ($i = 0; $i < $maxAttempts; $i++) {
|
||||
sleep(1);
|
||||
$this->getParameterValues($deviceId, [$paramName]);
|
||||
usleep(500000);
|
||||
|
||||
$device = $this->getDevice($deviceId);
|
||||
|
||||
// Access property using flat dot-notation key
|
||||
$val = self::getParam($device, $paramName);
|
||||
$this->log->debug("GenieACS: Poll attempt " . ($i + 1) . " value: " . json_encode($val));
|
||||
|
||||
if ($val && strpos($val, 'TR069-') === 0) {
|
||||
$username = $val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$username) {
|
||||
$this->log->debug("GenieACS: Failed to retrieve TR069 username.");
|
||||
return null;
|
||||
}
|
||||
|
||||
$ip = self::getExternalIP($this->getDevice($deviceId));
|
||||
if (!$ip) {
|
||||
$this->log->debug("GenieACS: Could not get external IP.");
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = [
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'ip' => $ip,
|
||||
'link' => "https://" . $ip . ":9090"
|
||||
];
|
||||
|
||||
$this->setCache($cacheKey, $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function getCache($key) {
|
||||
$file = TEMP_DIR . "/RadiusCache/" . md5($key) . ".json";
|
||||
if (file_exists($file)) {
|
||||
if (filemtime($file) < (time() - 1800)) {
|
||||
@unlink($file);
|
||||
return null;
|
||||
}
|
||||
return json_decode(file_get_contents($file), true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function setCache($key, $data) {
|
||||
$dir = TEMP_DIR . "/RadiusCache/";
|
||||
if (!is_dir($dir)) @mkdir($dir, 0777, true);
|
||||
file_put_contents($dir . md5($key) . ".json", json_encode($data));
|
||||
}
|
||||
|
||||
private function generatePassword($length) {
|
||||
$chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
return substr(str_shuffle(str_repeat($chars, ceil($length/strlen($chars)))), 1, $length);
|
||||
}
|
||||
|
||||
// Helpers to safely access device parameters from flat JSON structure
|
||||
private static function getParam($deviceData, $key) {
|
||||
if (!is_array($deviceData)) return null;
|
||||
if (isset($deviceData[$key]['value'][0])) {
|
||||
return $deviceData[$key]['value'][0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getExternalIP($deviceData) {
|
||||
// Try typical WAN paths
|
||||
$paths = [
|
||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress',
|
||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress',
|
||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress'
|
||||
];
|
||||
foreach ($paths as $path) {
|
||||
$val = self::getParam($deviceData, $path);
|
||||
if ($val) return $val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getDeviceId($deviceData) {
|
||||
return self::getParam($deviceData, 'DeviceID.ID');
|
||||
}
|
||||
|
||||
public static function getDeviceInfo($deviceData) {
|
||||
return [
|
||||
'manufacturer' => $deviceData['DeviceID.Manufacturer']['value'][0] ?? null,
|
||||
'productClass' => $deviceData['DeviceID.ProductClass']['value'][0] ?? null,
|
||||
'oui' => $deviceData['DeviceID.OUI']['value'][0] ?? null,
|
||||
'serialNumber' => $deviceData['DeviceID.SerialNumber']['value'][0] ?? null,
|
||||
'hardwareVersion' => $deviceData['InternetGatewayDevice.DeviceInfo.HardwareVersion']['value'][0] ?? null,
|
||||
'softwareVersion' => $deviceData['InternetGatewayDevice.DeviceInfo.SoftwareVersion']['value'][0] ?? null,
|
||||
'serialNumber' => self::getParam($deviceData, 'DeviceID.SerialNumber'),
|
||||
'hardwareVersion' => self::getParam($deviceData, 'InternetGatewayDevice.DeviceInfo.HardwareVersion'),
|
||||
'softwareVersion' => self::getParam($deviceData, 'InternetGatewayDevice.DeviceInfo.SoftwareVersion'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public static function getManagementIP($deviceData) {
|
||||
// Return any valid IP found, prioritizing private IPs if possible
|
||||
$ip1 = self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress');
|
||||
$ip2 = self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.ExternalIPAddress');
|
||||
|
||||
if ($ip1 && self::isPrivateIP($ip1)) return $ip1;
|
||||
if ($ip2 && self::isPrivateIP($ip2)) return $ip2;
|
||||
return $ip1 ?: $ip2;
|
||||
}
|
||||
|
||||
public static function getMacAddress($deviceData) {
|
||||
$mac1 = self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress');
|
||||
if ($mac1) return $mac1;
|
||||
return self::getParam($deviceData, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.MACAddress');
|
||||
}
|
||||
|
||||
private static function isPrivateIP($ip) {
|
||||
$parts = explode('.', $ip);
|
||||
if (count($parts) !== 4) return false;
|
||||
$first = (int)$parts[0];
|
||||
$second = (int)$parts[1];
|
||||
if ($first === 10) return true;
|
||||
if ($first === 172 && $second >= 16 && $second <= 31) return true;
|
||||
if ($first === 192 && $second === 168) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,33 @@ class Helper {
|
||||
$controller->layout()->setTemplate("VueViews/Vue");
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays Vue 3 component with the given header title.
|
||||
* Uses TT-Core component library instead of legacy Vue 2 components.
|
||||
*
|
||||
* @param mfBaseController $controller The controller instance to generate $JSGlobals for.
|
||||
* @param string $pageName The name of the Vue component to render.
|
||||
* @param string $headerTitle The title to display in the header.
|
||||
* @param array $additionalGlobals Additional global variables to pass to the Vue component.
|
||||
*/
|
||||
public static function renderVue3(mfBaseController $controller, string $pageName, string $headerTitle, array $additionalGlobals = []) {
|
||||
$JSGlobals = ["BASE_URL" => $controller::getUrl($pageName),
|
||||
"MF_URL" => $controller::getUrl(""),
|
||||
"DASHBOARD_URL" => $controller::getUrl("Dashboard"),
|
||||
"MF_APP_NAME" => MFAPPNAME_SLUG,
|
||||
"BASE_PATH" => $controller::getUrl(""),
|
||||
"PAGE_TITLE" => $headerTitle,
|
||||
"PATH" => [["text" => MFAPPNAME_SLUG, "href" => $controller::getUrl("Dashboard")],
|
||||
["text" => $headerTitle, "href" => $controller::getUrl($pageName)]],];
|
||||
|
||||
$JSGlobals = array_merge($JSGlobals, $additionalGlobals);
|
||||
|
||||
$controller->layout()->set("vueViewName", $pageName);
|
||||
$controller->layout()->set("JSGlobals", $JSGlobals);
|
||||
$controller->layout()->set("useVue3", true); // Flag to indicate Vue 3 mode
|
||||
$controller->layout()->setTemplate("VueViews/Vue3");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an array of objects to a CSV file.
|
||||
* @param array $rows The array of objects to convert to CSV.
|
||||
@@ -225,4 +252,63 @@ class Helper {
|
||||
|
||||
return array_map(fn($owner) => new Address($owner['id']), $results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AddressDB Netzgebiet IDs that a user has access to based on their Network ownership
|
||||
* @param User $user The user to get networks for
|
||||
* @return array Array of addressdb netzgebiet IDs
|
||||
*/
|
||||
public static function getADBNetworksFromUser($user): array {
|
||||
if ($user->isAdmin()) {
|
||||
// Admin has access to all networks
|
||||
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
|
||||
$sql = "SELECT id FROM Netzgebiet WHERE id IS NOT NULL";
|
||||
$result = $db->query($sql);
|
||||
$netzgebiete = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
return array_column($netzgebiete, 'id');
|
||||
}
|
||||
|
||||
// Get networks where user's address is the owner
|
||||
$networks = NetworkModel::search(['owner_id' => $user->address_id]);
|
||||
|
||||
// Also check user flags for additional networks
|
||||
$flagNetworkIds = json_decode($user->getFlag("workordermph_networks")->value() ?: '[]', true);
|
||||
if (!empty($flagNetworkIds)) {
|
||||
$additionalNetworks = NetworkModel::search(['id' => $flagNetworkIds]);
|
||||
$networks = array_merge($networks, $additionalNetworks);
|
||||
}
|
||||
|
||||
// Extract adb_netzgebiet_id from networks
|
||||
$netzgebietIds = [];
|
||||
foreach ($networks as $network) {
|
||||
if ($network->adb_netzgebiet_id) {
|
||||
$netzgebietIds[] = $network->adb_netzgebiet_id;
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique(array_filter($netzgebietIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get network owners that have WorkorderMph entries (based on Netzgebiet)
|
||||
* @return array Array of Address objects representing network owners
|
||||
*/
|
||||
public static function getMphNetworkOwners(): array {
|
||||
$db = FronkDB::singleton();
|
||||
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
|
||||
$fronkDbName = FRONKDB_DBNAME;
|
||||
|
||||
$sql = "SELECT DISTINCT a.id, a.company, a.lastname, a.firstname
|
||||
FROM `$fronkDbName`.`WorkorderMph` wm
|
||||
INNER JOIN `$addressDbName`.`Hausnummer` hn ON wm.hausnummerId = hn.id
|
||||
INNER JOIN `$addressDbName`.`Netzgebiet` ng ON hn.netzgebiet_id = ng.id
|
||||
INNER JOIN `$fronkDbName`.`Network` n ON n.adb_netzgebiet_id = ng.id
|
||||
INNER JOIN `$fronkDbName`.`Address` a ON n.owner_id = a.id
|
||||
WHERE a.id IS NOT NULL
|
||||
ORDER BY a.company, a.lastname, a.firstname";
|
||||
|
||||
$results = $db->fetch_all_assoc($db->query($sql)) ?? [];
|
||||
|
||||
return array_map(fn($owner) => new Address($owner['id']), $results);
|
||||
}
|
||||
}
|
||||
155
lib/mfBaseModelV2/README.md
Normal file
155
lib/mfBaseModelV2/README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# mfBaseModelV2
|
||||
|
||||
Modern PHP 8+ base model with typed properties and automatic journaling.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```php
|
||||
class ADBNetzgebiet extends mfBaseModelV2 {
|
||||
protected static string $__tableName = 'Netzgebiet';
|
||||
protected static string $__primaryKey = 'id'; // default
|
||||
|
||||
public int $id;
|
||||
public ?string $name = null;
|
||||
public ?string $extref = null;
|
||||
public int $create;
|
||||
public int $edit;
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Database
|
||||
|
||||
```php
|
||||
protected static ?array $__databaseConfig = [
|
||||
'host' => ADDRESSDB_DBHOST,
|
||||
'user' => ADDRESSDB_DBUSER,
|
||||
'pass' => ADDRESSDB_DBPASS,
|
||||
'name' => ADDRESSDB_DBNAME
|
||||
];
|
||||
```
|
||||
|
||||
## Static Methods
|
||||
|
||||
```php
|
||||
$model = MyModel::get(123); // by ID, returns ?static
|
||||
$model = MyModel::getFirst(['name' => 'foo']); // first match
|
||||
$all = MyModel::getAll($filter, $limit, $offset, $order);
|
||||
$all = MyModel::search($filter); // alias for getAll
|
||||
$count = MyModel::count($filter);
|
||||
```
|
||||
|
||||
## Filter Operators
|
||||
|
||||
| Prefix | SQL | Example |
|
||||
|--------|-----|---------|
|
||||
| (none) | LIKE %...% | `['name' => 'foo']` |
|
||||
| `=` | = exact | `['=name' => 'foo']` |
|
||||
| `!` | != / NOT IN | `['!status' => 'deleted']` |
|
||||
| `>` `<` `>=` `<=` | comparison | `['>create' => $timestamp]` |
|
||||
|
||||
**Special values:**
|
||||
```php
|
||||
['status' => null] // IS NULL
|
||||
['!status' => null] // IS NOT NULL
|
||||
['id' => [1, 2, 3]] // IN (1, 2, 3)
|
||||
['!id' => [1, 2, 3]] // NOT IN (1, 2, 3)
|
||||
```
|
||||
|
||||
## Ordering
|
||||
|
||||
```php
|
||||
MyModel::getAll([], null, 0, ['column' => 'name', 'dir' => 'ASC']);
|
||||
```
|
||||
|
||||
## Instance Methods
|
||||
|
||||
```php
|
||||
$model->save(); // insert or update
|
||||
$model->delete();
|
||||
$model->isLoaded();
|
||||
$model->getId();
|
||||
$model->toArray();
|
||||
$model->toJson();
|
||||
$model->getJournalHistory(); // returns change history
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
```php
|
||||
public function validate(): array {
|
||||
$errors = [];
|
||||
if (empty($this->name)) $errors[] = 'Name required';
|
||||
return $errors; // empty = valid
|
||||
}
|
||||
|
||||
protected function beforeSave(bool $isInsert): bool {
|
||||
return true; // false cancels save
|
||||
}
|
||||
|
||||
protected function afterSave(bool $isInsert, array $changes): void {
|
||||
// $changes = ['field' => ['old' => x, 'new' => y]]
|
||||
}
|
||||
```
|
||||
|
||||
## Journaling
|
||||
|
||||
Automatic change tracking to `Journal` table. Configure field labels:
|
||||
|
||||
```php
|
||||
protected static array $__journalFieldMap = [
|
||||
'name' => 'Name',
|
||||
'extref' => 'External Reference',
|
||||
];
|
||||
```
|
||||
|
||||
Disable per model:
|
||||
```php
|
||||
protected static bool $__enableJournaling = false;
|
||||
```
|
||||
|
||||
## Auto-timestamps
|
||||
|
||||
If properties exist, they're set automatically on save:
|
||||
- `create`, `create_by` - on insert
|
||||
- `edit`, `edit_by` - on insert/update
|
||||
|
||||
## Magic Properties with Intellisense
|
||||
|
||||
Use `@property-read` for lazy-loaded relations:
|
||||
|
||||
```php
|
||||
/**
|
||||
* @property-read ADBNetzgebietRelations $relations
|
||||
*/
|
||||
class ADBNetzgebiet extends mfBaseModelV2 {
|
||||
private ?ADBNetzgebietRelations $__relations = null;
|
||||
|
||||
public function __get(string $name) {
|
||||
if ($name === 'relations') {
|
||||
return $this->__relations ??= $this->loadRelations();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function loadRelations(): ADBNetzgebietRelations {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Typed relation class for IDE support:
|
||||
|
||||
```php
|
||||
class ADBNetzgebietRelations {
|
||||
/** @var array{id: int, name: string}[] */
|
||||
public array $networks = [];
|
||||
/** @var array{id: int, name: string}[] */
|
||||
public array $campaigns = [];
|
||||
}
|
||||
```
|
||||
|
||||
Usage with full autocomplete:
|
||||
```php
|
||||
$model = ADBNetzgebiet::get(1);
|
||||
$model->relations->networks; // IDE knows this is array{id: int, name: string}[]
|
||||
```
|
||||
373
lib/mfBaseModelV2/mfBaseModelV2.php
Normal file
373
lib/mfBaseModelV2/mfBaseModelV2.php
Normal file
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Modern base model with typed properties and automatic journaling.
|
||||
*
|
||||
* Filter operators: =exact, !not, >, <, >=, <=
|
||||
* Array values become IN/NOT IN clauses, null checks IS NULL/IS NOT NULL
|
||||
*/
|
||||
abstract class mfBaseModelV2 {
|
||||
|
||||
protected static string $__tableName = '';
|
||||
protected static string $__primaryKey = 'id';
|
||||
protected static ?array $__databaseConfig = null;
|
||||
protected static array $__journalFieldMap = [];
|
||||
protected static bool $__enableJournaling = true;
|
||||
|
||||
private static array $__db_instances = [];
|
||||
protected ?FronkDB $__db = null;
|
||||
protected ?mfLoghandler $__log = null;
|
||||
private ?stdClass $__originalData = null;
|
||||
private bool $__isLoaded = false;
|
||||
|
||||
public function __construct(int|string $id = null) {
|
||||
static::__init_db();
|
||||
$this->__db = self::$__db_instances[static::class];
|
||||
$this->__log = mfLoghandler::singleton();
|
||||
$this->__originalData = new stdClass();
|
||||
|
||||
if ($id !== null) $this->__load($id);
|
||||
}
|
||||
|
||||
protected static function __init_db(): void {
|
||||
if (isset(self::$__db_instances[static::class])) return;
|
||||
|
||||
if (empty(static::$__tableName)) {
|
||||
throw new Exception('$__tableName must be set in ' . get_called_class());
|
||||
}
|
||||
|
||||
self::$__db_instances[static::class] = static::$__databaseConfig !== null
|
||||
? FronkDB::singleton(
|
||||
static::$__databaseConfig['host'],
|
||||
static::$__databaseConfig['user'],
|
||||
static::$__databaseConfig['pass'],
|
||||
static::$__databaseConfig['name']
|
||||
)
|
||||
: FronkDB::singleton();
|
||||
}
|
||||
|
||||
protected static function __getDb(): FronkDB {
|
||||
static::__init_db();
|
||||
return self::$__db_instances[static::class];
|
||||
}
|
||||
|
||||
public static function get(int|string $id): ?static {
|
||||
static::__init_db();
|
||||
$model = new static();
|
||||
return $model->__load($id) ? $model : null;
|
||||
}
|
||||
|
||||
public static function getFirst(array $filter = [], array $order = []): ?static {
|
||||
$results = static::getAll($filter, 1, 0, $order);
|
||||
return $results[0] ?? null;
|
||||
}
|
||||
|
||||
public static function getAll(array $filter = [], int $limit = null, int $offset = 0, array $order = []): array {
|
||||
static::__init_db();
|
||||
$db = self::$__db_instances[static::class];
|
||||
$table = static::$__tableName;
|
||||
$whereSql = static::__buildFilterSql($filter);
|
||||
|
||||
$orderSql = "";
|
||||
if (!empty($order['column'])) {
|
||||
$dir = (strtoupper($order['dir'] ?? '') === 'DESC') ? 'DESC' : 'ASC';
|
||||
$orderSql = "ORDER BY `" . $db->escape($order['column']) . "` $dir";
|
||||
}
|
||||
|
||||
$limitSql = $limit !== null ? "LIMIT " . (int)$offset . ", " . (int)$limit : "";
|
||||
$res = $db->query("SELECT * FROM `$table` $whereSql $orderSql $limitSql");
|
||||
|
||||
$items = [];
|
||||
if ($db->num_rows($res)) {
|
||||
while ($data = $db->fetch_object($res)) {
|
||||
$model = new static();
|
||||
$model->__populate($data);
|
||||
$model->__isLoaded = true;
|
||||
$items[] = $model;
|
||||
}
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
public static function search(array $filter = [], int $limit = null, int $offset = 0, array $order = []): array {
|
||||
return static::getAll($filter, $limit, $offset, $order);
|
||||
}
|
||||
|
||||
public static function count(array $filter = []): int {
|
||||
static::__init_db();
|
||||
$db = self::$__db_instances[static::class];
|
||||
$whereSql = static::__buildFilterSql($filter);
|
||||
$res = $db->query("SELECT COUNT(*) as cnt FROM `" . static::$__tableName . "` $whereSql");
|
||||
return $db->num_rows($res) ? (int)$db->fetch_object($res)->cnt : 0;
|
||||
}
|
||||
|
||||
public function isLoaded(): bool { return $this->__isLoaded; }
|
||||
|
||||
public function getId(): int|string|null {
|
||||
return $this->{static::$__primaryKey} ?? null;
|
||||
}
|
||||
|
||||
public function save(): bool {
|
||||
try {
|
||||
$isInsert = !$this->__isLoaded;
|
||||
$userId = $this->__getUserId();
|
||||
$now = time();
|
||||
|
||||
if (property_exists($this, 'edit')) $this->edit = $now;
|
||||
if (property_exists($this, 'edit_by')) $this->edit_by = $userId;
|
||||
|
||||
if ($isInsert) {
|
||||
if (property_exists($this, 'create')) $this->create = $now;
|
||||
if (property_exists($this, 'create_by')) $this->create_by = $userId;
|
||||
}
|
||||
|
||||
$errors = $this->validate();
|
||||
if (!empty($errors)) {
|
||||
$this->__log->warn('Validation failed: ' . implode(', ', $errors));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->beforeSave($isInsert)) return false;
|
||||
|
||||
$data = $this->__getPublicData();
|
||||
$changes = $this->__getChangedFields($data);
|
||||
$pk = static::$__primaryKey;
|
||||
|
||||
if (!$isInsert && empty($changes)) return true;
|
||||
|
||||
if ($isInsert) {
|
||||
if (array_key_exists($pk, $data) && $data[$pk] === null) unset($data[$pk]);
|
||||
if (!$this->__db->insert(static::$__tableName, $data)) {
|
||||
throw new Exception("INSERT failed: " . $this->__db->getLastError());
|
||||
}
|
||||
$this->{$pk} = $this->__db->insert_id;
|
||||
$this->__isLoaded = true;
|
||||
} else {
|
||||
$pkValue = $this->{$pk};
|
||||
$updateData = [];
|
||||
foreach ($changes as $field => $change) $updateData[$field] = $change['new'];
|
||||
|
||||
if (!empty($updateData)) {
|
||||
if (!$this->__db->update(static::$__tableName, $updateData, "`$pk` = '" . $this->__db->escape($pkValue) . "'")) {
|
||||
throw new Exception("UPDATE failed: " . $this->__db->getLastError());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->__originalData = (object)$this->__getPublicData();
|
||||
$this->afterSave($isInsert, $changes);
|
||||
|
||||
if (static::$__enableJournaling) $this->__writeToJournal($changes, $isInsert);
|
||||
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
$this->__log->error("mfBaseModelV2 save() error: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(): bool {
|
||||
if (!$this->__isLoaded) return false;
|
||||
|
||||
$pk = static::$__primaryKey;
|
||||
$pkValue = $this->{$pk};
|
||||
|
||||
if ($this->__db->delete(static::$__tableName, "`$pk` = '" . $this->__db->escape($pkValue) . "'")) {
|
||||
if (static::$__enableJournaling) $this->__writeToJournal([], false, true);
|
||||
$this->__isLoaded = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getJournalHistory(): array {
|
||||
$journalDb = FronkDB::singleton();
|
||||
$pkValue = $this->{static::$__primaryKey} ?? null;
|
||||
if ($pkValue === null) return [];
|
||||
|
||||
$modelName = $journalDb->escape(get_called_class());
|
||||
$recordId = $journalDb->escape($pkValue);
|
||||
|
||||
$res = $journalDb->query("SELECT * FROM `Journal` WHERE `model` = '$modelName' AND `record_id` = '$recordId' ORDER BY `timestamp` DESC");
|
||||
$history = [];
|
||||
|
||||
if ($journalDb->num_rows($res)) {
|
||||
while ($row = $journalDb->fetch_object($res)) {
|
||||
if ($row->field && isset(static::$__journalFieldMap[$row->field])) {
|
||||
$row->field_readable = static::$__journalFieldMap[$row->field];
|
||||
}
|
||||
$history[] = $row;
|
||||
}
|
||||
}
|
||||
return $history;
|
||||
}
|
||||
|
||||
public function toArray(): array { return $this->__getPublicData(); }
|
||||
public function toJson(): string { return json_encode($this->toArray()); }
|
||||
|
||||
// Hooks
|
||||
public function validate(): array { return []; }
|
||||
protected function beforeSave(bool $isInsert): bool { return true; }
|
||||
protected function afterSave(bool $isInsert, array $changes): void {}
|
||||
|
||||
// Internal methods
|
||||
private function __load(int|string $id): bool {
|
||||
$pk = static::$__primaryKey;
|
||||
$res = $this->__db->select(static::$__tableName, "*", "`$pk` = '" . $this->__db->escape($id) . "' LIMIT 1");
|
||||
|
||||
if ($this->__db->num_rows($res)) {
|
||||
$this->__populate($this->__db->fetch_object($res));
|
||||
$this->__isLoaded = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function __populate(stdClass $data): void {
|
||||
$reflector = new ReflectionClass($this);
|
||||
foreach ($reflector->getProperties(ReflectionProperty::IS_PUBLIC) as $prop) {
|
||||
$name = $prop->getName();
|
||||
if (!property_exists($data, $name)) continue;
|
||||
|
||||
$type = $prop->getType()?->getName();
|
||||
$value = $data->{$name};
|
||||
|
||||
$this->{$name} = $value === null ? null : match ($type) {
|
||||
'int' => (int)$value,
|
||||
'float' => (float)$value,
|
||||
'bool' => (bool)$value,
|
||||
'string' => (string)$value,
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
$this->__originalData = (object)$this->__getPublicData();
|
||||
}
|
||||
|
||||
private function __getPublicData(): array {
|
||||
$data = [];
|
||||
$reflector = new ReflectionClass($this);
|
||||
foreach ($reflector->getProperties(ReflectionProperty::IS_PUBLIC) as $prop) {
|
||||
$name = $prop->getName();
|
||||
$data[$name] = $prop->isInitialized($this)
|
||||
? $this->{$name}
|
||||
: ($prop->hasDefaultValue() ? $prop->getDefaultValue() : null);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function __getChangedFields(array $currentData): array {
|
||||
$changes = [];
|
||||
if (!$this->__isLoaded || !$this->__originalData) {
|
||||
foreach ($currentData as $key => $value) $changes[$key] = ['old' => null, 'new' => $value];
|
||||
return $changes;
|
||||
}
|
||||
|
||||
foreach ($currentData as $key => $value) {
|
||||
if (!property_exists($this->__originalData, $key) || $this->__originalData->{$key} != $value) {
|
||||
$changes[$key] = ['old' => $this->__originalData->{$key} ?? null, 'new' => $value];
|
||||
}
|
||||
}
|
||||
return $changes;
|
||||
}
|
||||
|
||||
private function __writeToJournal(array $changes, bool $isInsert, bool $isDelete = false): void {
|
||||
try {
|
||||
$journalDb = FronkDB::singleton();
|
||||
$baseData = [
|
||||
'user_id' => $this->__getUserId(),
|
||||
'model' => get_called_class(),
|
||||
'record_id' => $this->{static::$__primaryKey},
|
||||
];
|
||||
|
||||
if ($isDelete) {
|
||||
$journalDb->insert('Journal', $baseData + ['action' => 'delete']);
|
||||
} elseif ($isInsert) {
|
||||
$journalDb->insert('Journal', $baseData + ['action' => 'create']);
|
||||
} else {
|
||||
foreach ($changes as $field => $change) {
|
||||
$journalDb->insert('Journal', $baseData + [
|
||||
'action' => 'update',
|
||||
'field' => $field,
|
||||
'old_value' => is_array($change['old']) || is_object($change['old']) ? json_encode($change['old']) : $change['old'],
|
||||
'new_value' => is_array($change['new']) || is_object($change['new']) ? json_encode($change['new']) : $change['new'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->__log->error("Journal write failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function __getUserId(): ?int {
|
||||
try {
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
return $me->id ?? null;
|
||||
} catch (Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds WHERE clause from filter array.
|
||||
* Operators: =exact, !not, >, <, >=, <=
|
||||
* Arrays become IN/NOT IN, null becomes IS NULL/IS NOT NULL
|
||||
*/
|
||||
private static function __buildFilterSql(array $filter): string {
|
||||
$whereClauses = ["1=1"];
|
||||
$db = self::$__db_instances[static::class];
|
||||
$reflector = new ReflectionClass(static::class);
|
||||
|
||||
foreach ($filter as $key => $value) {
|
||||
$column = $key;
|
||||
$operator = '=';
|
||||
$forceExact = false;
|
||||
|
||||
// Parse operator from key prefix
|
||||
if (str_starts_with($key, '>=')) { $operator = '>='; $column = substr($key, 2); }
|
||||
elseif (str_starts_with($key, '<=')) { $operator = '<='; $column = substr($key, 2); }
|
||||
elseif (str_starts_with($key, '!')) { $operator = '!='; $column = substr($key, 1); }
|
||||
elseif (str_starts_with($key, '>')) { $operator = '>'; $column = substr($key, 1); }
|
||||
elseif (str_starts_with($key, '<')) { $operator = '<'; $column = substr($key, 1); }
|
||||
elseif (str_starts_with($key, '=')) { $operator = '='; $column = substr($key, 1); $forceExact = true; }
|
||||
|
||||
if (!$reflector->hasProperty($column)) {
|
||||
mfLoghandler::singleton()->warn("Filter: Unknown property '$column' on " . static::class);
|
||||
continue;
|
||||
}
|
||||
|
||||
// NULL handling
|
||||
if ($value === null) {
|
||||
$whereClauses[] = "`$column` " . ($operator === '!=' ? 'IS NOT NULL' : 'IS NULL');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Array = IN/NOT IN
|
||||
if (is_array($value)) {
|
||||
$op = ($operator === '!=') ? 'NOT IN' : 'IN';
|
||||
if (empty($value)) {
|
||||
$whereClauses[] = ($op === 'IN') ? "0=1" : "1=1";
|
||||
continue;
|
||||
}
|
||||
$escaped = array_map(fn($v) => "'" . $db->escape($v) . "'", $value);
|
||||
$whereClauses[] = "`$column` $op (" . implode(',', $escaped) . ")";
|
||||
continue;
|
||||
}
|
||||
|
||||
// String lazy search vs exact/numeric
|
||||
$prop = $reflector->getProperty($column);
|
||||
$type = $prop->getType()?->getName() ?? 'string';
|
||||
|
||||
if ($type === 'string' && $operator === '=' && !$forceExact) {
|
||||
foreach (explode(' ', (string)$value) as $term) {
|
||||
if (empty($term)) continue;
|
||||
$whereClauses[] = "`$column` LIKE '%" . $db->escape($term) . "%'";
|
||||
}
|
||||
} else {
|
||||
$whereClauses[] = "`$column` $operator '" . $db->escape($value) . "'";
|
||||
}
|
||||
}
|
||||
|
||||
return "WHERE " . implode(" AND ", $whereClauses);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user