diff --git a/public/_sockchat.php b/public/_sockchat.php new file mode 100644 index 00000000..41cc616f --- /dev/null +++ b/public/_sockchat.php @@ -0,0 +1,2 @@ +addRoutes( Route::get('/info', Handler::call('index@InfoHandler')), Route::get('/info/([A-Za-z0-9_/]+)', true, Handler::call('page@InfoHandler')), + // Sock Chat + Route::create(['GET', 'POST'], '/_sockchat.php', Handler::call('phpFile@SockChatHandler')), + Route::get('/_sockchat/emotes', Handler::call('emotes@SockChatHandler')), + Route::get('/_sockchat/bans', Handler::call('bans@SockChatHandler')), + Route::get('/_sockchat/login', Handler::call('login@SockChatHandler')), + Route::post('/_sockchat/bump', Handler::call('bump@SockChatHandler')), + Route::post('/_sockchat/verify', Handler::call('verify@SockChatHandler')), + // Redirects Route::get('/index.php', Handler::redirect(url('index'), true)), Route::get('/info.php', Handler::redirect(url('info'), true)), diff --git a/src/Http/Handlers/SockChatHandler.php b/src/Http/Handlers/SockChatHandler.php new file mode 100644 index 00000000..04f45056 --- /dev/null +++ b/src/Http/Handlers/SockChatHandler.php @@ -0,0 +1,256 @@ +hashKey = file_get_contents($hashKeyPath); + } + + public function phpFile(Response $response, Request $request) { + $query = $request->getQueryParams(); + + if(isset($query['emotes'])) + return $this->emotes($response, $request); + + if(isset($query['bans']) && is_string($query['bans'])) + return $this->bans($response, $request->withHeader('X-SharpChat-Signature', $query['bans'])); + + $body = $request->getParsedBody(); + + if(isset($body['bump'], $body['hash']) && is_string($body['bump']) && is_string($body['hash'])) + return $this->bump( + $response, + $request->withHeader('X-SharpChat-Signature', $body['hash']) + ->withBody(Stream::create($body['bump'])) + ); + + $source = isset($body['user_id']) ? $body : $query; + + if(isset($source['user_id'], $source['token'], $source['ip'], $source['hash']) + && is_string($source['user_id']) && is_string($source['token']) + && is_string($source['ip']) && is_string($source['hash'])) + return $this->verify( + $response, + $request->withHeader('X-SharpChat-Signature', $source['hash']) + ->withBody(Stream::create(json_encode([ + 'user_id' => $source['user_id'], + 'token' => $source['token'], + 'ip' => $source['ip'], + ]))) + ); + + return $this->login($response, $request); + } + + public function emotes(Response $response, Request $request): array { + $response->setHeader('Access-Control-Allow-Origin', '*') + ->setHeader('Access-Control-Allow-Methods', 'GET'); + + $raw = Emoticon::all(); + $out = []; + + foreach($raw as $emote) { + $strings = []; + + foreach($emote->getStrings() as $string) { + $strings[] = sprintf(':%s:', $string->emote_string); + } + + $out[] = [ + 'Text' => $strings, + 'Image' => $emote->getUrl(), + 'Hierarchy' => $emote->getHierarchy(), + ]; + } + + return $out; + } + + public function bans(Response $response, Request $request): array { + $userHash = $request->getHeaderLine('X-SharpChat-Signature'); + $realHash = hash_hmac('sha256', 'givemethebeans', $this->hashKey); + + if(!hash_equals($realHash, $userHash)) + return []; + + return DB::prepare(' + SELECT uw.`user_id` AS `id`, DATE_FORMAT(uw.`warning_duration`, \'%Y-%m-%dT%TZ\') AS `expires`, INET6_NTOA(uw.`user_ip`) AS `ip`, u.`username` + FROM `msz_user_warnings` AS uw + LEFT JOIN `msz_users` AS u + ON u.`user_id` = uw.`user_id` + WHERE uw.`warning_type` = 3 + AND uw.`warning_duration` > NOW() + ')->fetchAll(); + } + + public function login(Response $response, Request $request) { + if(!user_session_active()) { + $response->redirect(url('auth-login')); + return; + } + + $params = $request->getQueryParams(); + + try { + $token = ChatToken::create(user_session_current('user_id')); + } catch(Exception $ex) { + $response->setHeader('X-SharpChat-Error', $ex->getMessage()); + return 500; + } + + if(MSZ_DEBUG && isset($params['dump'])) { + $ipAddr = $request->getServerParams()['REMOTE_ADDR']; + $hash = hash_hmac('sha256', implode('#', [$token->getUserId(), $token->getToken(), $ipAddr]), $this->hashKey); + + $response->setText(sprintf( + '/_sockchat.php?user_id=%d&token=%s&ip=%s&hash=%s', + $token->getUserId(), + $token->getToken(), + urlencode($ipAddr), + $hash + )); + return; + } + + $cookieName = Config::get('sockChat.cookie', Config::TYPE_STR, 'sockchat_auth'); + $cookieData = implode('_', [$token->getUserId(), $token->getToken()]); + $cookieDomain = '.' . $request->getHeaderLine('Host'); + setcookie($cookieName, $cookieData, $token->getExpirationTime(), '/', $cookieDomain); + + $configKey = isset($params['legacy']) ? 'sockChat.chatPath.legacy' : 'sockChat.chatPath.normal'; + $chatPath = Config::get($configKey, Config::TYPE_STR, '/'); + + if(MSZ_DEBUG) { + $response->setText(sprintf('Umi.Cookies.Set(\'%s\', \'%s\');', $cookieName, $cookieData)); + } else { + $response->redirect($chatPath); + } + } + + public function bump(Response $response, Request $request): void { + $userHash = $request->getHeaderLine('X-SharpChat-Signature'); + $bumpString = (string)$request->getBody(); + $realHash = hash_hmac('sha256', $bumpString, $this->hashKey); + + if(!hash_equals($realHash, $userHash)) + return; + + $bumpInfo = json_decode($bumpString); + + if(empty($bumpInfo)) + return; + + foreach($bumpInfo as $bumpUser) + user_bump_last_active($bumpUser->id, $bumpUser->ip); + } + + public function verify(Response $response, Request $request): array { + $userHash = $request->getHeaderLine('X-SharpChat-Signature'); + + if(strlen($userHash) !== 64) + return ['success' => false, 'reason' => 'length']; + + $authInfo = json_decode((string)$request->getBody()); + + if(!isset($authInfo->user_id, $authInfo->token, $authInfo->ip)) + return ['success' => false, 'reason' => 'data']; + + $realHash = hash_hmac('sha256', implode('#', [$authInfo->user_id, $authInfo->token, $authInfo->ip]), $this->hashKey); + + if(!hash_equals($realHash, $userHash)) + return ['success' => false, 'reason' => 'hash']; + + $authMethod = substr($authInfo->token, 0, 5); + + if($authMethod === 'PASS:') + return ['success' => false, 'reason' => 'unsupported']; + elseif($authMethod === 'SESS:') { + $sessionKey = substr($authInfo->token, 5); + + // use session token to log in + return ['success' => false, 'reason' => 'unimplemented']; + } else { + try { + $token = ChatToken::get($authInfo->user_id, $authInfo->token); + } catch(Exception $ex) { + return ['success' => false, 'reason' => 'token']; + } + + if($token->hasExpired()) { + $token->delete(); + return ['success' => false, 'reason' => 'expired']; + } + + $userId = $token->getUserId(); + } + + if(!isset($userId) || $userId < 1) + return ['success' => false, 'reason' => 'unknown']; + + $userInfo = User::get($userId); + + if($userInfo === null || !$userInfo->hasUserId()) + return ['success' => false, 'reason' => 'user']; + + $perms = self::PERMS_DEFAULT; + + if(perms_check_user(MSZ_PERMS_USER, $userInfo->user_id, MSZ_PERM_USER_MANAGE_USERS)) + $perms |= self::PERMS_MANAGE_USERS; + if(perms_check_user(MSZ_PERMS_USER, $userInfo->user_id, MSZ_PERM_USER_MANAGE_WARNINGS)) + $perms |= self::PERMS_MANAGE_WARNS; + if(perms_check_user(MSZ_PERMS_USER, $userInfo->user_id, MSZ_PERM_USER_CHANGE_BACKGROUND)) + $perms |= self::PERMS_CHANGE_BACKG; + if(perms_check_user(MSZ_PERMS_FORUM, $userInfo->user_id, MSZ_PERM_FORUM_MANAGE_FORUMS)) + $perms |= self::PERMS_MANAGE_FORUM; + + return [ + 'success' => true, + 'user_id' => $userInfo->getUserId(), + 'username' => $userInfo->getUsername(), + 'colour_raw' => $userInfo->getColourRaw(), + 'hierarchy' => $userInfo->getHierarchy(), + 'is_silenced' => date('c', user_warning_check_expiration($userInfo->getUserId(), MSZ_WARN_SILENCE)), + 'perms' => $perms, + ]; + } +} diff --git a/src/Http/HttpMessage.php b/src/Http/HttpMessage.php index 2b512041..2f22e3ce 100644 --- a/src/Http/HttpMessage.php +++ b/src/Http/HttpMessage.php @@ -48,7 +48,7 @@ abstract class HttpMessage implements MessageInterface { $lowerName = strtolower($name); foreach($this->headers as $headerName => $_) - if(strtolower($headerName) === $name) + if(strtolower($headerName) === $lowerName) return $headerName; return $nullOnNone ? null : $name; diff --git a/src/Http/HttpServerRequestMessage.php b/src/Http/HttpServerRequestMessage.php index f8846d97..503573a5 100644 --- a/src/Http/HttpServerRequestMessage.php +++ b/src/Http/HttpServerRequestMessage.php @@ -86,12 +86,6 @@ class HttpServerRequestMessage extends HttpRequestMessage implements ServerReque public function withParsedBody($data) { return (clone $this)->setParsedBody($data); } - public function getBodyParam(string $name, ?string $default = null): ?string { - if(!is_array($this->parsedBody)) - return $default; - - return $this->parsedBody[$name] ?? $default; - } public function getAttributes() { return $this->attributes; diff --git a/src/Users/ChatToken.php b/src/Users/ChatToken.php new file mode 100644 index 00000000..bb4f855e --- /dev/null +++ b/src/Users/ChatToken.php @@ -0,0 +1,82 @@ +user_id); + } + public function getUserId(): int { + return $this->user_id ?? 0; + } + + public function hasToken(): bool { + return isset($this->token_string); + } + public function getToken(): string { + return $this->token_string ?? ''; + } + + public function getCreationTime(): int { + return $this->token_created ?? 0; + } + public function getExpirationTime(): int { + return $this->getCreationTime() + self::TOKEN_LIFETIME; + } + public function hasExpired(): bool { + return $this->getExpirationTime() <= time(); + } + + public function delete(): void { + if(!$this->hasUserId() || !$this->hasToken()) + return; + + DB::prepare(' + DELETE FROM `msz_user_chat_tokens` + WHERE `user_id` = :user, + AND `token_string` = :token + ')->bind('user', $this->getUserId()) + ->bind('token', $this->getToken()) + ->execute(); + } + + public static function create(int $userId): self { + if($userId < 1) + throw new InvalidArgumentException('Invalid user id.'); + + $token = bin2hex(random_bytes(32)); + $create = DB::prepare(' + INSERT INTO `msz_user_chat_tokens` (`user_id`, `token_string`) + VALUES (:user, :token) + ')->bind('user', $userId)->bind('token', $token)->execute(); + + if(!$create) + throw new RuntimeException('Token creation failed.'); + + return self::get($userId, $token); + } + + public static function get(int $userId, string $token): self { + if($userId < 1) + throw new InvalidArgumentException('Invalid user id.'); + if(strlen($token) !== 64) + throw new InvalidArgumentException('Invalid token string.'); + + $token = DB::prepare(' + SELECT `user_id`, `token_string`, UNIX_TIMESTAMP(`token_created`) AS `token_created` + FROM `msz_user_chat_tokens` + WHERE `user_id` = :user + AND `token_string` = :token + ')->bind('user', $userId)->bind('token', $token)->fetchObject(self::class); + + if(empty($token)) + throw new RuntimeException('Token not found.'); + + return $token; + } +} diff --git a/src/Users/object.php b/src/Users/object.php index 8498dc9a..7ce924d2 100644 --- a/src/Users/object.php +++ b/src/Users/object.php @@ -1,6 +1,7 @@ user_id) && $this->user_id > 0; } + public function getUserId(): int { + return $this->user_id ?? 0; + } + + public function hasUsername(): bool { + return isset($this->username); + } + public function getUsername(): string { + return $this->username ?? ''; + } + + public function hasColour(): bool { + return isset($this->user_colour); + } + public function getColour(): Colour { + return new Colour($this->getColourRaw()); + } + public function getColourRaw(): int { + return $this->user_colour ?? 0x40000000; + } + + public function getHierarchy(): int { + return $this->hasUserId() ? user_get_hierarchy($this->getUserId()) : 0; + } public function hasPassword(): bool { return !empty($this->password);