src/Application/Abonnent/AbonnentService.php line 214

Open in your IDE?
  1. <?php
  2. namespace App\Application\Abonnent;
  3. use App\Entity\Abonnent\Abonnent;
  4. use App\Entity\Abonnent\Abonnenttoken;
  5. use App\Entity\Abonnent\Exceptions\AbonnentLoginFailedException;
  6. use App\Entity\Abonnent\Exceptions\AbonnentLoginInactiveException;
  7. use App\Entity\Abonnent\Exceptions\AbonnentToManyDevicesException;
  8. use App\Entity\Abonnent\Exceptions\AbonnentWithThisIdNotExistsException;
  9. use App\Entity\Abonnent\Exceptions\AbonnentWithThisUsernameNotExistsException;
  10. use App\Entity\Abonnent\ResetPasswordRequest;
  11. use Doctrine\DBAL\Exception;
  12. use Doctrine\ORM\EntityManagerInterface;
  13. use Psr\Log\LoggerInterface;
  14. use Symfony\Component\Filesystem\Filesystem;
  15. use Symfony\Component\HttpFoundation\Request;
  16. use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
  17. use Symfony\Component\Process\Process;
  18. use Symfony\Component\Serializer\Encoder\CsvEncoder;
  19. use Symfony\Component\Serializer\Serializer;
  20. class AbonnentService
  21. {
  22. public const string TOKEN_COOKIE_NAME = 'uwabonnenttoken'; // attention: on change, all user will be logged out after deploy
  23. public const int TOKEN_COOKIE_EXPIRE_IN_DAYS = 365;
  24. private string $abonnentenImportFileLog;
  25. public function __construct(
  26. private readonly string $abonnentenImportFile,
  27. private readonly EntityManagerInterface $em,
  28. private readonly LoggerInterface $logger,
  29. private readonly UserPasswordHasherInterface $userPasswordHasher
  30. )
  31. {
  32. $this->abonnentenImportFileLog = $this->abonnentenImportFile.'.sync.log';
  33. }
  34. public function findAbonnenten(AbonnentDataTransformerInterface $dataTransformer, AbonnentServiceQuery $query)
  35. {
  36. $data_ = [];
  37. $conn = $this->em->getConnection();
  38. $sqlFromValues = null;
  39. $sqlWhere = null;
  40. $sqlOrder = null;
  41. $sqlLimit = null;
  42. // SQL FROM
  43. $sqlFromValues = 'abonnent AS a';
  44. // SQL Limit
  45. $limit = $query->getPageSize();
  46. $limit += ($query->getCalcTotalRows()) ? 0 : 1; // Falls nicht Total Rows berechnet werden soll, dann berechne ich NextPageExist Wert (Performance Trick einfach ein Record mehr laden, welcher unten dann wieder entfernt wird)
  47. $offset = ($query->getPageNr() - 1) * $query->getPageSize();
  48. $sqlLimit = 'LIMIT '.$limit.' OFFSET '.$offset;
  49. // SQL WHERE bilden
  50. $andWhere = [];
  51. // SQL WHERE bilden ENDE
  52. // SQL ORDER
  53. if ($sort = $query->getSort()) {
  54. // Übersetzung der ServiceQuery Fields zu den internen DB Fields
  55. $sortFieldBridge = [
  56. 'username' => 'a.username',
  57. ];
  58. $orderTmp = [];
  59. foreach ($sort as $field => $order) {
  60. $orderTmp[] = $sortFieldBridge[$field].' '.strtoupper((string) $order);
  61. }
  62. if ($orderTmp !== []) {
  63. $sqlOrder = 'ORDER BY '.implode(',', $orderTmp);
  64. }
  65. }
  66. // SQL ORDER ENDE
  67. // SQL zusammenführen
  68. $theSQL = "SELECT a.id FROM $sqlFromValues $sqlWhere $sqlOrder $sqlLimit";
  69. // Alle Abonnenten IDs holen und in Array abspitzen
  70. $result = $conn->fetchAllAssociative($theSQL);
  71. foreach ($result as $id) {
  72. $data_[$id['id']] = null; // ID's vorbelegen, damit unten korrekt einsortiert werden kann
  73. }
  74. // gezielt Abonnenten via Doctrine in Objekte hydrieren
  75. if ($data_ !== []) {
  76. $dql = $this->em->createQuery('SELECT a, tokens FROM '.Abonnent::class.' a LEFT JOIN a.tokens tokens WHERE a.id IN (:id)');
  77. // evtl. bessere performance später: $dql = $this->em->createQuery('SELECT a, va FROM '. Artikel::class .' a LEFT JOIN a.verwandteArtikel va WHERE a.id IN (:id)');
  78. $dql->setParameter('id', array_keys($data_));
  79. $result = $dql->getResult();
  80. foreach ($result as $abonnent) {
  81. $dataTransformer->write($abonnent);
  82. $data_[$abonnent->getId()] = $dataTransformer->read();
  83. }
  84. }
  85. // Total Records (ohne Paging) berechnen
  86. if ($query->getCalcTotalRows()) {
  87. $theSQL = "SELECT COUNT('a.id') FROM $sqlFromValues $sqlWhere";
  88. $totalRecords = (int) $conn->fetchOne($theSQL);
  89. $query->setTotalRows($totalRecords);
  90. } elseif (count($data_) > $query->getPageSize()) {
  91. // Letzter Record wieder entfernen, welcher nur zur Berechnung des NextPageExist benötigt wurde
  92. array_pop($data_);
  93. $query->setPageNextExist(true);
  94. } else {
  95. $query->setPageNextExist(false);
  96. }
  97. return $data_;
  98. }
  99. public function getImportFilepath(): string
  100. {
  101. return $this->abonnentenImportFile;
  102. }
  103. public function getImportLogfilepath(): string
  104. {
  105. return $this->abonnentenImportFileLog;
  106. }
  107. /**
  108. * @throws AbonnentWithThisUsernameNotExistsException|AbonnentLoginInactiveException
  109. */
  110. protected function findAbonnentOrFail(
  111. string $username,
  112. string $password = null,
  113. ?string $token = null
  114. ): Abonnent
  115. {
  116. // input validation
  117. if ($password && $token) {
  118. throw new \InvalidArgumentException('Method only accept one of param $password or $token set');
  119. }
  120. $abonnent = null;
  121. if ($password) {
  122. $abonnent = $this->em->getRepository(Abonnent::class)->findOneBy(['username' => $username, 'isActive' => true]);
  123. $multipleEmails = $this->em->getRepository(Abonnent::class)->findBy(['username' => $username]);
  124. if (count($multipleEmails) && $abonnent === null) {
  125. throw new AbonnentLoginInactiveException();
  126. }
  127. } elseif ($token) {
  128. $token = $this->em->getRepository(Abonnenttoken::class)->findOneBy(['token' => $token]);
  129. $abonnent = $token ? $token->getAbonnent() : null;
  130. }
  131. if (!$abonnent) {
  132. throw new AbonnentWithThisUsernameNotExistsException();
  133. }
  134. return $abonnent;
  135. }
  136. /**
  137. * @param mixed|null $id
  138. *
  139. * @return Abonnent
  140. *
  141. * @throws AbonnentWithThisIdNotExistsException
  142. */
  143. protected function findAbonnentByIdOrFail($id): Abonnent
  144. {
  145. $abonnent = $this->em->getRepository(Abonnent::class)->find($id);
  146. if (!$abonnent) {
  147. throw new AbonnentWithThisIdNotExistsException();
  148. }
  149. return $abonnent;
  150. }
  151. /**
  152. * @throws AbonnentWithThisUsernameNotExistsException
  153. */
  154. protected function findAbonnentByUsernameOrFail(string $username): Abonnent
  155. {
  156. $abonnent = $this->em->getRepository(Abonnent::class)->findOneBy(['username' => $username]);
  157. if (!$abonnent) {
  158. throw new AbonnentWithThisUsernameNotExistsException();
  159. }
  160. return $abonnent;
  161. }
  162. /**
  163. * Is abonnent active.
  164. */
  165. public function isAuthenticated(Request $request): bool
  166. {
  167. return (bool) $this->authenticate($request);
  168. }
  169. /**
  170. * Find and return an active, valid abonnent only.
  171. *
  172. * @param Request $request with token cookie in it
  173. *
  174. * @return Abonnent|null
  175. */
  176. public function authenticate(Request $request): ?Abonnent
  177. {
  178. $tokenString = trim($request->cookies->get(self::TOKEN_COOKIE_NAME));
  179. if ($tokenString === '' || $tokenString === '0') {
  180. return null;
  181. }
  182. $token = $this->em->getRepository(Abonnenttoken::class)->findOneBy(['token' => $tokenString]);
  183. if (!$token) {
  184. return null;
  185. }
  186. // return abonnent only if active
  187. if (!$token->hasExpired() && $token->getAbonnent()->isActive()) {
  188. return $token->getAbonnent();
  189. }
  190. return null;
  191. }
  192. public function logout(Request $request): bool
  193. {
  194. $abonnent = $this->authenticate($request);
  195. if ($abonnent) {
  196. $token = $request->cookies->get(self::TOKEN_COOKIE_NAME);
  197. $abonnent->removeToken($token);
  198. $this->persistAbonnent($abonnent);
  199. return true;
  200. }
  201. return false;
  202. }
  203. /**
  204. * Abonnent login check, if ok, generate new token.
  205. *
  206. * @return Abonnenttoken
  207. */
  208. public function login(string $username, string $password): Abonnenttoken
  209. {
  210. try {
  211. $abonnent = $this->findAbonnentOrFail($username, $password);
  212. } catch (AbonnentWithThisUsernameNotExistsException) {
  213. throw new AbonnentLoginFailedException(sprintf('Abonnent with $username %s does not exist!', $username));
  214. }
  215. if (strlen($abonnent->getUsername() === 0 || $abonnent->getUsername() !== $username) !== 0) {
  216. throw new AbonnentLoginFailedException(sprintf('Username abonnent with $username %s not correct!', $username));
  217. }
  218. if (!$this->userPasswordHasher->isPasswordValid($abonnent, $password)) {
  219. throw new AbonnentLoginFailedException(sprintf('Wrong credentials for $username %s !', $abonnent->getUsername()));
  220. }
  221. if (!$abonnent->isActive()) {
  222. throw new AbonnentLoginInactiveException('Abonnent is inactive. Login not allowed!');
  223. }
  224. // create new token
  225. $expireAt = new \DateTime();
  226. $expireAt->add(new \DateInterval('P'.self::TOKEN_COOKIE_EXPIRE_IN_DAYS.'D'));
  227. try {
  228. $token = $abonnent->createToken($expireAt);
  229. $this->persistAbonnent($abonnent);
  230. } catch (AbonnentToManyDevicesException) {
  231. $abonnent->deleteAllTokens();
  232. $token = $abonnent->createToken($expireAt);
  233. $this->persistAbonnent($abonnent);
  234. }
  235. // end
  236. return $token;
  237. }
  238. /**
  239. * Import File Struktur:
  240. *
  241. * Username;Unique ID
  242. * muster.franz@bluewin.ch;100
  243. * muster2.franz2@bluewin.ch;23
  244. * ...
  245. * ...
  246. * END;
  247. *
  248. * @return array Reportdata
  249. */
  250. public function syncAbonnentenExportFileToDb(): array
  251. {
  252. $report = [
  253. 'sync_start' => date('d.m.Y H:i:s'),
  254. 'sync_end' => '',
  255. 'abonnenten_inserted' => [],
  256. 'abonnenten_updated' => [],
  257. 'abonnenten_activated' => [],
  258. 'abonnenten_deactivated' => [],
  259. 'abonnenten_deleted' => [],
  260. 'abonnenten_password_updated' => [],
  261. 'abonnenten_password_set' => [],
  262. 'parsed_lines' => 0,
  263. 'errors' => [],
  264. ];
  265. $activeIds = []; // Ids from import file, which should only valid activ abonnenten ids
  266. // get abonnenten from file
  267. $serializer = new Serializer([], [new CsvEncoder([CsvEncoder::DELIMITER_KEY => ';', 'no_headers' => true])]);
  268. $abonnenten = $serializer->decode(file_get_contents($this->abonnentenImportFile), 'csv');
  269. // Backup
  270. $name = date('Y-m-d-H-i-s_').'Online-Export.csv';
  271. $path = dirname($this->abonnentenImportFile).'/backup/';
  272. if (!file_exists($path)) {
  273. mkdir($path, 0777, true);
  274. }
  275. copy($this->abonnentenImportFile, $path.$name);
  276. $usernames = [];
  277. // iterate trough abonnenten from import file
  278. $importFileLineNr = 0; // neu 0 weil ohne header
  279. foreach ($abonnenten as $item) {
  280. ++$importFileLineNr;
  281. ++$report['parsed_lines'];
  282. // validate import line
  283. if (!is_array($item) || count($item) < 2) {
  284. $report['errors'][] = 'Line '.$importFileLineNr.': Has no valid data';
  285. continue;
  286. }
  287. // get data from line
  288. // Werte aus numerischen Indizes abrufen
  289. $id = trim((string) $item[0]); // Index 0 für Unique ID
  290. $username = strtolower(trim((string) $item[1])); // Index 1 für Username
  291. // neu gibt es am Schluss kein END; mehr - lasse es aber drinnen falls es doch wieder auftauchen würde
  292. // import file end reached
  293. if ($username === 'end') {
  294. break;
  295. }
  296. // not a valid record
  297. if (!is_numeric($id)) {
  298. $report['errors'][] = 'Line '.$importFileLineNr.': Id is not numeric';
  299. continue;
  300. }
  301. if (!$username || !$id) {
  302. $report['errors'][] = 'Line '.$importFileLineNr.': Username or Id is empty';
  303. continue;
  304. }
  305. // Check if same email / username already processed
  306. if (in_array($username, $usernames)) {
  307. $report['errors'][] = 'Line '.$importFileLineNr.': ['.$id.'] Username: '.$username.' already exists ';
  308. continue;
  309. }
  310. $usernames[] = $username;
  311. try {
  312. // update abonnent über id
  313. $abonnent = $this->findAbonnentByIdOrFail($id);
  314. if ($abonnent->getUsername() !== $username) {
  315. // Wenn Benutzer mit dieser E-Mail existiert -> Benutzer löschen
  316. $abonnentExists = $this->em->getRepository(Abonnent::class)->findOneBy(['username' => $username]);
  317. if ($abonnentExists) {
  318. $report['abonnenten_deleted'][] = 'Line : ['.$abonnentExists->getId().'] Username: '.$abonnentExists->getUserName().' deleted because Email is used for ['.$id.'] Username: '.$username;
  319. // Tokens löschen da sonst Constraint Fehler
  320. $abonnentExists->deleteAllTokens();
  321. // Passwort requests löschen
  322. $this->removePasswordRequests($abonnent);
  323. // Benutzer mit gleicher E-Mail löschen
  324. $em = $this->em;
  325. $em->remove($abonnentExists);
  326. $em->flush();
  327. }
  328. $report['abonnenten_updated'][] = '['.$id.'] '.$username.' updated username from '.$abonnent->getUsername();
  329. $abonnent->aktualisiereAbonnent($username);
  330. $this->persistAbonnent($abonnent);
  331. }
  332. if (!$abonnent->isActive()) {
  333. $abonnent->aktualisiereAbonnent(null, 1);
  334. $this->persistAbonnent($abonnent);
  335. $report['abonnenten_activated'][] = '['.$id.'] '.$username;
  336. }
  337. } catch (AbonnentWithThisIdNotExistsException) {
  338. try {
  339. // update abonnent über username
  340. $abonnent = $this->findAbonnentByUsernameOrFail($username);
  341. if ($abonnent->getId() !== (int) $id) {
  342. // Tokens löschen da sonst Constraint Fehler, wenn AboId geändert wird.
  343. $abonnent->deleteAllTokens();
  344. $this->persistAbonnent($abonnent);
  345. // Passwort requests löschen
  346. $this->removePasswordRequests($abonnent);
  347. $report['abonnenten_updated'][] = '['.$id.'] '.$username.' updated AboNr from '.$abonnent->getId();
  348. // Neue Abonummer updaten
  349. $abonnent->setId($id);
  350. // Passwort setzen, wenn nicht schon durch User gesetzt
  351. if ($abonnent->getLastPasswordChange() <= new \DateTime('2022-01-01')) {
  352. $abonnent->setPassword(
  353. $this->userPasswordHasher->hashPassword($abonnent, $id)
  354. );
  355. $report['abonnenten_password_updated'][] = '['.$id.'] '.$username;
  356. }
  357. $this->persistAbonnent($abonnent);
  358. }
  359. if (!$abonnent->isActive()) {
  360. $abonnent->aktualisiereAbonnent(null, 1);
  361. $this->persistAbonnent($abonnent);
  362. $report['abonnenten_activated'][] = '['.$id.'] '.$username;
  363. }
  364. } catch (AbonnentWithThisUsernameNotExistsException) {
  365. // create abonnent
  366. $abonnent = Abonnent::erstelleAbonnent($id, $username);
  367. // Passwort initial setzen
  368. $abonnent->setPassword(
  369. $this->userPasswordHasher->hashPassword($abonnent, $id)
  370. );
  371. $this->persistAbonnent($abonnent);
  372. $report['abonnenten_inserted'][] = '['.$id.'] '.$username;
  373. $report['abonnenten_password_set'][] = '['.$id.'] '.$username;
  374. }
  375. }
  376. // add active ids to list
  377. $activeIds[] = $id;
  378. }
  379. // deactivate abonnenten not in list
  380. $report['abonnenten_deactivated'] = $this->deactivateAbonnentenNotInList($activeIds);
  381. $report['sync_end'] = date('d.m.Y H:i:s');
  382. // write log to file
  383. $this->writeSyncLog($report);
  384. return $report;
  385. }
  386. /**
  387. * Löscht Duplikate von Abonnenten mit gleicher E-Mail.
  388. *
  389. * @return float|int|mixed|string
  390. *
  391. * @throws Exception
  392. */
  393. public function removeAbonnentenWithDuplicateUsernames(): mixed
  394. {
  395. // Manuelle Korrektur von Abo mit 2 Einträgen aber beide inaktiv.
  396. // update abonnent set is_active = 1 where id in (1057234, 1045250, 1057630, 1058937, 1058226, 1057578, 1058526, 1058823)
  397. $conn = $this->em->getConnection();
  398. $sql = '
  399. SELECT a.username,COUNT(*) as count FROM abonnent a
  400. GROUP BY username
  401. ORDER BY count DESC
  402. ';
  403. $stmt = $conn->prepare($sql);
  404. $resultSet = $stmt->executeQuery();
  405. // returns an array of arrays (i.e. a raw data set)
  406. $abonnenten = $resultSet->fetchAllAssociative();
  407. // Duplicated Usernames with more than 1 occurrence
  408. $duplicateUsernames = [];
  409. foreach ($abonnenten as $abonnent) {
  410. if ($abonnent['count'] > 1) {
  411. $duplicateUsernames[] = $abonnent['username'];
  412. }
  413. }
  414. // Select only inactive
  415. $query = $this->em->createQuery('SELECT a FROM '.Abonnent::class.' a WHERE a.isActive = 0 AND a.username IN (:duplicateUsernames)');
  416. // $query = $this->em->createQuery("SELECT a FROM ".Abonnent::class ." a WHERE a.username IN (:duplicateUsernames)");
  417. $query->setParameters(['duplicateUsernames' => $duplicateUsernames]);
  418. $abonnenten = $query->getResult();
  419. $removed = [];
  420. foreach ($abonnenten as $abonnent) {
  421. $removed[$abonnent->getUsername()][] = $abonnent;
  422. $this->em->remove($abonnent);
  423. }
  424. $this->em->flush();
  425. return $abonnenten;
  426. }
  427. protected function writeSyncLog(array $report)
  428. {
  429. // format $report array to human-readable content
  430. $content = '';
  431. foreach ($report as $key => $value) {
  432. $content .= strtoupper($key)."\n";
  433. if (is_array($value)) {
  434. $content .= implode("\n", $value)."\n";
  435. } else {
  436. $content .= $value."\n";
  437. }
  438. $content .= "\n\n";
  439. }
  440. $content .= "\n-----------------------------\n\n";
  441. // end
  442. // write to logfile
  443. $fs = new Filesystem();
  444. $fs->appendToFile($this->abonnentenImportFileLog, $content);
  445. // rename and compress logfile if to big
  446. if ($fs->exists($this->abonnentenImportFileLog)) {
  447. $logfile = new \SplFileObject($this->abonnentenImportFileLog, 'r');
  448. if ($logfile->getSize() > 5000000) {
  449. $date = new \DateTime();
  450. $appConsoleCommand = new Process('gzip -c '.$this->abonnentenImportFileLog.' >'.$this->abonnentenImportFileLog.'.'.$date->format('Ymd-His').'.gz');
  451. $appConsoleCommand->run();
  452. if ($appConsoleCommand->isSuccessful()) {
  453. unlink($this->abonnentenImportFileLog);
  454. } else {
  455. throw new \Exception('Error on compress logfile '.$this->abonnentenImportFileLog.' occurred!');
  456. }
  457. }
  458. }
  459. // end
  460. }
  461. /**
  462. * Deactivate abonnents where not in list.
  463. *
  464. * @param int[] $activeIds
  465. *
  466. * @return array
  467. */
  468. protected function deactivateAbonnentenNotInList(array $activeIds): array
  469. {
  470. $abonnentenDeactivated = [];
  471. if ($activeIds !== []) {
  472. $q = $this->em->createQuery('SELECT a FROM '.Abonnent::class.' a WHERE a.isActive = 1 AND a.id NOT IN ('.implode(',', array_map('intval', $activeIds)).')');
  473. $abonnenten = $q->getResult();
  474. /**
  475. * @var Abonnent $item
  476. */
  477. foreach ($abonnenten as $item) {
  478. $item->deaktiviereAbonnent();
  479. $this->persistAbonnent($item);
  480. $abonnentenDeactivated[] = '['.$item->getId().'] '.$item->getUsername();
  481. }
  482. }
  483. return $abonnentenDeactivated;
  484. }
  485. private function persistAbonnent(Abonnent $abonnent): void
  486. {
  487. $em = $this->em;
  488. $em->persist($abonnent);
  489. $em->flush();
  490. }
  491. private function removePasswordRequests(Abonnent $abonnent): void
  492. {
  493. $resetPasswordRequests = $this->em->getRepository(ResetPasswordRequest::class)->findBy(['user' => $abonnent]);
  494. foreach ($resetPasswordRequests as $resetPasswordRequest) {
  495. $this->em->remove($resetPasswordRequest);
  496. $this->em->flush();
  497. }
  498. }
  499. }