diff --git a/app/Casts/LegacyFileJson.php b/app/Casts/LegacyFileJson.php new file mode 100644 index 00000000000..7bf99445fe5 --- /dev/null +++ b/app/Casts/LegacyFileJson.php @@ -0,0 +1,31 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Casts; + +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; +use Illuminate\Database\Eloquent\Model; + +class LegacyFileJson implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes) + { + return isset($attributes['ext'], $attributes['hash']) + ? [ + 'ext' => $attributes['ext'], + 'hash' => $attributes['hash'], + ] : null; + } + + public function set(Model $model, string $key, mixed $value, array $attributes) + { + return [ + 'ext' => $value['ext'] ?? null, + 'hash' => $value['hash'] ?? null, + ]; + } +} diff --git a/app/Console/Commands/ForumTopicCoversCleanup.php b/app/Console/Commands/ForumTopicCoversCleanup.php index 2ed291a66b6..0d29fd80be7 100644 --- a/app/Console/Commands/ForumTopicCoversCleanup.php +++ b/app/Console/Commands/ForumTopicCoversCleanup.php @@ -51,7 +51,7 @@ public function handle() $deleted++; $progress->advance(); - $cover->deleteWithFile(); + $cover->delete(); } }); diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index eb5b75ce9ee..f6cc59ffcb9 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -90,9 +90,9 @@ public function cover() } try { - $user - ->profileCustomization() - ->setCover(Request::input('cover_id'), Request::file('cover_file')); + $profile = $user->profileCustomization(); + $profile->setCover(Request::input('cover_id'), Request::file('cover_file')); + $profile->save(); } catch (ImageProcessorException $e) { return error_popup($e->getMessage()); } diff --git a/app/Http/Controllers/Admin/ContestsController.php b/app/Http/Controllers/Admin/ContestsController.php index a7029a214c4..bbcee86f5a8 100644 --- a/app/Http/Controllers/Admin/ContestsController.php +++ b/app/Http/Controllers/Admin/ContestsController.php @@ -8,7 +8,6 @@ use App\Models\Contest; use App\Models\DeletedUser; use App\Models\UserContestEntry; -use GuzzleHttp; use ZipStream\ZipStream; class ContestsController extends Controller @@ -47,14 +46,11 @@ public function gimmeZip($id) return response()->streamDownload(function () use ($entries) { $zip = new ZipStream(); - $client = new GuzzleHttp\Client(); - $deletedUser = new DeletedUser(); foreach ($entries as $entry) { $targetDir = ($entry->user ?? $deletedUser)->username." ({$entry->user_id})"; $filename = sanitize_filename($entry->original_filename); - $file = $client->get($entry->fileUrl())->getBody(); - $zip->addFileFromPsr7Stream("$targetDir/{$filename}", $file); + $zip->addFile("$targetDir/{$filename}", $entry->file()->get()); } $zip->finish(); diff --git a/app/Http/Controllers/ContestEntriesController.php b/app/Http/Controllers/ContestEntriesController.php index df6975ab4d9..018b6ff6297 100644 --- a/app/Http/Controllers/ContestEntriesController.php +++ b/app/Http/Controllers/ContestEntriesController.php @@ -97,7 +97,7 @@ public function destroy($id) priv_check('ContestEntryDestroy', $entry)->ensureCan(); - $entry->deleteWithFile(); + $entry->delete(); return $contest->userEntries($user); } diff --git a/app/Http/Controllers/Forum/TopicCoversController.php b/app/Http/Controllers/Forum/TopicCoversController.php index dab773dbc82..9d3129f1924 100644 --- a/app/Http/Controllers/Forum/TopicCoversController.php +++ b/app/Http/Controllers/Forum/TopicCoversController.php @@ -78,7 +78,7 @@ public function destroy($id) if ($cover !== null) { priv_check('ForumTopicCoverEdit', $cover)->ensureCan(); - $cover->deleteWithFile(); + $cover->delete(); } return json_item($cover, new TopicCoverTransformer()); diff --git a/app/Libraries/ForumDefaultTopicCover.php b/app/Libraries/ForumDefaultTopicCover.php deleted file mode 100644 index 7596903bbda..00000000000 --- a/app/Libraries/ForumDefaultTopicCover.php +++ /dev/null @@ -1,35 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -namespace App\Libraries; - -use App\Models\Forum\TopicCover; -use App\Traits\Imageable; - -class ForumDefaultTopicCover -{ - use Imageable; - - public $hash; - public $ext; - public $id; - - public function __construct($id, $data) - { - $this->id = $id; - $this->hash = $data['hash'] ?? null; - $this->ext = $data['ext'] ?? null; - } - - public function getMaxDimensions() - { - return TopicCover::MAX_DIMENSIONS; - } - - public function getFileRoot() - { - return 'forum-default-topic-covers'; - } -} diff --git a/app/Libraries/ProfileCover.php b/app/Libraries/ProfileCover.php deleted file mode 100644 index 1191a7e1789..00000000000 --- a/app/Libraries/ProfileCover.php +++ /dev/null @@ -1,103 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -namespace App\Libraries; - -use App\Traits\Imageable; - -class ProfileCover -{ - use Imageable; - - public $data; - public $userId; - - private $availableIds = ['1', '2', '3', '4', '5', '6', '7', '8']; - - public function __construct($userId, $data) - { - $this->data = $data; - $this->userId = $userId; - } - - public function getMaxDimensions() - { - return [2400, 640]; - } - - public function getFileRoot() - { - return 'user-profile-covers'; - } - - public function getFileId() - { - return $this->userId; - } - - public function getFileProperties() - { - return array_get($this->data, 'file'); - } - - public function setFileProperties($props) - { - if ($this->data === null) { - $this->data = []; - } - - $this->data['file'] = $props; - } - - public function hasCustomCover() - { - return !isset($this->data['id']) && isset($this->data['file']); - } - - public function id() - { - if ($this->hasCustomCover()) { - return; - } - - if ($this->userId === null || $this->userId < 1) { - return; - } - - if (!in_array($this->data['id'] ?? null, $this->availableIds, true)) { - return $this->availableIds[$this->userId % count($this->availableIds)]; - } - - return $this->data['id']; - } - - public function set($id, $file) - { - if ($id !== null && in_array($id, $this->availableIds, true)) { - $this->data['id'] = $id; - } else { - $this->data['id'] = null; - } - - if ($file !== null) { - $this->storeFile($file->getRealPath()); - } - - return array_only($this->data, ['id', 'file']); - } - - public function url() - { - if ($this->hasCustomCover()) { - return $this->fileUrl(); - } - - $id = $this->id(); - - if ($id !== null) { - return config('app.url').'/images/headers/profile-covers/c'.$id.'.jpg'; - } - } -} diff --git a/app/Libraries/StorageUrl.php b/app/Libraries/StorageUrl.php new file mode 100644 index 00000000000..00d784dc20d --- /dev/null +++ b/app/Libraries/StorageUrl.php @@ -0,0 +1,19 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Libraries; + +class StorageUrl +{ + public static function make(?string $diskName, string $path): string + { + $diskName ??= config('filesystems.default'); + $baseUrl = config("filesystems.disks.{$diskName}.base_url"); + + return "{$baseUrl}/{$path}"; + } +} diff --git a/app/Libraries/StorageWithUrl.php b/app/Libraries/StorageWithUrl.php deleted file mode 100644 index 0728753848a..00000000000 --- a/app/Libraries/StorageWithUrl.php +++ /dev/null @@ -1,36 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -declare(strict_types=1); - -namespace App\Libraries; - -use Illuminate\Contracts\Filesystem\Filesystem; - -class StorageWithUrl -{ - private string $baseUrl; - private Filesystem $disk; - private string $diskName; - - public function __construct(?string $diskName = null) - { - $this->diskName = $diskName ?? config('filesystems.default'); - } - - public function url(string $path): string - { - $this->baseUrl ??= config("filesystems.disks.{$this->diskName}.base_url"); - - return "{$this->baseUrl}/{$path}"; - } - - public function __call($method, $parameters) - { - $this->disk ??= \Storage::disk($this->diskName); - - return $this->disk->$method(...$parameters); - } -} diff --git a/app/Libraries/Uploader.php b/app/Libraries/Uploader.php new file mode 100644 index 00000000000..dae03789420 --- /dev/null +++ b/app/Libraries/Uploader.php @@ -0,0 +1,120 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Libraries; + +use App\Exceptions\InvariantException; +use App\Models\Model; +use Illuminate\Http\File; +use League\Flysystem\Local\LocalFilesystemAdapter; + +class Uploader +{ + private static function process(string $name, ?array $options, string $srcPath): ?string + { + switch ($name) { + case 'image': + $processor = new ImageProcessor($srcPath, $options['maxDimensions'], $options['maxFileSize'] ?? 10_000_000); + $processor->process(); + + return $processor->ext(); + } + + throw new InvariantException('unknown process name'); + } + + private array|false|null $json = false; + + public function __construct( + private string $baseDir, + private Model $model, + private string $attr, + private array $processors = [], + ) { + } + + public function delete(): void + { + $this->setJson(null); + \Storage::deleteDirectory($this->dir()); + } + + public function get(): ?string + { + $path = $this->path(); + + return $path === null ? null : \Storage::get($path); + } + + public function store(string $srcPath, string $ext = ''): void + { + foreach ($this->processors as $processName => $processOptions) { + $newExt = static::process($processName, $processOptions, $srcPath); + if ($newExt !== null) { + $ext = $newExt; + } + } + + $this->delete(); + $this->setJson([ + 'ext' => $ext, + 'hash' => hash_file('sha256', $srcPath), + ]); + + $storage = \Storage::disk(); + + if ($storage->getAdapter() instanceof LocalFilesystemAdapter) { + $options = [ + 'visibility' => 'public', + 'directory_visibility' => 'public', + ]; + } + + $storage->putFileAs( + $this->dir(), + new File($srcPath), + $this->filename(), + $options ?? [], + ); + } + + public function url(): ?string + { + $path = $this->path(); + + return $path === null ? null : StorageUrl::make(null, $path); + } + + private function dir(): string + { + return "{$this->baseDir}/{$this->model->getKey()}"; + } + + private function filename(): ?string + { + if ($this->json === false) { + $this->json = $this->model->{$this->attr}; + } + + return $this->json === null + ? null + : "{$this->json['hash']}.{$this->json['ext']}"; + } + + private function path(): ?string + { + $filename = $this->filename(); + + return $filename === null ? null : "{$this->dir()}/{$filename}"; + } + + private function setJson(?array $json): void + { + $this->json = $json; + $this->model->{$this->attr} = $json; + } +} diff --git a/app/Libraries/User/CoverHelper.php b/app/Libraries/User/CoverHelper.php new file mode 100644 index 00000000000..042f8bad741 --- /dev/null +++ b/app/Libraries/User/CoverHelper.php @@ -0,0 +1,76 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Libraries\User; + +use App\Models\UserProfileCustomization; + +/** + * This class doesn't sync with underlying model to save attribute casting time + */ +class CoverHelper +{ + private const AVAILABLE_PRESET_IDS = ['1', '2', '3', '4', '5', '6', '7', '8']; + + public static function isValidPresetId(?string $presetId): bool + { + return $presetId !== null + && in_array($presetId, static::AVAILABLE_PRESET_IDS, true); + } + + private ?array $json; + + public function __construct(private UserProfileCustomization $userProfileCustomization) + { + $this->json = $this->userProfileCustomization->cover_json; + } + + public function customUrl(): ?string + { + return $this->userProfileCustomization->cover()->url(); + } + + public function presetId(): ?string + { + if ($this->hasCustomCover()) { + return null; + } + + $id = $this->userProfileCustomization->getKey(); + + if ($id === null || $id < 1) { + return null; + } + + $presetId = $this->json['id'] ?? null; + + return static::isValidPresetId($presetId) + ? $presetId + : static::AVAILABLE_PRESET_IDS[$id % count(static::AVAILABLE_PRESET_IDS)]; + } + + public function url(): ?string + { + return $this->hasCustomCover() + ? $this->customUrl() + : $this->presetUrl(); + } + + private function hasCustomCover(): bool + { + return !isset($this->json['id']) && isset($this->json['file']); + } + + private function presetUrl(): ?string + { + $presetId = $this->presetId(); + + return $presetId === null + ? null + : config('app.url').'/images/headers/profile-covers/c'.$presetId.'.jpg'; + } +} diff --git a/app/Models/Beatmapset.php b/app/Models/Beatmapset.php index 79512b546ce..bd97c47d32c 100644 --- a/app/Models/Beatmapset.php +++ b/app/Models/Beatmapset.php @@ -24,7 +24,7 @@ use App\Libraries\Commentable; use App\Libraries\Elasticsearch\Indexable; use App\Libraries\ImageProcessorService; -use App\Libraries\StorageWithUrl; +use App\Libraries\StorageUrl; use App\Libraries\Transactions\AfterCommit; use App\Traits\Memoizes; use App\Traits\Validatable; @@ -145,7 +145,6 @@ class Beatmapset extends Model implements AfterCommit, Commentable, Indexable, T public $timestamps = false; - private StorageWithUrl $storage; protected $casts = self::CASTS; protected $primaryKey = 'beatmapset_id'; protected $table = 'osu_beatmapsets'; @@ -419,7 +418,7 @@ public function coverURL($coverSize = 'cover', $customTimestamp = null) { $timestamp = $customTimestamp ?? $this->defaultCoverTimestamp(); - return $this->storage()->url($this->coverPath()."{$coverSize}.jpg?{$timestamp}"); + return StorageUrl::make(null, $this->coverPath()."{$coverSize}.jpg?{$timestamp}"); } public function coverPath() @@ -431,7 +430,7 @@ public function coverPath() public function storeCover($target_filename, $source_path) { - $this->storage()->put($this->coverPath().$target_filename, file_get_contents($source_path)); + \Storage::put($this->coverPath().$target_filename, file_get_contents($source_path)); } public function downloadLimited() @@ -444,15 +443,10 @@ public function previewURL() return '//b.ppy.sh/preview/'.$this->beatmapset_id.'.mp3'; } - public function storage(): StorageWithUrl - { - return $this->storage ??= new StorageWithUrl(); - } - public function removeCovers() { try { - $this->storage()->deleteDirectory($this->coverPath()); + \Storage::deleteDirectory($this->coverPath()); } catch (\Exception $e) { // ignore errors } diff --git a/app/Models/Forum/ForumCover.php b/app/Models/Forum/ForumCover.php index ebdf04bdc8c..2653625845b 100644 --- a/app/Models/Forum/ForumCover.php +++ b/app/Models/Forum/ForumCover.php @@ -5,9 +5,9 @@ namespace App\Models\Forum; -use App\Libraries\ForumDefaultTopicCover; +use App\Casts\LegacyFileJson; +use App\Libraries\Uploader; use App\Models\User; -use App\Traits\Imageable; use DB; /** @@ -26,25 +26,7 @@ */ class ForumCover extends Model { - use Imageable; - - protected $table = 'forum_forum_covers'; - - protected $casts = [ - 'default_topic_cover_json' => 'array', - ]; - - private $_defaultTopicCover; - - public function getMaxDimensions() - { - return [2000, 400]; - } - - public function getFileRoot() - { - return 'forum-covers'; - } + const MAX_DIMENSIONS = [2000, 400]; public static function upload($filePath, $user, $forum = null) { @@ -54,13 +36,22 @@ public static function upload($filePath, $user, $forum = null) $cover->save(); // get id $cover->user()->associate($user); $cover->forum()->associate($forum); - $cover->storeFile($filePath); + $cover->file()->store($filePath); $cover->save(); }); return $cover; } + protected $casts = [ + 'default_topic_cover_json' => 'array', + 'file_json' => LegacyFileJson::class, + ]; + protected $table = 'forum_forum_covers'; + + private Uploader $defaultTopicCoverUploader; + private Uploader $file; + public function forum() { return $this->belongsTo(Forum::class, 'forum_id'); @@ -71,41 +62,58 @@ public function user() return $this->belongsTo(User::class, 'user_id'); } - public function updateFile($filePath, $user) + public function getDefaultTopicCoverAttribute(): Uploader { - $this->user()->associate($user); - $this->storeFile($filePath); - $this->save(); + return $this->defaultTopicCoverUploader ??= new Uploader( + 'forum-default-topic-covers', + $this, + 'default_topic_cover_json', + ['image' => ['maxDimensions' => TopicCover::MAX_DIMENSIONS]], + ); + } - return $this->fresh(); + public function setDefaultTopicCoverAttribute($value): void + { + if (($value['_delete'] ?? false) === true) { + $this->defaultTopicCover->delete(); + } elseif (($value['cover_file'] ?? null) !== null) { + $this->defaultTopicCover->store($value['cover_file']); + } } - public function getDefaultTopicCoverAttribute() + public function setMainCoverAttribute($value): void { - if ($this->_defaultTopicCover === null) { - $this->_defaultTopicCover = new ForumDefaultTopicCover($this->id, $this->default_topic_cover_json); + if ($value['_delete'] ?? false) { + $this->file()->delete(); + } elseif (isset($value['cover_file'])) { + $this->file()->store($value['cover_file']); } + } + + public function delete() + { + $this->file()->delete(); + $this->defaultTopicCover->delete(); - return $this->_defaultTopicCover; + return parent::delete(); } - public function setMainCoverAttribute($value) + public function file(): Uploader { - if (($value['_delete'] ?? false) === true) { - $this->deleteFile(); - } elseif (($value['cover_file'] ?? null) !== null) { - $this->storeFile($value['cover_file']); - } + return $this->file ??= new Uploader( + 'forum-covers', + $this, + 'main_cover_json', + ['image' => ['maxDimensions' => static::MAX_DIMENSIONS]], + ); } - public function setDefaultTopicCoverAttribute($value) + public function updateFile($filePath, $user) { - if (($value['_delete'] ?? false) === true) { - $this->defaultTopicCover->deleteFile(); - } elseif (($value['cover_file'] ?? null) !== null) { - $this->defaultTopicCover->storeFile($value['cover_file']); - } + $this->user()->associate($user); + $this->file()->store($filePath); + $this->save(); - $this->default_topic_cover_json = $this->defaultTopicCover->getFileProperties(); + return $this->fresh(); } } diff --git a/app/Models/Forum/TopicCover.php b/app/Models/Forum/TopicCover.php index 199a70ce311..38b4988dd06 100644 --- a/app/Models/Forum/TopicCover.php +++ b/app/Models/Forum/TopicCover.php @@ -5,8 +5,9 @@ namespace App\Models\Forum; +use App\Casts\LegacyFileJson; +use App\Libraries\Uploader; use App\Models\User; -use App\Traits\Imageable; use DB; use Exception; @@ -23,27 +24,19 @@ */ class TopicCover extends Model { - use Imageable; - const MAX_DIMENSIONS = [2400, 580]; // To be passed to transformer for generating url for initial cover upload public ?int $newForumId = null; + protected $casts = [ + 'file_json' => LegacyFileJson::class, + ]; protected $table = 'forum_topic_covers'; + private Uploader $file; private $_owner = [false, null]; - public function getMaxDimensions() - { - return static::MAX_DIMENSIONS; - } - - public function getFileRoot() - { - return 'topic-covers'; - } - public static function findForUse($id, $user) { if ($user === null) { @@ -67,7 +60,7 @@ public static function upload($filePath, $user, $topic = null) $cover->save(); // get id $cover->user()->associate($user); $cover->topic()->associate($topic); - $cover->storeFile($filePath); + $cover->file()->store($filePath); $cover->save(); }); @@ -104,7 +97,7 @@ public function owner() public function updateFile($filePath, $user) { $this->user()->associate($user); - $this->storeFile($filePath); + $this->file()->store($filePath); $this->save(); return $this->fresh(); @@ -113,12 +106,29 @@ public function updateFile($filePath, $user) public function defaultFileUrl() { try { - return $this->topic->forum->cover->defaultTopicCover->fileUrl(); + return $this->topic->forum->cover->defaultTopicCover->url(); } catch (Exception $_e) { // do nothing } } + public function delete() + { + $this->file()->delete(); + + return parent::delete(); + } + + public function file(): Uploader + { + return $this->file ??= new Uploader( + 'topic-covers', + $this, + 'file_json', + ['image' => ['maxDimensions' => static::MAX_DIMENSIONS]], + ); + } + public function getForumId(): ?int { return $this->topic?->forum_id ?? $this->newForumId; diff --git a/app/Models/Traits/UserAvatar.php b/app/Models/Traits/UserAvatar.php index 276a621d7bf..e6308b56207 100644 --- a/app/Models/Traits/UserAvatar.php +++ b/app/Models/Traits/UserAvatar.php @@ -6,33 +6,28 @@ namespace App\Models\Traits; use App\Libraries\ImageProcessor; -use App\Libraries\StorageWithUrl; +use App\Libraries\StorageUrl; use ErrorException; trait UserAvatar { - private StorageWithUrl $avatarStorage; - - public function avatarStorage(): StorageWithUrl + private static function avatarDisk(): string { - return $this->avatarStorage ??= new StorageWithUrl(config('osu.avatar.storage')); - } - - public function setUserAvatarAttribute($value) - { - $this->attributes['user_avatar'] = presence($value) ?? ''; + return config('osu.avatar.storage'); } public function setAvatar($file) { + $storage = \Storage::disk(static::avatarDisk()); + if ($file === null) { - $this->avatarStorage()->delete($this->user_id); + $storage->delete($this->user_id); } else { $filePath = $file->getRealPath(); $processor = new ImageProcessor($filePath, [256, 256], 100000); $processor->process(); - $this->avatarStorage()->put($this->user_id, file_get_contents($filePath), 'public'); + $storage->put($this->user_id, file_get_contents($filePath), 'public'); $entry = $this->user_id.'_'.time().'.'.$processor->ext(); } @@ -59,7 +54,7 @@ public function setAvatar($file) } } - return $this->update(['user_avatar' => $entry ?? null]); + return $this->update(['user_avatar' => $entry ?? '']); } protected function getUserAvatar() @@ -68,6 +63,6 @@ protected function getUserAvatar() return $value === null ? config('osu.avatar.default') - : $this->avatarStorage()->url(str_replace('_', '?', $value)); + : StorageUrl::make(static::avatarDisk(), strtr($value, '_', '?')); } } diff --git a/app/Models/UserContestEntry.php b/app/Models/UserContestEntry.php index 1a57a3e0a80..a2e38cfc7c9 100644 --- a/app/Models/UserContestEntry.php +++ b/app/Models/UserContestEntry.php @@ -5,7 +5,8 @@ namespace App\Models; -use App\Traits\Uploadable; +use App\Casts\LegacyFileJson; +use App\Libraries\Uploader; use DB; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Http\UploadedFile; @@ -27,14 +28,12 @@ class UserContestEntry extends Model { use SoftDeletes; - use Uploadable; - protected $casts = ['deleted_at' => 'datetime']; + protected $casts = [ + 'file_json' => LegacyFileJson::class, + ]; - public function getFileRoot() - { - return 'user-contest-entries'; - } + private Uploader $file; public static function upload(UploadedFile $file, $user, $contest = null) { @@ -47,7 +46,7 @@ public static function upload(UploadedFile $file, $user, $contest = null) $entry->original_filename = $file->getClientOriginalName(); $entry->user()->associate($user); $entry->contest()->associate($contest); - $entry->storeFile($file->getRealPath(), $file->getClientOriginalExtension()); + $entry->file()->store($file->getRealPath(), $file->getClientOriginalExtension()); $entry->save(); }); @@ -63,4 +62,16 @@ public function contest() { return $this->belongsTo(Contest::class); } + + public function delete() + { + $this->file()->delete(); + + return parent::delete(); + } + + public function file(): Uploader + { + return $this->file ??= new Uploader('user-contest-entries', $this, 'file_json'); + } } diff --git a/app/Models/UserProfileCustomization.php b/app/Models/UserProfileCustomization.php index cc4ab6305ca..6d817a3dda5 100644 --- a/app/Models/UserProfileCustomization.php +++ b/app/Models/UserProfileCustomization.php @@ -5,8 +5,10 @@ namespace App\Models; -use App\Libraries\ProfileCover; +use App\Libraries\Uploader; +use App\Libraries\User\CoverHelper; use Illuminate\Database\Eloquent\Casts\AsArrayObject; +use Illuminate\Http\UploadedFile; /** * @property array|null $cover_json @@ -49,7 +51,7 @@ class UserProfileCustomization extends Model ]; protected $primaryKey = 'user_id'; - private $cover; + private Uploader $cover; public static function repairExtrasOrder($value) { @@ -67,22 +69,6 @@ public static function repairExtrasOrder($value) ); } - public function cover() - { - if ($this->cover === null) { - $this->cover = new ProfileCover($this->user_id, $this->cover_json); - } - - return $this->cover; - } - - public function setCover($id, $file) - { - $this->cover_json = $this->cover()->set($id, $file); - - $this->save(); - } - public function getAudioAutoplayAttribute() { return $this->options['audio_autoplay'] ?? false; @@ -185,6 +171,22 @@ public function setCommentsSortAttribute($value) $this->setOption('comments_sort', $value); } + public function getCoverFileJsonAttribute(): array + { + return $this->cover_json['file'] ?? [ + 'ext' => null, + 'hash' => null, + ]; + } + + public function setCoverFileJsonAttribute($value): void + { + $this->cover_json = [ + ...($this->cover_json ?? []), + 'file' => $value, + ]; + } + public function getForumPostsShowDeletedAttribute() { return $this->options['forum_posts_show_deleted'] ?? true; @@ -268,6 +270,33 @@ public function setProfileCoverExpandedAttribute($value) $this->setOption('profile_cover_expanded', get_bool($value)); } + public function cover() + { + return $this->cover ??= new Uploader( + 'user-profile-covers', + $this, + 'cover_file_json', + ['image' => ['maxDimensions' => [2400, 640]]], + ); + } + + public function coverHelper() + { + return new CoverHelper($this); + } + + public function setCover(?string $presetId, ?UploadedFile $file) + { + $this->cover_json = [ + ...($this->cover_json ?? []), + 'id' => CoverHelper::isValidPresetId($presetId) ? $presetId : null, + ]; + + if ($file !== null) { + $this->cover()->store($file->getRealPath()); + } + } + private function setOption($key, $value) { $this->options ??= []; diff --git a/app/Traits/Imageable.php b/app/Traits/Imageable.php deleted file mode 100644 index abc55d64ab8..00000000000 --- a/app/Traits/Imageable.php +++ /dev/null @@ -1,28 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -namespace App\Traits; - -use App\Libraries\ImageProcessor; - -trait Imageable -{ - use Uploadable { - storeFile as _storeFile; // rename storeFile in the Uploadable trait so we can override it - } - - /** - * Returns maximum dimensions of the image as an array of [width, height]. - */ - abstract public function getMaxDimensions(); - - public function storeFile($filePath) - { - $image = new ImageProcessor($filePath, $this->getMaxDimensions(), $this->getMaxFileSize()); - $image->process(); - - $this->_storeFile($image->inputPath, $image->ext()); - } -} diff --git a/app/Traits/Uploadable.php b/app/Traits/Uploadable.php deleted file mode 100644 index 51592e98a70..00000000000 --- a/app/Traits/Uploadable.php +++ /dev/null @@ -1,134 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -namespace App\Traits; - -use App\Libraries\StorageWithUrl; -use Illuminate\Http\File; -use League\Flysystem\Local\LocalFilesystemAdapter; - -trait Uploadable -{ - private StorageWithUrl $storage; - - /** - * Returns maximum size of the file in bytes. Defaults to 1 MB. - */ - public function getMaxFileSize() - { - return 1000000; - } - - /** - * Returns root path of where the files are to be stored. - */ - abstract public function getFileRoot(); - - public function getFileId() - { - return $this->id; - } - - /** - * Returns a hash with contents of at least 'hash' and 'ext' if there's - * image or otherwise null. - * - * Assumes attributes 'hash' and 'ext' of the object by default. - */ - public function getFileProperties() - { - if (!present($this->hash) || !present($this->ext)) { - return; - } - - return [ - 'hash' => $this->hash, - 'ext' => $this->ext, - ]; - } - - /** - * Sets file properties. Either a hash of 'hash' and 'ext' or null. - * - * Assumes attributes 'hash' and 'ext' of the object by default. - */ - public function setFileProperties($props) - { - $this->hash = $props['hash'] ?? null; - $this->ext = $props['ext'] ?? null; - } - - public function storage(): StorageWithUrl - { - return $this->storage ??= new StorageWithUrl(); - } - - public function fileDir() - { - return $this->getFileRoot().'/'.$this->getFileId(); - } - - public function fileName() - { - return $this->getFileProperties()['hash'].'.'.$this->getFileProperties()['ext']; - } - - public function filePath() - { - return $this->fileDir().'/'.$this->fileName(); - } - - public function fileUrl() - { - if ($this->getFileProperties() === null) { - return; - } - - return $this->storage()->url($this->filePath()); - } - - public function deleteWithFile() - { - $this->deleteFile(); - - return $this->delete(); - } - - public function deleteFile() - { - if ($this->getFileProperties() === null) { - return; - } - - $this->setFileProperties(null); - - return $this->storage()->deleteDirectory($this->fileDir()); - } - - public function storeFile($filePath, $fileExtension = '') - { - $this->deleteFile(); - $this->setFileProperties([ - 'hash' => hash_file('sha256', $filePath), - 'ext' => $fileExtension, - ]); - - $storage = $this->storage(); - - if ($storage->getAdapter() instanceof LocalFilesystemAdapter) { - $options = [ - 'visibility' => 'public', - 'directory_visibility' => 'public', - ]; - } - - $storage->putFileAs( - $this->fileDir(), - new File($filePath), - $this->fileName(), - $options ?? [], - ); - } -} diff --git a/app/Transformers/Forum/ForumCoverTransformer.php b/app/Transformers/Forum/ForumCoverTransformer.php index 72c80e1b5c8..8f2f255c2bc 100644 --- a/app/Transformers/Forum/ForumCoverTransformer.php +++ b/app/Transformers/Forum/ForumCoverTransformer.php @@ -12,11 +12,9 @@ class ForumCoverTransformer extends TransformerAbstract { public function transform(ForumCover $cover = null) { - if ($cover === null) { - $cover = new ForumCover(); - } + $cover ??= new ForumCover(); - if ($cover->getFileProperties() === null) { + if ($cover->main_cover_json === null) { $data = [ 'method' => 'post', 'url' => route('forum.forum-covers.store', ['forum_id' => $cover->forum_id]), @@ -27,11 +25,11 @@ public function transform(ForumCover $cover = null) 'url' => route('forum.forum-covers.update', [$cover, 'forum_id' => $cover->forum_id]), 'id' => $cover->id, - 'fileUrl' => $cover->fileUrl(), + 'fileUrl' => $cover->file()->url(), ]; } - $data['dimensions'] = $cover->getMaxDimensions(); + $data['dimensions'] = $cover::MAX_DIMENSIONS; return $data; } diff --git a/app/Transformers/Forum/TopicCoverTransformer.php b/app/Transformers/Forum/TopicCoverTransformer.php index 31caf758226..b15615267a6 100644 --- a/app/Transformers/Forum/TopicCoverTransformer.php +++ b/app/Transformers/Forum/TopicCoverTransformer.php @@ -12,7 +12,7 @@ class TopicCoverTransformer extends TransformerAbstract { public function transform(TopicCover $cover) { - if ($cover->getFileProperties() === null) { + if ($cover->file_json === null) { $data = [ 'method' => 'post', 'url' => route('forum.topic-covers.store', [ @@ -25,12 +25,12 @@ public function transform(TopicCover $cover) 'method' => 'put', 'url' => route('forum.topic-covers.update', [$cover, 'topic_id' => $cover->topic_id]), - 'id' => $cover->id, - 'fileUrl' => $cover->fileUrl(), + 'id' => $cover->getKey(), + 'fileUrl' => $cover->file()->url(), ]; } - $data['dimensions'] = $cover->getMaxDimensions(); + $data['dimensions'] = $cover::MAX_DIMENSIONS; $data['defaultFileUrl'] = $cover->defaultFileUrl(); return $data; diff --git a/app/Transformers/UserCompactTransformer.php b/app/Transformers/UserCompactTransformer.php index 9532222aebe..ab6c52d1339 100644 --- a/app/Transformers/UserCompactTransformer.php +++ b/app/Transformers/UserCompactTransformer.php @@ -199,12 +199,12 @@ public function includeCountry(User $user) public function includeCover(User $user) { - $profileCustomization = $this->userProfileCustomization($user); + $coverHelper = $this->userProfileCustomization($user)->coverHelper(); return $this->primitive([ - 'custom_url' => $profileCustomization->cover()->fileUrl(), - 'url' => $profileCustomization->cover()->url(), - 'id' => $profileCustomization->cover()->id(), + 'custom_url' => $coverHelper->customUrl(), + 'url' => $coverHelper->url(), + 'id' => $coverHelper->presetId(), ]); } diff --git a/app/Transformers/UserContestEntryTransformer.php b/app/Transformers/UserContestEntryTransformer.php index 309e002411e..b602b17b6b6 100644 --- a/app/Transformers/UserContestEntryTransformer.php +++ b/app/Transformers/UserContestEntryTransformer.php @@ -16,12 +16,14 @@ class UserContestEntryTransformer extends TransformerAbstract public function transform(UserContestEntry $entry) { + $url = $entry->file()->url(); + return [ 'id' => $entry->id, 'filename' => $entry->original_filename, 'filesize' => $entry->filesize, - 'url' => $entry->fileUrl(), - 'thumb' => mini_asset($entry->fileUrl()), + 'url' => $url, + 'thumb' => mini_asset($url), 'created_at' => json_time($entry->created_at), 'deleted' => $entry->deleted_at !== null, ];