]> BookStack Code Mirror - bookstack/blob - app/Uploads/UserAvatars.php
Lexical: Media form improvements
[bookstack] / app / Uploads / UserAvatars.php
1 <?php
2
3 namespace BookStack\Uploads;
4
5 use BookStack\Exceptions\HttpFetchException;
6 use BookStack\Http\HttpRequestService;
7 use BookStack\Users\Models\User;
8 use BookStack\Util\WebSafeMimeSniffer;
9 use Exception;
10 use GuzzleHttp\Psr7\Request;
11 use Illuminate\Support\Facades\Log;
12 use Illuminate\Support\Str;
13 use Psr\Http\Client\ClientExceptionInterface;
14
15 class UserAvatars
16 {
17     public function __construct(
18         protected ImageService $imageService,
19         protected HttpRequestService $http
20     ) {
21     }
22
23     /**
24      * Fetch and assign an avatar image to the given user.
25      */
26     public function fetchAndAssignToUser(User $user): void
27     {
28         if (!$this->avatarFetchEnabled()) {
29             return;
30         }
31
32         try {
33             $this->destroyAllForUser($user);
34             $avatar = $this->saveAvatarImage($user);
35             $user->avatar()->associate($avatar);
36             $user->save();
37         } catch (Exception $e) {
38             Log::error('Failed to save user avatar image', ['exception' => $e]);
39         }
40     }
41
42     /**
43      * Assign a new avatar image to the given user using the given image data.
44      */
45     public function assignToUserFromExistingData(User $user, string $imageData, string $extension): void
46     {
47         try {
48             $this->destroyAllForUser($user);
49             $avatar = $this->createAvatarImageFromData($user, $imageData, $extension);
50             $user->avatar()->associate($avatar);
51             $user->save();
52         } catch (Exception $e) {
53             Log::error('Failed to save user avatar image', ['exception' => $e]);
54         }
55     }
56
57     /**
58      * Assign a new avatar image to the given user by fetching from a remote URL.
59      */
60     public function assignToUserFromUrl(User $user, string $avatarUrl): void
61     {
62         try {
63             $this->destroyAllForUser($user);
64             $imageData = $this->getAvatarImageData($avatarUrl);
65
66             $mime = (new WebSafeMimeSniffer())->sniff($imageData);
67             [$format, $type] = explode('/', $mime, 2);
68             if ($format !== 'image' || !ImageService::isExtensionSupported($type)) {
69                 return;
70             }
71
72             $avatar = $this->createAvatarImageFromData($user, $imageData, $type);
73             $user->avatar()->associate($avatar);
74             $user->save();
75         } catch (Exception $e) {
76             Log::error('Failed to save user avatar image from URL', [
77                 'exception' => $e->getMessage(),
78                 'url'       => $avatarUrl,
79                 'user_id'   => $user->id,
80             ]);
81         }
82     }
83
84     /**
85      * Destroy all user avatars uploaded to the given user.
86      */
87     public function destroyAllForUser(User $user): void
88     {
89         $profileImages = Image::query()->where('type', '=', 'user')
90             ->where('uploaded_to', '=', $user->id)
91             ->get();
92
93         foreach ($profileImages as $image) {
94             $this->imageService->destroy($image);
95         }
96     }
97
98     /**
99      * Save an avatar image from an external service.
100      *
101      * @throws HttpFetchException
102      */
103     protected function saveAvatarImage(User $user, int $size = 500): Image
104     {
105         $avatarUrl = $this->getAvatarUrl();
106         $email = strtolower(trim($user->email));
107
108         $replacements = [
109             '${hash}'  => md5($email),
110             '${size}'  => $size,
111             '${email}' => urlencode($email),
112         ];
113
114         $userAvatarUrl = strtr($avatarUrl, $replacements);
115         $imageData = $this->getAvatarImageData($userAvatarUrl);
116
117         return $this->createAvatarImageFromData($user, $imageData, 'png');
118     }
119
120     /**
121      * Creates a new image instance and saves it in the system as a new user avatar image.
122      */
123     protected function createAvatarImageFromData(User $user, string $imageData, string $extension): Image
124     {
125         $imageName = Str::random(10) . '-avatar.' . $extension;
126
127         $image = $this->imageService->saveNew($imageName, $imageData, 'user', $user->id);
128         $image->created_by = $user->id;
129         $image->updated_by = $user->id;
130         $image->save();
131
132         return $image;
133     }
134
135     /**
136      * Get an image from a URL and return it as a string of image data.
137      *
138      * @throws HttpFetchException
139      */
140     protected function getAvatarImageData(string $url): string
141     {
142         try {
143             $client = $this->http->buildClient(5);
144             $responseCount = 0;
145
146             do {
147                 $response = $client->sendRequest(new Request('GET', $url));
148                 $responseCount++;
149                 $isRedirect = ($response->getStatusCode() === 301 || $response->getStatusCode() === 302);
150                 $url = $response->getHeader('Location')[0] ?? '';
151             } while ($responseCount < 3 && $isRedirect && is_string($url) && str_starts_with($url, 'http'));
152
153             if ($responseCount === 3) {
154                 throw new HttpFetchException("Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: {$url}");
155             }
156
157             if ($response->getStatusCode() !== 200) {
158                 throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]));
159             }
160
161             return (string) $response->getBody();
162         } catch (ClientExceptionInterface $exception) {
163             throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]), $exception->getCode(), $exception);
164         }
165     }
166
167     /**
168      * Check if fetching external avatars is enabled.
169      */
170     public function avatarFetchEnabled(): bool
171     {
172         $fetchUrl = $this->getAvatarUrl();
173
174         return str_starts_with($fetchUrl, 'http');
175     }
176
177     /**
178      * Get the URL to fetch avatars from.
179      */
180     public function getAvatarUrl(): string
181     {
182         $configOption = config('services.avatar_url');
183         if ($configOption === false) {
184             return '';
185         }
186
187         $url = trim($configOption);
188
189         if (empty($url) && !config('services.disable_services')) {
190             $url = 'https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon';
191         }
192
193         return $url;
194     }
195 }