-<?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
+ ) {
}
/**
}
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
{
$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;
}
/**
- * 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';
return $url;
}
-
-}
\ No newline at end of file
+}