* bump version * fix formatting locale reset after embedded controller sub-requests (#5944) * fix GHSA-c6w6-57jj-62vh * fix GHSA-m492-gv72-xvxj * fix GHSA-jr9p-4h4j-6c58 * make sure to only use JS logic to call API endpoints * fixes GHSA-r8vr-m544-qh4h * make sure to only use JS logic to call API endpoints * fix GHSA-rw46-qg69-vg6h * fix GHSA-pj8j-p4g4-4vw8 - prevent kimai from rendering images via markdown * fix GHSA-pj8j-p4g4-4vw8 - use a safe network client to prevent SSRF via images * fix GHSA-xv4r-4885-gwpg * fix GHSA-pgcc-vfmc-7cw5 - move GET routes to API with POST method to prevent CSRF * fix tooltip survives page reload * updated wizard images * split wizard and password reset subscriber into two classes * relax upper php limit * added zizmor workflow scans and apply findings * user permissions <name>_other_profile now respect teams * move all linting steps to new job * updated docker image version names * use .env.local for storing APP_SECRET * improve build order and use given tag as ref for checkout, not default main branch * improved APP_SECRET handling, see entrypoint.sh * use local code for building the image for more flexibility, added dockerignore
379 lines
11 KiB
PHP
379 lines
11 KiB
PHP
<?php
|
|
|
|
/*
|
|
* This file is part of the Kimai time-tracking app.
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Constants;
|
|
use App\Utils\FileHelper;
|
|
use App\Utils\PageSetup;
|
|
use App\Utils\ReleaseVersion;
|
|
use Composer\InstalledVersions;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
use Symfony\Component\Security\Csrf\CsrfToken;
|
|
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|
use Symfony\Contracts\Cache\CacheInterface;
|
|
use Symfony\Contracts\Cache\ItemInterface;
|
|
|
|
#[Route(path: '/doctor')]
|
|
#[IsGranted('system_information')]
|
|
final class DoctorController extends AbstractController
|
|
{
|
|
/**
|
|
* Directories which need to be writable by the webserver.
|
|
*/
|
|
public const DIRECTORIES_WRITABLE = [
|
|
'var/cache/',
|
|
'var/log/',
|
|
'var/packages/',
|
|
];
|
|
|
|
public function __construct(private string $projectDirectory, private string $kernelEnvironment, private FileHelper $fileHelper, private CacheInterface $cache)
|
|
{
|
|
}
|
|
|
|
#[Route(path: '/flush-log/{token}', name: 'doctor_flush_log', methods: ['GET'])]
|
|
#[IsGranted('system_configuration')]
|
|
public function deleteLogfileAction(string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
|
{
|
|
if (!$csrfTokenManager->isTokenValid(new CsrfToken('doctor.flush_log', $token))) {
|
|
$this->flashError('action.csrf.error');
|
|
|
|
return $this->redirectToRoute('doctor');
|
|
}
|
|
|
|
$csrfTokenManager->refreshToken('doctor.flush_log');
|
|
|
|
$logfile = $this->getLogFilename();
|
|
|
|
if (file_exists($logfile)) {
|
|
if (!is_writable($logfile)) {
|
|
$this->flashError('action.delete.error', 'Logfile cannot be written');
|
|
} else {
|
|
if (false === file_put_contents($logfile, '')) {
|
|
$this->flashError('action.delete.error', 'Failed writing to logfile');
|
|
} else {
|
|
$this->flashSuccess('action.delete.success');
|
|
}
|
|
}
|
|
}
|
|
|
|
return $this->redirectToRoute('doctor');
|
|
}
|
|
|
|
#[Route(path: '', name: 'doctor', methods: ['GET'])]
|
|
public function index(): Response
|
|
{
|
|
$logLines = 100;
|
|
$canDeleteLogfile = $this->isGranted('system_configuration') && is_writable($this->getLogFilename());
|
|
$page = new PageSetup('Doctor');
|
|
$page->setHelp('doctor.html');
|
|
|
|
$latestRelease = $this->getNextUpdateVersion();
|
|
if (\is_array($latestRelease) && \array_key_exists('version', $latestRelease)) {
|
|
if (version_compare(Constants::VERSION, (string) $latestRelease['version']) >= 0) {
|
|
$latestRelease = null;
|
|
}
|
|
}
|
|
|
|
return $this->render('doctor/index.html.twig', [
|
|
'page_setup' => $page,
|
|
'modules' => get_loaded_extensions(),
|
|
'environment' => $this->kernelEnvironment,
|
|
'info' => $this->getPhpInfo(),
|
|
'settings' => $this->getIniSettings(),
|
|
'extensions' => $this->getLoadedExtensions(),
|
|
'directories' => $this->getFilePermissions(),
|
|
'log_delete' => $canDeleteLogfile,
|
|
'logs' => $this->getLog(),
|
|
'logLines' => $logLines,
|
|
'logSize' => $this->getLogSize(),
|
|
'composer' => $this->getComposerPackages(),
|
|
'release' => $latestRelease,
|
|
'opcache' => $this->getOpcacheConfiguration()
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array{enabled: bool, status: false|array<mixed>}
|
|
*/
|
|
private function getOpcacheConfiguration(): array
|
|
{
|
|
$known = \function_exists('opcache_get_status');
|
|
$status = $known ? opcache_get_status() : false;
|
|
|
|
$enabled = \is_array($status) && $status['opcache_enabled'];
|
|
|
|
if ($enabled && \array_key_exists('scripts', $status)) {
|
|
unset($status['scripts']);
|
|
}
|
|
|
|
return [
|
|
'unknown' => !$known,
|
|
'enabled' => $enabled,
|
|
'status' => $status,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function getComposerPackages(): array
|
|
{
|
|
/** @var array<string, string> $versions */
|
|
$versions = [];
|
|
|
|
$rootPackage = InstalledVersions::getRootPackage()['name'];
|
|
foreach (InstalledVersions::getInstalledPackages() as $package) {
|
|
$versions[$package] = InstalledVersions::getPrettyVersion($package);
|
|
}
|
|
|
|
// remove kimai from the package list
|
|
$versions = array_filter($versions, function ($version, $name) use ($rootPackage): bool {
|
|
if ($name === $rootPackage) {
|
|
return false;
|
|
}
|
|
|
|
if ($version === null || $version === '*') {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}, ARRAY_FILTER_USE_BOTH);
|
|
|
|
ksort($versions);
|
|
|
|
return $versions;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, bool>
|
|
*/
|
|
private function getLoadedExtensions(): array
|
|
{
|
|
$json = file_get_contents(__DIR__ . '/../../composer.json');
|
|
if ($json === false) {
|
|
return ['Failed loading composer.json' => false];
|
|
}
|
|
|
|
$composer = json_decode($json, true);
|
|
if (!\is_array($composer)) {
|
|
return ['Failed parsing composer.json' => false];
|
|
}
|
|
|
|
if (!\array_key_exists('require', $composer)) {
|
|
return ['Missing requirements in composer.json' => false];
|
|
}
|
|
|
|
$results = [];
|
|
|
|
foreach ($composer['require'] as $name => $version) {
|
|
if (!str_starts_with($name, 'ext-')) {
|
|
continue;
|
|
}
|
|
$extName = str_replace('ext-', '', $name);
|
|
$results[$extName] = false;
|
|
if (\extension_loaded($extName)) {
|
|
$results[$extName] = true;
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
private function getLogSize(): int
|
|
{
|
|
$logfile = $this->getLogFilename();
|
|
|
|
return file_exists($logfile) ? filesize($logfile) : 0;
|
|
}
|
|
|
|
private function getLogFilename(): string
|
|
{
|
|
$logfileName = 'var/log/' . $this->kernelEnvironment . '.log';
|
|
|
|
return $this->projectDirectory . '/' . $logfileName;
|
|
}
|
|
|
|
private function getLog(int $lines = 100): array
|
|
{
|
|
$logfile = $this->getLogFilename();
|
|
|
|
if (!file_exists($logfile)) {
|
|
return ['Missing logfile'];
|
|
}
|
|
|
|
if (!is_readable($logfile)) {
|
|
return ['ATTENTION: Cannot read log file'];
|
|
}
|
|
|
|
$file = new \SplFileObject($logfile, 'r');
|
|
|
|
if ($file->getSize() === 0) {
|
|
return ['Empty logfile'];
|
|
}
|
|
|
|
$file->seek($file->getSize());
|
|
$last_line = $file->key();
|
|
while ($last_line - $lines < 0) {
|
|
$lines--;
|
|
}
|
|
$iterator = new \LimitIterator($file, $last_line - $lines, $last_line);
|
|
|
|
try {
|
|
$result = iterator_to_array($iterator);
|
|
} catch (\Exception $ex) {
|
|
$result = ['ATTENTION: Failed reading log file'];
|
|
}
|
|
|
|
if (!is_writable($logfile)) {
|
|
$result[] = 'ATTENTION: Logfile is not writable';
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
private function getFilePermissions(): array
|
|
{
|
|
$testPaths = [];
|
|
$baseDir = $this->projectDirectory . DIRECTORY_SEPARATOR;
|
|
|
|
foreach (self::DIRECTORIES_WRITABLE as $path) {
|
|
$fullPath = $baseDir . $path;
|
|
$fullUri = realpath($fullPath);
|
|
|
|
if ($fullUri === false && !file_exists($fullPath)) {
|
|
@mkdir($fullPath);
|
|
clearstatcache(true);
|
|
$fullUri = realpath($fullPath);
|
|
}
|
|
|
|
$testPaths[] = $fullUri;
|
|
}
|
|
|
|
$results = [];
|
|
$testPaths[] = $this->fileHelper->getDataDirectory();
|
|
foreach ($testPaths as $fullUri) {
|
|
$fullUri = rtrim($fullUri, DIRECTORY_SEPARATOR);
|
|
$tmp = str_replace($baseDir, '', $fullUri) . DIRECTORY_SEPARATOR;
|
|
if (is_readable($fullUri) && is_writable($fullUri)) {
|
|
$results[$tmp] = true;
|
|
} else {
|
|
$results[$tmp] = false;
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
private function getIniSettings(): array
|
|
{
|
|
$ini = [
|
|
'memory_limit',
|
|
'session.gc_maxlifetime',
|
|
'max_execution_time',
|
|
'date.timezone',
|
|
'allow_url_fopen',
|
|
'default_charset',
|
|
'default_mimetype',
|
|
'display_errors',
|
|
'error_log',
|
|
'error_reporting',
|
|
'log_errors',
|
|
'open_basedir',
|
|
'post_max_size',
|
|
'sys_temp_dir',
|
|
'date.timezone',
|
|
'session.gc_maxlifetime',
|
|
'disable_functions',
|
|
'opcache.enable',
|
|
'opcache.memory_consumption',
|
|
'opcache.interned_strings_buffer',
|
|
'opcache.max_accelerated_files',
|
|
'opcache.validate_timestamps',
|
|
];
|
|
|
|
$settings = [];
|
|
foreach ($ini as $name) {
|
|
try {
|
|
$settings[$name] = \ini_get($name);
|
|
} catch (\Exception $ex) {
|
|
$settings[$name] = "Couldn't load ini setting: " . $ex->getMessage();
|
|
}
|
|
}
|
|
|
|
return $settings;
|
|
}
|
|
|
|
/**
|
|
* @author https://php.net/manual/en/function.phpinfo.php#117961
|
|
* @return array
|
|
*/
|
|
private function getPhpInfo(): array
|
|
{
|
|
$plainText = function ($input): string {
|
|
return trim(html_entity_decode(strip_tags($input)));
|
|
};
|
|
|
|
ob_start();
|
|
phpinfo(1);
|
|
|
|
$phpinfo = ['phpinfo' => []];
|
|
|
|
if (preg_match_all(
|
|
'#(?:<h2.*?>(?:<a.*?>)?(.*?)(?:<\/a>)?<\/h2>)|' .
|
|
'(?:<tr.*?><t[hd].*?>(.*?)\s*</t[hd]>(?:<t[hd].*?>(.*?)\s*</t[hd]>(?:<t[hd].*?>(.*?)\s*</t[hd]>)?)?</tr>)#s',
|
|
ob_get_clean(),
|
|
$matches,
|
|
PREG_SET_ORDER
|
|
)) {
|
|
foreach ($matches as $match) {
|
|
$fn = $plainText;
|
|
if (isset($match[2]) && isset($match[3])) {
|
|
$keys1 = array_keys($phpinfo);
|
|
$phpinfo[end($keys1)][$fn($match[2])] = isset($match[4]) ? [$fn($match[3]), $fn($match[4])] : $fn($match[3]);
|
|
} else {
|
|
$keys1 = array_keys($phpinfo);
|
|
$phpinfo[end($keys1)][] = $fn($match[2]); // @phpstan-ignore-line
|
|
}
|
|
}
|
|
}
|
|
|
|
$phpInfo = $phpinfo['phpinfo'];
|
|
array_pop($phpInfo);
|
|
array_shift($phpInfo);
|
|
|
|
return $phpInfo;
|
|
}
|
|
|
|
/**
|
|
* @return array{'version': string, 'date': \DateTimeInterface, 'url': string, 'download': string, 'content': string}|null
|
|
*/
|
|
private function getNextUpdateVersion(): ?array
|
|
{
|
|
return $this->cache->get('kimai.update_release', function (ItemInterface $item) {
|
|
// we cache the result, no matter if the call failed: at the end, this is "just"
|
|
// an update note but an expensive call
|
|
|
|
$item->expiresAfter(86400); // one day
|
|
|
|
try {
|
|
$version = new ReleaseVersion();
|
|
|
|
return $version->getLatestReleaseFromGithub(true);
|
|
} catch (\Exception $ex) {
|
|
// something failed, retry tomorrow
|
|
}
|
|
|
|
return null;
|
|
});
|
|
}
|
|
}
|