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