diff --git a/composer.json b/composer.json index d945994..9668810 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,6 @@ "twig/twig": "^3.0", "erusev/parsedown": "~1.6", "geoip2/geoip2": "~2.0", - "jublonet/codebird-php": "^3.1", "chillerlan/php-qrcode": "^4.3", "whichbrowser/parser": "^2.0", "symfony/mailer": "^6.0" diff --git a/composer.lock b/composer.lock index ca46250..ab699ee 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d3c39d122a38515484c9d439ecee240b", + "content-hash": "b3b6cf189969ecb1dd838c23a4fe96e1", "packages": [ { "name": "chillerlan/php-qrcode", @@ -223,157 +223,6 @@ ], "time": "2021-10-28T20:44:15+00:00" }, - { - "name": "composer/installers", - "version": "v1.12.0", - "source": { - "type": "git", - "url": "https://github.com/composer/installers.git", - "reference": "d20a64ed3c94748397ff5973488761b22f6d3f19" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/installers/zipball/d20a64ed3c94748397ff5973488761b22f6d3f19", - "reference": "d20a64ed3c94748397ff5973488761b22f6d3f19", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^1.0 || ^2.0" - }, - "replace": { - "roundcube/plugin-installer": "*", - "shama/baton": "*" - }, - "require-dev": { - "composer/composer": "1.6.* || ^2.0", - "composer/semver": "^1 || ^3", - "phpstan/phpstan": "^0.12.55", - "phpstan/phpstan-phpunit": "^0.12.16", - "symfony/phpunit-bridge": "^4.2 || ^5", - "symfony/process": "^2.3" - }, - "type": "composer-plugin", - "extra": { - "class": "Composer\\Installers\\Plugin", - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Installers\\": "src/Composer/Installers" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Kyle Robinson Young", - "email": "kyle@dontkry.com", - "homepage": "https://github.com/shama" - } - ], - "description": "A multi-framework Composer library installer", - "homepage": "https://composer.github.io/installers/", - "keywords": [ - "Craft", - "Dolibarr", - "Eliasis", - "Hurad", - "ImageCMS", - "Kanboard", - "Lan Management System", - "MODX Evo", - "MantisBT", - "Mautic", - "Maya", - "OXID", - "Plentymarkets", - "Porto", - "RadPHP", - "SMF", - "Starbug", - "Thelia", - "Whmcs", - "WolfCMS", - "agl", - "aimeos", - "annotatecms", - "attogram", - "bitrix", - "cakephp", - "chef", - "cockpit", - "codeigniter", - "concrete5", - "croogo", - "dokuwiki", - "drupal", - "eZ Platform", - "elgg", - "expressionengine", - "fuelphp", - "grav", - "installer", - "itop", - "joomla", - "known", - "kohana", - "laravel", - "lavalite", - "lithium", - "magento", - "majima", - "mako", - "mediawiki", - "miaoxing", - "modulework", - "modx", - "moodle", - "osclass", - "pantheon", - "phpbb", - "piwik", - "ppi", - "processwire", - "puppet", - "pxcms", - "reindex", - "roundcube", - "shopware", - "silverstripe", - "sydes", - "sylius", - "symfony", - "tastyigniter", - "typo3", - "wordpress", - "yawik", - "zend", - "zikula" - ], - "support": { - "issues": "https://github.com/composer/installers/issues", - "source": "https://github.com/composer/installers/tree/v1.12.0" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2021-09-13T08:19:44+00:00" - }, { "name": "doctrine/lexer", "version": "1.2.3", @@ -626,70 +475,6 @@ }, "time": "2021-11-30T18:15:25+00:00" }, - { - "name": "jublonet/codebird-php", - "version": "3.1.0", - "source": { - "type": "git", - "url": "https://github.com/jublo/codebird-php.git", - "reference": "100a8e8f1928a5738b4476f0caf83f2c2ba6da5b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/jublo/codebird-php/zipball/100a8e8f1928a5738b4476f0caf83f2c2ba6da5b", - "reference": "100a8e8f1928a5738b4476f0caf83f2c2ba6da5b", - "shasum": "" - }, - "require": { - "composer/installers": "~1.0", - "ext-hash": "*", - "ext-json": "*", - "lib-openssl": "*", - "php": ">=5.5.0" - }, - "require-dev": { - "phpunit/phpunit": ">=3.7", - "satooshi/php-coveralls": ">=0.6", - "squizlabs/php_codesniffer": "2.*" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "GPL-3.0+" - ], - "authors": [ - { - "name": "Joshua Atkins", - "email": "joshua.atkins@jublo.net", - "homepage": "http://atkins.im/", - "role": "Developer" - }, - { - "name": "J.M.", - "email": "jm@jublo.net", - "homepage": "http://mynetx.net/", - "role": "Developer" - } - ], - "description": "Easy access to the Twitter REST API, Collections API, Streaming API, TON (Object Nest) API and Twitter Ads API — all from one PHP library.", - "homepage": "https://www.jublo.net/projects/codebird/php", - "keywords": [ - "api", - "networking", - "twitter" - ], - "support": { - "email": "support@jublo.net", - "issues": "https://github.com/jublonet/codebird-php/issues", - "source": "https://github.com/jublonet/codebird-php/releases" - }, - "time": "2016-02-15T18:38:55+00:00" - }, { "name": "maxmind-db/reader", "version": "v1.11.0", @@ -1967,5 +1752,5 @@ "prefer-lowest": false, "platform": [], "platform-dev": [], - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.3.0" } diff --git a/lib/index b/lib/index index 66a35f0..6e09904 160000 --- a/lib/index +++ b/lib/index @@ -1 +1 @@ -Subproject commit 66a35f030f9eec02d8e51710c7de2161d2a1796f +Subproject commit 6e099040138fb0f53d0d179a740c0a1ec5bd5258 diff --git a/misuzu.php b/misuzu.php index 62a3ef0..881e1db 100644 --- a/misuzu.php +++ b/misuzu.php @@ -108,6 +108,8 @@ define('MSZ_STORAGE', $cfg->getValue('storage.path', CfgType::T_STR, MSZ_ROOT . if(!is_dir(MSZ_STORAGE)) mkdir(MSZ_STORAGE, 0775, true); +$ctx = new MszContext($db, $cfg); + if(MSZ_CLI) { // Temporary backwards compatibility measure, remove this later if(realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) { if(($argv[1] ?? '') === 'cron' && ($argv[2] ?? '') === 'low') @@ -118,8 +120,6 @@ if(MSZ_CLI) { // Temporary backwards compatibility measure, remove this later return; } -$ctx = new MszContext($db, $cfg); - // Everything below here should eventually be moved to index.php, probably only initialised when required. // Serving things like the css/js doesn't need to initialise sessions. diff --git a/msz b/msz index 7969017..65c5180 100755 --- a/msz +++ b/msz @@ -12,9 +12,8 @@ if(!MSZ_CLI) $commands = new CommandCollection; $commands->addCommands( - new \Misuzu\Console\Commands\CronCommand, + new \Misuzu\Console\Commands\CronCommand($ctx), new \Misuzu\Console\Commands\MigrateCommand, new \Misuzu\Console\Commands\NewMigrationCommand, - new \Misuzu\Console\Commands\TwitterAuthCommand, ); $commands->dispatch(new CommandArgs($argv)); diff --git a/public/manage/general/twitter.php b/public/manage/general/twitter.php new file mode 100644 index 0000000..3598104 --- /dev/null +++ b/public/manage/general/twitter.php @@ -0,0 +1,61 @@ +getId(), MSZ_PERM_GENERAL_MANAGE_TWITTER)) { + echo render_error(403); + return; +} + +$tCfg = $cfg->scopeTo('twitter'); + +$tClient = $ctx->createTwitterClient(); +$tHasClientId = $tClient->hasClientId(); +$tHasAccessToken = $tClient->hasAccessToken(); +$tHasRefreshToken = $tClient->hasRefreshToken(); +$tExpires = $tClient->getAccessToken()->getExpiresTime(); + +if(isset($_GET['m'])) { + if(CSRF::validateRequest()) { + $mode = (string)filter_input(INPUT_GET, 'm'); + + if($mode === 'authorise' && $tHasClientId && !$tHasAccessToken) { + $tAuthorise = $tClient->authorise(TwitterClient::SYSTEM_SCOPES, url_prefix(false) . url('twitter-callback')); + setcookie('msz_twitter', $tAuthorise->getVerifier(), strtotime('+5 minutes'), '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); + header('Location: ' . $tAuthorise->getUri()); + return; + } + + if($mode === 'refresh' && $tHasClientId && $tHasAccessToken && $tHasRefreshToken) { + $tRefresh = TwitterAccessToken::fromTwitterResponse($tClient->authRefresh()); + TwitterAccessToken::save($tCfg->scopeTo('access'), $tRefresh); + header('Location: ' . url('manage-general-twitter')); + return; + } + + if($mode === 'revoke' && $tHasClientId && $tHasAccessToken) { + $tRevoke = $tClient->authRevoke(); + if(!empty($tRevoke->revoked)) + TwitterAccessToken::nuke($tCfg->scopeTo('access')); + + header('Location: ' . url('manage-general-twitter')); + return; + } + } + + header('Location: ' . url('manage-general-twitter')); + return; +} + +Template::render('manage.general.twitter', [ + 'twitter_has_oauth2' => $tHasClientId, + 'twitter_has_access' => $tHasAccessToken, + 'twitter_has_refresh' => $tHasRefreshToken, + 'twitter_expires' => $tExpires, +]); diff --git a/public/manage/news/post.php b/public/manage/news/post.php index 64c46d3..559986a 100644 --- a/public/manage/news/post.php +++ b/public/manage/news/post.php @@ -53,16 +53,11 @@ if(!empty($_POST['post']) && CSRF::validateRequest()) { if(!empty($isNew)) { if($postInfo->isFeatured()) { - $twitterApiKey = $cfg->getValue('twitter.api.key', CfgType::T_STR); - $twitterApiSecret = $cfg->getValue('twitter.api.secret', CfgType::T_STR); - $twitterToken = $cfg->getValue('twitter.token.key', CfgType::T_STR); - $twitterTokenSecret = $cfg->getValue('twitter.token.secret', CfgType::T_STR); + $twitter = $ctx->createTwitterClient(); - if(!empty($twitterApiKey) && !empty($twitterApiSecret) - && !empty($twitterToken) && !empty($twitterTokenSecret)) { - Twitter::init($twitterApiKey, $twitterApiSecret, $twitterToken, $twitterTokenSecret); + if($twitter->hasAccessToken()) { $url = url('news-post', ['post' => $postInfo->getId()]); - Twitter::sendTweet("News :: {$postInfo->getTitle()}\nhttps://{$_SERVER['HTTP_HOST']}{$url}"); + $twitter->sendTweet("News :: {$postInfo->getTitle()}\nhttps://{$_SERVER['HTTP_HOST']}{$url}"); } } diff --git a/src/Config/IConfig.php b/src/Config/IConfig.php index 8cbaf4b..1865686 100644 --- a/src/Config/IConfig.php +++ b/src/Config/IConfig.php @@ -1,6 +1,12 @@ context = $ctx; + } + public function getName(): string { return 'cron'; } @@ -26,13 +34,25 @@ class CronCommand implements CommandInterface { break; case 'func': - call_user_func($task['command']); + call_user_func([$this, $task['command']]); break; } } } } + private function syncForumStats(): void { + forum_count_synchronise(); + } + + private function refreshTwitterToken(): void { + $tClient = $this->context->createTwitterClient(); + if($tClient->hasAccessToken() && $tClient->hasRefreshToken()) { + $tRefresh = TwitterAccessToken::fromTwitterResponse($tClient->authRefresh()); + TwitterAccessToken::save($this->context->getConfig()->scopeTo('twitter.access'), $tRefresh); + } + } + private const TASKS = [ [ 'name' => 'Ensures main role exists.', @@ -141,7 +161,7 @@ class CronCommand implements CommandInterface { 'name' => 'Recount forum topics and posts.', 'type' => 'func', 'slow' => true, - 'command' => 'forum_count_synchronise', + 'command' => 'syncForumStats', ], [ 'name' => 'Clean up expired tfa tokens.', @@ -151,5 +171,11 @@ class CronCommand implements CommandInterface { WHERE `tfa_created` < NOW() - INTERVAL 15 MINUTE ", ], + [ + 'name' => 'Refresh Twitter authentication token.', + 'type' => 'func', + 'slow' => true, + 'command' => 'refreshTwitterToken', + ], ]; } diff --git a/src/Console/Commands/TwitterAuthCommand.php b/src/Console/Commands/TwitterAuthCommand.php deleted file mode 100644 index 660d6a9..0000000 --- a/src/Console/Commands/TwitterAuthCommand.php +++ /dev/null @@ -1,51 +0,0 @@ -users; }*/ + public function createTwitterClient(): TwitterClient { + return TwitterClient::create($this->config->scopeTo('twitter')); + } + public function setUpHttp(bool $legacy = false): void { $this->router = new HttpFx; $this->router->use('/', function($response) { @@ -111,6 +117,7 @@ class MszContext { $this->router->post('/forum/mark-as-read', msz_compat_handler('Forum', 'markAsReadPOST')); new SharpChatRoutes($this->router, $this->config->scopeTo('sockChat')); + new TwitterRoutes($this, $this->router, $this->config->scopeTo('twitter')); } private function registerLegacyRedirects(): void { diff --git a/src/Twitter.php b/src/Twitter.php deleted file mode 100644 index 2994899..0000000 --- a/src/Twitter.php +++ /dev/null @@ -1,59 +0,0 @@ -setToken($token, $tokenSecret); - } - - public static function createAuth(): ?string { - $codebird = Codebird::getInstance(); - $reply = $codebird->oauth_requestToken([ - 'oauth_callback' => 'oob', - ]); - - if(!$reply) - return null; - - self::setToken($reply->oauth_token, $reply->oauth_token_secret); - - return $codebird->oauth_authorize(); - } - - public static function completeAuth(string $pin): array { - $reply = Codebird::getInstance()->oauth_accessToken([ - 'oauth_verifier' => $pin, - ]); - - if(!$reply) - return []; - - self::setToken($reply->oauth_token, $reply->oauth_token_secret); - - return [ - 'token' => $reply->oauth_token, - 'token_secret' => $reply->oauth_token_secret, - ]; - } - - public static function sendTweet(string $text): void { - Codebird::getInstance()->statuses_update([ - 'status' => $text, - ]); - } -} diff --git a/src/Twitter/TwitterAccessToken.php b/src/Twitter/TwitterAccessToken.php new file mode 100644 index 0000000..f3220dd --- /dev/null +++ b/src/Twitter/TwitterAccessToken.php @@ -0,0 +1,92 @@ +type; + } + + public function getAccessToken(): string { + return $this->accessToken; + } + + public function hasAccessToken(): bool { + return $this->type !== '' && $this->accessToken !== ''; + } + + public function getExpiresTime(): int { + return $this->expires; + } + + public function hasExpires(): bool { + return time() > $this->expires; + } + + public function getScope(): array { + return $this->scope; + } + + public function getRefreshToken(): string { + return $this->refreshToken; + } + + public function hasRefreshToken(): bool { + return $this->refreshToken !== ''; + } + + public function __toString(): string { + return 'Bearer ' . $this->accessToken; + } + + public static function empty(): self { + return new static('', '', 0, [], ''); + } + + public static function fromTwitterResponse(object $obj): self { + return new static( + $obj->token_type ?? '', + $obj->access_token ?? '', + time() + ($obj->expires_in ?? 0), + explode(' ', ($obj->scope ?? '')), + $obj->refresh_token ?? '' + ); + } + + public static function load(IConfig $config): self { + return new static( + $config->getValue('type', CfgType::T_STR), + $config->getValue('token', CfgType::T_STR), + $config->getValue('expires', CfgType::T_INT), + $config->getValue('token', CfgType::T_ARR), + $config->getValue('refresh', CfgType::T_STR) + ); + } + + public static function save(IConfig $config, self $tokenInfo): void { + $config->setValue('type', $tokenInfo->getType()); + $config->setValue('token', $tokenInfo->getAccessToken()); + $config->setValue('expires', $tokenInfo->getExpiresTime()); + $config->setValue('scope', $tokenInfo->getScope()); + $config->setValue('refresh', $tokenInfo->getRefreshToken()); + } + + public static function nuke(IConfig $config): void { + $config->removeValue('type'); + $config->removeValue('token'); + $config->removeValue('expires'); + $config->removeValue('scope'); + $config->removeValue('refresh'); + } +} diff --git a/src/Twitter/TwitterAuthorisation.php b/src/Twitter/TwitterAuthorisation.php new file mode 100644 index 0000000..4c1487f --- /dev/null +++ b/src/Twitter/TwitterAuthorisation.php @@ -0,0 +1,115 @@ +clientId = $clientId; + $this->scope = $scope; + $this->redirect = $redirect; + + $this->state = self::generateState($clientId); + [$this->verifier, $this->verifierHash] = self::generateVerifier(); + } + + public function getClientId(): TwitterClientId { + return $this->clientId; + } + + public function getScope(): array { + return $this->scope; + } + + public function getRedirectUri(): string { + return $this->redirect; + } + + public function getState(): string { + return $this->state; + } + + public function getVerifier(): string { + return $this->verifier; + } + + public function getVerifierHash(): string { + return $this->verifierHash; + } + + public function getUri(): string { + return self::AUTHORIZE . '?' . http_build_query([ + 'response_type' => 'code', + 'client_id' => $this->clientId->getClientId(), + 'redirect_uri' => $this->redirect, + 'scope' => implode(' ', $this->scope), + 'state' => $this->state, + 'code_challenge' => $this->verifierHash, + 'code_challenge_method' => 'S256', + ], '', null, PHP_QUERY_RFC3986); + } + + public static function generateVerifier(): array { + $verifier = XString::random(self::VERIFIER_LENGTH); + return [ + $verifier, + Serialiser::uriBase64()->serialise(hash('sha256', $verifier, true)), + ]; + } + + private static function currentStateTime(): int { + return time() - self::STATE_EPOCH; + } + + public static function generateState(TwitterClientId $clientId): string { + $rng = XString::random(self::STATE_RNG_LENGTH); + $time = self::currentStateTime(); + + $string = $rng . ':' . (string)$time; + $hash = hash_hmac('sha256', $string, $clientId->getClientSecret(), true); + + $time = Serialiser::base62()->serialise($time); + $hash = Serialiser::uriBase64()->serialise($hash); + + return $rng . '.' . $time . '.' . $hash; + } + + public static function verifyState(TwitterClientId $clientId, string $state): bool { + $parts = explode('.', $state, 4); + if(count($parts) !== 3) + return false; + + $rng = $parts[0]; + if(strlen($rng) !== self::STATE_RNG_LENGTH) + return false; + + $currentTime = self::currentStateTime(); + $time = Serialiser::base62()->deserialise($parts[1]); + if($currentTime < $time || $currentTime >= ($time + self::STATE_TOLERANCE)) + return false; + + $hash = Serialiser::uriBase64()->deserialise($parts[2]); + if(strlen($hash) !== 32) + return false; + + $string = $rng . ':' . (string)$time; + $realHash = hash_hmac('sha256', $string, $clientId->getClientSecret(), true); + + return hash_equals($realHash, $hash); + } +} diff --git a/src/Twitter/TwitterClient.php b/src/Twitter/TwitterClient.php new file mode 100644 index 0000000..7a3d9ca --- /dev/null +++ b/src/Twitter/TwitterClient.php @@ -0,0 +1,188 @@ +clientId; + } + public function hasClientId(): bool { + return $this->clientId->hasClientId(); + } + + public function getAccessToken(): TwitterAccessToken { + return $this->accessToken; + } + public function hasAccessToken(): bool { + return $this->accessToken->hasAccessToken(); + } + public function hasRefreshToken(): bool { + return $this->accessToken->hasRefreshToken(); + } + + public function authorise(array $scope, string $redirect): TwitterAuthorisation { + return new TwitterAuthorisation($this->clientId, $scope, $redirect); + } + + public function token(string $code, string $verifier, string $redirect): object { + if(!$this->clientId->hasClientId()) + throw new RuntimeException('Need OAuth2 info in order to manage tokens.'); + + $req = json_decode(self::request('POST', self::API_OAUTH2_TOKEN, [ + 'Authorization: ' . (string)$this->clientId, + ], [], [ + 'code' => $code, + 'grant_type' => 'authorization_code', + 'code_verifier' => $verifier, + 'redirect_uri' => $redirect, // needed because???????? + ], false)); + + if($req === false) + return new RuntimeException('Unable to parse token response.'); + + return $req; + } + + public function authRefresh(): object { + if(!$this->clientId->hasClientId()) + throw new RuntimeException('Need OAuth2 info in order to manage tokens.'); + if(!$this->accessToken->hasRefreshToken()) + throw new RuntimeException('There is no refresh token.'); + + $req = json_decode(self::request('POST', self::API_OAUTH2_TOKEN, [ + 'Authorization: ' . (string)$this->clientId, + ], [], [ + 'refresh_token' => $this->accessToken->getRefreshToken(), + 'grant_type' => 'refresh_token', + ], false)); + + if($req === false) + return new RuntimeException('Unable to parse token response.'); + + return $req; + } + + public function authRevoke(): object { + if(!$this->clientId->hasClientId()) + throw new RuntimeException('Need OAuth2 info in order to manage tokens.'); + if(!$this->accessToken->hasAccessToken()) + throw new RuntimeException('Cannot revoke an access token we do not have.'); + + $req = json_decode(self::request('POST', self::API_OAUTH2_REVOKE, [ + 'Authorization: ' . (string)$this->clientId, + ], [], [ + 'token' => $this->accessToken->getAccessToken(), + 'token_type_hint' => 'access_token', + ], false)); + + if($req === false) + return new RuntimeException('Unable to parse token response.'); + + return $req; + } + + public function sendTweet(string $text): object { + if(!$this->accessToken->hasAccessToken()) + throw new RuntimeException('Need access token in order to post Tweets.'); + + $req = json_decode(self::request('POST', self::API_TWEETS, [ + 'Authorization: ' . (string)$this->accessToken, + ], [], [ + 'text' => $text, + ])); + + if($req === false) + return new RuntimeException('Unable to parse Tweet response.'); + + return $req; + } + + public function request( + string $method, + string $uri, + array $headers = [], + array $queryFields = [], + mixed $bodyFields = [], + bool $bodyAsJson = true, + ): string|bool { + if(!empty($queryFields)) + $uri .= '?' . http_build_query($queryFields, '', null, PHP_QUERY_RFC3986); + + $curl = curl_init($uri); + curl_setopt_array($curl, [ + CURLOPT_AUTOREFERER => true, + CURLOPT_FAILONERROR => false, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TCP_NODELAY => true, + CURLOPT_HEADER => false, + CURLOPT_NOBODY => false, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TCP_FASTOPEN => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_TIMEOUT => 2, + CURLOPT_USERAGENT => 'Misuzu TwitterClient/20230105', + ]); + + if($method === 'GET') + curl_setopt($curl, CURLOPT_HTTPGET, true); + elseif($method === 'HEAD') { + curl_setopt($curl, CURLOPT_HEADER, true); + curl_setopt($curl, CURLOPT_NOBODY, true); + } elseif($method === 'POST') + curl_setopt($curl, CURLOPT_POST, true); + else + curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method); + + if(!empty($bodyFields)) { + if($bodyAsJson) { + $headers[] = 'Content-Type: application/json'; + $bodyFields = json_encode($bodyFields); + } elseif(is_array($bodyFields)) { + $headers[] = 'Content-Type: application/x-www-form-urlencoded'; + $bodyFields = http_build_query($bodyFields, '', null, PHP_QUERY_RFC3986); + } + + curl_setopt($curl, CURLOPT_POSTFIELDS, $bodyFields); + } + + if(!empty($headers)) + curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); + + $out = curl_exec($curl); + curl_close($curl); + + return $out; + } + + public static function create(IConfig $config): self { + return new static( + TwitterClientId::load($config->scopeTo('oauth2')), + TwitterAccessToken::load($config->scopeTo('access')) + ); + } +} diff --git a/src/Twitter/TwitterClientId.php b/src/Twitter/TwitterClientId.php new file mode 100644 index 0000000..3ade07e --- /dev/null +++ b/src/Twitter/TwitterClientId.php @@ -0,0 +1,36 @@ +clientId !== '' && $this->clientSecret !== ''; + } + + public function getClientId(): string { + return $this->clientId; + } + + public function getClientSecret(): string { + return $this->clientSecret; + } + + public function __toString(): string { + return 'Basic ' . base64_encode($this->clientId . ':' . $this->clientSecret); + } + + public static function load(IConfig $config): self { + return new static( + $config->getValue('clientId', CfgType::T_STR), + $config->getValue('clientSecret', CfgType::T_STR) + ); + } +} diff --git a/src/Twitter/TwitterRoutes.php b/src/Twitter/TwitterRoutes.php new file mode 100644 index 0000000..23bbca3 --- /dev/null +++ b/src/Twitter/TwitterRoutes.php @@ -0,0 +1,53 @@ +context = $ctx; + $this->config = $config; + + $router->get('/_twitter/callback', [$this, 'callback']); + } + + private function getClientId(): TwitterClientId { + if($this->clientId === null) + $this->clientId = TwitterClientId::load($this->config->scopeTo('oauth2')); + return $this->clientId; + } + + public function callback($response, $request) { + $qState = (string)$request->getParam('state'); + $qCode = (string)$request->getParam('code'); + $cVerifier = (string)$request->getCookie('msz_twitter'); + + if(empty($qState) || empty($qCode) || empty($cVerifier)) + return 400; + + $response->removeCookie('msz_twitter', '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); + + $clientId = $this->getClientId(); + if(!TwitterAuthorisation::verifyState($clientId, $qState)) + return 403; + + $accessToken = TwitterAccessToken::empty(); + $client = new TwitterClient($clientId, $accessToken); + + $redirect = url_prefix(false) . url('twitter-callback'); + $tokenInfo = TwitterAccessToken::fromTwitterResponse($client->token($qCode, $cVerifier, $redirect)); + TwitterAccessToken::save($this->config->scopeTo('access'), $tokenInfo); + + $response->redirect(url('manage-general-twitter')); + } +} diff --git a/src/manage.php b/src/manage.php index 70fe880..a249e3e 100644 --- a/src/manage.php +++ b/src/manage.php @@ -17,6 +17,8 @@ function manage_get_menu(int $userId): array { $menu['General']['Settings'] = url('manage-general-settings'); if(perms_check_user(MSZ_PERMS_GENERAL, $userId, MSZ_PERM_GENERAL_MANAGE_BLACKLIST)) $menu['General']['IP Blacklist'] = url('manage-general-blacklist'); + if(perms_check_user(MSZ_PERMS_GENERAL, $userId, MSZ_PERM_GENERAL_MANAGE_TWITTER)) + $menu['General']['Twitter Connection'] = url('manage-general-twitter'); if(perms_check_user(MSZ_PERMS_USER, $userId, MSZ_PERM_USER_MANAGE_USERS)) $menu['Users & Roles']['Users'] = url('manage-users'); @@ -147,6 +149,11 @@ function manage_perms_list(array $rawPerms): array { 'title' => 'Can manage blacklistings.', 'perm' => MSZ_PERM_GENERAL_MANAGE_BLACKLIST, ], + [ + 'section' => 'manage-twitter', + 'title' => 'Can manage Twitter connection.', + 'perm' => MSZ_PERM_GENERAL_MANAGE_TWITTER, + ], ], ], [ diff --git a/src/perms.php b/src/perms.php index e36e5fb..5936106 100644 --- a/src/perms.php +++ b/src/perms.php @@ -6,6 +6,7 @@ define('MSZ_PERM_GENERAL_MANAGE_EMOTES', 0x00000004); define('MSZ_PERM_GENERAL_MANAGE_CONFIG', 0x00000008); define('MSZ_PERM_GENERAL_IS_TESTER', 0x00000010); define('MSZ_PERM_GENERAL_MANAGE_BLACKLIST', 0x00000020); +define('MSZ_PERM_GENERAL_MANAGE_TWITTER', 0x00000040); define('MSZ_PERMS_USER', 'user'); define('MSZ_PERM_USER_EDIT_PROFILE', 0x00000001); diff --git a/src/url.php b/src/url.php index 51d00e5..1cb2f81 100644 --- a/src/url.php +++ b/src/url.php @@ -85,11 +85,14 @@ define('MSZ_URLS', [ 'comment-pin' => ['/comments.php', ['c' => '', 'csrf' => '{csrf}', 'm' => 'pin']], 'comment-unpin' => ['/comments.php', ['c' => '', 'csrf' => '{csrf}', 'm' => 'unpin']], + 'twitter-callback' => ['/_twitter/callback'], + 'manage-index' => ['/manage'], 'manage-general-overview' => ['/manage/general'], 'manage-general-logs' => ['/manage/general/logs.php'], 'manage-general-blacklist' => ['/manage/general/blacklist.php'], + 'manage-general-twitter' => ['/manage/general/twitter.php'], 'manage-general-emoticons' => ['/manage/general/emoticons.php'], 'manage-general-emoticon' => ['/manage/general/emoticon.php', ['e' => '']], diff --git a/templates/manage/general/twitter.twig b/templates/manage/general/twitter.twig new file mode 100644 index 0000000..f114ad1 --- /dev/null +++ b/templates/manage/general/twitter.twig @@ -0,0 +1,40 @@ +{% extends 'manage/general/master.twig' %} +{% from 'macros.twig' import container_title %} + +{% block manage_content %} +
+ {{ container_title(' Twitter Connection') }} + +
+ Manages the Twitter connection for announcing news posts on the Twitter account. +
+ + {% if twitter_has_oauth2 %} + {% if twitter_has_access %} +
+ A Twitter user has been authenticated. + Current access token expires . +
+
+ {% if twitter_has_refresh %} + Refresh Access + {% endif %} + Revoke Access +
+ {% else %} +
+ No Twitter user has been authorised yet. + Before beginning authorization, make sure you're logged into Twitter with the desired user. +
+ + {% endif %} + {% else %} +
+ Twitter OAuth2 credentials have not been registered. + Add them through Settings as twitter.oauth2.clientId and twitter.oauth2.clientSecret. +
+ {% endif %} +
+{% endblock %} diff --git a/utility.php b/utility.php index 60cb7e7..4dcd629 100644 --- a/utility.php +++ b/utility.php @@ -1,5 +1,5 @@