<?php
namespace App\Service\Social;
use App\Util\Json;
use Facebook\Facebook;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\Request;
abstract class AbstractMetaService
{
public const NAME = null;
private const SCOPES = [
// common scopes
'email',
// facebook scopes
'pages_manage_metadata',
'pages_manage_posts',
'pages_read_engagement',
'pages_show_list',
// instagram scopes
'instagram_basic',
'instagram_content_publish',
// business scopes
'business_management',
];
protected const CHECK_SCOPES = [
'email',
];
protected const TASKS = [
'CREATE_CONTENT',
];
protected MetaPersistentDataHandler $metaPersistentDataHandler;
/**
* @var Facebook
*/
protected Facebook $client;
/**
* @param MetaPersistentDataHandler $metaPersistentDataHandler
* @param ParameterBagInterface $params
*/
public function __construct(
MetaPersistentDataHandler $metaPersistentDataHandler,
ParameterBagInterface $params
)
{
$this->metaPersistentDataHandler = $metaPersistentDataHandler;
// TODO: likely should update the param to reflect generic "meta" naming...
if ( ! $params->has('facebook')) {
throw new \LogicException();
}
$config = $params->get('facebook');
$this->client = new Facebook(
[
'app_id' => $config['key'],
'app_secret' => $config['secret'],
'default_graph_version' => 'v20.0',
'persistent_data_handler' => $metaPersistentDataHandler,
],
);
}
/**
* Get the URL to start the OAuth flow with.
*
* @param string $redirect
* @param array $state
* @return string
*/
public function requestAuthenticationUrl(
string $redirect,
array $state = []
): string
{
if ($state) {
$this->metaPersistentDataHandler->set(
'state',
base64_encode(
Json::encode($state),
),
);
}
return $this->client->getRedirectLoginHelper()->getLoginUrl(
$redirect,
self::SCOPES,
);
}
/**
* @param Request $request
* @return array
*/
public function parseAuthenticationState(Request $request): array
{
$requestState = $request->query->get('state');
$sessionState = $this->metaPersistentDataHandler->get('state');
if ($requestState !== $sessionState) {
throw new \RuntimeException();
}
return Json::decode(
base64_decode($requestState),
true,
);
}
/**
* Use this on the return from an OAuth authentication flow.
*
* @param string $redirect
* @return string
*/
public function processAuthenticationCallback(string $redirect): string
{
// check for any errors
if ($code = $this->client->getRedirectLoginHelper()->getErrorCode()) {
throw new \RuntimeException(
sprintf(
'Facebook authentication error: %s.',
$code,
),
);
}
// get an access token
// this will be likely be a short-lived user token
$accessToken = $this->client->getRedirectLoginHelper()->getAccessToken(
$redirect,
);
// if we didn't get an access token for some reason, we have a problem
if ( ! $accessToken) {
throw new \RuntimeException(
'Facebook returned an empty access token.',
);
}
// if the token is not a long-lived one, then we need to attempt to get a long-lived token
if ( ! $accessToken->isLongLived()) {
$accessToken = $this->client->getOAuth2Client()->getLongLivedAccessToken(
$accessToken,
);
}
// TODO: should we eventually return the full token so we can maybe log more information about it (like expiration)???
return $accessToken->getValue();
}
/**
* Gets data about the user for the given access token.
*
* @param string $accessToken
* @return object
*/
public function me(string $accessToken): object
{
$result = $this->client->get(
'/me',
$accessToken,
);
if ($result->isError()) {
$result->throwException();
}
$data = $result->getDecodedBody();
return (object) [
'id' => $data['id'],
'name' => $data['name'],
];
}
/**
* Check if page publishing permissions are set for a user.
*
* @param string $accessToken
* @return bool
*/
public function hasRequiredScopes(string $accessToken): bool
{
$result = $this->client->get(
'/me/permissions',
$accessToken
);
if ($result->isError()) {
$result->throwException();
}
$scopes = [];
foreach (static::CHECK_SCOPES as $scope) {
$scopes[$scope] = false;
foreach ($result->getDecodedBody()['data'] as $perm) {
if ($perm['permission'] === $scope) {
$scopes[$scope] = ($perm['status'] === 'granted');
break;
}
}
}
$missing = array_filter(
$scopes,
static function (bool $granted) {
return !$granted;
},
);
return (count($missing) === 0);
}
/**
* @param string $accessToken
* @return array<object>
*/
abstract public function getAccounts(string $accessToken): array;
/**
* Will get a normalized list of pages a user has access to.
*
* @param string $accessToken
* @return array<object>
*/
protected function getPages(string $accessToken): array
{
$result = $this->client->get(
'/me/accounts',
$accessToken,
);
if ($result->isError()) {
$result->throwException();
}
$pages = [];
foreach ($result->getDecodedBody()['data'] as $data) {
if ($this->hasRequiredTasks($data['tasks'] ?? null)) {
$pages[$data['id']] = (object) [
'id' => $data['id'],
'name' => $data['name'],
'token' => $data['access_token'],
];
}
}
return $pages;
}
/**
* Determines whether a set of params for page access are sufficient.
*
* @param array|null $tasks
* @return bool
*/
private function hasRequiredTasks(?array $tasks): bool
{
// if the data set did not have tasks defined (so null input), we can assume tasks are not used...
if ($tasks === null) {
return true;
}
// otherwise, tasks are defined
// check for all the ones we need to have for our system to work
foreach (self::TASKS as $task) {
if ( ! in_array($task, $tasks, true)) {
return false;
}
}
// no rejection yet, which means we should have matched everything we need
return true;
}
}