mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 08:18:08 +00:00
feat(profile): add uploadable avatars with local/S3 storage
Users can upload and remove profile pictures on the profile page. Admins choose local or instance S3 storage in advanced settings. Avatars are compressed to JPEG and served via a private cached route.
This commit is contained in:
1 parent
e50108a905
commit
51561e4bee
23 files changed
+677
-44
No files matched your search
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
|
||||
class AvatarStorageService
|
||||
{
|
||||
public function store(User $user, UploadedFile $upload): void
|
||||
{
|
||||
$settings = instanceSettings();
|
||||
$storageType = $settings->avatar_storage_type === 's3' && $settings->avatar_s3_storage_id ? 's3' : 'local';
|
||||
$s3StorageId = $storageType === 's3' ? $settings->avatar_s3_storage_id : null;
|
||||
$disk = $this->disk($storageType, $s3StorageId);
|
||||
$path = "avatars/{$user->id}/avatar.jpg";
|
||||
$contents = $this->compress($upload);
|
||||
|
||||
if (! $disk->put($path, $contents)) {
|
||||
throw new RuntimeException('Unable to store the profile picture.');
|
||||
}
|
||||
|
||||
$oldStorageType = $user->avatar_storage_type;
|
||||
$oldS3StorageId = $user->avatar_s3_storage_id;
|
||||
$oldPath = $user->avatar_path;
|
||||
|
||||
$user->update([
|
||||
'avatar_path' => $path,
|
||||
'avatar_storage_type' => $storageType,
|
||||
'avatar_s3_storage_id' => $s3StorageId,
|
||||
]);
|
||||
|
||||
if ($oldPath && ($oldStorageType !== $storageType || $oldS3StorageId !== $s3StorageId)) {
|
||||
$this->disk($oldStorageType ?? 'local', $oldS3StorageId)->delete($oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
public function contents(User $user): ?string
|
||||
{
|
||||
if (! $user->avatar_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$disk = $this->disk($user->avatar_storage_type ?? 'local', $user->avatar_s3_storage_id);
|
||||
} catch (RuntimeException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $disk->exists($user->avatar_path) ? $disk->get($user->avatar_path) : null;
|
||||
}
|
||||
|
||||
public function delete(User $user): void
|
||||
{
|
||||
if ($user->avatar_path) {
|
||||
$this->disk($user->avatar_storage_type ?? 'local', $user->avatar_s3_storage_id)
|
||||
->delete($user->avatar_path);
|
||||
}
|
||||
|
||||
$user->update([
|
||||
'avatar_path' => null,
|
||||
'avatar_storage_type' => null,
|
||||
'avatar_s3_storage_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function disk(string $storageType, ?int $s3StorageId): FilesystemAdapter
|
||||
{
|
||||
if ($storageType !== 's3') {
|
||||
return Storage::disk('local');
|
||||
}
|
||||
|
||||
$storage = S3Storage::query()->whereKey($s3StorageId)->where('is_usable', true)->first();
|
||||
if (! $storage) {
|
||||
throw new RuntimeException('The configured S3 storage is not available.');
|
||||
}
|
||||
|
||||
return $storage->filesystem();
|
||||
}
|
||||
|
||||
private function compress(UploadedFile $upload): string
|
||||
{
|
||||
$imageInfo = getimagesize($upload->getRealPath());
|
||||
if ($imageInfo && $imageInfo['mime'] === 'image/jpeg' && $imageInfo[0] <= 256 && $imageInfo[1] <= 256) {
|
||||
return file_get_contents($upload->getRealPath());
|
||||
}
|
||||
|
||||
if (extension_loaded('imagick')) {
|
||||
$image = new \Imagick($upload->getRealPath());
|
||||
$image->setIteratorIndex(0);
|
||||
$image->autoOrient();
|
||||
$image->cropThumbnailImage(256, 256);
|
||||
$image->stripImage();
|
||||
$image->setImageBackgroundColor('white');
|
||||
$image = $image->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
|
||||
$image->setImageFormat('jpeg');
|
||||
$image->setImageCompressionQuality(80);
|
||||
$contents = $image->getImagesBlob();
|
||||
$image->clear();
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
if (! function_exists('imagecreatefromstring') || ! function_exists('imagejpeg')) {
|
||||
throw new RuntimeException('ImageMagick or GD is required to process profile pictures that were not compressed by the browser.');
|
||||
}
|
||||
|
||||
$source = imagecreatefromstring(file_get_contents($upload->getRealPath()));
|
||||
if (! $source) {
|
||||
throw new RuntimeException('Unable to read the uploaded profile picture.');
|
||||
}
|
||||
|
||||
$sourceWidth = imagesx($source);
|
||||
$sourceHeight = imagesy($source);
|
||||
$cropSize = min($sourceWidth, $sourceHeight);
|
||||
$target = imagecreatetruecolor(256, 256);
|
||||
imagefill($target, 0, 0, imagecolorallocate($target, 255, 255, 255));
|
||||
imagecopyresampled(
|
||||
$target,
|
||||
$source,
|
||||
0,
|
||||
0,
|
||||
(int) (($sourceWidth - $cropSize) / 2),
|
||||
(int) (($sourceHeight - $cropSize) / 2),
|
||||
256,
|
||||
256,
|
||||
$cropSize,
|
||||
$cropSize,
|
||||
);
|
||||
|
||||
ob_start();
|
||||
imagejpeg($target, null, 80);
|
||||
$contents = ob_get_clean();
|
||||
imagedestroy($source);
|
||||
imagedestroy($target);
|
||||
|
||||
if (! is_string($contents)) {
|
||||
throw new RuntimeException('Unable to compress the profile picture.');
|
||||
}
|
||||
|
||||
return $contents;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user