Release 2.58 (#5952)
* 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
This commit is contained in:
@@ -18,6 +18,7 @@ use App\Repository\ActivityRateRepository;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\User\TeamService;
|
||||
use App\Utils\SearchTerm;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
@@ -27,6 +28,7 @@ use OpenApi\Attributes as OA;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
@@ -308,4 +310,39 @@ final class ActivityController extends BaseApiController
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create team for activity
|
||||
*
|
||||
* If a team with the activity's name already exists, it is reused.
|
||||
* The current user is added as teamlead (if not already), and the activity is bound to the team.
|
||||
*/
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'activity')]
|
||||
#[OA\Post(description: 'Creates (or reuses) a default team named after the activity, makes the current user a teamlead, and binds the activity to that team. Calling this multiple times is safe and will not create duplicate teams or bindings.', responses: [new OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', description: 'The activity to create a default team for', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}/team', name: 'post_activity_team', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function postDefaultTeamAction(Activity $activity, TeamService $teamService): Response
|
||||
{
|
||||
$name = $activity->getName();
|
||||
if ($name === null || $name === '') {
|
||||
throw new BadRequestHttpException('Cannot create default team for activity with empty name: ' . $activity->getId());
|
||||
}
|
||||
|
||||
$team = $teamService->findTeamByName($name);
|
||||
|
||||
if ($team === null) {
|
||||
$team = $teamService->createNewTeam($name);
|
||||
}
|
||||
|
||||
$team->addTeamlead($this->getUser());
|
||||
$team->addActivity($activity);
|
||||
|
||||
$teamService->saveTeam($team);
|
||||
|
||||
$view = new View($team, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(TeamController::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Form\API\CustomerRateApiForm;
|
||||
use App\Repository\CustomerRateRepository;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\User\TeamService;
|
||||
use App\Utils\SearchTerm;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
@@ -29,6 +30,7 @@ use OpenApi\Attributes as OA;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
@@ -401,4 +403,39 @@ final class CustomerController extends BaseApiController
|
||||
|
||||
return $this->viewHandler->handle(new View(null, Response::HTTP_NO_CONTENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create team for customer
|
||||
*
|
||||
* If a team with the customer's name already exists, it is reused.
|
||||
* The current user is added as teamlead (if not already), and the customer is bound to the team.
|
||||
*/
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'customer')]
|
||||
#[OA\Post(description: 'Creates (or reuses) a default team named after the customer, makes the current user a teamlead, and binds the customer to that team. Calling this multiple times is safe and will not create duplicate teams or bindings.', responses: [new OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', description: 'The customer to create a default team for', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}/team', name: 'post_customer_team', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function postDefaultTeamAction(Customer $customer, TeamService $teamService): Response
|
||||
{
|
||||
$name = $customer->getName();
|
||||
if ($name === null || $name === '') {
|
||||
throw new BadRequestHttpException('Cannot create default team for customer with empty name: ' . $customer->getId());
|
||||
}
|
||||
|
||||
$team = $teamService->findTeamByName($name);
|
||||
|
||||
if ($team === null) {
|
||||
$team = $teamService->createNewTeam($name);
|
||||
}
|
||||
|
||||
$team->addTeamlead($this->getUser());
|
||||
$team->addCustomer($customer);
|
||||
|
||||
$teamService->saveTeam($team);
|
||||
|
||||
$view = new View($team, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(TeamController::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRateRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\User\TeamService;
|
||||
use App\Utils\SearchTerm;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
@@ -30,6 +31,7 @@ use OpenApi\Attributes as OA;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
@@ -457,4 +459,39 @@ final class ProjectController extends BaseApiController
|
||||
|
||||
return $this->viewHandler->handle(new View(null, Response::HTTP_NO_CONTENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create team for project
|
||||
*
|
||||
* If a team with the project's name already exists, it is reused.
|
||||
* The current user is added as teamlead (if not already), and the project is bound to the team.
|
||||
*/
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'project')]
|
||||
#[OA\Post(description: 'Creates (or reuses) a default team named after the project, makes the current user a teamlead, and binds the project to that team. Calling this multiple times is safe and will not create duplicate teams or bindings.', responses: [new OA\Response(response: 200, description: 'Returns the team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', description: 'The project to create a default team for', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}/team', name: 'post_project_team', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function postDefaultTeamAction(Project $project, TeamService $teamService): Response
|
||||
{
|
||||
$name = $project->getName();
|
||||
if ($name === null || $name === '') {
|
||||
throw new BadRequestHttpException('Cannot create default team for project with empty name: ' . $project->getId());
|
||||
}
|
||||
|
||||
$team = $teamService->findTeamByName($name);
|
||||
|
||||
if ($team === null) {
|
||||
$team = $teamService->createNewTeam($name);
|
||||
}
|
||||
|
||||
$team->addTeamlead($this->getUser());
|
||||
$team->addProject($project);
|
||||
|
||||
$teamService->saveTeam($team);
|
||||
|
||||
$view = new View($team, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(TeamController::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ final class TeamController extends BaseApiController
|
||||
* Add team member
|
||||
*/
|
||||
#[IsGranted('edit', 'team')]
|
||||
#[IsGranted('access_user', 'member')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new user to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team which will receive the new member', required: true)]
|
||||
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to add (User ID)', required: true)]
|
||||
@@ -224,6 +225,7 @@ final class TeamController extends BaseApiController
|
||||
* The team is granted access to the customer.
|
||||
*/
|
||||
#[IsGranted('edit', 'team')]
|
||||
#[IsGranted('view', 'customer')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the customer', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
|
||||
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to grant acecess to (Customer ID)', required: true)]
|
||||
@@ -274,6 +276,7 @@ final class TeamController extends BaseApiController
|
||||
* The team is granted access to the project.
|
||||
*/
|
||||
#[IsGranted('edit', 'team')]
|
||||
#[IsGranted('view', 'project')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the project', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
|
||||
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to grant acecess to (Project ID)', required: true)]
|
||||
@@ -324,6 +327,7 @@ final class TeamController extends BaseApiController
|
||||
* The team is granted access to the activity.
|
||||
*/
|
||||
#[IsGranted('edit', 'team')]
|
||||
#[IsGranted('view', 'activity')]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the team including the activity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
|
||||
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to grant acecess to (Activity ID)', required: true)]
|
||||
|
||||
@@ -429,16 +429,11 @@ final class TimesheetController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Stop active timesheet
|
||||
*
|
||||
* This route is available via GET and PATCH, as users over and over again run into errors when stopping.
|
||||
* Likely caused by a slow JS engine and a fast-click after page reload.
|
||||
*/
|
||||
#[IsGranted('stop', 'timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Stops an active timesheet and returns it afterwards.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to stop', required: true)]
|
||||
#[Route(methods: ['GET'], path: '/{id}/stop', name: 'stop_timesheet_get', requirements: ['id' => '\d+'])]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}/stop', name: 'stop_timesheet', requirements: ['id' => '\d+'])]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
public function stopAction(Timesheet $timesheet): Response
|
||||
{
|
||||
$this->service->stopTimesheet($timesheet);
|
||||
@@ -457,8 +452,6 @@ final class TimesheetController extends BaseApiController
|
||||
#[IsGranted('start', 'timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Restart a timesheet for the same customer, project, activity combination. The current user will be the owner of the new record. Kimai tries to stop running records, which is expected to fail depending on the configured rules. Data will be copied from the original record if requested.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to restart', required: true)]
|
||||
#[OA\Get(x: ['internal' => true])]
|
||||
#[Route(methods: ['GET'], path: '/{id}/restart', name: 'restart_timesheet_get', requirements: ['id' => '\d+'])]
|
||||
#[Route(methods: ['PATCH'], path: '/{id}/restart', name: 'restart_timesheet', requirements: ['id' => '\d+'])]
|
||||
#[Rest\RequestParam(name: 'copy', requirements: 'all', strict: true, nullable: true, description: 'Whether data should be copied to the new entry. Allowed values: all (default: nothing is copied)')]
|
||||
#[Rest\RequestParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Changes the restart date to the given one (default: now)')]
|
||||
|
||||
@@ -17,11 +17,11 @@ final class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.57.0';
|
||||
public const VERSION = '2.58.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 25700;
|
||||
public const VERSION_ID = 25800;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -31,7 +31,6 @@ use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\TeamQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\User\TeamService;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Exception;
|
||||
@@ -40,7 +39,6 @@ use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
@@ -306,34 +304,6 @@ final class ActivityController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/create_team', name: 'activity_team_create', methods: ['GET'])]
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'activity')]
|
||||
public function createDefaultTeamAction(Activity $activity, TeamService $teamService): Response
|
||||
{
|
||||
$name = $activity->getName();
|
||||
if ($name === null) {
|
||||
throw new BadRequestHttpException('Cannot create default team for activity with empty name: ' . $activity->getId());
|
||||
}
|
||||
|
||||
$defaultTeam = $teamService->findTeamByName($name);
|
||||
|
||||
if (null === $defaultTeam) {
|
||||
$defaultTeam = $teamService->createNewTeam($name);
|
||||
}
|
||||
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addActivity($activity);
|
||||
|
||||
try {
|
||||
$teamService->saveTeam($defaultTeam);
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('activity_details', ['id' => $activity->getId()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/edit', name: 'admin_activity_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('edit', 'activity')]
|
||||
public function editAction(Activity $activity, Request $request, ActivityService $activityService, SystemConfiguration $configuration): Response
|
||||
|
||||
@@ -34,7 +34,6 @@ use App\Repository\Query\TeamQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\Query\VisibilityInterface;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\User\TeamService;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
@@ -42,7 +41,6 @@ use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
@@ -188,34 +186,6 @@ final class CustomerController extends AbstractController
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/create_team', name: 'customer_team_create', methods: ['GET'])]
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'customer')]
|
||||
public function createDefaultTeamAction(Customer $customer, TeamService $teamService): Response
|
||||
{
|
||||
$name = $customer->getName();
|
||||
if ($name === null) {
|
||||
throw new BadRequestHttpException('Cannot create default team for customer with empty name: ' . $customer->getId());
|
||||
}
|
||||
|
||||
$defaultTeam = $teamService->findTeamByName($name);
|
||||
|
||||
if (null === $defaultTeam) {
|
||||
$defaultTeam = $teamService->createNewTeam($name);
|
||||
}
|
||||
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addCustomer($customer);
|
||||
|
||||
try {
|
||||
$teamService->saveTeam($defaultTeam);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/projects/{page}', defaults: ['page' => 1], name: 'customer_projects', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('view', 'customer')]
|
||||
public function projectsAction(Customer $customer, int $page, ProjectRepository $projectRepository): Response
|
||||
|
||||
@@ -32,6 +32,7 @@ final class DoctorController extends AbstractController
|
||||
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)
|
||||
|
||||
@@ -208,12 +208,14 @@ final class ExportController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/template-create', name: 'export_template_create', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_export_template')]
|
||||
public function createExportTemplate(Request $request, ExportTemplateRepository $repository): Response
|
||||
{
|
||||
return $this->editExportForm($this->generateUrl('export_template_create'), $request, $repository, new ExportTemplate());
|
||||
}
|
||||
|
||||
#[Route(path: '/template-edit/{exportTemplate}', name: 'export_template_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_export_template')]
|
||||
public function editExportTemplate(ExportTemplate $exportTemplate, Request $request, ExportTemplateRepository $repository): Response
|
||||
{
|
||||
return $this->editExportForm($this->generateUrl('export_template_edit', ['exportTemplate' => $exportTemplate->getId()]), $request, $repository, $exportTemplate);
|
||||
|
||||
@@ -37,7 +37,6 @@ use App\Repository\Query\TeamQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\Query\VisibilityInterface;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\User\TeamService;
|
||||
use App\Utils\Context;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
@@ -46,7 +45,6 @@ use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfToken;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
@@ -219,34 +217,6 @@ final class ProjectController extends AbstractController
|
||||
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/create_team', name: 'project_team_create', methods: ['GET'])]
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'project')]
|
||||
public function createDefaultTeamAction(Project $project, TeamService $teamService): Response
|
||||
{
|
||||
$name = $project->getName();
|
||||
if ($name === null) {
|
||||
throw new BadRequestHttpException('Cannot create default team for project with empty name: ' . $project->getId());
|
||||
}
|
||||
|
||||
$defaultTeam = $teamService->findTeamByName($name);
|
||||
|
||||
if (null === $defaultTeam) {
|
||||
$defaultTeam = $teamService->createNewTeam($name);
|
||||
}
|
||||
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addProject($project);
|
||||
|
||||
try {
|
||||
$teamService->saveTeam($defaultTeam);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/activities/{page}', defaults: ['page' => 1], name: 'project_activities', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('view', 'project')]
|
||||
public function activitiesAction(Project $project, int $page, ActivityRepository $activityRepository): Response
|
||||
|
||||
@@ -25,11 +25,11 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
|
||||
$timesheet = $payload['timesheet'];
|
||||
if ($timesheet->getId() !== null) {
|
||||
if ($timesheet->isRunning() && $this->isGranted('stop', $timesheet)) {
|
||||
$event->addAction('stop', ['url' => $this->path('stop_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-stop', 'attr' => ['data-event' => 'kimai.timesheetStop kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.stop.error', 'data-msg-success' => 'timesheet.stop.success']]);
|
||||
$event->addAction('stop', ['url' => '#', 'class' => 'api-link dd-ts-stop', 'attr' => ['data-event' => 'kimai.timesheetStop kimai.timesheetUpdate', 'data-href' => $this->path('stop_timesheet', ['id' => $timesheet->getId()]), 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.stop.error', 'data-msg-success' => 'timesheet.stop.success']]);
|
||||
}
|
||||
|
||||
if (!$timesheet->isRunning() && $this->isGranted('start', $timesheet)) {
|
||||
$event->addAction('repeat', ['title' => 'repeat', 'url' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]);
|
||||
$event->addAction('repeat', ['title' => 'repeat', 'url' => '#', 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-href' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $timesheet)) {
|
||||
|
||||
70
src/EventSubscriber/PasswordResetSubscriber.php
Normal file
70
src/EventSubscriber/PasswordResetSubscriber.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?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\EventSubscriber;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
class PasswordResetSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly AuthorizationCheckerInterface $security,
|
||||
private readonly TokenStorageInterface $storage,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
// higher priority is executed earlier - need to be higher than wizard
|
||||
KernelEvents::REQUEST => ['onKernelRequest', -20]
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
// ignore sub-requests
|
||||
if (!$event->isMainRequest() || null === ($token = $this->storage->getToken())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uri = $event->getRequest()->getRequestUri();
|
||||
|
||||
// never trigger password reset on API calls
|
||||
// TODO 3.0 remove /register/
|
||||
if (str_starts_with($uri, '/api/') || stripos($uri, '/register/') !== false || stripos($uri, '/wizard/') !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!($user instanceof User)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('IS_AUTHENTICATED_FULLY')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$user->requiresPasswordReset()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => 'password']));
|
||||
$event->setResponse($response);
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,13 @@
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
|
||||
/**
|
||||
* When visiting the homepage, this listener redirects the user to the most
|
||||
@@ -24,7 +26,8 @@ final class RedirectToLocaleSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly LocaleService $localeService
|
||||
private readonly LocaleService $localeService,
|
||||
private readonly TokenStorageInterface $storage,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -32,7 +35,9 @@ final class RedirectToLocaleSubscriber implements EventSubscriberInterface
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::REQUEST => ['onKernelRequest']
|
||||
// the higher the priority (default: 0), the earlier it is executed
|
||||
// runs on default priority to make sure we have the correct locale in the URL
|
||||
KernelEvents::REQUEST => ['onKernelRequest', 0]
|
||||
];
|
||||
}
|
||||
|
||||
@@ -52,15 +57,26 @@ final class RedirectToLocaleSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$allLanguages = $this->localeService->getTranslatedLocales();
|
||||
$preferredLanguage = null;
|
||||
|
||||
// Add the default locale at the first position of the array, because getPreferredLanguage()
|
||||
// returns the first element when no appropriate language is found
|
||||
array_unshift($allLanguages, 'en');
|
||||
if (null !== ($token = $this->storage->getToken())) {
|
||||
$user = $token->getUser();
|
||||
if ($user instanceof User) {
|
||||
$preferredLanguage = $user->getLanguage();
|
||||
}
|
||||
}
|
||||
|
||||
$preferredLanguage = $request->getPreferredLanguage(array_unique($allLanguages));
|
||||
if ($preferredLanguage === null){
|
||||
$allLanguages = $this->localeService->getTranslatedLocales();
|
||||
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('homepage', ['_locale' => $preferredLanguage]));
|
||||
// Add the default locale at the first position of the array, because getPreferredLanguage()
|
||||
// returns the first element when no appropriate language is found
|
||||
array_unshift($allLanguages, 'en');
|
||||
|
||||
$preferredLanguage = $request->getPreferredLanguage(array_unique($allLanguages));
|
||||
}
|
||||
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('homepage', ['_locale' => $preferredLanguage ?? 'en']));
|
||||
$event->setResponse($response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\EventSubscriber;
|
||||
use App\Entity\User;
|
||||
use App\Twig\LocaleFormatExtensions;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
@@ -19,6 +20,8 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class UserEnvironmentSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
private ?string $userLocale = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly TokenStorageInterface $tokenStorage,
|
||||
private readonly AuthorizationCheckerInterface $auth,
|
||||
@@ -30,10 +33,30 @@ final class UserEnvironmentSubscriber implements EventSubscriberInterface
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::REQUEST => ['prepareEnvironment', -100],
|
||||
// runs as first one in Kimai, to make sure we use the correct locales for rendering
|
||||
KernelEvents::REQUEST => ['prepareEnvironment', -10],
|
||||
// don't know why do we use -20
|
||||
KernelEvents::FINISH_REQUEST => ['restoreLocale', -20],
|
||||
];
|
||||
}
|
||||
|
||||
public function restoreLocale(FinishRequestEvent $event): void
|
||||
{
|
||||
if ($event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->userLocale === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// LocaleSwitcher (called by LocaleAwareListener) overwrites \Locale::getDefault() with the URL
|
||||
// locale during sub-requests. Restore both the PHP default and the Twig formatter locale to
|
||||
// the user's formatting locale that was saved during the main request.
|
||||
\Locale::setDefault($this->userLocale);
|
||||
$this->localeFormatExtensions->setLocale($this->userLocale);
|
||||
}
|
||||
|
||||
public function prepareEnvironment(RequestEvent $event): void
|
||||
{
|
||||
// ignore sub-requests
|
||||
@@ -55,6 +78,7 @@ final class UserEnvironmentSubscriber implements EventSubscriberInterface
|
||||
}
|
||||
|
||||
// the locale is primarily used for formatting values, so we depend on the user locale if available
|
||||
$this->userLocale = $locale;
|
||||
\Locale::setDefault($locale);
|
||||
$this->localeFormatExtensions->setLocale($locale);
|
||||
}
|
||||
|
||||
@@ -22,35 +22,31 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
class WizardSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UrlGeneratorInterface $urlGenerator,
|
||||
private AuthorizationCheckerInterface $security,
|
||||
private TokenStorageInterface $storage,
|
||||
private SystemConfiguration $systemConfiguration
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly AuthorizationCheckerInterface $security,
|
||||
private readonly TokenStorageInterface $storage,
|
||||
private readonly SystemConfiguration $systemConfiguration
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::REQUEST => ['onKernelRequest']
|
||||
KernelEvents::REQUEST => ['onKernelRequest', -30]
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
// ignore sub-requests
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore events like the toolbar where we do not have a token
|
||||
if (null === ($token = $this->storage->getToken())) {
|
||||
// ignore sub-requests and un-authenticated events
|
||||
if (!$event->isMainRequest() || null === ($token = $this->storage->getToken())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uri = $event->getRequest()->getRequestUri();
|
||||
|
||||
// never require 2FA on API calls
|
||||
// never trigger wizard on API calls
|
||||
// TODO 3.0 remove /register/
|
||||
if (str_starts_with($uri, '/api/') || stripos($uri, '/register/') !== false || stripos($uri, '/wizard/') !== false) {
|
||||
return;
|
||||
}
|
||||
@@ -65,11 +61,6 @@ class WizardSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->requiresPasswordReset()) {
|
||||
$response = new RedirectResponse($this->urlGenerator->generate('wizard', ['wizard' => 'password']));
|
||||
$event->setResponse($response);
|
||||
}
|
||||
|
||||
if ($user->isRegularUserOnly() && !$this->systemConfiguration->isUserWizardActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ use App\Constants;
|
||||
use App\Utils\FileHelper;
|
||||
use Mpdf\Config\ConfigVariables;
|
||||
use Mpdf\Config\FontVariables;
|
||||
use Mpdf\Container\SimpleContainer;
|
||||
use Mpdf\Http\ClientInterface;
|
||||
use Mpdf\Mpdf;
|
||||
use Mpdf\Output\Destination;
|
||||
|
||||
@@ -20,7 +22,8 @@ final class MPdfConverter implements HtmlToPdfConverter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FileHelper $fileHelper,
|
||||
private readonly string $cacheDirectory
|
||||
private readonly string $cacheDirectory,
|
||||
private readonly ?ClientInterface $httpClient = null,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -116,7 +119,17 @@ final class MPdfConverter implements HtmlToPdfConverter
|
||||
unset($options['additional_xmp_rdf']);
|
||||
}
|
||||
|
||||
$mpdf = new Mpdf($options);
|
||||
// Inject a safe HTTP client into mPDF (via its service container) so
|
||||
// remote resources referenced from Twig templates — typically `<img
|
||||
// src="...">` for company logos — cannot be abused to probe private
|
||||
// networks. The configured Symfony client is decorated with
|
||||
// NoPrivateNetworkHttpClient at the service-container level.
|
||||
// @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
|
||||
$container = $this->httpClient !== null
|
||||
? new SimpleContainer(['httpClient' => $this->httpClient])
|
||||
: null;
|
||||
|
||||
$mpdf = new Mpdf($options, $container);
|
||||
$mpdf->creator = Constants::SOFTWARE;
|
||||
|
||||
if (\count($associatedFiles) > 0) {
|
||||
|
||||
76
src/Pdf/SafeRemoteContentClient.php
Normal file
76
src/Pdf/SafeRemoteContentClient.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?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\Pdf;
|
||||
|
||||
use Mpdf\Http\ClientInterface;
|
||||
use Mpdf\PsrHttpMessageShim\Response;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Bridges mPDF's HTTP client to Symfonys NoPrivateNetworkHttpClient.
|
||||
*
|
||||
* Prevents the PDF renderer from issuing outbound requests to private network targets,
|
||||
* closing the SSRF vector for `<img src="...">` references in custom Twig invoice templates.
|
||||
*
|
||||
* Blocked requests are translated to a non-2xx response so mPDF logs the failure
|
||||
* and renders a placeholder for the missing image without aborting PDF generation.
|
||||
*
|
||||
* @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
|
||||
*/
|
||||
final class SafeRemoteContentClient implements ClientInterface
|
||||
{
|
||||
/**
|
||||
* Timeout in seconds: a slow or unreachable remote target should not block PDF rendering.
|
||||
*/
|
||||
private const TIMEOUT = 10;
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client)
|
||||
{
|
||||
}
|
||||
|
||||
public function sendRequest(RequestInterface $request): Response
|
||||
{
|
||||
try {
|
||||
$response = $this->client->request(
|
||||
$request->getMethod(),
|
||||
(string) $request->getUri(),
|
||||
[
|
||||
'headers' => $this->flattenHeaders($request),
|
||||
'timeout' => self::TIMEOUT,
|
||||
'max_duration' => self::TIMEOUT,
|
||||
]
|
||||
);
|
||||
|
||||
return new Response(
|
||||
$response->getStatusCode(),
|
||||
[],
|
||||
$response->getContent(false)
|
||||
);
|
||||
} catch (HttpClientExceptionInterface) {
|
||||
// Request blocked (private network), DNS failure, timeout, etc.
|
||||
return new Response(502);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function flattenHeaders(RequestInterface $request): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($request->getHeaders() as $name => $values) {
|
||||
$headers[$name] = implode(', ', $values);
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,10 @@ final class PackageManager
|
||||
*/
|
||||
private function findAvailablePackages(string $path): array
|
||||
{
|
||||
if (!file_exists($path) || !is_readable($path) || !is_dir($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$packages = [];
|
||||
|
||||
$directory = new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::FOLLOW_SYMLINKS);
|
||||
|
||||
@@ -199,7 +199,7 @@ final class RolePermissionManager
|
||||
return $this->checkTeamLeadAccess($timesheet->getUser()?->getTeams() ?? [], $user);
|
||||
}
|
||||
|
||||
public function checkUserAccess(User $subject, User $user): bool
|
||||
public function checkUserAccess(User $subject, User $user, bool $onlyEnabled = true): bool
|
||||
{
|
||||
if ($subject->getId() === $user->getId()) {
|
||||
return true;
|
||||
@@ -209,10 +209,12 @@ final class RolePermissionManager
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$subject->isEnabled()) {
|
||||
if ($onlyEnabled && !$subject->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// system accounts are used for admins or API-only accounts
|
||||
// and should not be accessed by less-privileged users (e.g. teamleads)
|
||||
if (!$user->isSystemAccount() && $subject->isSystemAccount()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ final class MarkdownExtension implements RuntimeExtensionInterface
|
||||
{
|
||||
private ?bool $markdownEnabled = null;
|
||||
|
||||
public function __construct(private Markdown $markdown, private SystemConfiguration $configuration)
|
||||
public function __construct(
|
||||
private readonly Markdown $markdown,
|
||||
private readonly SystemConfiguration $configuration
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -32,10 +35,6 @@ final class MarkdownExtension implements RuntimeExtensionInterface
|
||||
|
||||
/**
|
||||
* Transforms entity and user comments (customer, project, activity ...) into HTML.
|
||||
*
|
||||
* @param string|null $content
|
||||
* @param bool $fullLength
|
||||
* @return string
|
||||
*/
|
||||
public function commentContent(?string $content, bool $fullLength = true): string
|
||||
{
|
||||
@@ -58,10 +57,6 @@ final class MarkdownExtension implements RuntimeExtensionInterface
|
||||
|
||||
/**
|
||||
* Transforms the entities comment (customer, project, activity ...) into a one-liner.
|
||||
*
|
||||
* @param string|null $content
|
||||
* @param bool $fullLength
|
||||
* @return string
|
||||
*/
|
||||
public function commentOneLiner(?string $content, bool $fullLength = true): string
|
||||
{
|
||||
@@ -88,9 +83,6 @@ final class MarkdownExtension implements RuntimeExtensionInterface
|
||||
|
||||
/**
|
||||
* Transforms the timesheet description content into HTML.
|
||||
*
|
||||
* @param string|null $content
|
||||
* @return string
|
||||
*/
|
||||
public function timesheetContent(?string $content): string
|
||||
{
|
||||
@@ -107,9 +99,6 @@ final class MarkdownExtension implements RuntimeExtensionInterface
|
||||
|
||||
/**
|
||||
* Transforms the given Markdown content into HTML
|
||||
*
|
||||
* @param string $content
|
||||
* @return string
|
||||
*/
|
||||
public function markdownToHtml(string $content): string
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace App\Utils;
|
||||
final class Markdown
|
||||
{
|
||||
private ?ParsedownExtension $parser = null;
|
||||
private ?\Parsedown $parserFull = null;
|
||||
private ?Parsedown $parserFull = null;
|
||||
|
||||
public function toHtml(string $text): string
|
||||
{
|
||||
|
||||
@@ -17,6 +17,20 @@ class Parsedown extends \Parsedown
|
||||
/** @var array<string> */
|
||||
private array $ids = [];
|
||||
|
||||
/**
|
||||
* Overwritten to open links in new windows
|
||||
*/
|
||||
protected function inlineUrl($Excerpt): ?array // @phpstan-ignore missingType.parameter,missingType.iterableValue
|
||||
{
|
||||
$block = parent::inlineUrl($Excerpt);
|
||||
|
||||
if (isset($block['element']['attributes']) && \is_array($block['element']['attributes'])) {
|
||||
$block['element']['attributes']['target'] = '_blank';
|
||||
}
|
||||
|
||||
return $block;
|
||||
}
|
||||
|
||||
protected function blockHeader($Line)
|
||||
{
|
||||
$block = parent::blockHeader($Line);
|
||||
@@ -84,4 +98,42 @@ class Parsedown extends \Parsedown
|
||||
|
||||
return $Block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown image syntax `` is rewritten to a link `<a href="url">alt</a>`.
|
||||
*
|
||||
* Rationale: emitting `<img src="url">` would cause downstream renderers
|
||||
* (e.g. mPDF on the server, browsers in the UI) to automatically fetch
|
||||
* the remote URL. For server-side renderers this is a server-side request
|
||||
* forgery vector; in the UI it is a tracking/privacy issue. Hand-written
|
||||
* `<img>` in Twig templates (custom invoice templates etc.) is not
|
||||
* affected — only images derived from Markdown input are neutralised
|
||||
* here. The resulting `<a href>` is still passed through Parsedown's
|
||||
* `safeLinksWhitelist` filtering when safe-mode is enabled.
|
||||
*
|
||||
* @see https://github.com/kimai/kimai/security/advisories/GHSA-pj8j-p4g4-4vw8
|
||||
*/
|
||||
protected function inlineImage($Excerpt): ?array // @phpstan-ignore missingType.parameter,missingType.iterableValue
|
||||
{
|
||||
$Image = parent::inlineImage($Excerpt);
|
||||
|
||||
if ($Image === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$src = $Image['element']['attributes']['src'] ?? '';
|
||||
$alt = $Image['element']['attributes']['alt'] ?? '';
|
||||
|
||||
$Image['element'] = [
|
||||
'name' => 'a',
|
||||
'text' => $alt !== '' ? $alt : $src,
|
||||
'attributes' => [
|
||||
'href' => $src,
|
||||
'rel' => 'noopener noreferrer',
|
||||
'target' => '_blank',
|
||||
],
|
||||
];
|
||||
|
||||
return $Image;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
namespace App\Utils;
|
||||
|
||||
/**
|
||||
* This Class extends the default Parsedown Class for custom methods.
|
||||
* The default markdown implementation.
|
||||
*/
|
||||
final class ParsedownExtension extends Parsedown
|
||||
{
|
||||
@@ -42,31 +42,4 @@ final class ParsedownExtension extends Parsedown
|
||||
'|' => ['Table'],
|
||||
'~' => ['FencedCode'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Overwritten to open links in new windows
|
||||
*/
|
||||
protected function inlineUrl($Excerpt): ?array
|
||||
{
|
||||
$block = parent::inlineUrl($Excerpt);
|
||||
|
||||
if (isset($block['element']['attributes']) && \is_array($block['element']['attributes'])) {
|
||||
$block['element']['attributes']['target'] = '_blank';
|
||||
}
|
||||
|
||||
return $block;
|
||||
}
|
||||
|
||||
protected function blockTable($Line, ?array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
|
||||
{
|
||||
$Block = parent::blockTable($Line, $Block);
|
||||
|
||||
if ($Block === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$Block['element']['attributes']['class'] = 'table';
|
||||
|
||||
return $Block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ final class TimesheetVoter extends Voter
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!($user instanceof User)) {
|
||||
if (!($user instanceof User) || $user->getId() === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -91,10 +91,10 @@ final class TimesheetVoter extends Voter
|
||||
|
||||
switch ($attribute) {
|
||||
case 'is_owner':
|
||||
return (!$subject instanceof MultiUserTimesheet) && $user === $subject->getUser();
|
||||
return (!$subject instanceof MultiUserTimesheet) && $user->getId() === $subject->getUser()?->getId();
|
||||
|
||||
case self::START:
|
||||
if (!$this->canStart($subject)) {
|
||||
if (!$this->canStart($user, $subject)) {
|
||||
return false;
|
||||
}
|
||||
$permission .= $attribute;
|
||||
@@ -115,7 +115,7 @@ final class TimesheetVoter extends Voter
|
||||
break;
|
||||
|
||||
case 'duplicate':
|
||||
if (!$this->canStart($subject)) {
|
||||
if (!$this->canStart($user, $subject)) {
|
||||
return false;
|
||||
}
|
||||
$permission = self::EDIT;
|
||||
@@ -146,11 +146,10 @@ final class TimesheetVoter extends Voter
|
||||
return $this->permissionManager->hasRolePermission($user, $permission . '_other_timesheet');
|
||||
}
|
||||
|
||||
private function canStart(Timesheet $timesheet): bool
|
||||
private function canStart(User $user, Timesheet $timesheet): bool
|
||||
{
|
||||
// possible improvements for the future:
|
||||
// we could check the amount of active entries (maybe slow)
|
||||
// if a teamlead starts an entry for another user, check that this user is part of his team (needs to be done for teams)
|
||||
|
||||
if (null === $timesheet->getActivity()) {
|
||||
return false;
|
||||
@@ -172,6 +171,18 @@ final class TimesheetVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
// starting and duplicating both create a NEW record under the referenced
|
||||
// project and activity, so the current user must still have team-based
|
||||
// access to them - historical ownership of the original timesheet is not
|
||||
// sufficient (otherwise old entries would survive an access revocation).
|
||||
if (!$this->permissionManager->checkTeamAccessProject($timesheet->getProject(), $user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->permissionManager->checkTeamAccessActivity($timesheet->getActivity(), $user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,11 +62,7 @@ final class UserVoter extends Voter
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!($user instanceof User)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!($subject instanceof User)) {
|
||||
if (!($user instanceof User) || !($subject instanceof User)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -111,15 +107,14 @@ final class UserVoter extends Voter
|
||||
|
||||
$permission = $attribute;
|
||||
|
||||
// extend me for "team" support later on
|
||||
if ($subject->getId() === $user->getId()) {
|
||||
$permission .= '_own';
|
||||
} else {
|
||||
$permission .= '_other';
|
||||
return $this->permissionManager->hasRolePermission($user, $permission . '_own_profile');
|
||||
}
|
||||
|
||||
$permission .= '_profile';
|
||||
if (!$this->permissionManager->hasRolePermission($user, $permission . '_other_profile')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->permissionManager->hasRolePermission($user, $permission);
|
||||
return $this->permissionManager->checkUserAccess($subject, $user, false);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user