misuzu/src/SharpChat/SharpChatRoutes.php
flash 383e2ed0e0 Rewrote the user information class.
This one took multiple days and it pretty invasive into the core of Misuzu so issue might (will) arise, there's also some features that have gone temporarily missing in the mean time and some inefficiencies introduced that will be fixed again at a later time.
The old class isn't gone entirely because I still have to figure out what I'm gonna do about validation, but for the most part this knocks out one of the "layers of backwards compatibility", as I've been referring to it, and is moving us closer to a future where Flashii actually gets real updates.
If you run into anything that's broken and you're inhibited from reporting it through the forum, do it through chat or mail me at flashii-issues@flash.moe.
2023-08-02 22:12:47 +00:00

437 lines
15 KiB
PHP

<?php
namespace Misuzu\SharpChat;
use RuntimeException;
use Index\Colour\Colour;
use Index\Routing\IRouter;
use Index\Http\HttpFx;
use Misuzu\AuthToken;
use Misuzu\MisuzuContext;
use Misuzu\Auth\Sessions;
use Misuzu\Config\IConfig;
use Misuzu\Emoticons\Emotes;
use Misuzu\Users\Bans;
use Misuzu\Users\Users;
final class SharpChatRoutes {
private IConfig $config;
private MisuzuContext $context;
private Bans $bans;
private Emotes $emotes;
private Users $users;
private Sessions $sessions;
private string $hashKey;
public function __construct(
IRouter $router,
IConfig $config,
MisuzuContext $context,
Bans $bans,
Emotes $emotes,
Users $users,
Sessions $sessions
) {
$this->config = $config;
$this->context = $context;
$this->bans = $bans;
$this->emotes = $emotes;
$this->users = $users;
$this->sessions = $sessions;
$this->hashKey = $this->config->getString('hashKey', 'woomy');
// Simplify default error pages
if($router instanceof HttpFx)
$router->use('/_sockchat', function() use($router) {
$router->addErrorHandler(400, function($response) {
$response->setContent('HTTP 400');
});
$router->addErrorHandler(403, function($response) {
$response->setContent('HTTP 403');
});
$router->addErrorHandler(404, function($response) {
$response->setContent('HTTP 404');
});
$router->addErrorHandler(500, function($response) {
$response->setContent('HTTP 500');
});
$router->addErrorHandler(503, function($response) {
$response->setContent('HTTP 503');
});
});
// Public endpoints
$router->get('/_sockchat/emotes', [$this, 'getEmotes']);
$router->get('/_sockchat/login', [$this, 'getLogin']);
$router->options('/_sockchat/token', [$this, 'getToken']);
$router->get('/_sockchat/token', [$this, 'getToken']);
// Private endpoints
$router->post('/_sockchat/bump', [$this, 'postBump']);
$router->post('/_sockchat/verify', [$this, 'postVerify']);
$router->get('/_sockchat/bans/list', [$this, 'getBanList']);
$router->get('/_sockchat/bans/check', [$this, 'getBanCheck']);
$router->post('/_sockchat/bans/create', [$this, 'postBanCreate']);
$router->delete('/_sockchat/bans/revoke', [$this, 'deleteBanRevoke']);
}
public function getEmotes($response, $request): array {
$response->setHeader('Access-Control-Allow-Origin', '*');
$response->setHeader('Access-Control-Allow-Methods', 'GET');
$emotes = $this->emotes->getAllEmotes(withStrings: true);
$out = [];
foreach($emotes as $emoteInfo) {
$strings = [];
foreach($emoteInfo->getStrings() as $stringInfo)
$strings[] = sprintf(':%s:', $stringInfo->getString());
$out[] = [
'Text' => $strings,
'Image' => $emoteInfo->getUrl(),
'Hierarchy' => $emoteInfo->getMinRank(),
];
}
return $out;
}
public function getLogin($response, $request): void {
if(!$this->context->isLoggedIn()) {
$response->redirect(url('auth-login'));
return;
}
$response->redirect($this->config->getString(
($request->hasParam('legacy') ? 'chatPath.legacy' : 'chatPath.normal'),
'/'
));
}
public function getToken($response, $request) {
$host = $request->hasHeader('Host') ? $request->getHeaderFirstLine('Host') : '';
$origin = $request->hasHeader('Origin') ? $request->getHeaderFirstLine('Origin') : '';
$originHost = strtolower(parse_url($origin, PHP_URL_HOST) ?? '');
if(!empty($originHost) && $originHost !== $host) {
$whitelist = $this->config->getArray('origins', []);
if(!in_array($originHost, $whitelist))
return 403;
$originProto = strtolower(parse_url($origin, PHP_URL_SCHEME));
$origin = $originProto . '://' . $originHost;
$response->setHeader('Access-Control-Allow-Origin', $origin);
$response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET');
$response->setHeader('Access-Control-Allow-Credentials', 'true');
$response->setHeader('Vary', 'Origin');
}
if($request->getMethod() === 'OPTIONS')
return 204;
if(!$this->context->hasAuthToken())
return ['ok' => false, 'err' => 'token'];
$tokenInfo = $this->context->getAuthToken();
try {
$sessionInfo = $this->sessions->getSession(sessionToken: $tokenInfo->getSessionToken());
} catch(RuntimeException $ex) {
return ['ok' => false, 'err' => 'session'];
}
if($sessionInfo->hasExpired())
return ['ok' => false, 'err' => 'expired'];
$userInfo = $this->users->getUser($sessionInfo->getUserId(), 'id');
$userId = $tokenInfo->hasImpersonatedUserId() && $userInfo->isSuperUser()
? $tokenInfo->getImpersonatedUserId()
: $userInfo->getId();
return [
'ok' => true,
'usr' => (int)$userId,
'tkn' => $tokenInfo->pack(),
];
}
public function postBump($response, $request) {
if(!$request->hasHeader('X-SharpChat-Signature'))
return 400;
if($request->isFormContent()) {
$content = $request->getContent();
$bumpList = $content->getParam('u', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY);
if(!is_array($bumpList))
return 400;
$userTime = (int)$content->getParam('t', FILTER_SANITIZE_NUMBER_INT);
$signature = "bump#{$userTime}";
foreach($bumpList as $userId => $ipAddr)
$signature .= "#{$userId}:{$ipAddr}";
} else return 400;
$userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
$realHash = hash_hmac('sha256', $signature, $this->hashKey);
if(!hash_equals($realHash, $userHash))
return 403;
if($userTime < time() - 60)
return 403;
foreach($bumpList as $userId => $ipAddr)
$this->users->recordUserActivity($userId, remoteAddr: $ipAddr);
}
public function postVerify($response, $request) {
if(!$request->hasHeader('X-SharpChat-Signature'))
return 400;
if($request->isFormContent()) {
$content = $request->getContent();
$authMethod = (string)$content->getParam('method');
$authToken = (string)$content->getParam('token');
$ipAddress = (string)$content->getParam('ipaddr');
} else
return ['success' => false, 'reason' => 'request'];
$userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
if(strlen($userHash) !== 64)
return ['success' => false, 'reason' => 'length'];
if(empty($authMethod) || empty($authToken) || empty($ipAddress))
return ['success' => false, 'reason' => 'data'];
$signature = "verify#{$authMethod}#{$authToken}#{$ipAddress}";
$realHash = hash_hmac('sha256', $signature, $this->hashKey);
if(!hash_equals($realHash, $userHash))
return ['success' => false, 'reason' => 'hash'];
if($authMethod === 'SESS' || $authMethod === 'Misuzu') {
$authTokenInfo = AuthToken::unpack($authToken);
if($authTokenInfo->isValid())
$authToken = $authTokenInfo->getSessionToken();
try {
$sessionInfo = $this->sessions->getSession(sessionToken: $authToken);
} catch(RuntimeException $ex) {
return ['success' => false, 'reason' => 'token'];
}
if($sessionInfo->hasExpired()) {
$this->sessions->deleteSessions(sessionInfos: $sessionInfo);
return ['success' => false, 'reason' => 'expired'];
}
$this->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $ipAddress);
$userInfo = $this->users->getUser($sessionInfo->getUserId(), 'id');
if($authTokenInfo->hasImpersonatedUserId() && $userInfo->isSuperUser()) {
$userInfoReal = $userInfo;
try {
$userInfo = $this->users->getUser($authTokenInfo->getImpersonatedUserId(), 'id');
} catch(RuntimeException $ex) {
$userInfo = $userInfoReal;
}
}
} else {
return ['success' => false, 'reason' => 'unsupported'];
}
if(empty($userInfo))
return ['success' => false, 'reason' => 'user'];
$this->users->recordUserActivity($userInfo, remoteAddr: $ipAddress);
$userColour = $this->users->getUserColour($userInfo);
$userRank = $this->users->getUserRank($userInfo);
return [
'success' => true,
'user_id' => (int)$userInfo->getId(),
'username' => $userInfo->getName(),
'colour_raw' => Colour::toMisuzu($userColour),
'rank' => $userRank,
'hierarchy' => $userRank,
'perms' => SharpChatPerms::convert($userInfo),
];
}
public function getBanList($response, $request) {
if(!$request->hasHeader('X-SharpChat-Signature'))
return 400;
$userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
$userTime = (int)$request->getParam('x', FILTER_SANITIZE_NUMBER_INT);
$realHash = hash_hmac('sha256', "list#{$userTime}", $this->hashKey);
if(!hash_equals($realHash, $userHash) || $userTime < time() - 60)
return 403;
$list = [];
$bans = $this->bans->getBans(activeOnly: true);
$userInfos = [];
foreach($bans as $banInfo) {
$userId = $banInfo->getUserId();
if(array_key_exists($userId, $userInfos))
$userInfo = $userInfos[$userId];
else
$userInfos[$userId] = $userInfo = $this->users->getUser($userId, 'id');
$userColour = $this->users->getUserColour($userInfo);
$isPerma = $banInfo->isPermanent();
$list[] = [
'is_ban' => true,
'user_id' => $userId,
'user_name' => $userInfo->getName(),
'user_colour' => Colour::toMisuzu($userColour),
'ip_addr' => '::',
'is_perma' => $isPerma,
'expires' => date('c', $isPerma ? 0x7FFFFFFF : $banInfo->getExpiresTime())
];
}
return $list;
}
public function getBanCheck($response, $request) {
if(!$request->hasHeader('X-SharpChat-Signature'))
return 400;
$userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
$userTime = (int)$request->getParam('x', FILTER_SANITIZE_NUMBER_INT);
$ipAddress = (string)$request->getParam('a');
$userId = (string)$request->getParam('u');
$userIdIsName = (int)$request->getParam('n', FILTER_SANITIZE_NUMBER_INT);
$realHash = hash_hmac('sha256', "check#{$userTime}#{$userId}#{$ipAddress}#{$userIdIsName}", $this->hashKey);
if(!hash_equals($realHash, $userHash) || $userTime < time() - 60)
return 403;
if($userIdIsName)
try {
$userInfo = $this->users->getUser($userId, 'name');
$userId = (string)$userInfo->getId();
} catch(RuntimeException $ex) {
$userId = '';
}
$banInfo = $this->bans->tryGetActiveBan($userId);
if($banInfo === null)
return ['is_ban' => false];
$isPerma = $banInfo->isPermanent();
return [
'is_ban' => true,
'user_id' => $banInfo->getUserId(),
'ip_addr' => '::',
'is_perma' => $isPerma,
'expires' => date('c', $isPerma ? 0x7FFFFFFF : $banInfo->getExpiresTime()),
];
}
public function postBanCreate($response, $request): int {
if(!$request->hasHeader('X-SharpChat-Signature') || !$request->isFormContent())
return 400;
$userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
$content = $request->getContent();
$userTime = (int)$content->getParam('t', FILTER_SANITIZE_NUMBER_INT);
$userId = (string)$content->getParam('ui', FILTER_SANITIZE_NUMBER_INT);
$userAddr = (string)$content->getParam('ua');
$modId = (string)$content->getParam('mi', FILTER_SANITIZE_NUMBER_INT);
$modAddr = (string)$content->getParam('ma');
$duration = (int)$content->getParam('d', FILTER_SANITIZE_NUMBER_INT);
$isPermanent = (int)$content->getParam('p', FILTER_SANITIZE_NUMBER_INT);
$reason = (string)$content->getParam('r');
$signature = implode('#', [
'create', $userTime, $userId, $userAddr,
$modId, $modAddr, $duration, $isPermanent, $reason,
]);
$realHash = hash_hmac('sha256', $signature, $this->hashKey);
if(!hash_equals($realHash, $userHash) || $userTime < time() - 60)
return 403;
if(empty($reason))
$reason = 'Banned through chat.';
// maybe also adds the last couple lines of the chat log to the private reason
$comment = sprintf('User IP address: %s, Moderator IP address: %s', $userAddr, $modAddr);
if($isPermanent)
$expires = null;
else {
$now = time();
$expires = $now + $duration;
if($expires < $now)
return 400;
}
// IPs cannot be banned on their own
// substituting with the unused Railgun account for now.
if(empty($userId))
$userId = 69;
if(empty($modId))
$modId = 69;
try {
$modInfo = $this->users->getUser($modId, 'id');
} catch(RuntimeException $ex) {
return 404;
}
try {
$userInfo = $this->users->getUser($userId, 'id');
} catch(RuntimeException $ex) {
return 404;
}
try {
$this->bans->createBan(
$userInfo,
$expires,
$reason,
$comment,
modInfo: $modInfo
);
} catch(RuntimeException $ex) {
return 500;
}
return 201;
}
public function deleteBanRevoke($response, $request): int {
if(!$request->hasHeader('X-SharpChat-Signature'))
return 400;
$userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
$userTime = (int)$request->getParam('x', FILTER_SANITIZE_NUMBER_INT);
$type = (string)$request->getParam('t');
$subject = (string)$request->getParam('s');
$realHash = hash_hmac('sha256', "revoke#{$userTime}#{$type}#{$subject}", $this->hashKey);
if(!hash_equals($realHash, $userHash) || $userTime < time() - 60)
return 403;
if($type !== 'user')
return 404;
$banInfo = $this->bans->tryGetActiveBan($subject);
if($banInfo === null)
return 404;
$this->bans->deleteBans($banInfo);
return 204;
}
}