skins->checkHash($hash) || $this->capes->checkHash($hash); } public function getLocalPath(string $hash): string { return self::TEXTURES_PATH . '/' . $hash . '.png'; } public function getRemotePath(string $hash, bool $includeDomain): string { return ($includeDomain ? $this->baseUrl : '') . self::TEXTURES_DIR . '/' . $hash . '.png'; } public function deleteLocalFileMaybe(string $hash): void { $path = $this->getLocalPath($hash); if(is_file($path) && !$this->checkHash($hash)) unlink($path); } #[HttpMiddleware('/skins')] public function verifyRequest($response, $request) { if(!$this->authInfo->success) return 403; try { $this->linkInfo = $this->accountLinks->getLink(userInfo: $this->authInfo->user_id); } catch(RuntimeException $ex) { $response->redirect('/clients'); return true; } if($request->getMethod() === 'POST') { if(!$request->isFormContent()) return 400; $body = $request->getContent(); if(!$body->hasParam('csrfp') || !$this->csrfp->verifyToken((string)$body->getParam('csrfp'))) return 403; } } private const SKINS_ERRORS = [ 'skin' => [ 'model' => 'Invalid model selected.', 'size' => 'Skins may not be larger than 512KiB', 'format' => 'Uploaded file was not an acceptable image.', ], 'cape' => [ 'size' => 'Capes may not be larger than 256KiB', 'format' => 'Uploaded file was not an acceptable image.', ], ]; #[HttpGet('/skins')] public function getSkins($response, $request) { $skinInfo = $this->skins->getSkin($this->linkInfo); $skinPath = $skinInfo === null ? null : $this->getRemotePath($skinInfo->getHash(), false); $capeInfo = $this->capes->getCape($this->linkInfo); $capePath = $capeInfo === null ? null : $this->getRemotePath($capeInfo->getHash(), false); $template = $this->templating->load('skins/index', [ 'skin' => $skinInfo, 'skin_path' => $skinPath, 'cape' => $capeInfo, 'cape_path' => $capePath, 'link_info' => $this->linkInfo, ]); $errorCode = (string)$request->getParam('error'); if($errorCode !== '') { $errorCode = explode(':', $errorCode, 2); if(count($errorCode) === 2 && array_key_exists($errorCode[0], self::SKINS_ERRORS) && array_key_exists($errorCode[1], self::SKINS_ERRORS[$errorCode[0]])) $template->setVars([ 'error' => [ 'section' => $errorCode[0], 'code' => $errorCode[1], 'message' => self::SKINS_ERRORS[$errorCode[0]][$errorCode[1]], ], ]); } return $template; } #[HttpPost('/skins/upload-skin')] public function postUploadSkin($response, $request) { $body = $request->getContent(); if(!$body->hasUploadedFile('texture')) return 400; $texture = $body->getUploadedFile('texture'); $model = (string)$body->getParam('model'); if(!in_array($model, Skins::MODELS)) { $response->redirect('/skins?error=skin:model'); return; } if($texture->getSize() > 512000) { $response->redirect('/skins?error=skin:size'); return; } $skinInfo = $this->skins->getSkin($this->linkInfo); $tmpPath = $texture->getLocalFileName(); $hasNewFile = is_file($tmpPath); if($hasNewFile) { try { $imagick = new Imagick($tmpPath); $imagick->setImageFormat('png'); $imagick->setBackgroundColor(new ImagickPixel('transparent')); $imagick->setImageExtent(64, $imagick->getImageHeight() < 64 ? 32 : 64); $imagick->stripImage(); $imagick->writeImage(); $imagick->destroy(); } catch(ImagickException $ex) { $response->redirect('/skins?error=skin:format'); return; } $hash = hash_file('sha256', $tmpPath); $localPath = $this->getLocalPath($hash); } else { $hash = $skinInfo->getHash(); } try { try { // apply new skin if($hasNewFile && !is_file($localPath)) $texture->moveTo($localPath); $this->skins->updateSkin($this->linkInfo, $hash, $model); } finally { // see about deleting the old one if($skinInfo !== null) $this->deleteLocalFileMaybe($skinInfo->getHash()); } } finally { // try to delete new one if something went awry if($hasNewFile) $this->deleteLocalFileMaybe($hash); } $response->redirect('/skins'); } #[HttpPost('/skins/delete-skin')] public function postDeleteSkin($response) { $skinInfo = $this->skins->getSkin($this->linkInfo); if($skinInfo !== null) { $this->skins->deleteSkin(userInfo: $this->linkInfo); $this->deleteLocalFileMaybe($skinInfo->getHash()); } $response->redirect('/skins'); } #[HttpPost('/skins/upload-cape')] public function postUploadCape($response, $request) { $body = $request->getContent(); if(!$body->hasUploadedFile('texture')) return 400; $texture = $body->getUploadedFile('texture'); if($texture->getSize() > 256000) { $response->redirect('/skins?error=cape:size'); return; } $tmpPath = $texture->getLocalFileName(); try { $imagick = new Imagick($tmpPath); $imagick->setImageFormat('png'); $imagick->setBackgroundColor(new ImagickPixel('transparent')); $imagick->setImageExtent(64, 32); $imagick->stripImage(); $imagick->writeImage(); $imagick->destroy(); } catch(ImagickException $ex) { $response->redirect('/skins?error=cape:format'); return; } $hash = hash_file('sha256', $tmpPath); $localPath = $this->getLocalPath($hash); try { // get previous cape $capeInfo = $this->capes->getCape($this->linkInfo); try { // apply new cape if(!is_file($localPath)) $texture->moveTo($localPath); $this->capes->updateCape($this->linkInfo, $hash); } finally { // see about deleting the old one if($capeInfo !== null) $this->deleteLocalFileMaybe($capeInfo->getHash()); } } finally { // try to delete new one if something went awry $this->deleteLocalFileMaybe($hash); } $response->redirect('/skins'); } #[HttpPost('/skins/delete-cape')] public function postDeleteCape($response) { $capeInfo = $this->capes->getCape($this->linkInfo); if($capeInfo !== null) { $this->capes->deleteCape(userInfo: $this->linkInfo); $this->deleteLocalFileMaybe($capeInfo->getHash()); } $response->redirect('/skins'); } #[HttpPost('/skins/import')] public function postImport($response, $request) { $body = $request->getContent(); $userAgent = $request->getHeaderLine('User-Agent'); $userName = (string)$body->getParam('username'); if($userName === '') $userName = $this->linkInfo->getName(); $resolveUUID = MojangInterop::getMinecraftUUID($userName, $userAgent); if($resolveUUID !== null) { $profileInfo = MojangInterop::getSessionMinecraftProfile($resolveUUID->id, $userAgent); if(isset($profileInfo->properties)) foreach($profileInfo->properties as $prop) { if($prop->name === 'textures') { $textureInfo = json_decode(base64_decode($prop->value)); if(!isset($textureInfo->textures)) break; if(isset($textureInfo->textures->SKIN)) { $url = $textureInfo->textures->SKIN->url; $model = 'classic'; if(isset($textureInfo->textures->SKIN->metadata) && isset($textureInfo->textures->SKIN->metadata->model)) $model = $textureInfo->textures->SKIN->metadata->model; $hash = null; $tmpFile = sys_get_temp_dir() . '/mc-import-skin-' . XString::random(8) . '.png'; try { file_put_contents($tmpFile, file_get_contents($url)); $hash = hash_file('sha256', $tmpFile); $localPath = $this->getLocalPath($hash); rename($tmpFile, $localPath); $this->skins->updateSkin($this->linkInfo, $hash, $model); } finally { if(is_file($tmpFile)) unlink($tmpFile); if($hash !== null) $this->deleteLocalFileMaybe($hash); } } if(isset($textureInfo->textures->CAPE)) { $url = $textureInfo->textures->CAPE->url; $hash = null; $tmpFile = sys_get_temp_dir() . '/mc-import-cape-' . XString::random(8) . '.png'; try { file_put_contents($tmpFile, file_get_contents($url)); $hash = hash_file('sha256', $tmpFile); $localPath = $this->getLocalPath($hash); rename($tmpFile, $localPath); $this->capes->updateCape($this->linkInfo, $hash); } finally { if(is_file($tmpFile)) unlink($tmpFile); if($hash !== null) $this->deleteLocalFileMaybe($hash); } } break; } } } $response->redirect('/skins'); } #[HttpGet('/session/minecraft/profile/([a-fA-F0-9\-]+)')] public function getSessionMinecraftProfile($response, $request, string $id) { try { $uuid = Uuid::fromString($id); } catch(InvalidArgumentException $ex) { $response->setStatusCode(400); return [ 'path' => sprintf('/session/minecraft/profile/%s', $id), 'errorMessage' => sprintf('Not a valid UUID: %s', $id), ]; } if(MojangInterop::isMojangId($uuid)) return MojangInterop::proxySessionMinecraftProfile($response, $request, $id); $response->setCacheControl('max-age=30'); if(!MojangInterop::isOfflineId($uuid)) return 204; try { $linkInfo = $this->accountLinks->getLink(uuid: $uuid); } catch(RuntimeException $ex) { return 204; } $textures = []; $skinInfo = $this->skins->getSkin($linkInfo); if($skinInfo !== null) { $texture = ['url' => $this->getRemotePath($skinInfo->getHash(), true)]; if(!$skinInfo->isClassic()) $texture['metadata'] = ['model' => $skinInfo->getModel()]; $textures['SKIN'] = $texture; } $capeInfo = $this->capes->getCape($linkInfo); if($capeInfo !== null) $textures['CAPE'] = ['url' => $this->getRemotePath($capeInfo->getHash(), true)]; $profileId = (string)$uuid->getHex(); $profileName = $linkInfo->getName(); return [ 'id' => $profileId, 'name' => $profileName, 'profileActions' => [], 'properties' => [ [ 'name' => 'textures', 'value' => base64_encode(json_encode([ 'timestamp' => MojangInterop::currentTime(), 'profileId' => $profileId, 'profileName' => $profileName, 'textures' => $textures, ], JSON_UNESCAPED_SLASHES)), ], ], ]; } #[HttpGet('/users/profiles/minecraft/([A-Za-z0-9_]+)')] public function getUsersMinecraftProfile($response, $request, string $name) { try { $linkInfo = $this->accountLinks->getLink(name: $name); } catch(RuntimeException $ex) { $response->setStatusCode(404); return [ 'path' => sprintf('/users/profiles/minecraft/%s', $name), 'errorMessage' => 'Couldn\'t find any profile with that name', ]; } return [ 'id' => (string)$linkInfo->getUUID()->getHex(), 'name' => $linkInfo->getName(), ]; } // quirky path and two of them to achieve equal string length with http://s3.amazonaws.com/MinecraftSkins/ for flashii.net and edgii.net #[HttpGet('/s3MinecraftSkins/([A-Za-z0-9_]+).png')] #[HttpGet('/s3s3MinecraftSkins/([A-Za-z0-9_]+).png')] public function getS3MinecraftSkin($response, $request, string $name) { try { $linkInfo = $this->accountLinks->getLink(name: $name); } catch(RuntimeException $ex) { return 404; } $skinInfo = $this->skins->getSkin($linkInfo); if($skinInfo === null) return 404; $response->accelRedirect($this->getRemotePath($skinInfo->getHash(), false)); $response->setContentType('image/png'); $response->setFileName("{$name}.png", false); } }