<?php
namespace App\Controller;
use App\Entity\Book;
use App\Entity\Subscription;
use App\Entity\MagicToken;
use App\Entity\Token;
use App\Entity\User;
use App\Entity\UserAttribute;
use App\Entity\UserAttributeValue;
use App\Entity\Country;
use App\Entity\UserGroup;
use App\Form\Model\Security\RegisterModel;
use App\Form\Model\Security\ResetModel;
use App\Form\Type\Security\RegisterType;
use App\Form\Type\Security\ResetType;
use App\Service\GeoIP2;
use App\Service\Raynet;
use App\Service\TeacherProfileService;
use App\Utils\TokenUtil;
use DateTime;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Swift_Mailer;
use Swift_Message;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @Route("/{_locale}", name="security_")
*/
class SecurityController extends AbstractController
{
/**
* @var EntityManagerInterface
*/
private $em;
/**
* @var TranslatorInterface
*/
private $t;
/**
* @var UserPasswordEncoderInterface
*/
private $encoder;
/**
* SecurityController constructor.
* @param EntityManagerInterface $em
* @param TranslatorInterface $t
* @param UserPasswordEncoderInterface $encoder
*/
public function __construct(EntityManagerInterface $em, TranslatorInterface $t, UserPasswordEncoderInterface $encoder)
{
$this->em = $em;
$this->t = $t;
$this->encoder = $encoder;
}
/**
* @Route("/login/", name="login")
*
* @param AuthenticationUtils $authenticationUtils
* @return Response
*/
public function loginAction(Request $request, AuthenticationUtils $authenticationUtils): Response
{
if ($this->get('security.authorization_checker')->isGranted('ROLE_USER')) {
return $this->redirect($this->generateUrl('app_library_index', $request->query->all()));
}
return $this->redirect($this->generateUrl('security_home', $request->query->all()));
}
/**
* @Route("/", name="home")
*
* @param Request $request
* @param AuthenticationUtils $authenticationUtils
* @return Response
*/
public function appLoginAction(Request $request, AuthenticationUtils $authenticationUtils): Response
{
// TODO: redirect, when user is logged in?
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
$session = $this->get('session');
$redirect = $session->get('redirect_url') ?? $this->generateUrl('app_library_index', $request->query->all());
return $this->render('app/home.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
'redirect' => $redirect,
]);
}
/**
* Clean login page (login form + SSO + register link only), used as the OAuth2 /authorize landing page.
*
* @Route("/sign-in/", name="sign_in")
*/
public function signInAction(Request $request, AuthenticationUtils $authenticationUtils): Response
{
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
$session = $this->get('session');
$redirect = $session->get('redirect_url') ?? $this->generateUrl('app_library_index', $request->query->all());
return $this->render('app/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
'redirect' => $redirect,
]);
}
/**
* Consumes a single-use magic token (from POST /api/account/session-bridge), logs the user
* into the web session and drops them on the locked-down standalone settings screen.
*
* @Route("/account/session-bridge/{token}", name="account_session_bridge", methods={"GET"})
*/
public function sessionBridgeAction(Request $request, string $token): Response
{
$magic = $this->em->getRepository(MagicToken::class)->findOneBy(['code' => $token]);
if ($magic instanceof MagicToken) {
$expired = (new DateTime())->getTimestamp() > $magic->getExpiration()->getTimestamp();
$user = $magic->getUser();
// Single use: drop it whether valid or expired.
$this->em->remove($magic);
$this->em->flush();
if (!$expired) {
$authToken = new UsernamePasswordToken($user, null, 'main', $user->getRoles());
$this->get('security.token_storage')->setToken($authToken);
$session = $request->getSession();
$session->set('_security_main', serialize($authToken));
$session->set('account_bridge', true);
return $this->redirectToRoute('app_settings_index');
}
}
// Render the error on the standalone page instead of redirecting to the homepage:
// the app must not be able to fall back to a normal login that escapes the locked flow.
return $this->render('app/account_bridge_error.html.twig');
}
/**
* @Route("/new-password/", name="new_password")
*
* @return Response
*/
public function newPasswordAction(): Response
{
return $this->render('security/password.new.html.twig');
}
/**
* @Route("/new-password/handle/", name="new_password_handle")
*
* @param Request $request
* @param Swift_Mailer $mailer
* @return Response
*/
public function newPasswordHandleAction(Request $request, Swift_Mailer $mailer): Response
{
$email = $request->request->get('address') ?? $request->query->get('address');
$user = $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
if (!$user instanceof User) {
$this->addFlash('danger', $this->t->trans('password.reset.mail.invalid'));
return $this->redirectToRoute('security_new_password');
}
try {
$tokenValue = TokenUtil::generate(64);
$token = $this->em->getRepository(Token::class)->findOneBy(['user' => $user]);
if (!$token instanceof Token) {
$token = new Token();
}
$expirationDate = (new DateTime())->modify('+1 hour');
$token
->setCode($tokenValue)
->setUser($user)
->setExpiration($expirationDate);
$this->em->persist($token);
$this->em->flush();
$message = (new Swift_Message("Forgotten Password"))
->setFrom($_SERVER['MAILER_SENDER_EMAIL'], $_SERVER['MAILER_SENDER_NAME'])
->setTo($user->getEmail())
->setBody(
$this->renderView('mail/password.reset.html.twig', [
'magic_link' => $this->generateUrl('security_create_password', ['code' => $token->getCode()],
UrlGeneratorInterface::ABSOLUTE_URL),
]),
'text/html'
);
if ($klerkId = $this->getParameter('klerk_forget')) {
$message->getHeaders()->addTextHeader('X-CampaignID', $klerkId);
}
$mailer->send($message);
$this->addFlash('success', $this->t->trans('password.reset.mail.success'));
return $this->redirectToRoute('security_home');
} catch (Exception $e) {
$this->addFlash('danger', $this->t->trans('password.reset.error.internal'));
return $this->redirectToRoute('security_new_password');
}
}
/**
* @Route("/reset-password/{code}/", name="create_password")
*
* @param Request $request
* @param string $code
* @return Response
* @throws Exception
*/
public function resetPasswordAction(Request $request, string $code): Response
{
$token = $this->em->getRepository(Token::class)->findOneBy(['code' => $code]);
if (!$token instanceof Token) {
$this->addFlash('danger', $this->t->trans('password.reset.notrequested'));
return $this->redirectToRoute('security_home');
}
if ((new DateTime())->getTimestamp() > $token->getExpiration()->getTimestamp()) {
$this->addFlash('danger', $this->t->trans('password.reset.expired'));
return $this->redirectToRoute('security_home');
}
$form = $this->createForm(ResetType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var ResetModel $model */
$model = $form->getData();
try {
$user = $token->getUser();
$password = $this->encoder->encodePassword($user, $model->password);
$user->setPassword($password);
$user->setActive(true);
$this->em->persist($user);
$this->em->flush();
$this->addFlash('success', $this->t->trans('password.reset.success'));
return $this->redirectToRoute('security_home');
} catch (Exception $e) {
// $this->addFlash('danger', $this->t->trans('password.reset.error.internal'));
throw $e;
}
}
return $this->render('security/password.reset.html.twig', [
'form' => $form->createView(),
]);
}
/**
* Confirms a newly registered user's email address (reuses the password-reset Token).
*
* @Route("/verify-email/{code}/", name="verify_email")
*
* @param string $code
* @param Swift_Mailer $mailer
* @return Response
* @throws Exception
*/
public function verifyEmailAction(string $code, Swift_Mailer $mailer): Response
{
$token = $this->em->getRepository(Token::class)->findOneBy(['code' => $code]);
if (!$token instanceof Token || (new DateTime())->getTimestamp() > $token->getExpiration()->getTimestamp()) {
$this->addFlash('danger', $this->t->trans('register.verify.invalid'));
return $this->redirectToRoute('security_home');
}
$user = $token->getUser();
$user->setActive(true);
$this->em->remove($token);
$this->em->flush();
//8.6.2026 na přání klienta vypnuto posílání uvítacího e-mailu na učitele
if (!in_array(UserGroup::ROLE_TEACHER, $user->getRoles(), true)) {
$message = (new Swift_Message($this->t->trans('registration.title', [], 'mailing')))
->setFrom($_SERVER['MAILER_SENDER_EMAIL'], $_SERVER['MAILER_SENDER_NAME'])
->setTo($user->getEmail())
->setBody(
$this->renderView('mail/registration.html.twig'),
'text/html'
);
if ($klerkId = $this->getParameter('klerk_welcome')) {
$message->getHeaders()->addTextHeader('X-CampaignID', $klerkId);
}
$mailer->send($message);
}
$this->addFlash('success', $this->t->trans('register.verify.success'));
return $this->redirectToRoute('security_home');
}
/**
* Autocomplete endpoint for the school select on the registration page.
* Reads the slim JSON produced by `app:import-schools` and returns
* matching rows in the shape Select2 expects.
*
* @Route("/register/schools-search/", name="register_schools_search", methods={"GET"})
*/
public function registerSchoolsSearchAction(Request $request): JsonResponse
{
$query = trim((string) $request->query->get('q', ''));
$kindFilter = trim((string) $request->query->get('kind', '')); // '', 'zs', 'ms', 'zs_ms'
$limit = 30;
static $cache = null;
if ($cache === null) {
$path = $this->getParameter('kernel.project_dir') . '/var/data/skoly.json';
$cache = is_file($path)
? (json_decode((string) file_get_contents($path), true) ?: [])
: [];
}
// Map UI kind onto accepted kinds in the data (OR within the array):
// 'zs' → entry must have 'zs'
// 'ms' → entry must have 'ms'
// 'zs_ms' → entry must have 'zs' OR 'ms' (any of them)
// ''/'other' → no filter
$anyOf = [];
if ($kindFilter === 'zs') { $anyOf = ['zs']; }
if ($kindFilter === 'ms') { $anyOf = ['ms']; }
if ($kindFilter === 'zs_ms') { $anyOf = ['zs', 'ms']; }
$normalize = static function (string $s): string {
$s = mb_strtolower($s, 'UTF-8');
if (function_exists('iconv')) {
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if ($ascii !== false) {
$s = $ascii;
}
}
return $s;
};
$needle = $normalize($query);
$matches = [];
foreach ($cache as $row) {
if ($anyOf) {
$kinds = $row['kinds'] ?? '';
$hit = false;
foreach ($anyOf as $k) {
if (strpos($kinds, $k) !== false) {
$hit = true;
break;
}
}
if (!$hit) {
continue;
}
}
if ($needle !== '') {
$hay = $normalize($row['name'] . ' ' . ($row['address'] ?? ''));
if (strpos($hay, $needle) === false) {
continue;
}
}
$matches[] = [
'id' => $row['id'],
'text' => $row['name'] . ($row['address'] ? ' — ' . $row['address'] : ''),
'name' => $row['name'],
'address' => $row['address'] ?? '',
];
if (count($matches) >= $limit) {
break;
}
}
return new JsonResponse(['results' => $matches]);
}
/**
* @Route("/register/teacher-info/", name="register_teacher_info")
*
* @param Request $request
* @param TeacherProfileService $teacherProfile
* @return Response
*/
public function registerTeacherInfoAction(Request $request, TeacherProfileService $teacherProfile, GeoIP2 $geoIP2): Response
{
/** @var User|null $user */
$user = $this->getUser();
if (!$user instanceof User || !in_array(UserGroup::ROLE_TEACHER, $user->getRoles(), true)) {
return $this->redirectToRoute('security_home');
}
if ($request->isMethod('POST')) {
if (!$this->isCsrfTokenValid('teacher_info', $request->request->get('_token'))) {
$this->addFlash('danger', $this->t->trans('register.error.internal'));
return $this->redirectToRoute('security_register_teacher_info');
}
$teacherProfile->apply($user, $request);
$countryCode = trim((string) $request->request->get('teacher_country', ''));
if ($countryCode !== '') {
$country = $this->em->getRepository(Country::class)->findOneBy(['code' => $countryCode]);
if ($country instanceof Country) {
$user->setCountry($country);
}
}
$this->em->flush();
if (!$teacherProfile->isComplete($user)) {
$this->addFlash('danger', $this->t->trans('teacher.info.incomplete'));
return $this->redirectToRoute('security_register_teacher_info');
}
$this->addFlash('success', $this->t->trans('settings.success'));
return $this->redirectToRoute('app_library_index');
}
$country = $user->getCountry();
return $this->render('app/register_teacher_info.html.twig', [
'prefill' => $teacherProfile->getPrefill($user),
'cz' => $country !== null && $country->getCode() === 'CZ',
'countries' => $this->em->getRepository(Country::class)->findBy([], ['name' => 'ASC']),
'current_country' => $country?->getCode() ?? $geoIP2->getCountry()?->getCode(),
]);
}
/**
* @Route("/register/", name="register")
*
* @param Request $request
* @param Swift_Mailer $mailer
* @param GeoIP2 $geoIP2
* @return Response
*/
public function registerAction(Request $request, Swift_Mailer $mailer, GeoIP2 $geoIP2, Raynet $raynet, TeacherProfileService $teacherProfile): Response
{
$form = $this->createForm(RegisterType::class, new RegisterModel($geoIP2->getCountry()));
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var RegisterModel $model */
$model = $form->getData();
if ($model->emailConfirm !== null) {
return $this->redirectToRoute('security_register');
}
if (!$request->request->get('cf-turnstile-response') || !$this->validateTurnstile($request->request->get('cf-turnstile-response'))) {
$this->addFlash('danger', $this->t->trans('register.error.internal'));
return $this->redirectToRoute('security_register');
}
try {
$group = $this->em->getRepository(UserGroup::class)->findOneBy(['role' => $model->role]);
$user = (new User())
->setUsername($model->email)
->setEmail($model->email)
->setGroup($group)
->setCountry($model->country)
->setActive(false);
$password = $this->encoder->encodePassword($user, $model->password);
$user->setPassword($password);
$firstnameAttr = $this->em->getRepository(UserAttribute::class)->findOneBy(['name' => 'firstname']);
$lastnameAttr = $this->em->getRepository(UserAttribute::class)->findOneBy(['name' => 'lastname']);
$firstnameAttrValue = (new UserAttributeValue())
->setUser($user)
->setAttribute($firstnameAttr)
->setValue($model->firstname);
$lastnameAttrValue = (new UserAttributeValue())
->setUser($user)
->setAttribute($lastnameAttr)
->setValue($model->lastname);
$this->em->persist($user);
$this->em->persist($firstnameAttrValue);
$this->em->persist($lastnameAttrValue);
if ($model->role === UserGroup::ROLE_TEACHER) {
$teacherProfile->apply($user, $request);
}
//temporarily add 1 month subscription of all books here
$books = $this->em->getRepository(Book::class)->findAll();
foreach ($books as $book) {
$subscription = new Subscription($user, $book, new DateTime('+1 month'), null, true, false, false);
$this->em->persist($subscription);
}
try {
$this->em->flush();
} catch (UniqueConstraintViolationException $e) {
$this->addFlash('danger', $this->t->trans('register.error.existing'));
return $this->redirectToRoute('security_home');
}
$this->addFlash('success', $this->t->trans('register.verify.sent'));
$verificationToken = (new Token())
->setCode(TokenUtil::generate(64))
->setUser($user)
->setExpiration((new DateTime())->modify('+1 week'));
$this->em->persist($verificationToken);
$this->em->flush();
$message = (new Swift_Message($this->t->trans('register.verify.title', [], 'mailing')))
->setFrom($_SERVER['MAILER_SENDER_EMAIL'], $_SERVER['MAILER_SENDER_NAME'])
->setTo($user->getEmail())
->setBody(
$this->renderView('mail/password.reset.html.twig', [
'magic_link' => $this->generateUrl('security_verify_email', ['code' => $verificationToken->getCode()],
UrlGeneratorInterface::ABSOLUTE_URL),
'content_key' => 'register.verify.mailcontent',
]),
'text/html'
);
$mailer->send($message);
//send to raynet
if (($model->role === UserGroup::ROLE_TEACHER) && ($_SERVER['APP_ENV'] === 'prod')) {
$raynet->insertLead(
'Contact from MyWow!',
$model->firstname,
$model->lastname,
$model->email,
'',
$model->country->getCode()
);
}
return $this->redirectToRoute('security_home');
} catch (Exception $e) {
$this->addFlash('danger', $this->t->trans('register.error.internal'));
return $this->redirectToRoute('security_home');
}
}
return $this->render('app/register.html.twig', [
'form' => $form->createView(),
]);
}
private function validateTurnstile($token) {
$url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
$data = [
'secret' => $this->getParameter('cf_turnstile_secret'),
'response' => $token,
'remoteip' => $_SERVER['REMOTE_ADDR']
];
$options = [
'http' => [
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'method' => 'POST',
'content' => http_build_query($data)
]
];
$context = stream_context_create($options);
$response = file_get_contents($url, false, $context);
if ($response === FALSE) {
return false;
}
return json_decode($response, true)['success'] ?? false;
}
}