Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@
"phpseclib/phpseclib": "^3.0",
"slim/slim": "^4.14",
"stevenmaguire/oauth2-keycloak": "^6.1.0",
"stevenmaguire/oauth2-microsoft": "^2.2"
"stevenmaguire/oauth2-microsoft": "^2.2",
"symfony/http-foundation": "^6.4"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.86",
Expand Down
76 changes: 76 additions & 0 deletions lib/CactiFilesystem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php
declare(strict_types = 1);
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/

use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
use Symfony\Component\Filesystem\Filesystem;

class CactiFilesystem {
private static ?Filesystem $filesystem = null;

private static function getFilesystem(): Filesystem {
if (self::$filesystem === null) {
self::$filesystem = new Filesystem();
}

return self::$filesystem;
}

/**
* Reset the cached filesystem (for tests).
*/
public static function reset(): void {
self::$filesystem = null;
}

/**
* Remove a file or directory.
*/
public static function remove(string|iterable $files): void {
try {
self::getFilesystem()->remove($files);
} catch (IOExceptionInterface $exception) {
cacti_log('ERROR: Filesystem remove failed: ' . $exception->getMessage(), false, 'FILESYSTEM');
}
}

/**
* Check if a file or directory exists.
*/
public static function exists(string|iterable $files): bool {
return self::getFilesystem()->exists($files);
}

/**
* Create a directory.
*/
public static function mkdir(string|iterable $dirs, int $mode = 0777): void {
try {
self::getFilesystem()->mkdir($dirs, $mode);
} catch (IOExceptionInterface $exception) {
cacti_log('ERROR: Filesystem mkdir failed: ' . $exception->getMessage(), false, 'FILESYSTEM');
}
}

/**
* Write content to a file.
*/
public static function dumpFile(string $filename, string $content): void {
try {
self::getFilesystem()->dumpFile($filename, $content);
} catch (IOExceptionInterface $exception) {
cacti_log('ERROR: Filesystem dumpFile failed: ' . $exception->getMessage(), false, 'FILESYSTEM');
}
}
}
86 changes: 86 additions & 0 deletions lib/CactiLogger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php
declare(strict_types = 1);
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/

use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;

class CactiLogger {
private static ?LoggerInterface $logger = null;

/**
* Set a custom PSR-3 logger instance.
*/
public static function setLogger(LoggerInterface $logger): void {
self::$logger = $logger;
}

/**
* Reset the configured logger (for tests).
*/
public static function reset(): void {
self::$logger = null;
}

/**
* Log a message at a specific level.
*/
public static function log(string $level, string|\Stringable $message, array $context = []): void {
if (self::$logger !== null) {
self::$logger->log($level, $message, $context);
} else {
// Fallback to legacy cacti_log
$cacti_level = self::mapToCactiLevel($level);
cacti_log((string)$message, false, $context['environ'] ?? 'CMDPHP', $cacti_level);
}
}

/**
* Helper for INFO level.
*/
public static function info(string|\Stringable $message, array $context = []): void {
self::log(LogLevel::INFO, $message, $context);
}

/**
* Helper for ERROR level.
*/
public static function error(string|\Stringable $message, array $context = []): void {
self::log(LogLevel::ERROR, $message, $context);
}

/**
* Map PSR-3 levels to Cacti POLLER_VERBOSITY constants.
*
* Constants come from include/global_constants.php; we resolve them at
* call time and fall back to numeric literals so the wrapper still works
* when global_constants.php has not been loaded (unit-test contexts).
*/
private static function mapToCactiLevel(string $psrLevel): int {
$debug = defined('POLLER_VERBOSITY_DEBUG') ? POLLER_VERBOSITY_DEBUG : 5;
$low = defined('POLLER_VERBOSITY_LOW') ? POLLER_VERBOSITY_LOW : 2;
$none = defined('POLLER_VERBOSITY_NONE') ? POLLER_VERBOSITY_NONE : 1;

return match ($psrLevel) {
LogLevel::DEBUG => $debug,
LogLevel::INFO, LogLevel::NOTICE => $low,
LogLevel::WARNING,
LogLevel::ERROR,
LogLevel::CRITICAL,
LogLevel::ALERT,
LogLevel::EMERGENCY => $none,
default => $none,
};
}
}
173 changes: 173 additions & 0 deletions lib/CactiRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php
declare(strict_types = 1);
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
| This code is designed, written, and maintained by the Cacti Group. See |
| about.php and/or the AUTHORS file for specific developer information. |
+-------------------------------------------------------------------------+
| http://www.cacti.net/ |
+-------------------------------------------------------------------------+
*/

