]> BookStack Code Mirror - bookstack/blobdiff - app/Uploads/UserAvatars.php
Customization: Added parent tag classes
[bookstack] / app / Uploads / UserAvatars.php
index f1509bbb8079b163f9ce15b035adf430622a8fc9..0cc640f225c118adf7e00829cba67d1dac19001a 100644 (file)
@@ -1,19 +1,23 @@
-<?php namespace BookStack\Uploads;
+<?php
+
+namespace BookStack\Uploads;
 
-use BookStack\Auth\User;
 use BookStack\Exceptions\HttpFetchException;
+use BookStack\Http\HttpRequestService;
+use BookStack\Users\Models\User;
+use BookStack\Util\WebSafeMimeSniffer;
 use Exception;
+use GuzzleHttp\Psr7\Request;
 use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
+use Psr\Http\Client\ClientExceptionInterface;
 
 class UserAvatars
 {
-    protected $imageService;
-    protected $http;
-
-    public function __construct(ImageService $imageService, HttpFetcher $http)
-    {
-        $this->imageService = $imageService;
-        $this->http = $http;
+    public function __construct(
+        protected ImageService $imageService,
+        protected HttpRequestService $http
+    ) {
     }
 
     /**
@@ -26,17 +30,75 @@ class UserAvatars
         }
 
         try {
+            $this->destroyAllForUser($user);
             $avatar = $this->saveAvatarImage($user);
             $user->avatar()->associate($avatar);
             $user->save();
         } catch (Exception $e) {
-            Log::error('Failed to save user avatar image');
+            Log::error('Failed to save user avatar image', ['exception' => $e]);
+        }
+    }
+
+    /**
+     * Assign a new avatar image to the given user using the given image data.
+     */
+    public function assignToUserFromExistingData(User $user, string $imageData, string $extension): void
+    {
+        try {
+            $this->destroyAllForUser($user);
+            $avatar = $this->createAvatarImageFromData($user, $imageData, $extension);
+            $user->avatar()->associate($avatar);
+            $user->save();
+        } catch (Exception $e) {
+            Log::error('Failed to save user avatar image', ['exception' => $e]);
+        }
+    }
+
+    /**
+     * Assign a new avatar image to the given user by fetching from a remote URL.
+     */
+    public function assignToUserFromUrl(User $user, string $avatarUrl): void
+    {
+        try {
+            $this->destroyAllForUser($user);
+            $imageData = $this->getAvatarImageData($avatarUrl);
+
+            $mime = (new WebSafeMimeSniffer())->sniff($imageData);
+            [$format, $type] = explode('/', $mime, 2);
+            if ($format !== 'image' || !ImageService::isExtensionSupported($type)) {
+                return;
+            }
+
+            $avatar = $this->createAvatarImageFromData($user, $imageData, $type);
+            $user->avatar()->associate($avatar);
+            $user->save();
+        } catch (Exception $e) {
+            Log::error('Failed to save user avatar image from URL', [
+                'exception' => $e->getMessage(),
+                'url'       => $avatarUrl,
+                'user_id'   => $user->id,
+            ]);
+        }
+    }
+
+    /**
+     * Destroy all user avatars uploaded to the given user.
+     */
+    public function destroyAllForUser(User $user): void
+    {
+        $profileImages = Image::query()->where('type', '=', 'user')
+            ->where('uploaded_to', '=', $user->id)
+            ->get();
+
+        foreach ($profileImages as $image) {
+            $this->imageService->destroy($image);
         }
     }
 
     /**
      * Save an avatar image from an external service.
-     * @throws Exception
+     *
+     * @throws HttpFetchException
      */
     protected function saveAvatarImage(User $user, int $size = 500): Image
     {
@@ -44,15 +106,24 @@ class UserAvatars
         $email = strtolower(trim($user->email));
 
         $replacements = [
-            '${hash}' => md5($email),
-            '${size}' => $size,
+            '${hash}'  => md5($email),
+            '${size}'  => $size,
             '${email}' => urlencode($email),
         ];
 
         $userAvatarUrl = strtr($avatarUrl, $replacements);
-        $imageName = str_replace(' ', '-', $user->id . '-avatar.png');
         $imageData = $this->getAvatarImageData($userAvatarUrl);
 
+        return $this->createAvatarImageFromData($user, $imageData, 'png');
+    }
+
+    /**
+     * Creates a new image instance and saves it in the system as a new user avatar image.
+     */
+    protected function createAvatarImageFromData(User $user, string $imageData, string $extension): Image
+    {
+        $imageName = Str::random(10) . '-avatar.' . $extension;
+
         $image = $this->imageService->saveNew($imageName, $imageData, 'user', $user->id);
         $image->created_by = $user->id;
         $image->updated_by = $user->id;
@@ -62,34 +133,58 @@ class UserAvatars
     }
 
     /**
-     * Gets an image from url and returns it as a string of image data.
-     * @throws Exception
+     * Get an image from a URL and return it as a string of image data.
+     *
+     * @throws HttpFetchException
      */
     protected function getAvatarImageData(string $url): string
     {
         try {
-            $imageData = $this->http->fetch($url);
-        } catch (HttpFetchException $exception) {
-            throw new Exception(trans('errors.cannot_get_image_from_url', ['url' => $url]));
+            $client = $this->http->buildClient(5);
+            $responseCount = 0;
+
+            do {
+                $response = $client->sendRequest(new Request('GET', $url));
+                $responseCount++;
+                $isRedirect = ($response->getStatusCode() === 301 || $response->getStatusCode() === 302);
+                $url = $response->getHeader('Location')[0] ?? '';
+            } while ($responseCount < 3 && $isRedirect && is_string($url) && str_starts_with($url, 'http'));
+
+            if ($responseCount === 3) {
+                throw new HttpFetchException("Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: {$url}");
+            }
+
+            if ($response->getStatusCode() !== 200) {
+                throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]));
+            }
+
+            return (string) $response->getBody();
+        } catch (ClientExceptionInterface $exception) {
+            throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]), $exception->getCode(), $exception);
         }
-        return $imageData;
     }
 
     /**
      * Check if fetching external avatars is enabled.
      */
-    protected function avatarFetchEnabled(): bool
+    public function avatarFetchEnabled(): bool
     {
         $fetchUrl = $this->getAvatarUrl();
-        return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0;
+
+        return str_starts_with($fetchUrl, 'http');
     }
 
     /**
      * Get the URL to fetch avatars from.
      */
-    protected function getAvatarUrl(): string
+    public function getAvatarUrl(): string
     {
-        $url = trim(config('services.avatar_url'));
+        $configOption = config('services.avatar_url');
+        if ($configOption === false) {
+            return '';
+        }
+
+        $url = trim($configOption);
 
         if (empty($url) && !config('services.disable_services')) {
             $url = 'https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon';