Release 2.53 (#5878)
This commit is contained in:
@@ -72,15 +72,14 @@ final class ActivityController extends BaseApiController
|
||||
|
||||
/** @var array<int> $projects */
|
||||
$projects = $paramFetcher->get('projects');
|
||||
$project = $paramFetcher->get('project');
|
||||
if (\is_string($project) && $project !== '') {
|
||||
$projects[] = $project;
|
||||
$pr = $paramFetcher->get('project');
|
||||
if (\is_string($pr) && $pr !== '') {
|
||||
$projects[] = $pr;
|
||||
}
|
||||
|
||||
foreach (array_unique($projects) as $projectId) {
|
||||
$project = $projectRepository->find($projectId);
|
||||
if ($project === null) {
|
||||
throw $this->createNotFoundException('Unknown project: ' . $projectId);
|
||||
foreach ($projectRepository->findByIds(array_unique($projects)) as $project) {
|
||||
if (!$this->isGranted('access', $project)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Project: ' . $project->getId());
|
||||
}
|
||||
$query->addProject($project);
|
||||
}
|
||||
|
||||
@@ -85,15 +85,14 @@ final class ProjectController extends BaseApiController
|
||||
|
||||
/** @var array<int> $customers */
|
||||
$customers = $paramFetcher->get('customers');
|
||||
$customer = $paramFetcher->get('customer');
|
||||
if (\is_string($customer) && $customer !== '') {
|
||||
$customers[] = $customer;
|
||||
$cu = $paramFetcher->get('customer');
|
||||
if (\is_string($cu) && $cu !== '') {
|
||||
$customers[] = $cu;
|
||||
}
|
||||
|
||||
foreach (array_unique($customers) as $customerId) {
|
||||
$customer = $customerRepository->find($customerId);
|
||||
if ($customer === null) {
|
||||
throw $this->createNotFoundException('Unknown customer: ' . $customerId);
|
||||
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
|
||||
if (!$this->isGranted('access', $customer)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
|
||||
}
|
||||
$query->addCustomer($customer);
|
||||
}
|
||||
|
||||
@@ -126,45 +126,42 @@ final class TimesheetController extends BaseApiController
|
||||
|
||||
/** @var array<int> $customers */
|
||||
$customers = $paramFetcher->get('customers');
|
||||
$customer = $paramFetcher->get('customer');
|
||||
if (\is_string($customer) && $customer !== '') {
|
||||
$customers[] = $customer;
|
||||
$cu = $paramFetcher->get('customer');
|
||||
if (\is_string($cu) && $cu !== '') {
|
||||
$customers[] = $cu;
|
||||
}
|
||||
|
||||
foreach (array_unique($customers) as $customerId) {
|
||||
$customer = $customerRepository->find($customerId);
|
||||
if ($customer === null) {
|
||||
throw $this->createNotFoundException('Unknown customer: ' . $customerId);
|
||||
foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
|
||||
if (!$this->isGranted('access', $customer)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
|
||||
}
|
||||
$query->addCustomer($customer);
|
||||
}
|
||||
|
||||
/** @var array<int> $projects */
|
||||
$projects = $paramFetcher->get('projects');
|
||||
$project = $paramFetcher->get('project');
|
||||
if (\is_string($project) && $project !== '') {
|
||||
$projects[] = $project;
|
||||
$pr = $paramFetcher->get('project');
|
||||
if (\is_string($pr) && $pr !== '') {
|
||||
$projects[] = $pr;
|
||||
}
|
||||
|
||||
foreach (array_unique($projects) as $projectId) {
|
||||
$project = $projectRepository->find($projectId);
|
||||
if ($project === null) {
|
||||
throw $this->createNotFoundException('Unknown project: ' . $project);
|
||||
foreach ($projectRepository->findByIds(array_unique($projects)) as $project) {
|
||||
if (!$this->isGranted('access', $project)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Project: ' . $project->getId());
|
||||
}
|
||||
$query->addProject($project);
|
||||
}
|
||||
|
||||
/** @var array<int> $activities */
|
||||
$activities = $paramFetcher->get('activities');
|
||||
$activity = $paramFetcher->get('activity');
|
||||
if (\is_string($activity) && $activity !== '') {
|
||||
$activities[] = $activity;
|
||||
$ac = $paramFetcher->get('activity');
|
||||
if (\is_string($ac) && $ac !== '') {
|
||||
$activities[] = $ac;
|
||||
}
|
||||
|
||||
foreach (array_unique($activities) as $activityId) {
|
||||
$activity = $activityRepository->find($activityId);
|
||||
if ($activity === null) {
|
||||
throw $this->createNotFoundException('Unknown activity: ' . $activity);
|
||||
foreach ($activityRepository->findByIds(array_unique($activities)) as $activity) {
|
||||
if (!$this->isGranted('access', $activity)) {
|
||||
throw $this->createAccessDeniedException('Cannot access Activity: ' . $activity->getId());
|
||||
}
|
||||
$query->addActivity($activity);
|
||||
}
|
||||
@@ -234,7 +231,6 @@ final class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
$data = $this->repository->getPagerfantaForQuery($query);
|
||||
|
||||
$view = new View($data, 200);
|
||||
|
||||
$full = $paramFetcher->get('full');
|
||||
@@ -486,8 +482,7 @@ final class TimesheetController extends BaseApiController
|
||||
|
||||
$copy = $paramFetcher->get('copy');
|
||||
if ($copy === 'all') {
|
||||
$copyTimesheet->setHourlyRate($timesheet->getHourlyRate());
|
||||
$copyTimesheet->setFixedRate($timesheet->getFixedRate());
|
||||
// we do NOT copy rates, as those should always be calculated from the configured settings
|
||||
$copyTimesheet->setDescription($timesheet->getDescription());
|
||||
$copyTimesheet->setBillable($timesheet->isBillable());
|
||||
|
||||
|
||||
@@ -236,16 +236,18 @@ final class UserController extends BaseApiController
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to set the custom-field value for', required: true)]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(type: 'array', items: new OA\Items(new Model(type: UserPreference::class))))]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}/preferences', requirements: ['id' => '\d+'])]
|
||||
public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher): Response
|
||||
public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserService $userService): Response
|
||||
{
|
||||
$event = new PrepareUserEvent($profile, false);
|
||||
$dispatcher->dispatch($event);
|
||||
$dirty = false;
|
||||
|
||||
foreach ($request->request->all() as $preference) {
|
||||
// why is this not handled by FosRestBundle ?
|
||||
if (!\is_array($preference)) {
|
||||
throw new BadRequestHttpException('Invalid request, array expected');
|
||||
}
|
||||
|
||||
if (!\array_key_exists('name', $preference) || !\array_key_exists('value', $preference)) {
|
||||
throw new BadRequestHttpException('Missing required parameter "name" or "value"');
|
||||
}
|
||||
@@ -253,14 +255,23 @@ final class UserController extends BaseApiController
|
||||
$name = $preference['name'];
|
||||
$value = $preference['value'];
|
||||
|
||||
// TODO allow to update preferences that are used internally but not registered via PrepareUserEvent
|
||||
|
||||
if (null === ($meta = $profile->getPreference($name))) {
|
||||
throw $this->createNotFoundException(\sprintf('Unknown custom-field "%s" requested', $name));
|
||||
}
|
||||
|
||||
if (!$meta->isEnabled()) {
|
||||
throw $this->createAccessDeniedException('User tried to update preference: ' . $name);
|
||||
}
|
||||
|
||||
$meta->setValue($value);
|
||||
$dirty = true;
|
||||
}
|
||||
|
||||
$this->repository->saveUser($profile);
|
||||
if ($dirty) {
|
||||
$userService->saveUser($profile);
|
||||
}
|
||||
|
||||
$view = new View($profile, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace App\Configuration;
|
||||
|
||||
final class SamlConfiguration implements SamlConfigurationInterface
|
||||
{
|
||||
public function __construct(private SystemConfiguration $configuration)
|
||||
public function __construct(private readonly SystemConfiguration $configuration)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -54,4 +54,9 @@ final class SamlConfiguration implements SamlConfigurationInterface
|
||||
{
|
||||
return $this->configuration->getSamlConnection();
|
||||
}
|
||||
|
||||
public function cleanupLongRelayState(): bool
|
||||
{
|
||||
return (bool) $this->configuration->find('saml.connection.cleanupLongRelayState');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,4 +46,7 @@ interface SamlConfigurationInterface
|
||||
public function isRolesResetOnLogin(): bool;
|
||||
|
||||
public function getConnection(): array;
|
||||
|
||||
// TODO 3.0 activate me
|
||||
//public function cleanupLongRelayState(): bool;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,6 @@ final class SystemConfiguration
|
||||
$array = &$replaced;
|
||||
while (\count($keys) > 1) {
|
||||
$search = array_shift($keys);
|
||||
/* @phpstan-ignore-next-line */
|
||||
if (!\array_key_exists($search, $array) || !\is_array($array[$search])) {
|
||||
$array[$search] = [];
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.52.0';
|
||||
public const VERSION = '2.53.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 25200;
|
||||
public const VERSION_ID = 25300;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -66,13 +66,20 @@ final class SamlController extends AbstractController
|
||||
throw new ServiceUnavailableHttpException(message: 'Unknown firewall.');
|
||||
}
|
||||
|
||||
// this can be an absolute URL including query parameters
|
||||
$redirectTarget = $this->getTargetPath($session, $firewallName);
|
||||
if ($redirectTarget === null || $redirectTarget === '') {
|
||||
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
}
|
||||
|
||||
$url = $this->authFactory->create()->login($redirectTarget, [], false, false, true);
|
||||
// the protocol defines max 80 byte for RelayState, even if most IdP support more - see #5752
|
||||
if (method_exists($this->samlConfiguration, 'cleanupLongRelayState') && $this->samlConfiguration->cleanupLongRelayState()) {
|
||||
if (\strlen($redirectTarget) > 80 && ($pos = stripos($redirectTarget, '?')) !== false) {
|
||||
$redirectTarget = substr($redirectTarget, 0, $pos);
|
||||
}
|
||||
}
|
||||
|
||||
$url = $this->authFactory->create()->login($redirectTarget, [], false, false, true);
|
||||
if ($url === null) {
|
||||
throw new \RuntimeException('SAML login failed');
|
||||
}
|
||||
|
||||
@@ -26,19 +26,6 @@ use Symfony\Contracts\Cache\ItemInterface;
|
||||
#[IsGranted('system_information')]
|
||||
final class DoctorController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Required PHP extensions for Kimai.
|
||||
*/
|
||||
public const REQUIRED_EXTENSIONS = [
|
||||
'intl',
|
||||
'json',
|
||||
'mbstring',
|
||||
'pdo',
|
||||
'xml',
|
||||
'xsl',
|
||||
'zip',
|
||||
];
|
||||
|
||||
/**
|
||||
* Directories which need to be writable by the webserver.
|
||||
*/
|
||||
@@ -118,7 +105,8 @@ final class DoctorController extends AbstractController
|
||||
*/
|
||||
private function getOpcacheConfiguration(): array
|
||||
{
|
||||
$status = \function_exists('opcache_get_status') ? opcache_get_status() : false;
|
||||
$known = \function_exists('opcache_get_status');
|
||||
$status = $known ? opcache_get_status() : false;
|
||||
|
||||
$enabled = \is_array($status) && $status['opcache_enabled'];
|
||||
|
||||
@@ -127,6 +115,7 @@ final class DoctorController extends AbstractController
|
||||
}
|
||||
|
||||
return [
|
||||
'unknown' => !$known,
|
||||
'enabled' => $enabled,
|
||||
'status' => $status,
|
||||
];
|
||||
@@ -168,9 +157,27 @@ final class DoctorController extends AbstractController
|
||||
*/
|
||||
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 (self::REQUIRED_EXTENSIONS as $extName) {
|
||||
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;
|
||||
|
||||
@@ -99,7 +99,7 @@ final class AppExtension extends Extension
|
||||
foreach (range(0, $iterator->getDepth()) as $depth) {
|
||||
$keys[] = $iterator->getSubIterator($depth)->key();
|
||||
}
|
||||
$newConfig[implode('.', $keys)] = $value;
|
||||
$newConfig[implode('.', $keys)] = $value; // @phpstan-ignore argument.type
|
||||
}
|
||||
|
||||
$container->setParameter('kimai.config', $newConfig);
|
||||
|
||||
@@ -862,6 +862,9 @@ final class Configuration implements ConfigurationInterface
|
||||
->scalarNode('baseurl')->end()
|
||||
->booleanNode('strict')->end()
|
||||
->booleanNode('debug')->end()
|
||||
->booleanNode('cleanupLongRelayState')
|
||||
->defaultFalse()
|
||||
->end()
|
||||
->arrayNode('idp')
|
||||
->children()
|
||||
->scalarNode('entityId')->end()
|
||||
|
||||
@@ -141,6 +141,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
* Contact email
|
||||
*/
|
||||
#[ORM\Column(name: 'email', type: Types::STRING, length: 75, nullable: true)]
|
||||
#[Assert\Email(mode: 'html5')]
|
||||
#[Assert\Length(max: 75)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Customer_Entity'])]
|
||||
@@ -148,6 +149,8 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
private ?string $email = null;
|
||||
#[ORM\Column(name: 'homepage', type: Types::STRING, length: 100, nullable: true)]
|
||||
#[Assert\Length(max: 100)]
|
||||
#[Assert\Url]
|
||||
#[Assert\NoSuspiciousCharacters]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'homepage')]
|
||||
|
||||
@@ -81,6 +81,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
*/
|
||||
#[ORM\Column(name: 'alias', type: Types::STRING, length: 60, nullable: true)]
|
||||
#[Assert\Length(max: 60)]
|
||||
#[Constraints\NoHtmlSpecialCharacters]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'alias')]
|
||||
@@ -96,6 +97,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
*/
|
||||
#[ORM\Column(name: 'title', type: Types::STRING, length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50)]
|
||||
#[Constraints\NoHtmlSpecialCharacters]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Exporter\Expose(label: 'title')]
|
||||
@@ -162,6 +164,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
#[Assert\NotBlank(groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
#[Assert\Regex(pattern: '/\//', match: false, groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
#[Assert\Length(min: 2, max: 64, groups: ['Registration', 'UserCreate', 'Profile'])]
|
||||
#[Constraints\NoHtmlSpecialCharacters]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?string $username = null;
|
||||
|
||||
@@ -11,12 +11,16 @@ namespace App\Export\Package\CellFormatter;
|
||||
|
||||
final class ArrayFormatter implements CellFormatterInterface
|
||||
{
|
||||
/**
|
||||
* @param mixed|array<int, string> $value
|
||||
* @return mixed
|
||||
*/
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (!\is_array($value)) {
|
||||
throw new \InvalidArgumentException('Only arrays are supported');
|
||||
}
|
||||
|
||||
return implode(', ', $value);
|
||||
return implode(', ', $value); // @phpstan-ignore argument.type
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@ final class ArrayFormatter implements CellFormatterInterface
|
||||
throw new \InvalidArgumentException('Unsupported value given, only array is supported');
|
||||
}
|
||||
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), implode(';', $value));
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), implode(';', $value)); // @phpstan-ignore argument.type
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ final class SelectWithApiDataExtension extends AbstractTypeExtension
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var array{create: string, select: bool, route: string, route_params: array<string, string>, empty_route_params: array<string, string>, reload: string} $apiData */
|
||||
$apiData = $options['api_data'];
|
||||
|
||||
if (!\is_array($apiData)) {
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace App\Form\Type;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Email;
|
||||
|
||||
final class MailType extends AbstractType
|
||||
{
|
||||
@@ -20,9 +19,7 @@ final class MailType extends AbstractType
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'label' => 'email',
|
||||
'constraints' => [
|
||||
new Email(['mode' => 'html5'])
|
||||
],
|
||||
// no constraint by default, as the form or used entities should add that
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,9 +25,17 @@ final class WeekDaysType extends AbstractType
|
||||
{
|
||||
$builder->addModelTransformer(new CallbackTransformer(
|
||||
function ($weekdays): array {
|
||||
if ($weekdays === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return explode(',', $weekdays);
|
||||
},
|
||||
function ($weekdays): string {
|
||||
if ($weekdays === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return implode(',', $weekdays);
|
||||
}
|
||||
));
|
||||
|
||||
@@ -77,7 +77,7 @@ class UserCreateType extends UserEditType
|
||||
parent::configureOptions($resolver);
|
||||
|
||||
$resolver->setDefaults([
|
||||
'validation_groups' => ['UserCreate', 'Registration'],
|
||||
'validation_groups' => ['UserCreate', 'Registration', 'Default'],
|
||||
'include_roles' => false,
|
||||
'include_teams' => false,
|
||||
]);
|
||||
|
||||
@@ -31,7 +31,7 @@ class UserEditType extends AbstractType
|
||||
{
|
||||
use ColorTrait;
|
||||
|
||||
public function __construct(private SystemConfiguration $configuration)
|
||||
public function __construct(private readonly SystemConfiguration $configuration)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ class UserEditType extends AbstractType
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'validation_groups' => ['Profile'],
|
||||
'validation_groups' => ['Profile', 'Default'],
|
||||
'data_class' => User::class,
|
||||
'csrf_protection' => true,
|
||||
'csrf_field_name' => '_token',
|
||||
|
||||
@@ -37,7 +37,7 @@ use Doctrine\ORM\QueryBuilder;
|
||||
class ActivityRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* @param int[] $activityIds
|
||||
* @param array<int, string|int> $activityIds
|
||||
* @return array<Activity>
|
||||
*/
|
||||
public function findByIds(array $activityIds): array
|
||||
|
||||
@@ -37,7 +37,7 @@ use Doctrine\ORM\QueryBuilder;
|
||||
class CustomerRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* @param int[] $customerIDs
|
||||
* @param array<int, string|int> $customerIDs
|
||||
* @return array<Customer>
|
||||
*/
|
||||
public function findByIds(array $customerIDs): array
|
||||
|
||||
@@ -40,7 +40,7 @@ use Doctrine\ORM\QueryBuilder;
|
||||
class ProjectRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* @param int[] $projectIds
|
||||
* @param array<int, string|int> $projectIds
|
||||
* @return array<Project>
|
||||
*/
|
||||
public function findByIds(array $projectIds): array
|
||||
|
||||
@@ -631,11 +631,13 @@ class TimesheetRepository extends EntityRepository
|
||||
|
||||
if ($query->hasProjects()) {
|
||||
$qb->andWhere($qb->expr()->in('t.project', ':project'))
|
||||
->setParameter('project', $query->getProjectIds());
|
||||
} elseif ($query->hasCustomers()) {
|
||||
->setParameter('project', $query->getProjects());
|
||||
}
|
||||
|
||||
if ($query->hasCustomers()) {
|
||||
$requiresCustomer = true;
|
||||
$qb->andWhere($qb->expr()->in('p.customer', ':customer'))
|
||||
->setParameter('customer', $query->getCustomerIds());
|
||||
->setParameter('customer', $query->getCustomers());
|
||||
}
|
||||
|
||||
$tags = $query->getTags();
|
||||
|
||||
@@ -150,7 +150,7 @@ final class SamlProvider
|
||||
}
|
||||
|
||||
if (!empty($results)) {
|
||||
return implode(' ', $results);
|
||||
return implode(' ', $results); // @phpstan-ignore argument.type
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -24,11 +24,38 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
|
||||
|
||||
protected function determineTargetUrl(Request $request): string
|
||||
{
|
||||
// see https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
|
||||
// if using the Deflate encoding, RelayState will be submitted using the query
|
||||
$relayState = $request->request->get('RelayState', $request->query->get('RelayState'));
|
||||
if (\is_scalar($relayState)) {
|
||||
$relayState = (string) $relayState;
|
||||
if ($relayState !== $this->httpUtils->generateUri($request, (string) $this->options['login_path'])) {
|
||||
return $relayState;
|
||||
|
||||
if (\is_string($relayState)) {
|
||||
$values = parse_url($relayState);
|
||||
|
||||
// we use only the path part of the URL to prevent external redirects
|
||||
$path = null;
|
||||
if (\is_array($values) && \array_key_exists('path', $values)) {
|
||||
$path = $values['path'];
|
||||
}
|
||||
|
||||
if (\is_string($path)
|
||||
&& $path !== ''
|
||||
&& str_starts_with($path, '/')
|
||||
&& !str_starts_with($path, '//')
|
||||
&& !str_contains($path, '\\')
|
||||
) {
|
||||
$target = $this->httpUtils->generateUri($request, $path);
|
||||
$loginUrl = $this->httpUtils->generateUri($request, (string) $this->options['login_path']);
|
||||
|
||||
if (\array_key_exists('scheme', $values) && str_starts_with($values['scheme'], 'http')) {
|
||||
if (\array_key_exists('host', $values) && !str_starts_with($target, $values['scheme'] . '://' . $values['host'])) {
|
||||
$target = null;
|
||||
}
|
||||
}
|
||||
|
||||
// make sure that the login URL is not the target, which would be an endless loop for the user
|
||||
if ($target !== null && $target !== $loginUrl) {
|
||||
return $target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
48
src/Twig/ContractExtensions.php
Normal file
48
src/Twig/ContractExtensions.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?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\Twig;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\WorkingTime\Calculator\WorkingTimeCalculator;
|
||||
use App\WorkingTime\WorkingTimeService;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigTest;
|
||||
|
||||
final class ContractExtensions extends AbstractExtension
|
||||
{
|
||||
/** @var array<string, WorkingTimeCalculator> */
|
||||
private array $calculators = [];
|
||||
|
||||
public function __construct(private readonly WorkingTimeService $workingTimeService)
|
||||
{
|
||||
}
|
||||
|
||||
public function getTests(): array
|
||||
{
|
||||
return [
|
||||
/* @var array{user: User, date: \DateTimeInterface} $values */
|
||||
new TwigTest('work_day', function (array $values): bool {
|
||||
$user = $values['user'];
|
||||
if ($user->getId() === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$id = 'user_' . $user->getId();
|
||||
if (!\array_key_exists($id, $this->calculators)) {
|
||||
$this->calculators[$id] = $this->workingTimeService->getContractMode($user)->getCalculator($user);
|
||||
}
|
||||
|
||||
$date = $values['date'];
|
||||
|
||||
return $this->calculators[$id]->isWorkDay($date);
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,12 @@ final class StrictPolicy implements SecurityPolicyInterface
|
||||
}
|
||||
|
||||
if ($obj instanceof User) {
|
||||
if (\in_array($lcm, ['getpassword', 'gettotpsecret', 'getplainpassword', 'getconfirmationtoken', 'gettotpauthenticationconfiguration'], true)) {
|
||||
if (str_contains($lcm, 'password')
|
||||
|| str_contains($lcm, 'totp')
|
||||
|| str_contains($lcm, 'api')
|
||||
|| str_contains($lcm, 'secret')
|
||||
|| str_contains($lcm, 'token')
|
||||
) {
|
||||
throw new SecurityNotAllowedMethodError('Tried to access user secrets', User::class, $method);
|
||||
}
|
||||
}
|
||||
|
||||
29
src/Validator/Constraints/NoHtmlSpecialCharacters.php
Normal file
29
src/Validator/Constraints/NoHtmlSpecialCharacters.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_PROPERTY)]
|
||||
final class NoHtmlSpecialCharacters extends Constraint
|
||||
{
|
||||
public const SPECIAL_CHARACTERS_FOUND = 'kimai-html-character-001';
|
||||
|
||||
protected const ERROR_NAMES = [
|
||||
self::SPECIAL_CHARACTERS_FOUND => 'These characters are not allowed: {{ chars }}',
|
||||
];
|
||||
|
||||
public string $message = 'These characters are not allowed: {{ chars }}';
|
||||
|
||||
public function getTargets(): string
|
||||
{
|
||||
return self::PROPERTY_CONSTRAINT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class NoHtmlSpecialCharactersValidator extends ConstraintValidator
|
||||
{
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
{
|
||||
if (!($constraint instanceof NoHtmlSpecialCharacters)) {
|
||||
throw new UnexpectedTypeException($constraint, NoHtmlSpecialCharacters::class);
|
||||
}
|
||||
|
||||
if (!\is_string($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_contains($value, '<')
|
||||
|| str_contains($value, '>')
|
||||
|| str_contains($value, '"')
|
||||
// there are many family names that use the ' (like O'Hara), so we cannot forbid them
|
||||
) {
|
||||
$this->context->buildViolation(NoHtmlSpecialCharacters::getErrorName(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND))
|
||||
->setTranslationDomain('validators')
|
||||
->setParameter('{{ chars }}', '< " >')
|
||||
->setCode(NoHtmlSpecialCharacters::SPECIAL_CHARACTERS_FOUND)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
namespace App\Voter;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\User;
|
||||
use App\Security\RolePermissionManager;
|
||||
@@ -33,6 +35,7 @@ final class ActivityVoter extends Voter
|
||||
'time',
|
||||
'delete',
|
||||
'permissions',
|
||||
'access',
|
||||
];
|
||||
|
||||
public function __construct(private readonly RolePermissionManager $permissionManager)
|
||||
@@ -54,6 +57,37 @@ final class ActivityVoter extends Voter
|
||||
return $subject instanceof Activity && $this->supportsAttribute($attribute);
|
||||
}
|
||||
|
||||
private function checkTeamPermission(Activity|Project|Customer $subject, User $user): bool
|
||||
{
|
||||
if ($user->canSeeAllData()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($subject instanceof Activity && $subject->getProject() !== null) {
|
||||
if (!$this->checkTeamPermission($subject->getProject(), $user)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($subject instanceof Project && $subject->getCustomer() !== null) {
|
||||
if (!$this->checkTeamPermission($subject->getCustomer(), $user)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($subject->getTeams()->count() === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($subject->getTeams() as $team) {
|
||||
if ($user->isInTeam($team)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
@@ -62,6 +96,12 @@ final class ActivityVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
// this is a virtual permission, only meant to be used by developer
|
||||
// it checks if access to the given activity is potentially possible
|
||||
if ($attribute === 'access') {
|
||||
return $this->checkTeamPermission($subject, $user);
|
||||
}
|
||||
|
||||
if ($this->permissionManager->hasRolePermission($user, $attribute . '_activity')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Voter;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\User;
|
||||
@@ -35,6 +36,7 @@ final class ProjectVoter extends Voter
|
||||
'permissions',
|
||||
'comments',
|
||||
'details',
|
||||
'access',
|
||||
];
|
||||
|
||||
public function __construct(private readonly RolePermissionManager $permissionManager)
|
||||
@@ -56,6 +58,31 @@ final class ProjectVoter extends Voter
|
||||
return $subject instanceof Project && $this->supportsAttribute($attribute);
|
||||
}
|
||||
|
||||
private function checkTeamPermission(Project|Customer $subject, User $user): bool
|
||||
{
|
||||
if ($user->canSeeAllData()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($subject instanceof Project && $subject->getCustomer() !== null) {
|
||||
if (!$this->checkTeamPermission($subject->getCustomer(), $user)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($subject->getTeams()->count() === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($subject->getTeams() as $team) {
|
||||
if ($user->isInTeam($team)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
@@ -64,6 +91,12 @@ final class ProjectVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
// this is a virtual permission, only meant to be used by developer
|
||||
// it checks if access to the given project is potentially possible
|
||||
if ($attribute === 'access') {
|
||||
return $this->checkTeamPermission($subject, $user);
|
||||
}
|
||||
|
||||
if ($this->permissionManager->hasRolePermission($user, $attribute . '_project')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ use App\WorkingTime\Model\Year;
|
||||
use App\WorkingTime\Model\YearPerUserSummary;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
final class WorkingTimeService
|
||||
/**
|
||||
* @final not final for mocking in tests
|
||||
*/
|
||||
class WorkingTimeService
|
||||
{
|
||||
private const LATEST_APPROVAL_PREF = '_latest_approval';
|
||||
private const LATEST_APPROVAL_FORMAT = 'Y-m-d H:i:s';
|
||||
|
||||
Reference in New Issue
Block a user