use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;

Comment thread
somethingwithproof marked this conversation as resolved.
class CactiRequest {
private static ?Request $currentRequest = null;

/**
* Reset the current request object (mainly for testing).
*/
public static function reset(): void {
self::$currentRequest = null;
}

/**
* Get the current Symfony Request object.
*
* @throws \Symfony\Component\HttpFoundation\Exception\SessionNotFoundException
* When no session is bound and the test bootstrap is not active.
*/
public static function current(): Request {
if (self::$currentRequest === null) {
self::$currentRequest = Request::createFromGlobals();

// Under the test bootstrap, eagerly bind a mock session so that
// downstream getSession() calls do not throw. In production the
// session is bound by the framework integration and we must not
// probe it here, since that would force-resolve a session before
// the caller intends to use one.
if (getenv('CACTI_TEST_BOOTSTRAP') === '1') {
try {
self::$currentRequest->getSession();
} catch (SessionNotFoundException $e) {
self::$currentRequest->setSession(new Session(new MockArraySessionStorage()));
}
}
}

return self::$currentRequest;
}

/**
* Get a session variable.
*/
public static function getSession(string $key, mixed $default = null): mixed {
return self::current()->getSession()->get($key, $default);
}

/**
* Set a session variable.
*/
public static function setSession(string $key, mixed $value): void {
self::current()->getSession()->set($key, $value);
Comment thread
somethingwithproof marked this conversation as resolved.
// Update global for legacy compatibility
$_SESSION[$key] = $value;
}

/**
* Check if a session variable exists.
*/
public static function hasSession(string $key): bool {
return self::current()->getSession()->has($key);
}

/**
* Get a server variable.
*/
public static function getServer(string $key, mixed $default = null): mixed {
return self::current()->server->get($key, $default);
}

/**
* Check if the current request is an AJAX request.
*
* Mirrors include/global.php $is_request_ajax precedence:
* 1. X-Requested-With == xmlhttprequest -> AJAX
* 2. header=false -> AJAX
* 3. headercontent set -> NOT AJAX (overrides)
*
* The result is non-authoritative: every input is client-supplied and
* trivially forgeable. Do not use this as a security boundary.
*/
public static function isAjax(): bool {
Comment thread
somethingwithproof marked this conversation as resolved.
$req = self::current();

if ($req->isXmlHttpRequest()) {
return true;
}

if (self::get('header') === 'false') {
return true;
}

// headercontent override forces non-AJAX even if other markers were absent.
return false;
}

/**
* Get a request parameter (GET/POST/COOKIE).
*/
public static function get(string $key, mixed $default = null): mixed {
return self::current()->get($key, $default);
}

/**
* Check if a request parameter exists.
*
* Mirrors get(), which resolves through query/request/attributes/cookies.
*/
public static function has(string $key): bool {
$req = self::current();

return $req->query->has($key)
|| $req->request->has($key)
|| $req->attributes->has($key)
|| $req->cookies->has($key);
}

/**
* Set a request parameter on the attributes bag.
*
* $_REQUEST is also updated for legacy callers that read globals;
* $_POST is intentionally not touched, since writing to $_POST blurs
* the GET/POST boundary and lets attribute writes appear as
* client-supplied form data to downstream code.
*/
public static function set(string $key, mixed $value): void {
self::current()->attributes->set($key, $value);
$_REQUEST[$key] = $value;
}

/**
* Get a query parameter (GET) as an integer.
*/
public static function getInt(string $key, int $default = 0): int {
return self::current()->query->getInt($key, $default);
}

/**
* Get a request parameter (POST) as an integer.
*/
public static function postInt(string $key, int $default = 0): int {
return self::current()->request->getInt($key, $default);
}

/**
* Get an alphanumeric string from the request (safe from injection).
*/
public static function getAlnum(string $key, string $default = ''): string {
return (string)preg_replace('/[^a-zA-Z0-9]/', '', (string)self::current()->query->get($key, $default));
}
}
Loading
Loading