From 77a58bbe4a1a7b5acf4cd6d8211acf85714eff30 Mon Sep 17 00:00:00 2001 From: SANYE-YA <122528746+SANYE-CH@users.noreply.github.com> Date: Thu, 7 Aug 2025 05:17:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=94=B9Imagick=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把Imagick获取到的GD数据转为二进制交给Imagick处理 --- app/Http/Controllers/SkinlibController.php | 565 ++++++--------------- 1 file changed, 148 insertions(+), 417 deletions(-) diff --git a/app/Http/Controllers/SkinlibController.php b/app/Http/Controllers/SkinlibController.php index 268c61d6..41737791 100644 --- a/app/Http/Controllers/SkinlibController.php +++ b/app/Http/Controllers/SkinlibController.php @@ -2,460 +2,191 @@ namespace App\Http\Controllers; +use App\Models\Player; use App\Models\Texture; use App\Models\User; -use Blessing\Filter; -use Blessing\Rejection; -use Illuminate\Contracts\Events\Dispatcher; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Filesystem\FilesystemAdapter; +use Blessing\Minecraft; +use Carbon\Carbon; use Illuminate\Http\Request; -use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; -use Illuminate\Validation\Rule; use Intervention\Image\Facades\Image; -use League\CommonMark\GithubFlavoredMarkdownConverter; -class SkinlibController extends Controller +class TextureController extends Controller { public function __construct() { - $this->middleware(function (Request $request, $next) { - /** @var User */ - $user = $request->user(); - /** @var Texture */ - $texture = $request->route('texture'); + $this->middleware('cache.headers:public;max_age='.option('cache_expire_time')) + ->only(['json']); - if ($texture->uploader != $user->uid && !$user->isAdmin()) { - return json(trans('skinlib.no-permission'), 1) - ->setStatusCode(403); - } - - return $next($request); - })->only(['rename', 'privacy', 'type', 'delete']); - - $this->middleware(function (Request $request, $next) { - /** @var User */ - $user = $request->user(); - /** @var Texture */ - $texture = $request->route('texture'); - - if (!$texture->public) { - if (!Auth::check() || ($user->uid != $texture->uploader && !$user->isAdmin())) { - $statusCode = (int) option('status_code_for_private'); - if ($statusCode === 404) { - abort($statusCode, trans('skinlib.show.deleted')); - } else { - abort(403, trans('skinlib.show.private')); - } - } - } - - return $next($request); - })->only(['show', 'info']); + $this->middleware('cache.headers:etag;public;max_age='.option('cache_expire_time')) + ->only([ + 'preview', + 'raw', + 'texture', + 'avatarByPlayer', + 'avatarByUser', + 'avatarByTexture', + ]); } - public function library(Request $request) + public function json($player) { - $user = Auth::user(); + $player = Player::where('name', $player)->firstOrFail(); + $isBanned = $player->user->permission === User::BANNED; + abort_if($isBanned, 403, trans('general.player-banned')); - // Available filters: skin, steve, alex, cape - $type = $request->input('filter', 'skin'); - $uploader = $request->input('uploader'); - $keyword = $request->input('keyword'); - $sort = $request->input('sort', 'time'); - $sortBy = $sort == 'time' ? 'upload_at' : $sort; - - return Texture::orderBy($sortBy, 'desc') - ->when( - $type === 'skin', - fn (Builder $query) => $query->whereIn('type', ['steve', 'alex']), - fn (Builder $query) => $query->where('type', $type), - ) - ->when($keyword, fn (Builder $query, $keyword) => $query->like('name', $keyword)) - ->when($uploader, fn (Builder $query, $uploader) => $query->where('uploader', $uploader)) - ->when($user, function (Builder $query, User $user) { - if (!$user->isAdmin()) { - // use closure-style `where` clause to lift up SQL priority - return $query->where(function (Builder $query) use ($user) { - $query - ->where('public', true) - ->orWhere('uploader', $user->uid); - }); - } - }, function (Builder $query) { - // show public textures only to anonymous visitors - return $query->where('public', true); - }) - ->join('users', 'uid', 'uploader') - ->select(['tid', 'name', 'type', 'uploader', 'public', 'likes', 'nickname']) - ->paginate(20); + return response()->json($player)->setLastModified($player->last_modified); } - public function show(Filter $filter, Texture $texture) + public function previewByHash(Minecraft $minecraft, Request $request, $hash) + { + $texture = Texture::where('hash', $hash)->firstOrFail(); + + return $this->preview($minecraft, $request, $texture); + } + + public function preview(Minecraft $minecraft, Request $request, Texture $texture) +{ + $tid = $texture->tid; + $hash = $texture->hash; + $usePNG = $request->has('png') || !(imagetypes() & IMG_WEBP); + $format = $usePNG ? 'png' : 'webp'; + + $disk = Storage::disk('textures'); + abort_if($disk->missing($hash), 404); + + $height = (int) $request->query('height', 200); + $now = Carbon::now(); + $response = Cache::remember( + 'preview-t'.$tid."-$format", + option('enable_preview_cache') ? $now->addYear() : $now->addMinute(), + function () use ($minecraft, $disk, $texture, $hash, $height, $usePNG) { + $file = $disk->get($hash); + if ($texture->type === 'cape') { + $gdResource = $minecraft->renderCape($file, $height); + } else { + $gdResource = $minecraft->renderSkin($file, 12, $texture->type === 'alex'); + } + + ob_start(); + imagepng($gdResource); + $imageBlob = ob_get_clean(); + imagedestroy($gdResource); + $imagick = new \Imagick(); + $imagick->readImageBlob($imageBlob); + + + if (!$usePNG) { + $imagick->setImageFormat('webp'); + } + + + $response = response($imagick->getImageBlob(), 200, [ + 'Content-Type' => $usePNG ? 'image/png' : 'image/webp', + 'Content-Length' => strlen($imagick->getImageBlob()), + ]); + $response->setLastModified(Carbon::createFromTimestamp($disk->lastModified($hash))); + return $response; + } + ); + + return $response; +} + + public function raw($tid) + { + abort_unless(option('allow_downloading_texture'), 403); + + $texture = Texture::findOrFail($tid); + + return $this->texture($texture->hash); + } + + public function texture(string $hash) { - /** @var User */ - $user = Auth::user(); - /** @var FilesystemAdapter */ $disk = Storage::disk('textures'); + abort_if($disk->missing($hash), 404); - if ($disk->missing($texture->hash)) { - if (option('auto_del_invalid_texture')) { - $texture->delete(); - } - abort(404, trans('skinlib.show.deleted')); - } + $lastModified = Carbon::createFromTimestamp($disk->lastModified($hash)); - $badges = []; - $uploader = $texture->owner; - if ($uploader) { - if ($uploader->isAdmin()) { - $badges[] = ['text' => 'STAFF', 'color' => 'primary']; - } - - $badges = $filter->apply('user_badges', $badges, [$uploader]); - } - - $grid = [ - 'layout' => [ - ['md-8', 'md-4'], - ], - 'widgets' => [ - [ - ['shared.previewer'], - ['skinlib.widgets.show.side'], - ], - ], - ]; - $grid = $filter->apply('grid:skinlib.show', $grid); - - return view('skinlib.show') - ->with('texture', $texture) - ->with('grid', $grid) - ->with('extra', [ - 'download' => (bool) option('allow_downloading_texture'), - 'currentUid' => $user ? $user->uid : 0, - 'admin' => $user && $user->isAdmin(), - 'inCloset' => $user && $user->closet()->where('tid', $texture->tid)->count() > 0, - 'uploaderExists' => (bool) $uploader, - 'nickname' => optional($uploader)->nickname ?? trans('general.unexistent-user'), - 'report' => intval(option('reporter_score_modification', 0)), - 'badges' => $badges, - ]); + return response($disk->get($hash)) + ->withHeaders([ + 'Content-Type' => 'image/png', + 'Content-Length' => $disk->size($hash), + ]) + ->setLastModified($lastModified); } - public function info(Texture $texture) + public function avatarByPlayer(Minecraft $minecraft, Request $request, $name) { - return $texture; + $player = Player::where('name', $name)->firstOrFail(); + + return $this->avatar($minecraft, $request, $player->skin); } - public function upload(Filter $filter) + public function avatarByUser(Minecraft $minecraft, Request $request, $uid) { - $grid = [ - 'layout' => [ - ['md-6', 'md-6'], - ], - 'widgets' => [ - [ - ['skinlib.widgets.upload.input'], - ['shared.previewer'], - ], - ], - ]; - $grid = $filter->apply('grid:skinlib.upload', $grid); + $texture = Texture::find(optional(User::find($uid))->avatar); - $converter = new GithubFlavoredMarkdownConverter(); - - return view('skinlib.upload') - ->with('grid', $grid) - ->with('extra', [ - 'rule' => ($regexp = option('texture_name_regexp')) - ? trans('skinlib.upload.name-rule-regexp', compact('regexp')) - : trans('skinlib.upload.name-rule'), - 'privacyNotice' => trans( - 'skinlib.upload.private-score-notice', - ['score' => option('private_score_per_storage')] - ), - 'score' => (int) auth()->user()->score, - 'scorePublic' => (int) option('score_per_storage'), - 'scorePrivate' => (int) option('private_score_per_storage'), - 'closetItemCost' => (int) option('score_per_closet_item'), - 'award' => (int) option('score_award_per_texture'), - 'contentPolicy' => $converter->convertToHtml(option_localized('content_policy'))->getContent(), - ]); + return $this->avatar($minecraft, $request, $texture); } - public function handleUpload( - Request $request, - Filter $filter, - Dispatcher $dispatcher, - ) { - $file = $request->file('file'); - if ($file && !$file->isValid()) { - Log::error($file->getErrorMessage()); + public function avatarByHash(Minecraft $minecraft, Request $request, $hash) + { + $texture = Texture::where('hash', $hash)->first(); + + return $this->avatar($minecraft, $request, $texture); + } + + public function avatarByTexture(Minecraft $minecraft, Request $request, $tid) + { + $texture = Texture::find($tid); + + return $this->avatar($minecraft, $request, $texture); + } + + protected function avatar(Minecraft $minecraft, Request $request, ?Texture $texture) + { + if (!empty($texture) && $texture->type !== 'steve' && $texture->type !== 'alex') { + return abort(422); } - $data = $request->validate([ - 'name' => [ - 'required', - option('texture_name_regexp') ? 'regex:'.option('texture_name_regexp') : 'string', - ], - 'file' => 'required|mimes:png|max:'.option('max_upload_file_size'), - 'type' => ['required', Rule::in(['steve', 'alex', 'cape'])], - 'public' => 'required|boolean', - ]); + $size = (int) $request->query('size', 100); + $mode = $request->has('3d') ? '3d' : '2d'; + $usePNG = $request->has('png') || !(imagetypes() & IMG_WEBP); + $format = $usePNG ? 'png' : 'webp'; - /** @var UploadedFile */ - $file = $filter->apply('uploaded_texture_file', $file); + $disk = Storage::disk('textures'); + if (is_null($texture) || $disk->missing($texture->hash)) { + // TODO: refactor + return Image::configure(['driver' => 'imagick'])->make(resource_path("misc/textures/avatar$mode.png")) + ->resize($size, $size) + ->response($usePNG ? 'png' : 'webp', 100); + } - $name = $data['name']; - $name = $filter->apply('uploaded_texture_name', $name, [$file]); + $hash = $texture->hash; + $now = Carbon::now(); + $response = Cache::remember( + 'avatar-'.$mode.'-t'.$texture->tid.'-s'.$size."-$format", + option('enable_avatar_cache') ? $now->addYear() : $now->addMinute(), + function () use ($minecraft, $disk, $hash, $size, $mode, $usePNG) { + $file = $disk->get($hash); + if ($mode === '3d') { + $image = $minecraft->render3dAvatar($file, 25); + } else { + $image = $minecraft->render2dAvatar($file, 25); + } - $can = $filter->apply('can_upload_texture', true, [$file, $name]); - if ($can instanceof Rejection) { - return json($can->getReason(), 1); - } + $lastModified = Carbon::createFromTimestamp($disk->lastModified($hash)); - $type = $data['type']; - $size = getimagesize($file); - - $maxWidth = option('max_texture_width', 8192); - if ($size[0] > $maxWidth) { - $message = trans('skinlib.upload.too-wide', [ - 'width' => $size[0], - 'maxWidth' => $maxWidth, - ]); - - return json($message, 1); - } - - if ($size[0] % 64 != 0 || $size[1] % 32 != 0) { - $message = trans('skinlib.upload.invalid-size', [ - 'type' => $type === 'cape' ? trans('general.cape') : trans('general.skin'), - 'width' => $size[0], - 'height' => $size[1], - ]); - - return json($message, 1); - } - - $ratio = $size[0] / $size[1]; - if ($type == 'steve' || $type == 'alex') { - if ($ratio != 2 && $ratio != 1 || $type === 'alex' && $ratio === 2) { - $message = trans('skinlib.upload.invalid-size', [ - 'type' => trans('general.skin'), - 'width' => $size[0], - 'height' => $size[1], - ]); - - return json($message, 1); + return \Intervention\Image\ImageManagerStatic::configure(['driver' => 'gd'])->make($image) + ->resize($size, $size) + ->response($usePNG ? 'png' : 'webp', 100) + ->setLastModified($lastModified); } - } elseif ($type == 'cape') { - if ($ratio != 2) { - $message = trans('skinlib.upload.invalid-size', [ - 'type' => trans('general.cape'), - 'width' => $size[0], - 'height' => $size[1], - ]); - - return json($message, 1); - } - } - - $image = Image::make($file); - $imagick = $image->getCore(); - $imagick->setOption('png:compression-filter', '0'); - $imagick->setOption('png:compression-level', '9'); - $imagick->setOption('png:compression-strategy', '0'); - $imagick->setOption('png:exclude-chunk', 'all'); - $imagick->stripImage(); - $sanitized = $image->encode('png')->getEncoded(); - - $hash = hash('sha256', $image->encoded); - $hash = $filter->apply('uploaded_texture_hash', $hash, [$image]); - - /** @var User */ - $user = Auth::user(); - - $duplicated = Texture::where('hash', $hash) - ->where( - fn (Builder $query) => $query->where('public', true)->orWhere('uploader', $user->uid) - ) - ->first(); - if ($duplicated) { - // if the texture already uploaded was set to private, - // then allow to re-upload it. - return json(trans('skinlib.upload.repeated'), 2, ['tid' => $duplicated->tid]); - } - - $fileSize = ceil(strlen($sanitized) / 1024); - $isPublic = is_string($data['public']) - ? $data['public'] === '1' - : $data['public']; - $cost = $fileSize * ( - $isPublic - ? option('score_per_storage') - : option('private_score_per_storage') ); - $cost += option('score_per_closet_item'); - $cost -= option('score_award_per_texture', 0); - if ($user->score < $cost) { - return json(trans('skinlib.upload.lack-score'), 1); - } - $dispatcher->dispatch('texture.uploading', [$image, $name, $hash]); - - $texture = new Texture(); - $texture->name = $name; - $texture->type = $type; - $texture->hash = $hash; - $texture->size = $fileSize; - $texture->public = $isPublic; - $texture->uploader = $user->uid; - $texture->likes = 1; - $texture->save(); - - /** @var FilesystemAdapter */ - $disk = Storage::disk('textures'); - if ($disk->missing($hash)) { - $disk->put($hash, $sanitized); - } - - $user->score -= $cost; - $user->closet()->attach($texture->tid, ['item_name' => $name]); - $user->save(); - - $dispatcher->dispatch('texture.uploaded', [$texture, $image]); - - return json(trans('skinlib.upload.success', ['name' => $name]), 0, [ - 'tid' => $texture->tid, - ]); - } - - public function delete(Texture $texture, Dispatcher $dispatcher, Filter $filter) - { - $can = $filter->apply('can_delete_texture', true, [$texture]); - if ($can instanceof Rejection) { - return json($can->getReason(), 1); - } - - $dispatcher->dispatch('texture.deleting', [$texture]); - - // check if file occupied - if (Texture::where('hash', $texture->hash)->count() === 1) { - Storage::disk('textures')->delete($texture->hash); - } - - $texture->delete(); - - $dispatcher->dispatch('texture.deleted', [$texture]); - - return json(trans('skinlib.delete.success'), 0); - } - - public function privacy(Texture $texture, Dispatcher $dispatcher, Filter $filter) - { - $can = $filter->apply('can_update_texture_privacy', true, [$texture]); - if ($can instanceof Rejection) { - return json($can->getReason(), 1); - } - - $uploader = $texture->owner; - $score_diff = $texture->size - * (option('private_score_per_storage') - option('score_per_storage')) - * ($texture->public ? -1 : 1); - if ($texture->public && option('take_back_scores_after_deletion', true)) { - $score_diff -= option('score_award_per_texture', 0); - } - if ($uploader->score + $score_diff < 0) { - return json(trans('skinlib.upload.lack-score'), 1); - } - - if (!$texture->public) { - $duplicated = Texture::where('hash', $texture->hash) - ->where('public', true) - ->first(); - if ($duplicated) { - return json(trans('skinlib.upload.repeated'), 2, ['tid' => $duplicated->tid]); - } - } - - $dispatcher->dispatch('texture.privacy.updating', [$texture]); - - $uploader->score += $score_diff; - $uploader->save(); - - $texture->public = !$texture->public; - $texture->save(); - - $dispatcher->dispatch('texture.privacy.updated', [$texture]); - - $message = trans('skinlib.privacy.success', [ - 'privacy' => ( - $texture->public - ? trans('general.public') - : trans('general.private')), - ]); - - return json($message, 0); - } - - public function rename( - Request $request, - Dispatcher $dispatcher, - Filter $filter, - Texture $texture, - ) { - $data = $request->validate(['name' => [ - 'required', - option('texture_name_regexp') - ? 'regex:'.option('texture_name_regexp') - : 'string', - ]]); - $name = $data['name']; - - $can = $filter->apply('can_update_texture_name', true, [$texture, $name]); - if ($can instanceof Rejection) { - return json($can->getReason(), 1); - } - - $dispatcher->dispatch('texture.name.updating', [$texture, $name]); - - $old = $texture->replicate(); - $texture->name = $name; - $texture->save(); - - $dispatcher->dispatch('texture.name.updated', [$texture, $old]); - - return json(trans('skinlib.rename.success', ['name' => $name]), 0); - } - - public function type( - Request $request, - Dispatcher $dispatcher, - Filter $filter, - Texture $texture, - ) { - $data = $request->validate([ - 'type' => ['required', Rule::in(['steve', 'alex', 'cape'])], - ]); - $type = $data['type']; - - $can = $filter->apply('can_update_texture_type', true, [$texture, $type]); - if ($can instanceof Rejection) { - return json($can->getReason(), 1); - } - - $dispatcher->dispatch('texture.type.updating', [$texture, $type]); - - $old = $texture->replicate(); - $texture->type = $type; - $texture->save(); - - $dispatcher->dispatch('texture.type.updated', [$texture, $old]); - - return json(trans('skinlib.model.success', ['model' => $type]), 0); + return $response; } }