mince/src/SkinsRoutes.php

426 lines
15 KiB
PHP

<?php
namespace Mince;
use Imagick;
use ImagickException;
use ImagickPixel;
use InvalidArgumentException;
use RuntimeException;
use Index\XString;
use Index\Http\Routing\{RouteHandler,HttpGet,HttpMiddleware,HttpPost};
use Index\Security\CSRFP;
use Ramsey\Uuid\Uuid;
use Sasae\SasaeEnvironment;
class SkinsRoutes extends RouteHandler {
private const TEXTURES_DIR = '/textures';
private const TEXTURES_PATH = MCR_DIR_PUB . self::TEXTURES_DIR;
private AccountLinkInfo $linkInfo;
public function __construct(
private SasaeEnvironment $templating,
private AccountLinks $accountLinks,
private Skins $skins,
private Capes $capes,
private CSRFP $csrfp,
private object $authInfo,
private string $baseUrl
) {
if(!is_dir(self::TEXTURES_PATH))
throw new RuntimeException('Textures directory does not exist.');
if(!is_writable(self::TEXTURES_PATH))
throw new RuntimeException('Textures directory is not writable.');
}
public function checkHash(string $hash): bool {
return $this->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);
}
}