├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config └── services.php ├── contao ├── dca │ └── tl_module.php ├── languages │ ├── de │ │ ├── modules.php │ │ └── tl_module.php │ └── en │ │ ├── modules.php │ │ └── tl_module.php └── templates │ └── modules │ └── mod_cfg_instagram.html5 ├── docs ├── README.md └── images │ ├── instagram-1.png │ ├── instagram-2.png │ └── preview.png └── src ├── CodefogInstagramBundle.php ├── ContaoManager └── Plugin.php ├── Controller ├── FrontendModule │ └── InstagramController.php └── InstagramController.php ├── Cron └── RefreshAccessTokenCron.php ├── DependencyInjection ├── CodefogInstagramExtension.php └── Configuration.php ├── EventListener └── ModuleListener.php └── InstagramClient.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: codefog 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Codefog 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Instagram Bundle for Contao Open Source CMS 2 | 3 | ![](https://img.shields.io/packagist/v/codefog/contao-instagram.svg) 4 | ![](https://img.shields.io/packagist/l/codefog/contao-instagram.svg) 5 | ![](https://img.shields.io/packagist/dt/codefog/contao-instagram.svg) 6 | 7 | Instagram is a bundle for the [Contao Open Source CMS](https://contao.org). 8 | 9 | Contao bundle that allows to display the Instagram recent user feed on your website. It allows to specify you 10 | the number of items displayed and comes up with a simple cache system. 11 | 12 | **Note:** the bundle requires an Instagram account and Facebook App to work! 13 | 14 | ![](docs/images/preview.png) 15 | 16 | ## Installation 17 | 18 | Install the bundle via Composer: 19 | 20 | ``` 21 | composer require codefog/contao-instagram 22 | ``` 23 | 24 | ## Documentation 25 | 26 | [Read the documentation](docs/README.md) 27 | 28 | ## Copyright 29 | 30 | This project has been created and is maintained by [Codefog](https://codefog.pl). 31 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codefog/contao-instagram", 3 | "description": "Instagram bundle for Contao Open Source CMS", 4 | "keywords": [ 5 | "contao", 6 | "instagram", 7 | "feed" 8 | ], 9 | "type": "contao-bundle", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Codefog", 14 | "homepage": "https://codefog.pl" 15 | }, 16 | { 17 | "name": "Kamil Kuzminski", 18 | "homepage": "https://github.com/qzminski", 19 | "role": "developer" 20 | } 21 | ], 22 | "require": { 23 | "php": "^8.1", 24 | "ext-json": "*", 25 | "contao/core-bundle": "^5.3" 26 | }, 27 | "require-dev": { 28 | "contao/manager-plugin": "^2.0" 29 | }, 30 | "conflict": { 31 | "contao/manager-plugin": "<2.0 || >=3.0" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Codefog\\InstagramBundle\\": "src/" 36 | } 37 | }, 38 | "extra": { 39 | "contao-manager-plugin": "Codefog\\InstagramBundle\\ContaoManager\\Plugin" 40 | }, 41 | "config": { 42 | "allow-plugins": { 43 | "contao-components/installer": true, 44 | "ocramius/package-versions": true, 45 | "contao-community-alliance/composer-plugin": true, 46 | "contao/manager-plugin": true, 47 | "php-http/discovery": true 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | services(); 9 | 10 | $services->defaults() 11 | ->autoconfigure() 12 | ->autowire() 13 | ->bind('$accessTokenTtl', '%instagram_access_token_ttl%') 14 | ->bind('$cacheTtl', '%instagram_cache_ttl%') 15 | ->bind('$projectDir', '%kernel.project_dir%') 16 | ; 17 | 18 | $services 19 | ->load('Codefog\\InstagramBundle\\', __DIR__ . '/../src') 20 | ->exclude(__DIR__ . '/../src/ContaoManager') 21 | ->exclude(__DIR__ . '/../src/FrontendModule') 22 | ; 23 | 24 | $services->set(\Codefog\InstagramBundle\Controller\InstagramController::class)->public(); 25 | $services->set(\Codefog\InstagramBundle\EventListener\ModuleListener::class)->public(); 26 | }; 27 | -------------------------------------------------------------------------------- /contao/dca/tl_module.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Kamil Kuzminski 10 | * @license MIT 11 | */ 12 | 13 | /** 14 | * Add global callbacks. 15 | */ 16 | $GLOBALS['TL_DCA']['tl_module']['config']['onsubmit_callback'][] = [\Codefog\InstagramBundle\EventListener\ModuleListener::class, 'onSubmitCallback']; 17 | 18 | /* 19 | * Add palettes 20 | */ 21 | $GLOBALS['TL_DCA']['tl_module']['subpalettes']['cfg_instagramStoreFiles'] = 'cfg_instagramStoreFolder,imgSize'; 22 | 23 | $GLOBALS['TL_DCA']['tl_module']['palettes']['__selector__'][] = 'cfg_instagramStoreFiles'; 24 | $GLOBALS['TL_DCA']['tl_module']['palettes']['cfg_instagram'] = '{title_legend},name,headline,type;{config_legend},cfg_instagramAppId,cfg_instagramAppSecret,cfg_instagramAccessToken,cfg_instagramRequestToken,numberOfItems,cfg_skipSslVerification,cfg_instagramFetchComments,cfg_instagramMediaTypes,cfg_instagramStoreFiles;{template_legend:hide},customTpl;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID,space'; 25 | 26 | /* 27 | * Add fields 28 | */ 29 | $GLOBALS['TL_DCA']['tl_module']['fields']['cfg_instagramAccessTokenTstamp'] = [ 30 | 'eval' => ['rgxp' => 'datim'], 31 | 'sql' => "int(10) unsigned NOT NULL default '0'", 32 | ]; 33 | 34 | $GLOBALS['TL_DCA']['tl_module']['fields']['cfg_instagramAppId'] = [ 35 | 'label' => &$GLOBALS['TL_LANG']['tl_module']['cfg_instagramAppId'], 36 | 'exclude' => true, 37 | 'inputType' => 'text', 38 | 'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'], 39 | 'sql' => "varchar(255) NOT NULL default ''", 40 | ]; 41 | 42 | $GLOBALS['TL_DCA']['tl_module']['fields']['cfg_instagramAppSecret'] = [ 43 | 'label' => &$GLOBALS['TL_LANG']['tl_module']['cfg_instagramAppSecret'], 44 | 'exclude' => true, 45 | 'inputType' => 'text', 46 | 'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'], 47 | 'sql' => "varchar(255) NOT NULL default ''", 48 | ]; 49 | 50 | $GLOBALS['TL_DCA']['tl_module']['fields']['cfg_instagramAccessToken'] = [ 51 | 'label' => &$GLOBALS['TL_LANG']['tl_module']['cfg_instagramAccessToken'], 52 | 'exclude' => true, 53 | 'inputType' => 'text', 54 | 'eval' => ['readonly' => true, 'tl_class' => 'w50'], 55 | 'sql' => "varchar(255) NOT NULL default ''", 56 | ]; 57 | 58 | $GLOBALS['TL_DCA']['tl_module']['fields']['cfg_instagramRequestToken'] = [ 59 | 'label' => &$GLOBALS['TL_LANG']['tl_module']['cfg_instagramRequestToken'], 60 | 'exclude' => true, 61 | 'inputType' => 'checkbox', 62 | 'eval' => ['doNotSaveEmpty' => true, 'tl_class' => 'w50 m12'], 63 | 'save_callback' => [ 64 | [\Codefog\InstagramBundle\EventListener\ModuleListener::class, 'onRequestTokenSave'], 65 | ], 66 | ]; 67 | 68 | $GLOBALS['TL_DCA']['tl_module']['fields']['cfg_skipSslVerification'] = [ 69 | 'label' => &$GLOBALS['TL_LANG']['tl_module']['cfg_skipSslVerification'], 70 | 'exclude' => true, 71 | 'inputType' => 'checkbox', 72 | 'eval' => ['tl_class' => 'w50 m12'], 73 | 'sql' => "char(1) NOT NULL default ''", 74 | ]; 75 | 76 | $GLOBALS['TL_DCA']['tl_module']['fields']['cfg_instagramFetchComments'] = [ 77 | 'label' => &$GLOBALS['TL_LANG']['tl_module']['cfg_instagramFetchComments'], 78 | 'exclude' => true, 79 | 'inputType' => 'checkbox', 80 | 'eval' => ['tl_class' => 'clr'], 81 | 'sql' => "char(1) NOT NULL default ''", 82 | ]; 83 | 84 | $GLOBALS['TL_DCA']['tl_module']['fields']['cfg_instagramMediaTypes'] = [ 85 | 'label' => &$GLOBALS['TL_LANG']['tl_module']['cfg_instagramMediaTypes'], 86 | 'exclude' => true, 87 | 'inputType' => 'checkbox', 88 | 'options' => ['IMAGE', 'VIDEO', 'CAROUSEL_ALBUM'], 89 | 'reference' => &$GLOBALS['TL_LANG']['tl_module']['cfg_instagramMediaTypesRef'], 90 | 'eval' => ['mandatory' => true, 'multiple' => true, 'tl_class' => 'clr'], 91 | 'sql' => ['type' => 'string', 'length' => 255, 'default' => 'a:3:{i:0;s:5:"IMAGE";i:1;s:5:"VIDEO";i:2;s:14:"CAROUSEL_ALBUM";}'], 92 | ]; 93 | 94 | $GLOBALS['TL_DCA']['tl_module']['fields']['cfg_instagramStoreFiles'] = [ 95 | 'label' => &$GLOBALS['TL_LANG']['tl_module']['cfg_instagramStoreFiles'], 96 | 'exclude' => true, 97 | 'inputType' => 'checkbox', 98 | 'eval' => ['submitOnChange' => true, 'tl_class' => 'clr'], 99 | 'sql' => "char(1) NOT NULL default ''", 100 | ]; 101 | 102 | $GLOBALS['TL_DCA']['tl_module']['fields']['cfg_instagramStoreFolder'] = [ 103 | 'label' => &$GLOBALS['TL_LANG']['tl_module']['cfg_instagramStoreFolder'], 104 | 'exclude' => true, 105 | 'inputType' => 'fileTree', 106 | 'eval' => ['mandatory' => true, 'fieldType' => 'radio', 'tl_class' => 'clr'], 107 | 'sql' => 'binary(16) NULL', 108 | ]; 109 | -------------------------------------------------------------------------------- /contao/languages/de/modules.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Kamil Kuzminski 10 | * @license MIT 11 | */ 12 | 13 | /** 14 | * Frontend modules. 15 | */ 16 | $GLOBALS['TL_LANG']['FMD']['cfg_instagram'] = ['Instagram Feed']; 17 | -------------------------------------------------------------------------------- /contao/languages/de/tl_module.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Kamil Kuzminski 10 | * @license MIT 11 | */ 12 | 13 | /** 14 | * Fields. 15 | */ 16 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramAppId'] = ['Instagram App ID', 'Bitte geben Sie die Instagram App ID ein.']; 17 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramAppSecret'] = ['Instagram App Secret', 'Bitte geben Sie das Instagram App Secret ein.']; 18 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramAccessToken'] = ['Instagram Access Token', 'Dies ist ein automatich erzeugter Wert, welcher beim Abschicken des Formulars generiert wird.']; 19 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramAccessTokenTstamp'] = ['Letztes Update des Instagram Access Token']; 20 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramRequestToken'] = ['Access Token anfordern', 'Aktivieren Sie diese Option um einen neuen Instagram Access Token anzufordern.']; 21 | $GLOBALS['TL_LANG']['tl_module']['cfg_skipSslVerification'] = ['SSL-Verifikation überspringen', 'Die SSL-Verifikation während API-Anfragen überspringen (nicht empfohlen).']; 22 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramStoreFiles'] = ['Instagram Dateien speichern', 'Speichere die Instagram-Dateien lokal.']; 23 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramStoreFolder'] = ['Ordner für gespeicherte Instagram-Dateien', 'Wählen Sie den Ordner aus, in dem die Instagram-Dateien gespeichert werden sollen.']; 24 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramMediaTypes'] = ['Instagram-Mediatypen', 'Wählen Sie hier die Instagram-Mediatypen aus, die angezeigt werden sollen.']; 25 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramFetchComments'] = ['Kommentare abrufen', 'Aktivieren Sie diese Option, um die Kommentar abzurufen.']; 26 | -------------------------------------------------------------------------------- /contao/languages/en/modules.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Kamil Kuzminski 10 | * @license MIT 11 | */ 12 | 13 | /** 14 | * Frontend modules. 15 | */ 16 | $GLOBALS['TL_LANG']['FMD']['cfg_instagram'] = ['Instagram feed']; 17 | -------------------------------------------------------------------------------- /contao/languages/en/tl_module.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Kamil Kuzminski 10 | * @license MIT 11 | */ 12 | 13 | /** 14 | * Fields. 15 | */ 16 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramAppId'] = ['Instagram App ID', 'Please enter the Instagram App ID.']; 17 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramAppSecret'] = ['Instagram App Secret', 'Please enter the Instagram App Secret.']; 18 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramAccessToken'] = ['Instagram access token', 'This is an auto-generated value that will be filled in when you submit the form.']; 19 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramAccessTokenTstamp'] = ['Instagram access token last update']; 20 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramRequestToken'] = ['Request access token and update feed', 'Check this box and save the record to request the access token and update the feed.']; 21 | $GLOBALS['TL_LANG']['tl_module']['cfg_skipSslVerification'] = ['Skip SSL verification', 'Skip the SSL verification during API requests (not recommended).']; 22 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramStoreFiles'] = ['Store Instagram files', 'Store the Instagram files on locally.']; 23 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramStoreFolder'] = ['Instagram store folder', 'Please choose the Instagram store folder.']; 24 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramMediaTypes'] = ['Instagram media types', 'Here you can choose the Instagram media types that should be shown.']; 25 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramFetchComments'] = ['Fetch the comments data', 'Check this box to fetch the comments data.']; 26 | 27 | /** 28 | * Reference types 29 | */ 30 | $GLOBALS['TL_LANG']['tl_module']['cfg_instagramMediaTypesRef'] = [ 31 | 'CAROUSEL_ALBUM' => 'Carousel album', 32 | 'IMAGE' => 'Image', 33 | 'VIDEO' => 'Video', 34 | ]; 35 | -------------------------------------------------------------------------------- /contao/templates/modules/mod_cfg_instagram.html5: -------------------------------------------------------------------------------- 1 | extend('block_unsearchable'); ?> 2 | 3 | block('content'); ?> 4 | 5 | items as $item): ?> 6 | 15 | 16 | 17 | endblock(); ?> 18 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Instagram – Documentation 2 | 3 | ## Prerequisites 4 | 5 | You must own an [Instagram business account](https://help.instagram.com/502981923235522) to be able to use this extension and display the feed on your website. 6 | 7 | ## Create an Instagram app 8 | 9 | First of all you have to create the Instagram app. For that please follow the [official Getting Started guide](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/create-a-meta-app-with-instagram) 10 | up until point 6 (inclusive). 11 | 12 | Then, it is important to configure all app URIs, as described in Step 9 (Set up business login): 13 | 14 | - Valid OAuth Redirect URIs 15 | - Deauthorize Callback URL 16 | - Data Deletion Request Callback URL 17 | 18 | you have to enter your domain name + `/_instagram/auth` suffix (without a trailing slash!), e.g. `https://domain.tld/_instagram/auth`: 19 | 20 | ![](images/instagram-1.png) 21 | 22 | Here you should also copy the *Instagram App ID* and *Instagram App Secret* keys to your clipboard. 23 | 24 | 25 | ## Create a frontend module 26 | 27 | Now go to the Contao backend and create the `Instagram` front end module. Fill in the necessary data, 28 | check the `Request access token and update feed` checkboxbox and save the record. 29 | 30 | ![](images/instagram-2.png) 31 | 32 | If you have configured your app properly, you should now see the screen prompting you for the authorization. 33 | Click the button to authorize yourself for your app and you should be taken back to the Contao backend. 34 | 35 | Please ensure that the `Instagram access token` is now filled in. You can now safely add the module to the page. 36 | 37 | 38 | ## Template data 39 | 40 | The displayed template data out of box is very simple, as only the images are displayed. If you need more information, 41 | you should check out the `$this->items` and `$this->user` variables. 42 | 43 | You can do that by dumping the variables inside `mod_cfg_instagram.html5` template: 44 | 45 | ```php 46 | showTemplateVars(); ?> 47 | ``` 48 | 49 | 50 | ## Cache configuration 51 | 52 | By default, the Instagram data is cached for 1 hour. You can change it by adding the below configuration to your 53 | `config/config.yml` (or `app/config/config.yml` for Contao 4.4) file: 54 | 55 | ```yaml 56 | codefog_instagram: 57 | access_token_ttl: 86400 # access token local time to live after which Contao will make a request to refresh the token, in seconds 58 | cache_ttl: 3600 # feed data cache local time to live, in seconds 59 | ``` 60 | 61 | Afterwards you may need to rebuild the app cache, e.g. using Contao Manager! 62 | 63 | 64 | ## Cron job 65 | 66 | As of version 2.0, there is a cron job that refreshes the access token once per day 67 | 68 | 69 | ## Data restrictions 70 | 71 | Before you report any bugs regarding the missing Instagram feed data, be sure that you have read the official 72 | documentation that contains information about the data you can obtain from the API: 73 | 74 | 1. https://developers.facebook.com/docs/instagram-basic-display-api/ 75 | 76 | > As of version 2.0 of the extension and changes in Instagram API, it is no longer possible to filter media files 77 | > by a #hashtag. This option has been dropped in version 2.0.0 of the extension. 78 | 79 | 80 | ## Troubleshooting 81 | 82 | If at some point the extension does not work make sure to check the system logs. Please open an issue on Github 83 | if your error is not listed below. 84 | 85 | ### cURL error 3: malformed 86 | 87 | This error can happen occasionally and cause the feed to appear and disappear from the page (see #22). It is likely 88 | caused by SSL verification failure. To fix this problem, you should contact your hosting provider. Another solution 89 | would be to skip SSL verification in the frontend module settings (not recommended). 90 | 91 | ### Error message: Insufficient developer role 92 | 93 | Go to `Facebook Developers > App Roles > Roles` and click the `Add Instagram Testers` button. 94 | From the search list of the Instagram accounts, find your account you would like to connect. 95 | 96 | Then, log in to your Instagram account and go to the `Settings > Apps and websites > Tester Invites` and accept 97 | the invitation. 98 | 99 | After that, request the access token from within Contao once again. 100 | -------------------------------------------------------------------------------- /docs/images/instagram-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codefog/contao-instagram/2c213b88de84b838a9c4fdfe32548d43f6897a34/docs/images/instagram-1.png -------------------------------------------------------------------------------- /docs/images/instagram-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codefog/contao-instagram/2c213b88de84b838a9c4fdfe32548d43f6897a34/docs/images/instagram-2.png -------------------------------------------------------------------------------- /docs/images/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codefog/contao-instagram/2c213b88de84b838a9c4fdfe32548d43f6897a34/docs/images/preview.png -------------------------------------------------------------------------------- /src/CodefogInstagramBundle.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Kamil Kuzminski 10 | * @license MIT 11 | */ 12 | 13 | namespace Codefog\InstagramBundle; 14 | 15 | use Symfony\Component\HttpKernel\Bundle\Bundle; 16 | 17 | class CodefogInstagramBundle extends Bundle 18 | { 19 | public function getPath(): string 20 | { 21 | return \dirname(__DIR__); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ContaoManager/Plugin.php: -------------------------------------------------------------------------------- 1 | 11 | * @author Kamil Kuzminski 12 | * @license MIT 13 | */ 14 | 15 | namespace Codefog\InstagramBundle\ContaoManager; 16 | 17 | use Codefog\InstagramBundle\CodefogInstagramBundle; 18 | use Contao\CoreBundle\ContaoCoreBundle; 19 | use Contao\ManagerPlugin\Bundle\BundlePluginInterface; 20 | use Contao\ManagerPlugin\Bundle\Config\BundleConfig; 21 | use Contao\ManagerPlugin\Bundle\Parser\ParserInterface; 22 | use Contao\ManagerPlugin\Routing\RoutingPluginInterface; 23 | use Symfony\Component\Config\Loader\LoaderResolverInterface; 24 | use Symfony\Component\HttpKernel\KernelInterface; 25 | 26 | class Plugin implements BundlePluginInterface, RoutingPluginInterface 27 | { 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function getBundles(ParserInterface $parser): array 32 | { 33 | return [ 34 | BundleConfig::create(CodefogInstagramBundle::class)->setLoadAfter([ContaoCoreBundle::class]), 35 | ]; 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | public function getRouteCollection(LoaderResolverInterface $resolver, KernelInterface $kernel) 42 | { 43 | return $resolver->resolve('@CodefogInstagramBundle/src/Controller', 'attribute')->load(__DIR__.'/../Controller'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Controller/FrontendModule/InstagramController.php: -------------------------------------------------------------------------------- 1 | cfg_instagramAccessToken || 0 === \count($items = $this->getFeedItems($model))) { 34 | return new Response(); 35 | } 36 | 37 | $template->items = $this->generateItems($model, $items); 38 | $template->user = $this->getUserData($model); 39 | 40 | return $template->getResponse(); 41 | } 42 | 43 | /** 44 | * Generate the items. 45 | */ 46 | protected function generateItems(ModuleModel $moduleModel, array $items): array 47 | { 48 | foreach ($items as &$item) { 49 | // Fetch the comments 50 | if (isset($item['id'])) { 51 | $item['comments'] = $this->getCommentsForMedia($moduleModel, $item['id']); 52 | } 53 | 54 | // Skip the items that are not local Contao files 55 | if (!isset($item['contao']['uuid']) 56 | || null === ($fileModel = FilesModel::findByPk($item['contao']['uuid'])) 57 | || !is_file(Path::join($this->projectDir, $fileModel->path)) 58 | ) { 59 | continue; 60 | } 61 | 62 | $figure = $this->studio 63 | ->createFigureBuilder() 64 | ->from($fileModel) 65 | ->setSize($moduleModel->imgSize) 66 | ->buildIfResourceExists(); 67 | 68 | if (null !== $figure) { 69 | $figure->applyLegacyTemplateData($item['contao']['picture'] = new \stdClass()); 70 | } 71 | } 72 | 73 | return $items; 74 | } 75 | 76 | /** 77 | * Get the user data from Instagram. 78 | */ 79 | protected function getUserData(ModuleModel $moduleModel): array 80 | { 81 | $response = $this->client->getUserData($moduleModel->cfg_instagramAccessToken, (int) $moduleModel->id, true, (bool) $moduleModel->cfg_skipSslVerification); 82 | 83 | if (null === $response) { 84 | return []; 85 | } 86 | 87 | return $response; 88 | } 89 | 90 | /** 91 | * Get the feed items from Instagram. 92 | */ 93 | protected function getFeedItems(ModuleModel $moduleModel): array 94 | { 95 | $time = time(); 96 | 97 | // Refresh the token if it expired (according to local TTL value) 98 | if (($time - $this->accessTokenTtl) > $moduleModel->cfg_instagramAccessTokenTstamp && ($token = $this->client->refreshAccessToken($moduleModel->cfg_instagramAccessToken)) !== null) { 99 | $moduleModel->cfg_instagramAccessToken = $token; 100 | $moduleModel->cfg_instagramAccessTokenTstamp = $time; 101 | 102 | $this->connection->update('tl_module', ['cfg_instagramAccessToken' => $token, 'cfg_instagramAccessTokenTstamp' => $time], ['id' => $moduleModel->id]); 103 | } 104 | 105 | $response = $this->client->getMediaData($moduleModel->cfg_instagramAccessToken, (int) $moduleModel->id, true, (bool) $moduleModel->cfg_skipSslVerification); 106 | 107 | if (empty($response['data'])) { 108 | return []; 109 | } 110 | 111 | $data = $response['data']; 112 | $allowedMediaTypes = StringUtil::deserialize($moduleModel->cfg_instagramMediaTypes); 113 | 114 | // Filter out the media types we don't want 115 | if (is_array($allowedMediaTypes) && !empty($allowedMediaTypes)) { 116 | $data = array_filter($data, static fn ($item) => in_array($item['media_type'], $allowedMediaTypes, true)); 117 | $data = array_values($data); 118 | } 119 | 120 | // Store the files locally 121 | if ($moduleModel->cfg_instagramStoreFiles) { 122 | $data = $this->client->storeMediaFiles($moduleModel->cfg_instagramStoreFolder, $data, (bool) $moduleModel->cfg_skipSslVerification); 123 | } 124 | 125 | // Limit the number of items 126 | if ($moduleModel->numberOfItems > 0) { 127 | $data = \array_slice($data, 0, $moduleModel->numberOfItems); 128 | } 129 | 130 | return $data; 131 | } 132 | 133 | /** 134 | * Get comments for a media item. 135 | */ 136 | protected function getCommentsForMedia(ModuleModel $moduleModel, string $mediaId): array 137 | { 138 | if (!$moduleModel->cfg_instagramFetchComments) { 139 | return []; 140 | } 141 | 142 | $response = $this->client->getCommentsForMedia($moduleModel->cfg_instagramAccessToken, $mediaId, (int) $moduleModel->id, true, (bool) $moduleModel->cfg_skipSslVerification); 143 | 144 | if (empty($response['data'])) { 145 | return []; 146 | } 147 | 148 | foreach ($response['data'] as &$comment) { 149 | $comment = $this->client->getDetailsForComment($moduleModel->cfg_instagramAccessToken, $comment['id'], (int) $moduleModel->id, true, (bool) $moduleModel->cfg_skipSslVerification); 150 | } 151 | 152 | return $response['data']; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Controller/InstagramController.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Kamil Kuzminski 10 | * @license MIT 11 | */ 12 | 13 | namespace Codefog\InstagramBundle\Controller; 14 | 15 | use Codefog\InstagramBundle\EventListener\ModuleListener; 16 | use Codefog\InstagramBundle\InstagramClient; 17 | use Contao\BackendUser; 18 | use Doctrine\DBAL\Connection; 19 | use Symfony\Component\HttpFoundation\RedirectResponse; 20 | use Symfony\Component\HttpFoundation\Request; 21 | use Symfony\Component\HttpFoundation\RequestStack; 22 | use Symfony\Component\HttpFoundation\Response; 23 | use Symfony\Component\Routing\Annotation\Route; 24 | use Symfony\Component\Routing\Generator\UrlGeneratorInterface; 25 | use Symfony\Component\Routing\RouterInterface; 26 | use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; 27 | 28 | #[Route('_instagram', defaults: ['_scope' => 'backend', '_token_check' => false])] 29 | class InstagramController 30 | { 31 | public function __construct( 32 | private readonly InstagramClient $client, 33 | private readonly Connection $connection, 34 | private readonly RequestStack $requestStack, 35 | private readonly RouterInterface $router, 36 | private readonly TokenStorageInterface $tokenStorage, 37 | ) 38 | { 39 | } 40 | 41 | #[Route('/auth', name: 'instagram_auth', methods: ['GET'])] 42 | public function authAction(Request $request): Response 43 | { 44 | // Missing code query parameter 45 | if (!($code = $request->query->get('code'))) { 46 | return new Response(Response::$statusTexts[Response::HTTP_BAD_REQUEST], Response::HTTP_BAD_REQUEST); 47 | } 48 | 49 | // User not logged in 50 | if (null === ($user = $this->getBackendUser())) { 51 | return new Response(Response::$statusTexts[Response::HTTP_UNAUTHORIZED], Response::HTTP_UNAUTHORIZED); 52 | } 53 | 54 | $sessionData = $this->requestStack->getSession()->get(ModuleListener::SESSION_KEY); 55 | 56 | // Module ID not found in session 57 | if (null === $sessionData || !isset($sessionData['moduleId'])) { 58 | return new Response(Response::$statusTexts[Response::HTTP_BAD_REQUEST], Response::HTTP_BAD_REQUEST); 59 | } 60 | 61 | // Module not found 62 | if (false === ($module = $this->connection->fetchAssociative('SELECT * FROM tl_module WHERE id=?', [$sessionData['moduleId']]))) { 63 | return new Response(Response::$statusTexts[Response::HTTP_BAD_REQUEST], Response::HTTP_BAD_REQUEST); 64 | } 65 | 66 | $accessToken = $this->client->getAccessToken( 67 | $module['cfg_instagramAppId'], 68 | $module['cfg_instagramAppSecret'], 69 | $code, 70 | $this->router->generate('instagram_auth', [], UrlGeneratorInterface::ABSOLUTE_URL), 71 | (bool) $module['cfg_skipSslVerification'] 72 | ); 73 | 74 | if (null === $accessToken) { 75 | return new Response(Response::$statusTexts[Response::HTTP_INTERNAL_SERVER_ERROR], Response::HTTP_INTERNAL_SERVER_ERROR); 76 | } 77 | 78 | // Get the user and media data 79 | $this->client->getUserData($accessToken, (int) $module['id'], false, (bool) $module['cfg_skipSslVerification']); 80 | $mediaData = $this->client->getMediaData($accessToken, (int) $module['id'], false, (bool) $module['cfg_skipSslVerification']); 81 | 82 | // Optionally store the media data locally 83 | if ($module['cfg_instagramStoreFiles'] && null !== $mediaData) { 84 | $this->client->storeMediaFiles($module['cfg_instagramStoreFolder'], $mediaData, (bool) $module['cfg_skipSslVerification']); 85 | } 86 | 87 | // Store the access token and remove temporary session key 88 | $this->connection->update('tl_module', ['cfg_instagramAccessToken' => $accessToken], ['id' => $sessionData['moduleId']]); 89 | $this->requestStack->getSession()->remove(ModuleListener::SESSION_KEY); 90 | 91 | return new RedirectResponse($sessionData['backUrl']); 92 | } 93 | 94 | /** 95 | * Get the backend user. 96 | */ 97 | private function getBackendUser(): ?BackendUser 98 | { 99 | if (null === ($token = $this->tokenStorage->getToken())) { 100 | return null; 101 | } 102 | 103 | $user = $token->getUser(); 104 | 105 | if (!($user instanceof BackendUser)) { 106 | return null; 107 | } 108 | 109 | return $user; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Cron/RefreshAccessTokenCron.php: -------------------------------------------------------------------------------- 1 | connection->fetchAllAssociative('SELECT id, cfg_instagramAccessToken FROM tl_module WHERE type=?', ['cfg_instagram']); 22 | 23 | foreach ($modules as $module) { 24 | $newToken = $this->client->refreshAccessToken($module['cfg_instagramAccessToken']); 25 | 26 | if ($newToken !== null) { 27 | $this->connection->update('tl_module', [ 28 | 'cfg_instagramAccessToken' => $newToken, 29 | 'cfg_instagramAccessTokenTstamp' => time(), 30 | ], ['id' => $module['id']]); 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/DependencyInjection/CodefogInstagramExtension.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Kamil Kuzminski 10 | * @license MIT 11 | */ 12 | 13 | namespace Codefog\InstagramBundle\DependencyInjection; 14 | 15 | use Symfony\Component\Config\FileLocator; 16 | use Symfony\Component\DependencyInjection\ContainerBuilder; 17 | use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; 18 | use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; 19 | 20 | class CodefogInstagramExtension extends ConfigurableExtension 21 | { 22 | /** 23 | * @inheritDoc 24 | */ 25 | protected function loadInternal(array $mergedConfig, ContainerBuilder $container): void 26 | { 27 | $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../../config')); 28 | $loader->load('services.php'); 29 | 30 | $container->setParameter('instagram_cache_ttl', (int) $mergedConfig['cache_ttl']); 31 | $container->setParameter('instagram_access_token_ttl', (int) $mergedConfig['access_token_ttl']); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode() 19 | ->children() 20 | ->integerNode('access_token_ttl')->defaultValue(86400)->end() 21 | ->integerNode('cache_ttl')->defaultValue(3600)->end() 22 | ->end() 23 | ; 24 | 25 | return $treeBuilder; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/EventListener/ModuleListener.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Kamil Kuzminski 10 | * @license MIT 11 | */ 12 | 13 | namespace Codefog\InstagramBundle\EventListener; 14 | 15 | use Contao\CoreBundle\Exception\RedirectResponseException; 16 | use Contao\DataContainer; 17 | use Contao\Environment; 18 | use Contao\Input; 19 | use Symfony\Component\HttpFoundation\RequestStack; 20 | use Symfony\Component\Routing\Generator\UrlGeneratorInterface; 21 | use Symfony\Component\Routing\RouterInterface; 22 | 23 | class ModuleListener 24 | { 25 | public const SESSION_KEY = 'instagram-module-id'; 26 | 27 | public function __construct( 28 | private readonly RouterInterface $router, 29 | private readonly RequestStack $requestStack, 30 | ) 31 | { 32 | } 33 | 34 | /** 35 | * On submit callback. 36 | */ 37 | public function onSubmitCallback(DataContainer $dc) 38 | { 39 | if ('cfg_instagram' === $dc->activeRecord->type && $dc->activeRecord->cfg_instagramAppId && Input::post('cfg_instagramRequestToken')) { 40 | $this->requestAccessToken($dc->activeRecord->cfg_instagramAppId); 41 | } 42 | } 43 | 44 | /** 45 | * On the request token save. 46 | */ 47 | public function onRequestTokenSave() 48 | { 49 | return null; 50 | } 51 | 52 | /** 53 | * Request the Instagram access token. 54 | */ 55 | private function requestAccessToken(string $clientId): void 56 | { 57 | $session = $this->requestStack->getSession(); 58 | $session->set(self::SESSION_KEY, [ 59 | 'moduleId' => Input::get('id'), 60 | 'backUrl' => Environment::get('uri'), 61 | ]); 62 | 63 | $session->save(); 64 | 65 | $data = [ 66 | 'app_id' => $clientId, 67 | 'redirect_uri' => $this->router->generate('instagram_auth', [], UrlGeneratorInterface::ABSOLUTE_URL), 68 | 'response_type' => 'code', 69 | 'scope' => 'instagram_business_basic,instagram_manage_comments', 70 | ]; 71 | 72 | throw new RedirectResponseException('https://api.instagram.com/oauth/authorize/?'.http_build_query($data)); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/InstagramClient.php: -------------------------------------------------------------------------------- 1 | 9 | * @author Kamil Kuzminski 10 | * @license MIT 11 | */ 12 | 13 | namespace Codefog\InstagramBundle; 14 | 15 | use Contao\CoreBundle\Framework\ContaoFramework; 16 | use Contao\File; 17 | use Contao\FilesModel; 18 | use Contao\StringUtil; 19 | use Psr\Log\LoggerInterface; 20 | use Symfony\Component\Filesystem\Path; 21 | use Symfony\Contracts\Cache\CacheInterface; 22 | use Symfony\Contracts\Cache\ItemInterface; 23 | use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface; 24 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 25 | use Symfony\Contracts\HttpClient\HttpClientInterface; 26 | 27 | class InstagramClient 28 | { 29 | public function __construct( 30 | private readonly CacheInterface $appCache, 31 | private readonly ContaoFramework $framework, 32 | private readonly HttpClientInterface $httpClient, 33 | private readonly LoggerInterface $contaoLogger, 34 | private readonly int $cacheTtl, 35 | private readonly string $projectDir, 36 | ) 37 | { 38 | } 39 | 40 | /** 41 | * Get the data from Instagram. 42 | */ 43 | public function getData(string $url, array $query = [], int $moduleId = null, bool $cache = true, bool $skipSslVerification = false): ?array 44 | { 45 | $cacheKey = md5($url . '_' . ($moduleId ?? '0')); 46 | 47 | if (!$cache) { 48 | $this->appCache->delete($cacheKey); 49 | } 50 | 51 | return $this->appCache->get($cacheKey, function (ItemInterface $item) use ($url, $query, $skipSslVerification) { 52 | $item->expiresAfter($this->cacheTtl); 53 | 54 | try { 55 | return $this->httpClient->request('GET', $url, ['query' => $query, 'verify_host' => !$skipSslVerification, 'verify_peer' => !$skipSslVerification])->toArray(); 56 | } catch (TransportExceptionInterface | HttpExceptionInterface $e) { 57 | $this->contaoLogger->error(sprintf('Unable to fetch Instagram data from "%s": %s', $url, $e->getMessage())); 58 | $item->expiresAfter(0); 59 | 60 | return null; 61 | } 62 | }); 63 | } 64 | 65 | /** 66 | * Get the media data. 67 | */ 68 | public function getMediaData(string $accessToken, int $moduleId = null, bool $cache = true, bool $skipSslVerification = false): ?array 69 | { 70 | return $this->getData('https://graph.instagram.com/me/media', [ 71 | 'access_token' => $accessToken, 72 | 'fields' => 'id,caption,media_type,media_url,like_count,permalink,thumbnail_url,timestamp', 73 | ], $moduleId, $cache, $skipSslVerification); 74 | } 75 | 76 | /** 77 | * Get the user data. 78 | */ 79 | public function getUserData(string $accessToken, int $moduleId = null, bool $cache = true, bool $skipSslVerification = false): ?array 80 | { 81 | return $this->getData('https://graph.instagram.com/me', [ 82 | 'access_token' => $accessToken, 83 | 'fields' => 'id,username', 84 | ], $moduleId, $cache, $skipSslVerification); 85 | } 86 | 87 | /** 88 | * Get the Comments for a Media Item 89 | */ 90 | public function getCommentsForMedia(string $instagramAccessToken, string $mediaId, int $moduleId = null, bool $cache = true, bool $skipSslVerification = false): ?array 91 | { 92 | return $this->getData(sprintf('https://graph.instagram.com/%s/comments', $mediaId), [ 93 | 'access_token' => $instagramAccessToken, 94 | 'fields' => 'id,text,timestamp', 95 | ], $moduleId, $cache, $skipSslVerification); 96 | } 97 | 98 | /** 99 | * Get Details for a Comment 100 | */ 101 | public function getDetailsForComment(string $instagramAccessToken, string $commentId, int $moduleId = null, bool $cache = true, bool $skipSslVerification = false): ?array 102 | { 103 | return $this->getData( sprintf('https://graph.instagram.com/%s', $commentId), [ 104 | 'access_token' => $instagramAccessToken, 105 | 'fields' => 'id,parent_id,from,text,like_count,hidden,timestamp', 106 | ], $moduleId, $cache, $skipSslVerification); 107 | } 108 | 109 | /** 110 | * Store the media files locally. 111 | * 112 | * @throws \RuntimeException 113 | */ 114 | public function storeMediaFiles(string $targetUuid, array $data, bool $skipSslVerification = false): array 115 | { 116 | $this->framework->initialize(); 117 | 118 | if (null === ($folderModel = FilesModel::findByPk($targetUuid)) || !is_dir(Path::join($this->projectDir, $folderModel->path))) { 119 | throw new \RuntimeException('The target folder does not exist'); 120 | } 121 | 122 | // Support raw responses as well 123 | if (isset($data['data'])) { 124 | $data = $data['data']; 125 | } 126 | 127 | foreach ($data as &$item) { 128 | switch ($item['media_type']) { 129 | case 'IMAGE': 130 | case 'CAROUSEL_ALBUM': 131 | $url = $item['media_url']; 132 | break; 133 | case 'VIDEO': 134 | $url = $item['thumbnail_url']; 135 | break; 136 | default: 137 | continue 2; 138 | } 139 | 140 | // Skip if the URL does not exist (#39) 141 | if (!$url) { 142 | continue; 143 | } 144 | 145 | $extension = pathinfo(explode('?', $url)[0], PATHINFO_EXTENSION); 146 | $file = new File(sprintf('%s/%s.%s', $folderModel->path, $item['id'], $extension)); 147 | 148 | // Download the image 149 | if (!$file->exists()) { 150 | try { 151 | $response = $this->httpClient->request('GET', $url, [ 152 | 'verify_host' => !$skipSslVerification, 153 | 'verify_peer' => !$skipSslVerification, 154 | ])->getContent(); 155 | } catch (TransportExceptionInterface | HttpExceptionInterface $e) { 156 | $this->contaoLogger->error(sprintf('Unable to fetch Instagram image from "%s": %s', $url, $e->getMessage())); 157 | 158 | continue; 159 | } 160 | 161 | // Save the image and add sync the database 162 | $file->write($response); 163 | $file->close(); 164 | } 165 | 166 | // Store the UUID in cache 167 | if ($file->exists() && ($uuid = $file->getModel()?->uuid)) { 168 | $item['contao']['uuid'] = StringUtil::binToUuid($uuid); 169 | } 170 | } 171 | 172 | return $data; 173 | } 174 | 175 | /** 176 | * Refresh the access token. 177 | */ 178 | public function refreshAccessToken(string $token): ?string 179 | { 180 | try { 181 | $response = $this->httpClient->request('GET', 'https://graph.instagram.com/refresh_access_token', [ 182 | 'query' => [ 183 | 'grant_type' => 'ig_refresh_token', 184 | 'access_token' => $token, 185 | ], 186 | ])->toArray(); 187 | } catch (TransportExceptionInterface | HttpExceptionInterface $e) { 188 | $this->contaoLogger->error(sprintf('Unable to refresh the Instagram access token: %s', $e->getMessage())); 189 | 190 | return null; 191 | } 192 | 193 | return $response['access_token']; 194 | } 195 | 196 | /** 197 | * Get the access token. 198 | */ 199 | public function getAccessToken(string $appId, string $appSecret, string $code, string $redirectUri, bool $skipSslVerification = false): ?string 200 | { 201 | if (($token = $this->getShortLivedAccessToken($appId, $appSecret, $code, $redirectUri, $skipSslVerification)) === null) { 202 | return null; 203 | } 204 | 205 | return $this->getLongLivedAccessToken($token, $appSecret, $skipSslVerification); 206 | } 207 | 208 | /** 209 | * Get the short lived access token 210 | */ 211 | private function getShortLivedAccessToken(string $appId, string $appSecret, string $code, string $redirectUri, bool $skipSslVerification = false): ?string 212 | { 213 | try { 214 | $response = $this->httpClient->request('POST', 'https://api.instagram.com/oauth/access_token', [ 215 | 'verify_host' => !$skipSslVerification, 216 | 'verify_peer' => !$skipSslVerification, 217 | 'body' => [ 218 | 'app_id' => $appId, 219 | 'app_secret' => $appSecret, 220 | 'grant_type' => 'authorization_code', 221 | 'redirect_uri' => $redirectUri, 222 | 'code' => $code, 223 | ], 224 | ])->toArray(); 225 | } catch (TransportExceptionInterface | HttpExceptionInterface $e) { 226 | $this->contaoLogger->error(sprintf('Unable to fetch the Instagram short-lived access token: %s', $e->getMessage())); 227 | 228 | return null; 229 | } 230 | 231 | return $response['access_token']; 232 | } 233 | 234 | /** 235 | * Get the long lived access token 236 | */ 237 | private function getLongLivedAccessToken(string $token, string $appSecret, bool $skipSslVerification = false): ?string 238 | { 239 | try { 240 | $response = $this->httpClient->request('GET', 'https://graph.instagram.com/access_token', [ 241 | 'verify_host' => !$skipSslVerification, 242 | 'verify_peer' => !$skipSslVerification, 243 | 'query' => [ 244 | 'grant_type' => 'ig_exchange_token', 245 | 'client_secret' => $appSecret, 246 | 'access_token' => $token, 247 | ], 248 | ])->toArray(); 249 | } catch (TransportExceptionInterface | HttpExceptionInterface $e) { 250 | $this->contaoLogger->error(sprintf('Unable to fetch the Instagram long-lived access token: %s', $e->getMessage())); 251 | 252 | return null; 253 | } 254 | 255 | return $response['access_token']; 256 | } 257 | } 258 | --------------------------------------------------------------------------------