Lines 100.00% 25 / 25
Functions and Methods 100.00% 4 / 4
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
AvatarUpdater 100.00% 25 / 25 100.00% 4 / 4 14 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 update 100.00% 8 / 8 100.00% 1 / 1 6
 isGooglePicture 100.00% 2 / 2 100.00% 1 / 1 3
 fetch 100.00% 14 / 14 100.00% 1 / 1 4
1<?php
2
3declare(strict_types=1);
4
5namespace App\Service;
6
7use App\Entity\User;
8use Doctrine\ORM\EntityManagerInterface;
9use Psr\Log\LoggerInterface;
10use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
11use Symfony\Contracts\HttpClient\HttpClientInterface;
12
13/**
14 * Keeps the user's Google profile picture (the ID token's "picture" claim) as a 100 px JPEG data:
15 * URI, as 4e-website's AvatarHelper: pages show it without loading anything from Google. It is
16 * fetched again only when the URL changes; a failure keeps the old picture and never stops a sign-in.
17 */
18final readonly class AvatarUpdater
19{
20    public const int SIZE = 100;
21
22    /** Google serves profile pictures from here; nothing else is fetched. */
23    private const string PICTURE_HOST = '.googleusercontent.com';
24
25    private const int MAX_BYTES = 2_000_000;
26
27    public function __construct(
28        private HttpClientInterface $httpClient,
29        private EntityManagerInterface $entityManager,
30        private LoggerInterface $logger,
31    ) {
32    }
33
34    public function update(User $user, mixed $pictureUrl): void
35    {
36        if (!\is_string($pictureUrl) || !$this->isGooglePicture($pictureUrl)
37            || ($pictureUrl === $user->getAvatarSource() && null !== $user->getAvatar())
38        ) {
39            return;
40        }
41
42        $avatar = $this->fetch($pictureUrl);
43        if (null === $avatar) {
44            return;
45        }
46
47        $user->setAvatar($avatar, $pictureUrl);
48        $this->entityManager->flush();
49    }
50
51    private function isGooglePicture(string $url): bool
52    {
53        $host = \parse_url($url, \PHP_URL_HOST);
54
55        return \str_starts_with($url, 'https://') && \is_string($host) && \str_ends_with($host, self::PICTURE_HOST);
56    }
57
58    /** The picture as a data: URI, or null (logged) when it can't be fetched or read. */
59    private function fetch(string $url): ?string
60    {
61        try {
62            $content = $this->httpClient->request('GET', $url, ['timeout' => 5, 'max_redirects' => 0])->getContent();
63        } catch (ExceptionInterface $exception) {
64            $this->logger->warning('Fetching the Google profile picture failed: {message}', ['message' => $exception->getMessage()]);
65
66            return null;
67        }
68
69        $image = \strlen($content) <= self::MAX_BYTES ? @\imagecreatefromstring($content) : false;
70        if (false === $image) {
71            $this->logger->warning('The Google profile picture is not an image.');
72
73            return null;
74        }
75
76        // A centered square, scaled down
77        $side = \min(\imagesx($image), \imagesy($image));
78        $square = \imagecreatetruecolor(self::SIZE, self::SIZE);
79        \imagecopyresampled($square, $image, 0, 0, \intdiv(\imagesx($image) - $side, 2), \intdiv(\imagesy($image) - $side, 2), self::SIZE, self::SIZE, $side, $side);
80
81        \ob_start();
82        \imagejpeg($square, null, 85);
83
84        return 'data:image/jpeg;base64,'.\base64_encode((string) \ob_get_clean());
85    }
86}