2023-08-22 23:47:37 +00:00
|
|
|
<?php
|
|
|
|
namespace Mince;
|
|
|
|
|
|
|
|
use Imagick;
|
|
|
|
use ImagickException;
|
|
|
|
use ImagickPixel;
|
|
|
|
use InvalidArgumentException;
|
|
|
|
use RuntimeException;
|
|
|
|
use Index\XString;
|
2024-03-28 23:28:19 +00:00
|
|
|
use Index\Http\Routing\{RouteHandler,HttpGet,HttpMiddleware,HttpPost};
|
2023-08-22 23:47:37 +00:00
|
|
|
use Index\Security\CSRFP;
|
2024-07-12 15:31:06 +00:00
|
|
|
use Ramsey\Uuid\{Uuid,UuidInterface};
|
2023-08-24 23:13:29 +00:00
|
|
|
use Sasae\SasaeEnvironment;
|
2023-08-22 23:47:37 +00:00
|
|
|
|
2024-02-21 16:08:45 +00:00
|
|
|
class SkinsRoutes extends RouteHandler {
|
2023-08-22 23:47:37 +00:00
|
|
|
private const TEXTURES_DIR = '/textures';
|
|
|
|
private const TEXTURES_PATH = MCR_DIR_PUB . self::TEXTURES_DIR;
|
|
|
|
|
|
|
|
private AccountLinkInfo $linkInfo;
|
|
|
|
|
|
|
|
public function __construct(
|
2023-08-24 23:13:29 +00:00
|
|
|
private SasaeEnvironment $templating,
|
2023-08-22 23:47:37 +00:00
|
|
|
private AccountLinks $accountLinks,
|
|
|
|
private Skins $skins,
|
|
|
|
private Capes $capes,
|
|
|
|
private CSRFP $csrfp,
|
2023-08-22 23:54:15 +00:00
|
|
|
private object $authInfo,
|
|
|
|
private string $baseUrl
|
2023-08-22 23:47:37 +00:00
|
|
|
) {
|
|
|
|
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 {
|
2023-08-22 23:54:15 +00:00
|
|
|
return ($includeDomain ? $this->baseUrl : '') . self::TEXTURES_DIR . '/' . $hash . '.png';
|
2023-08-22 23:47:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public function deleteLocalFileMaybe(string $hash): void {
|
|
|
|
$path = $this->getLocalPath($hash);
|
|
|
|
if(is_file($path) && !$this->checkHash($hash))
|
|
|
|
unlink($path);
|
|
|
|
}
|
|
|
|
|
2024-03-28 23:28:19 +00:00
|
|
|
#[HttpMiddleware('/skins')]
|
2023-08-22 23:47:37 +00:00
|
|
|
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.',
|
|
|
|
],
|
|
|
|
];
|
|
|
|
|
2024-03-28 23:28:19 +00:00
|
|
|
#[HttpGet('/skins')]
|
2023-08-22 23:47:37 +00:00
|
|
|
public function getSkins($response, $request) {
|
2023-08-24 23:13:29 +00:00
|
|
|
$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,
|
|
|
|
]);
|
|
|
|
|
2023-08-22 23:47:37 +00:00
|
|
|
$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]]))
|
2023-08-24 23:13:29 +00:00
|
|
|
$template->setVars([
|
2023-08-22 23:47:37 +00:00
|
|
|
'error' => [
|
|
|
|
'section' => $errorCode[0],
|
|
|
|
'code' => $errorCode[1],
|
|
|
|
'message' => self::SKINS_ERRORS[$errorCode[0]][$errorCode[1]],
|
|
|
|
],
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
|
2023-08-24 23:13:29 +00:00
|
|
|
return $template;
|
2023-08-22 23:47:37 +00:00
|
|
|
}
|
|
|
|
|
2024-03-28 23:28:19 +00:00
|
|
|
#[HttpPost('/skins/upload-skin')]
|
2023-08-22 23:47:37 +00:00
|
|
|
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');
|
|
|
|
}
|
|
|
|
|
2024-03-28 23:28:19 +00:00
|
|
|
#[HttpPost('/skins/delete-skin')]
|
2023-08-22 23:47:37 +00:00
|
|
|
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');
|
|
|
|
}
|
|
|
|
|
2024-03-28 23:28:19 +00:00
|
|
|
#[HttpPost('/skins/upload-cape')]
|
2023-08-22 23:47:37 +00:00
|
|
|
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');
|
|
|
|
}
|
|
|
|
|
2024-03-28 23:28:19 +00:00
|
|
|
#[HttpPost('/skins/delete-cape')]
|
2023-08-22 23:47:37 +00:00
|
|
|
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');
|
|
|
|
}
|
|
|
|
|
2024-03-28 23:28:19 +00:00
|
|
|
#[HttpPost('/skins/import')]
|
2023-08-22 23:47:37 +00:00
|
|
|
public function postImport($response, $request) {
|
|
|
|
$body = $request->getContent();
|
|
|
|
$userAgent = $request->getHeaderLine('User-Agent');
|
|
|
|
|
2023-08-23 20:59:04 +00:00
|
|
|
$userName = (string)$body->getParam('username');
|
|
|
|
if($userName === '')
|
|
|
|
$userName = $this->linkInfo->getName();
|
|
|
|
|
|
|
|
$resolveUUID = MojangInterop::getMinecraftUUID($userName, $userAgent);
|
2023-08-22 23:47:37 +00:00
|
|
|
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');
|
|
|
|
}
|
|
|
|
|
2024-07-12 15:31:06 +00:00
|
|
|
private function getProfileInfo(UuidInterface $uuid, bool $includeProfileActions) {
|
2023-08-22 23:47:37 +00:00
|
|
|
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();
|
|
|
|
|
2024-07-12 15:31:06 +00:00
|
|
|
$profileInfo = [
|
2023-08-22 23:47:37 +00:00
|
|
|
'id' => $profileId,
|
|
|
|
'name' => $profileName,
|
|
|
|
'properties' => [
|
|
|
|
[
|
|
|
|
'name' => 'textures',
|
|
|
|
'value' => base64_encode(json_encode([
|
|
|
|
'timestamp' => MojangInterop::currentTime(),
|
|
|
|
'profileId' => $profileId,
|
|
|
|
'profileName' => $profileName,
|
|
|
|
'textures' => $textures,
|
|
|
|
], JSON_UNESCAPED_SLASHES)),
|
|
|
|
],
|
|
|
|
],
|
|
|
|
];
|
2024-07-12 15:31:06 +00:00
|
|
|
|
|
|
|
if($includeProfileActions)
|
|
|
|
$profileInfo['profileActions'] = [];
|
|
|
|
|
|
|
|
return $profileInfo;
|
|
|
|
}
|
|
|
|
|
|
|
|
#[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)) {
|
|
|
|
$raw = MojangInterop::proxySessionMinecraftProfile(null, $request, $id);
|
|
|
|
$result = json_decode($raw);
|
|
|
|
if(!is_object($result))
|
|
|
|
return $raw;
|
|
|
|
|
|
|
|
$uuid = MojangInterop::createOfflinePlayerUUID($result->name);
|
|
|
|
}
|
|
|
|
|
|
|
|
$response->setCacheControl('max-age=30');
|
|
|
|
|
|
|
|
return $this->getProfileInfo($uuid, true);
|
|
|
|
}
|
|
|
|
|
|
|
|
#[HttpPost('/session/minecraft/join')]
|
|
|
|
public function postSessionMinecraftJoin($response, $request) {
|
|
|
|
if(!$request->isJsonContent())
|
|
|
|
return 415;
|
|
|
|
|
|
|
|
// just accept this always idk if it matters
|
|
|
|
return 204;
|
|
|
|
}
|
|
|
|
|
|
|
|
#[HttpGet('/session/minecraft/hasJoined')]
|
|
|
|
public function getSessionMinecraftHasJoined($response, $request) {
|
|
|
|
$userName = (string)$request->getParam('username');
|
|
|
|
$serverId = (string)$request->getParam('serverId');
|
|
|
|
$remoteAddr = (string)$request->getParam('ip');
|
|
|
|
$uuid = MojangInterop::createOfflinePlayerUUID($userName);
|
|
|
|
|
|
|
|
return $this->getProfileInfo($uuid, false);
|
2023-08-22 23:47:37 +00:00
|
|
|
}
|
|
|
|
|
2024-03-28 23:28:19 +00:00
|
|
|
#[HttpGet('/users/profiles/minecraft/([A-Za-z0-9_]+)')]
|
2023-08-22 23:47:37 +00:00
|
|
|
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(),
|
|
|
|
];
|
|
|
|
}
|
2024-02-21 16:32:21 +00:00
|
|
|
|
|
|
|
// quirky path and two of them to achieve equal string length with http://s3.amazonaws.com/MinecraftSkins/ for flashii.net and edgii.net
|
2024-03-28 23:28:19 +00:00
|
|
|
#[HttpGet('/s3MinecraftSkins/([A-Za-z0-9_]+).png')]
|
|
|
|
#[HttpGet('/s3s3MinecraftSkins/([A-Za-z0-9_]+).png')]
|
2024-02-21 16:32:21 +00:00
|
|
|
public function getS3MinecraftSkin($response, $request, string $name) {
|
|
|
|
try {
|
2024-03-28 23:28:19 +00:00
|
|
|
$linkInfo = $this->accountLinks->getLink(name: $name);
|
2024-02-21 16:32:21 +00:00
|
|
|
} 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');
|
2024-03-28 23:28:19 +00:00
|
|
|
$response->setFileName("{$name}.png", false);
|
2024-02-21 16:32:21 +00:00
|
|
|
}
|
2023-08-22 23:47:37 +00:00
|
|
|
}
|