3 namespace BookStack\Uploads;
5 use BookStack\Exceptions\HttpFetchException;
6 use BookStack\Http\HttpRequestService;
7 use BookStack\Users\Models\User;
8 use BookStack\Util\WebSafeMimeSniffer;
10 use GuzzleHttp\Psr7\Request;
11 use Illuminate\Support\Facades\Log;
12 use Illuminate\Support\Str;
13 use Psr\Http\Client\ClientExceptionInterface;
17 public function __construct(
18 protected ImageService $imageService,
19 protected HttpRequestService $http
24 * Fetch and assign an avatar image to the given user.
26 public function fetchAndAssignToUser(User $user): void
28 if (!$this->avatarFetchEnabled()) {
33 $this->destroyAllForUser($user);
34 $avatar = $this->saveAvatarImage($user);
35 $user->avatar()->associate($avatar);
37 } catch (Exception $e) {
38 Log::error('Failed to save user avatar image', ['exception' => $e]);
43 * Assign a new avatar image to the given user using the given image data.
45 public function assignToUserFromExistingData(User $user, string $imageData, string $extension): void
48 $this->destroyAllForUser($user);
49 $avatar = $this->createAvatarImageFromData($user, $imageData, $extension);
50 $user->avatar()->associate($avatar);
52 } catch (Exception $e) {
53 Log::error('Failed to save user avatar image', ['exception' => $e]);
58 * Assign a new avatar image to the given user by fetching from a remote URL.
60 public function assignToUserFromUrl(User $user, string $avatarUrl): void
63 $this->destroyAllForUser($user);
64 $imageData = $this->getAvatarImageData($avatarUrl);
66 $mime = (new WebSafeMimeSniffer())->sniff($imageData);
67 [$format, $type] = explode('/', $mime, 2);
68 if ($format !== 'image' || !ImageService::isExtensionSupported($type)) {
72 $avatar = $this->createAvatarImageFromData($user, $imageData, $type);
73 $user->avatar()->associate($avatar);
75 } catch (Exception $e) {
76 Log::error('Failed to save user avatar image from URL', [
77 'exception' => $e->getMessage(),
79 'user_id' => $user->id,
85 * Destroy all user avatars uploaded to the given user.
87 public function destroyAllForUser(User $user): void
89 $profileImages = Image::query()->where('type', '=', 'user')
90 ->where('uploaded_to', '=', $user->id)
93 foreach ($profileImages as $image) {
94 $this->imageService->destroy($image);
99 * Save an avatar image from an external service.
101 * @throws HttpFetchException
103 protected function saveAvatarImage(User $user, int $size = 500): Image
105 $avatarUrl = $this->getAvatarUrl();
106 $email = strtolower(trim($user->email));
109 '${hash}' => md5($email),
111 '${email}' => urlencode($email),
114 $userAvatarUrl = strtr($avatarUrl, $replacements);
115 $imageData = $this->getAvatarImageData($userAvatarUrl);
117 return $this->createAvatarImageFromData($user, $imageData, 'png');
121 * Creates a new image instance and saves it in the system as a new user avatar image.
123 protected function createAvatarImageFromData(User $user, string $imageData, string $extension): Image
125 $imageName = Str::random(10) . '-avatar.' . $extension;
127 $image = $this->imageService->saveNew($imageName, $imageData, 'user', $user->id);
128 $image->created_by = $user->id;
129 $image->updated_by = $user->id;
136 * Get an image from a URL and return it as a string of image data.
138 * @throws HttpFetchException
140 protected function getAvatarImageData(string $url): string
143 $client = $this->http->buildClient(5);
147 $response = $client->sendRequest(new Request('GET', $url));
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'));
153 if ($responseCount === 3) {
154 throw new HttpFetchException("Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: {$url}");
157 if ($response->getStatusCode() !== 200) {
158 throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]));
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);
168 * Check if fetching external avatars is enabled.
170 public function avatarFetchEnabled(): bool
172 $fetchUrl = $this->getAvatarUrl();
174 return str_starts_with($fetchUrl, 'http');
178 * Get the URL to fetch avatars from.
180 public function getAvatarUrl(): string
182 $configOption = config('services.avatar_url');
183 if ($configOption === false) {
187 $url = trim($configOption);
189 if (empty($url) && !config('services.disable_services')) {
190 $url = 'https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon';