Rewrote Twitter connection (v1.1 -> v2).

This commit is contained in:
flash 2023-01-05 03:20:31 +00:00
parent cb40fdc7c4
commit eafdc28d5e
22 changed files with 647 additions and 344 deletions

View file

@ -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"

219
composer.lock generated
View file

@ -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"
}

@ -1 +1 @@
Subproject commit 66a35f030f9eec02d8e51710c7de2161d2a1796f
Subproject commit 6e099040138fb0f53d0d179a740c0a1ec5bd5258

View file

@ -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.

3
msz
View file

@ -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));

View file

@ -0,0 +1,61 @@
<?php
namespace Misuzu;
use Misuzu\Config\CfgType;
use Misuzu\Users\User;
use Misuzu\Twitter\TwitterAccessToken;
use Misuzu\Twitter\TwitterClient;
require_once '../../../misuzu.php';
if(!User::hasCurrent() || !perms_check_user(MSZ_PERMS_GENERAL, User::getCurrent()->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,
]);

View file

@ -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}");
}
}

View file

@ -1,6 +1,12 @@
<?php
namespace Misuzu\Config;
// getValue (and hasValue?) should probably be replaced with something that allows grouped loading
// that way the entire config doesn't have to be kept in memory on every request
// this probably has to be delayed until the backwards compat static Config object isn't needed anymore
// otherwise there'd be increased overhead
// bulk operations for setValue and removeValue would also be cool
interface IConfig {
function scopeTo(string $prefix): IConfig;
function getNames(): array;

View file

@ -2,10 +2,18 @@
namespace Misuzu\Console\Commands;
use Misuzu\DB;
use Misuzu\MszContext;
use Misuzu\Console\CommandArgs;
use Misuzu\Console\CommandInterface;
use Misuzu\Twitter\TwitterAccessToken;
class CronCommand implements CommandInterface {
private MszContext $context;
public function __construct(MszContext $ctx) {
$this->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',
],
];
}

View file

@ -1,51 +0,0 @@
<?php
namespace Misuzu\Console\Commands;
use Misuzu\Config;
use Misuzu\Config\CfgType;
use Misuzu\Twitter;
use Misuzu\Console\CommandArgs;
use Misuzu\Console\CommandInterface;
class TwitterAuthCommand implements CommandInterface {
public function getName(): string {
return 'twitter-auth';
}
public function getSummary(): string {
return 'Creates Twitter authentication tokens.';
}
public function dispatch(CommandArgs $args): void {
$apiKey = Config::get('twitter.api.key', CfgType::T_STR);
$apiSecret = Config::get('twitter.api.secret', CfgType::T_STR);
if(empty($apiKey) || empty($apiSecret)) {
echo 'No Twitter api keys set in config.' . PHP_EOL;
return;
}
Twitter::init($apiKey, $apiSecret);
echo 'Twitter Authentication' . PHP_EOL;
$authPage = Twitter::createAuth();
if(empty($authPage)) {
echo 'Request to begin authentication failed.' . PHP_EOL;
return;
}
echo 'Go to the page below and paste the pin code displayed.' . PHP_EOL . $authPage . PHP_EOL;
$pin = readline('Pin: ');
$authComplete = Twitter::completeAuth($pin);
if(empty($authComplete)) {
echo 'Invalid pin code.' . PHP_EOL;
return;
}
echo 'Authentication successful!' . PHP_EOL
. "Token: {$authComplete['token']}" . PHP_EOL
. "Token Secret: {$authComplete['token_secret']}" . PHP_EOL;
}
}

View file

@ -5,6 +5,8 @@ use Misuzu\Template;
use Misuzu\Config\IConfig;
use Misuzu\SharpChat\SharpChatRoutes;
use Misuzu\Users\Users;
use Misuzu\Twitter\TwitterClient;
use Misuzu\Twitter\TwitterRoutes;
use Index\Data\IDbConnection;
use Index\Http\HttpFx;
use Index\Http\HttpRequest;
@ -42,6 +44,10 @@ class MszContext {
return $this->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 {

View file

@ -1,59 +0,0 @@
<?php
namespace Misuzu;
use Codebird\Codebird;
final class Twitter {
public static function init(
string $apiKey,
string $apiSecretKey,
?string $token = null,
?string $tokenSecret = null
): void {
Codebird::setConsumerKey($apiKey, $apiSecretKey);
if($token !== null && $tokenSecret !== null) {
self::setToken($token, $tokenSecret);
}
}
public static function setToken(string $token, string $tokenSecret): void {
Codebird::getInstance()->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,
]);
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace Misuzu\Twitter;
use Stringable;
use Misuzu\Config\IConfig;
use Misuzu\Config\CfgType;
class TwitterAccessToken implements Stringable {
public function __construct(
private string $type,
private string $accessToken,
private int $expires,
private array $scope,
private string $refreshToken
) {}
public function getType(): string {
return $this->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');
}
}

View file

@ -0,0 +1,115 @@
<?php
namespace Misuzu\Twitter;
use Index\XString;
use Index\Serialisation\Serialiser;
class TwitterAuthorisation {
private const AUTHORIZE = 'https://twitter.com/i/oauth2/authorize';
private const STATE_RNG_LENGTH = 16;
private const STATE_EPOCH = 1661126400;
private const STATE_TOLERANCE = 5 * 60;
private const VERIFIER_LENGTH = 48;
private TwitterClientId $clientId;
private array $scope;
private string $redirect;
private string $state;
private string $verifier;
private string $verifierHash;
public function __construct(TwitterClientId $clientId, array $scope, string $redirect) {
$this->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);
}
}

View file

@ -0,0 +1,188 @@
<?php
namespace Misuzu\Twitter;
use RuntimeException;
use Misuzu\Config\IConfig;
class TwitterClient {
public const SYSTEM_SCOPES = [
'tweet.read', 'tweet.write',
'users.read', 'offline.access',
'follows.read', 'follows.write',
'like.read', 'like.write',
];
private const API_BASE = 'https://api.twitter.com';
private const API_V2 = self::API_BASE . '/2';
private const API_OAUTH2 = self::API_V2 . '/oauth2';
private const API_OAUTH2_TOKEN = self::API_OAUTH2 . '/token';
private const API_OAUTH2_REVOKE = self::API_OAUTH2 . '/revoke';
private const API_TWEETS = self::API_V2 . '/tweets';
public function __construct(
private TwitterClientId $clientId,
private TwitterAccessToken $accessToken
) {}
public function getClientId(): TwitterClientId {
return $this->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'))
);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace Misuzu\Twitter;
use Stringable;
use Misuzu\Config\IConfig;
use Misuzu\Config\CfgType;
class TwitterClientId implements Stringable {
public function __construct(
private string $clientId,
private string $clientSecret
) {}
public function hasClientId(): bool {
return $this->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)
);
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Misuzu\Twitter;
use Index\Http\HttpFx;
use Misuzu\MszContext;
use Misuzu\Config\IConfig;
use Misuzu\Twitter\TwitterAccessToken;
use Misuzu\Twitter\TwitterAuthorisation;
use Misuzu\Twitter\TwitterClient;
use Misuzu\Twitter\TwitterClientId;
final class TwitterRoutes {
private MszContext $context;
private IConfig $config;
private ?TwitterClientId $clientId = null;
public function __construct(MszContext $ctx, HttpFx $router, IConfig $config) {
$this->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'));
}
}

View file

@ -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,
],
],
],
[

View file

@ -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);

View file

@ -85,11 +85,14 @@ define('MSZ_URLS', [
'comment-pin' => ['/comments.php', ['c' => '<comment>', 'csrf' => '{csrf}', 'm' => 'pin']],
'comment-unpin' => ['/comments.php', ['c' => '<comment>', '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' => '<emote>']],

View file

@ -0,0 +1,40 @@
{% extends 'manage/general/master.twig' %}
{% from 'macros.twig' import container_title %}
{% block manage_content %}
<div class="container manage-settings">
{{ container_title('<i class="fab fa-twitter fa-fw"></i> Twitter Connection') }}
<div class="manage__description">
Manages the Twitter connection for announcing news posts on the Twitter account.
</div>
{% if twitter_has_oauth2 %}
{% if twitter_has_access %}
<div style="padding: 2px 5px">
A Twitter user has been authenticated.
Current access token expires <time datetime="{{ twitter_expires|date('c') }}" title="{{ twitter_expires|date('r') }}">{{ twitter_expires|time_diff }}</time>.
</div>
<div class="manage__emote__actions">
{% if twitter_has_refresh %}
<a class="input__button" href="?m=refresh&amp;csrf={{ csrf_token() }}">Refresh Access</a>
{% endif %}
<a class="input__button" href="?m=revoke&amp;csrf={{ csrf_token() }}">Revoke Access</a>
</div>
{% else %}
<div style="padding: 2px 5px">
No Twitter user has been authorised yet.
Before beginning authorization, make sure you're logged into Twitter with the desired user.
</div>
<div class="manage__emote__actions">
<a class="input__button" href="?m=authorise&amp;csrf={{ csrf_token() }}">Begin Authorisation</a>
</div>
{% endif %}
{% else %}
<div style="padding: 2px 5px">
Twitter OAuth2 credentials have not been registered.
Add them through <a href="{{ url('manage-general-settings') }}" class="link">Settings</a> as <a href="{{ url('manage-general-setting', {'name': 'twitter.oauth2.clientId', 'type': 'string'}) }}" class="link"><code>twitter.oauth2.clientId</code></a> and <a href="{{ url('manage-general-setting', {'name': 'twitter.oauth2.clientSecret', 'type': 'string'}) }}" class="link"><code>twitter.oauth2.clientSecret</code></a>.
</div>
{% endif %}
</div>
{% endblock %}

View file

@ -1,5 +1,5 @@
<?php
use \Index\Colour\Colour;
use Index\Colour\Colour;
function array_test(array $array, callable $func): bool {
foreach($array as $value)