hashKey = $this->config->getString('hashKey', 'woomy'); } #[HttpOptions('/_sockchat/emotes')] #[HttpGet('/_sockchat/emotes')] public function getEmotes($response, $request): array|int { $response->setHeader('Access-Control-Allow-Origin', '*'); $response->setHeader('Access-Control-Allow-Methods', 'GET'); $response->setHeader('Access-Control-Allow-Headers', 'Cache-Control'); if($request->getMethod() === 'OPTIONS') return 204; $emotes = $this->emotes->getEmotes(orderBy: 'order'); $out = []; foreach($emotes as $emoteInfo) { $strings = []; foreach($this->emotes->getEmoteStrings($emoteInfo) as $stringInfo) $strings[] = sprintf(':%s:', $stringInfo->getString()); $out[] = [ 'Text' => $strings, 'Image' => $emoteInfo->getUrl(), 'Hierarchy' => $emoteInfo->getMinRank(), ]; } return $out; } #[HttpGet('/_sockchat/login')] public function getLogin($response, $request) { if(!$this->authInfo->isLoggedIn()) { $response->redirect($this->urls->format('auth-login')); return; } $response->redirect($this->config->getString( ($request->hasParam('legacy') ? 'chatPath.legacy' : 'chatPath.normal'), '/' )); } private function canImpersonateUserId(UserInfo $impersonator, string $targetId): bool { if($impersonator->isSuperUser()) return true; $whitelist = $this->impersonateConfig->getArray(sprintf('allow.u%s', $impersonator->getId())); return in_array($targetId, $whitelist, true); } #[HttpOptions('/_sockchat/token')] #[HttpGet('/_sockchat/token')] 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; $tokenInfo = $this->authInfo->getTokenInfo(); if(!$tokenInfo->hasSessionToken()) return ['ok' => false, 'err' => 'token']; try { $sessionInfo = $this->authCtx->getSessions()->getSession(sessionToken: $tokenInfo->getSessionToken()); } catch(RuntimeException $ex) { return ['ok' => false, 'err' => 'session']; } if($sessionInfo->hasExpired()) return ['ok' => false, 'err' => 'expired']; if($sessionInfo->getUserId() !== $tokenInfo->getUserId()) return ['ok' => false, 'err' => 'user']; $userInfo = $this->usersCtx->getUsers()->getUser($sessionInfo->getUserId(), 'id'); $userId = $tokenInfo->hasImpersonatedUserId() && $this->canImpersonateUserId($userInfo, $tokenInfo->getImpersonatedUserId()) ? $tokenInfo->getImpersonatedUserId() : $userInfo->getId(); $tokenPacker = $this->authCtx->createAuthTokenPacker(); return [ 'ok' => true, 'usr' => (int)$userId, 'tkn' => $tokenPacker->pack($tokenInfo), ]; } #[HttpPost('/_sockchat/bump')] 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->usersCtx->getUsers()->recordUserActivity($userId, remoteAddr: $ipAddr); } #[HttpPost('/_sockchat/verify')] 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') { $tokenPacker = $this->authCtx->createAuthTokenPacker(); $tokenInfo = $tokenPacker->unpack($authToken); if($tokenInfo->isEmpty()) { // don't support using the raw session key for Misuzu format if($authMethod !== 'SESS') return ['success' => false, 'reason' => 'format']; $sessionToken = $authToken; } else $sessionToken = $tokenInfo->getSessionToken(); try { $sessionInfo = $this->authCtx->getSessions()->getSession(sessionToken: $sessionToken); } catch(RuntimeException $ex) { return ['success' => false, 'reason' => 'token']; } if($sessionInfo->hasExpired()) { $this->authCtx->getSessions()->deleteSessions(sessionInfos: $sessionInfo); return ['success' => false, 'reason' => 'expired']; } $this->authCtx->getSessions()->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $ipAddress); $userInfo = $this->usersCtx->getUsers()->getUser($sessionInfo->getUserId(), 'id'); if($tokenInfo->hasImpersonatedUserId() && $this->canImpersonateUserId($userInfo, $tokenInfo->getImpersonatedUserId())) { $userInfoReal = $userInfo; try { $userInfo = $this->usersCtx->getUsers()->getUser($tokenInfo->getImpersonatedUserId(), 'id'); } catch(RuntimeException $ex) { $userInfo = $userInfoReal; } } } else { return ['success' => false, 'reason' => 'unsupported']; } $this->usersCtx->getUsers()->recordUserActivity($userInfo, remoteAddr: $ipAddress); $userColour = $this->usersCtx->getUsers()->getUserColour($userInfo); $userRank = $this->usersCtx->getUsers()->getUserRank($userInfo); $chatPerms = $this->perms->getPermissions('chat', $userInfo); return [ 'success' => true, 'user_id' => (int)$userInfo->getId(), 'username' => $userInfo->getName(), 'colour_raw' => Colour::toMisuzu($userColour), 'rank' => $userRank, 'hierarchy' => $userRank, 'perms' => $chatPerms->getCalculated(), 'super' => $userInfo->isSuperUser(), ]; } #[HttpGet('/_sockchat/bans/list')] 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->usersCtx->getBans()->getBans(activeOnly: true); foreach($bans as $banInfo) { $userId = $banInfo->getUserId(); $userInfo = $this->usersCtx->getUserInfo($userId); $userColour = $this->usersCtx->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; } #[HttpGet('/_sockchat/bans/check')] 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->usersCtx->getUsers()->getUser($userId, 'name'); $userId = (string)$userInfo->getId(); } catch(RuntimeException $ex) { $userId = ''; } $banInfo = $this->usersCtx->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()), ]; } #[HttpPost('/_sockchat/bans/create')] 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->usersCtx->getUsers()->getUser($modId, 'id'); } catch(RuntimeException $ex) { return 404; } try { $userInfo = $this->usersCtx->getUsers()->getUser($userId, 'id'); } catch(RuntimeException $ex) { return 404; } try { $this->usersCtx->getBans()->createBan( $userInfo, $expires, $reason, $comment, modInfo: $modInfo ); } catch(RuntimeException $ex) { return 500; } return 201; } #[HttpDelete('/_sockchat/bans/revoke')] 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->usersCtx->tryGetActiveBan($subject); if($banInfo === null) return 404; $this->usersCtx->getBans()->deleteBans($banInfo); return 204; } }