From 247ae1824f73ad18d4dda15aa7adf95262f56ef8 Mon Sep 17 00:00:00 2001 From: Yassine Doghri <yassine@doghri.fr> Date: Wed, 14 Apr 2021 15:58:40 +0000 Subject: [PATCH] refactor(analytics): move all analytics files to a new Libraries/Analytics folder - add page hit on podcast activity page - update development docs --- Dockerfile | 6 +- app/Config/Analytics.php | 35 ++ app/Config/Autoload.php | 1 + app/Config/Routes.php | 37 -- app/Controllers/Admin/AnalyticsData.php | 70 --- app/Controllers/Episode.php | 7 +- app/Controllers/Install.php | 17 +- app/Controllers/Podcast.php | 2 + .../Seeds/FakePodcastsAnalyticsSeeder.php | 15 +- .../Seeds/FakeWebsiteAnalyticsSeeder.php | 5 +- app/Entities/Episode.php | 43 +- app/Helpers/analytics_helper.php | 357 -------------- app/Helpers/url_helper.php | 2 +- app/Libraries/Analytics/Config/Analytics.php | 38 ++ app/Libraries/Analytics/Config/Routes.php | 74 +++ .../Controllers/AnalyticsController.php | 54 +++ .../EpisodeAnalyticsController.php} | 22 +- .../UnknownUserAgentsController.php} | 6 +- ...7-12-01-120000_add_analytics_podcasts.php} | 2 +- ...000_add_analytics_podcasts_by_episode.php} | 2 +- ...130000_add_analytics_podcasts_by_hour.php} | 2 +- ...0000_add_analytics_podcasts_by_player.php} | 2 +- ...000_add_analytics_podcasts_by_country.php} | 2 +- ...0000_add_analytics_podcasts_by_region.php} | 2 +- ...7-12-01-160000_add_podcasts_platforms.php} | 2 +- ...0000_add_analytics_website_by_browser.php} | 2 +- ...0000_add_analytics_website_by_referer.php} | 2 +- ...0_add_analytics_website_by_entry_page.php} | 2 +- ...0000_add_analytics_unknown_useragents.php} | 6 +- ...0000_add_analytics_podcasts_procedure.php} | 2 +- ...nalytics_unknown_useragents_procedure.php} | 2 +- ...10000_add_analytics_website_procedure.php} | 2 +- .../Analytics}/Entities/AnalyticsPodcasts.php | 2 +- .../Entities/AnalyticsPodcastsByCountry.php | 2 +- .../Entities/AnalyticsPodcastsByEpisode.php | 2 +- .../Entities/AnalyticsPodcastsByHour.php | 2 +- .../Entities/AnalyticsPodcastsByPlayer.php | 2 +- .../Entities/AnalyticsPodcastsByRegion.php | 2 +- .../Entities/AnalyticsPodcastsByService.php | 4 +- .../Entities/AnalyticsUnknownUseragents.php | 2 +- .../Entities/AnalyticsWebsiteByBrowser.php | 2 +- .../Entities/AnalyticsWebsiteByEntryPage.php | 2 +- .../Entities/AnalyticsWebsiteByReferer.php | 2 +- .../Analytics/Helpers/analytics_helper.php | 451 ++++++++++++++++++ .../Models/AnalyticsPodcastByCountryModel.php | 4 +- .../Models/AnalyticsPodcastByEpisodeModel.php | 90 ++++ .../Models/AnalyticsPodcastByHourModel.php | 4 +- .../Models/AnalyticsPodcastByPlayerModel.php | 4 +- .../Models/AnalyticsPodcastByRegionModel.php | 4 +- .../Models/AnalyticsPodcastByServiceModel.php | 4 +- .../Models/AnalyticsPodcastModel.php | 4 +- .../AnalyticsUnknownUseragentsModel.php | 4 +- .../Models/AnalyticsWebsiteByBrowserModel.php | 4 +- .../AnalyticsWebsiteByEntryPageModel.php | 4 +- .../Models/AnalyticsWebsiteByRefererModel.php | 4 +- .../Models/UnknownUserAgentsModel.php | 2 +- app/Models/AnalyticsPodcastByEpisodeModel.php | 145 ------ app/Views/admin/podcast/analytics/index.php | 16 +- docs/setup-development.md | 26 +- 59 files changed, 865 insertions(+), 752 deletions(-) create mode 100644 app/Config/Analytics.php delete mode 100644 app/Controllers/Admin/AnalyticsData.php delete mode 100644 app/Helpers/analytics_helper.php create mode 100644 app/Libraries/Analytics/Config/Analytics.php create mode 100644 app/Libraries/Analytics/Config/Routes.php create mode 100644 app/Libraries/Analytics/Controllers/AnalyticsController.php rename app/{Controllers/Analytics.php => Libraries/Analytics/Controllers/EpisodeAnalyticsController.php} (83%) rename app/{Controllers/UnknownUserAgents.php => Libraries/Analytics/Controllers/UnknownUserAgentsController.php} (70%) rename app/{Database/Migrations/2020-06-08-120000_add_analytics_podcasts.php => Libraries/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php} (97%) rename app/{Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_episode.php => Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php} (97%) rename app/{Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_hour.php => Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php} (97%) rename app/{Database/Migrations/2020-06-08-140000_add_analytics_podcasts_by_player.php => Libraries/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php} (97%) rename app/{Database/Migrations/2020-06-08-150000_add_analytics_podcasts_by_country.php => Libraries/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php} (97%) rename app/{Database/Migrations/2020-06-08-160000_add_analytics_podcasts_by_region.php => Libraries/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php} (97%) rename app/{Database/Migrations/2020-06-08-160000_add_podcasts_platforms.php => Libraries/Analytics/Database/Migrations/2017-12-01-160000_add_podcasts_platforms.php} (97%) rename app/{Database/Migrations/2020-06-08-170000_add_analytics_website_by_browser.php => Libraries/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php} (97%) rename app/{Database/Migrations/2020-06-08-180000_add_analytics_website_by_referer.php => Libraries/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php} (97%) rename app/{Database/Migrations/2020-06-08-190000_add_analytics_website_by_entry_page.php => Libraries/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php} (97%) rename app/{Database/Migrations/2020-06-08-210000_add_analytics_unknown_useragents.php => Libraries/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php} (93%) rename app/{Database/Migrations/2020-06-11-210000_add_analytics_podcasts_procedure.php => Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php} (98%) rename app/{Database/Migrations/2020-06-11-210000_add_analytics_unknown_useragents_procedure.php => Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_unknown_useragents_procedure.php} (96%) rename app/{Database/Migrations/2020-06-11-210000_add_analytics_website_procedure.php => Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_website_procedure.php} (97%) rename app/{ => Libraries/Analytics}/Entities/AnalyticsPodcasts.php (94%) rename app/{ => Libraries/Analytics}/Entities/AnalyticsPodcastsByCountry.php (95%) rename app/{ => Libraries/Analytics}/Entities/AnalyticsPodcastsByEpisode.php (93%) rename app/{ => Libraries/Analytics}/Entities/AnalyticsPodcastsByHour.php (93%) rename app/{ => Libraries/Analytics}/Entities/AnalyticsPodcastsByPlayer.php (94%) rename app/{ => Libraries/Analytics}/Entities/AnalyticsPodcastsByRegion.php (95%) rename app/{ => Libraries/Analytics}/Entities/AnalyticsPodcastsByService.php (91%) rename app/{ => Libraries/Analytics}/Entities/AnalyticsUnknownUseragents.php (93%) rename app/{ => Libraries/Analytics}/Entities/AnalyticsWebsiteByBrowser.php (93%) rename app/{ => Libraries/Analytics}/Entities/AnalyticsWebsiteByEntryPage.php (94%) rename app/{ => Libraries/Analytics}/Entities/AnalyticsWebsiteByReferer.php (93%) create mode 100644 app/Libraries/Analytics/Helpers/analytics_helper.php rename app/{ => Libraries/Analytics}/Models/AnalyticsPodcastByCountryModel.php (95%) create mode 100644 app/Libraries/Analytics/Models/AnalyticsPodcastByEpisodeModel.php rename app/{ => Libraries/Analytics}/Models/AnalyticsPodcastByHourModel.php (92%) rename app/{ => Libraries/Analytics}/Models/AnalyticsPodcastByPlayerModel.php (98%) rename app/{ => Libraries/Analytics}/Models/AnalyticsPodcastByRegionModel.php (93%) rename app/{ => Libraries/Analytics}/Models/AnalyticsPodcastByServiceModel.php (93%) rename app/{ => Libraries/Analytics}/Models/AnalyticsPodcastModel.php (98%) rename app/{ => Libraries/Analytics}/Models/AnalyticsUnknownUseragentsModel.php (82%) rename app/{ => Libraries/Analytics}/Models/AnalyticsWebsiteByBrowserModel.php (92%) rename app/{ => Libraries/Analytics}/Models/AnalyticsWebsiteByEntryPageModel.php (92%) rename app/{ => Libraries/Analytics}/Models/AnalyticsWebsiteByRefererModel.php (96%) rename app/{ => Libraries/Analytics}/Models/UnknownUserAgentsModel.php (95%) delete mode 100644 app/Models/AnalyticsPodcastByEpisodeModel.php diff --git a/Dockerfile b/Dockerfile index 01ab228ea6..5b64fb8483 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,9 +20,9 @@ RUN docker-php-ext-configure gd --with-jpeg-dir=/usr/include/ \ RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli RUN echo "file_uploads = On\n" \ - "memory_limit = 100M\n" \ - "upload_max_filesize = 100M\n" \ - "post_max_size = 120M\n" \ + "memory_limit = 512M\n" \ + "upload_max_filesize = 500M\n" \ + "post_max_size = 512M\n" \ "max_execution_time = 300\n" \ > /usr/local/etc/php/conf.d/uploads.ini diff --git a/app/Config/Analytics.php b/app/Config/Analytics.php new file mode 100644 index 0000000000..4391703d4d --- /dev/null +++ b/app/Config/Analytics.php @@ -0,0 +1,35 @@ +<?php + +namespace Config; + +use Analytics\Config\Analytics as AnalyticsBase; + +class Analytics extends AnalyticsBase +{ + /** + * -------------------------------------------------------------------- + * Route filters options + * -------------------------------------------------------------------- + */ + public $routeFilters = [ + 'analytics-full-data' => 'permission:podcasts-view,podcast-view', + 'analytics-data' => 'permission:podcasts-view,podcast-view', + 'analytics-filtered-data' => 'permission:podcasts-view,podcast-view', + ]; + + public function __construct() + { + parent::__construct(); + + // set the analytics gateway behind the admin gateway. + // Only logged in users should be able to view analytics + $this->gateway = config('App')->adminGateway . '/analytics'; + } + + public function getEnclosureUrl($enclosureUri) + { + helper('media'); + + return media_base_url($enclosureUri); + } +} diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index de861bdb00..adb5834998 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -43,6 +43,7 @@ class Autoload extends AutoloadConfig APP_NAMESPACE => APPPATH, // For custom app namespace 'Config' => APPPATH . 'Config', 'ActivityPub' => APPPATH . 'Libraries/ActivityPub', + 'Analytics' => APPPATH . 'Libraries/Analytics', ]; /** diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 3e3a16fc9c..3200946785 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -70,18 +70,6 @@ $routes->group(config('App')->installGateway, function ($routes) { ]); }); -// Route for podcast audio file analytics (/audio/pack(podcast_id,episode_id,bytes_threshold,filesize,duration,date)/podcast_folder/filename.mp3) -$routes->head('audio/(:base64)/(:any)', 'Analytics::hit/$1/$2', [ - 'as' => 'analytics_hit', -]); -$routes->get('audio/(:base64)/(:any)', 'Analytics::hit/$1/$2', [ - 'as' => 'analytics_hit', -]); - -// Show the Unknown UserAgents -$routes->get('.well-known/unknown-useragents', 'UnknownUserAgents'); -$routes->get('.well-known/unknown-useragents/(:num)', 'UnknownUserAgents/$1'); - $routes->get('.well-known/platforms', 'Platform'); // Admin area @@ -237,31 +225,6 @@ $routes->group( ); }); - $routes->get( - 'analytics-data/(:segment)', - 'AnalyticsData::getData/$1/$2', - [ - 'as' => 'analytics-full-data', - 'filter' => 'permission:podcasts-view,podcast-view', - ], - ); - $routes->get( - 'analytics-data/(:segment)/(:segment)', - 'AnalyticsData::getData/$1/$2/$3', - [ - 'as' => 'analytics-data', - 'filter' => 'permission:podcasts-view,podcast-view', - ], - ); - $routes->get( - 'analytics-data/(:segment)/(:segment)/(:num)', - 'AnalyticsData::getData/$1/$2/$3/$4', - [ - 'as' => 'analytics-filtered-data', - 'filter' => 'permission:podcasts-view,podcast-view', - ], - ); - // Podcast episodes $routes->group('episodes', function ($routes) { $routes->get('/', 'Episode::list/$1', [ diff --git a/app/Controllers/Admin/AnalyticsData.php b/app/Controllers/Admin/AnalyticsData.php deleted file mode 100644 index ba5e1673a3..0000000000 --- a/app/Controllers/Admin/AnalyticsData.php +++ /dev/null @@ -1,70 +0,0 @@ -<?php - -/** - * @copyright 2020 Podlibre - * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 - * @link https://castopod.org/ - */ - -namespace App\Controllers\Admin; - -use App\Models\PodcastModel; -use App\Models\EpisodeModel; - -class AnalyticsData extends BaseController -{ - /** - * @var \App\Entities\Podcast|null - */ - protected $podcast; - protected $className; - protected $methodName; - protected $episode; - - public function _remap($method, ...$params) - { - if (count($params) > 1) { - if (!($this->podcast = (new PodcastModel())->find($params[0]))) { - throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound( - 'Podcast not found: ' . $params[0] - ); - } - $this->className = '\App\Models\Analytics' . $params[1] . 'Model'; - $this->methodName = - 'getData' . (empty($params[2]) ? '' : $params[2]); - if (count($params) > 3) { - if ( - !($this->episode = (new EpisodeModel()) - ->where([ - 'podcast_id' => $this->podcast->id, - 'id' => $params[3], - ]) - ->first()) - ) { - throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound( - 'Episode not found: ' . $params[3] - ); - } - } - } - - return $this->$method(); - } - public function getData() - { - $analytics_model = new $this->className(); - $methodName = $this->methodName; - if ($this->episode) { - return $this->response->setJSON( - $analytics_model->$methodName( - $this->podcast->id, - $this->episode->id - ) - ); - } else { - return $this->response->setJSON( - $analytics_model->$methodName($this->podcast->id) - ); - } - } -} diff --git a/app/Controllers/Episode.php b/app/Controllers/Episode.php index 31eee58fa0..b791a6b068 100644 --- a/app/Controllers/Episode.php +++ b/app/Controllers/Episode.php @@ -44,8 +44,6 @@ class Episode extends BaseController public function index() { - $episodeModel = new EpisodeModel(); - self::triggerWebpageHit($this->podcast->id); $locale = service('request')->getLocale(); @@ -65,7 +63,7 @@ class Episode extends BaseController 'persons' => $podcastPersons, ]; - $secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode( + $secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode( $this->podcast->id, ); @@ -112,7 +110,6 @@ class Episode extends BaseController $cacheName = "page_podcast{$this->episode->podcast_id}_episode{$this->episode->id}_embeddable_player_{$theme}_{$locale}"; if (!($cachedView = cache($cacheName))) { - $episodeModel = new EpisodeModel(); $theme = EpisodeModel::$themes[$theme]; $data = [ @@ -121,7 +118,7 @@ class Episode extends BaseController 'theme' => $theme, ]; - $secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode( + $secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode( $this->podcast->id, ); diff --git a/app/Controllers/Install.php b/app/Controllers/Install.php index d612b9194c..c3e60160a6 100644 --- a/app/Controllers/Install.php +++ b/app/Controllers/Install.php @@ -111,7 +111,7 @@ class Install extends Controller // show database config view to fix value session()->setFlashdata( 'error', - lang('Install.messages.databaseConnectError') + lang('Install.messages.databaseConnectError'), ); return view('install/database_config'); @@ -159,7 +159,7 @@ class Install extends Controller return redirect() ->to( (empty(host_url()) ? config('App')->baseURL : host_url()) . - config('App')->installGateway + config('App')->installGateway, ) ->withInput() ->with('errors', $this->validator->getErrors()); @@ -181,8 +181,8 @@ class Install extends Controller // redirect to full install url with new baseUrl input return redirect(0)->to( reduce_double_slashes( - $baseUrl . '/' . config('App')->installGateway - ) + $baseUrl . '/' . config('App')->installGateway, + ), ); } @@ -209,14 +209,14 @@ class Install extends Controller self::writeEnv([ 'database.default.hostname' => $this->request->getPost( - 'db_hostname' + 'db_hostname', ), 'database.default.database' => $this->request->getPost('db_name'), 'database.default.username' => $this->request->getPost( - 'db_username' + 'db_username', ), 'database.default.password' => $this->request->getPost( - 'db_password' + 'db_password', ), 'database.default.DBPrefix' => $this->request->getPost('db_prefix'), ]); @@ -258,6 +258,7 @@ class Install extends Controller !$migrations->setNamespace('Myth\Auth')->latest(); !$migrations->setNamespace('ActivityPub')->latest(); + !$migrations->setNamespace('Analytics')->latest(); !$migrations->setNamespace(APP_NAMESPACE)->latest(); } @@ -296,7 +297,7 @@ class Install extends Controller [ 'email' => 'required|valid_email|is_unique[users.email]', 'password' => 'required|strong_password', - ] + ], ); if (!$this->validate($rules)) { diff --git a/app/Controllers/Podcast.php b/app/Controllers/Podcast.php index 1529dd0947..ef11e95483 100644 --- a/app/Controllers/Podcast.php +++ b/app/Controllers/Podcast.php @@ -37,6 +37,8 @@ class Podcast extends BaseController public function activity() { + self::triggerWebpageHit($this->podcast->id); + helper('persons'); $persons = []; construct_person_array($this->podcast->persons, $persons); diff --git a/app/Database/Seeds/FakePodcastsAnalyticsSeeder.php b/app/Database/Seeds/FakePodcastsAnalyticsSeeder.php index 4af1128bc6..7adb67fad8 100644 --- a/app/Database/Seeds/FakePodcastsAnalyticsSeeder.php +++ b/app/Database/Seeds/FakePodcastsAnalyticsSeeder.php @@ -10,6 +10,7 @@ */ namespace App\Database\Seeds; + use App\Models\PodcastModel; use App\Models\EpisodeModel; @@ -23,16 +24,16 @@ class FakePodcastsAnalyticsSeeder extends Seeder $jsonUserAgents = json_decode( file_get_contents( - 'https://raw.githubusercontent.com/opawg/user-agents/master/src/user-agents.json' + 'https://raw.githubusercontent.com/opawg/user-agents/master/src/user-agents.json', ), - true + true, ); $jsonRSSUserAgents = json_decode( file_get_contents( - 'https://raw.githubusercontent.com/opawg/podcast-rss-useragents/master/src/rss-ua.json' + 'https://raw.githubusercontent.com/opawg/podcast-rss-useragents/master/src/rss-ua.json', ), - true + true, ); if ($podcast) { @@ -60,7 +61,7 @@ class FakePodcastsAnalyticsSeeder extends Seeder ->findAll(); foreach ($episodes as $episode) { $age = floor( - ($date - strtotime($episode->published_at)) / 86400 + ($date - strtotime($episode->published_at)) / 86400, ); $proba1 = floor(exp(3 - $age / 40)) + 1; @@ -97,7 +98,7 @@ class FakePodcastsAnalyticsSeeder extends Seeder $cityReader = new \GeoIp2\Database\Reader( WRITEPATH . - 'uploads/GeoLite2-City/GeoLite2-City.mmdb' + 'uploads/GeoLite2-City/GeoLite2-City.mmdb', ); $countryCode = 'N/A'; @@ -196,7 +197,7 @@ class FakePodcastsAnalyticsSeeder extends Seeder ->insertBatch($analytics_podcasts_by_region); } } else { - echo "Create one podcast and some episodes first.\n"; + echo "COULD NOT POPULATE DATABASE:\n\tCreate a podcast with episodes first.\n"; } } } diff --git a/app/Database/Seeds/FakeWebsiteAnalyticsSeeder.php b/app/Database/Seeds/FakeWebsiteAnalyticsSeeder.php index daa37da925..af6661ad4a 100644 --- a/app/Database/Seeds/FakeWebsiteAnalyticsSeeder.php +++ b/app/Database/Seeds/FakeWebsiteAnalyticsSeeder.php @@ -10,6 +10,7 @@ */ namespace App\Database\Seeds; + use App\Models\PodcastModel; use App\Models\EpisodeModel; @@ -193,7 +194,7 @@ class FakeWebsiteAnalyticsSeeder extends Seeder ->findAll(); foreach ($episodes as $episode) { $age = floor( - ($date - strtotime($episode->published_at)) / 86400 + ($date - strtotime($episode->published_at)) / 86400, ); $proba1 = floor(exp(3 - $age / 40)) + 1; @@ -254,7 +255,7 @@ class FakeWebsiteAnalyticsSeeder extends Seeder ->insertBatch($website_by_referer); } } else { - echo "Create one podcast and some episodes first.\n"; + echo "COULD NOT POPULATE DATABASE:\n\tCreate a podcast with episodes first.\n"; } } } diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index ced727ebd5..e7fdac994e 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -336,37 +336,14 @@ class Episode extends Entity { helper('analytics'); - return base_url( - route_to( - 'analytics_hit', - base64_url_encode( - pack( - 'I*', - $this->attributes['podcast_id'], - $this->attributes['id'], - // bytes_threshold: number of bytes that must be downloaded for an episode to be counted in download analytics - // - if file is shorter than 60sec, then it's enclosure_filesize - // - if file is longer than 60 seconds then it's enclosure_headersize + 60 seconds - $this->attributes['enclosure_duration'] <= 60 - ? $this->attributes['enclosure_filesize'] - : $this->attributes['enclosure_headersize'] + - floor( - (($this->attributes['enclosure_filesize'] - - $this->attributes[ - 'enclosure_headersize' - ]) / - $this->attributes[ - 'enclosure_duration' - ]) * - 60, - ), - $this->attributes['enclosure_filesize'], - $this->attributes['enclosure_duration'], - strtotime($this->attributes['published_at']), - ), - ), - $this->attributes['enclosure_uri'], - ), + return generate_episode_analytics_url( + $this->podcast_id, + $this->id, + $this->enclosure_uri, + $this->enclosure_duration, + $this->enclosure_filesize, + $this->enclosure_headersize, + $this->published_at, ); } @@ -520,9 +497,9 @@ class Episode extends Entity empty($this->getPodcast()->partner_image_url) ? '' : "<div><a href=\"{$this->getPartnerLink( - $serviceSlug + $serviceSlug, )}\" rel=\"sponsored noopener noreferrer\" target=\"_blank\"><img src=\"{$this->getPartnerImage( - $serviceSlug + $serviceSlug, )}\" alt=\"Partner image\" /></a></div>") . $this->attributes['description_html'] . (empty($this->getPodcast()->episode_description_footer_html) diff --git a/app/Helpers/analytics_helper.php b/app/Helpers/analytics_helper.php deleted file mode 100644 index e7b393dbaa..0000000000 --- a/app/Helpers/analytics_helper.php +++ /dev/null @@ -1,357 +0,0 @@ -<?php - -/** - * @copyright 2020 Podlibre - * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 - * @link https://castopod.org/ - */ - -/** - * Encode Base64 for URLs - */ -function base64_url_encode($input) -{ - return strtr(base64_encode($input), '+/=', '._-'); -} - -/** - * Decode Base64 from URL - */ -function base64_url_decode($input) -{ - return base64_decode(strtr($input, '._-', '+/=')); -} - -/** - * Set user country in session variable, for analytics purpose - */ -function set_user_session_deny_list_ip() -{ - $session = \Config\Services::session(); - $session->start(); - - if (!$session->has('denyListIp')) { - $session->set( - 'denyListIp', - \Podlibre\Ipcat\IpDb::find($_SERVER['REMOTE_ADDR']) != null, - ); - } -} - -/** - * Set user country in session variable, for analytics purpose - */ -function set_user_session_location() -{ - $session = \Config\Services::session(); - $session->start(); - - $location = [ - 'countryCode' => 'N/A', - 'regionCode' => 'N/A', - 'latitude' => null, - 'longitude' => null, - ]; - - // Finds location: - if (!$session->has('location')) { - try { - $cityReader = new \GeoIp2\Database\Reader( - WRITEPATH . 'uploads/GeoLite2-City/GeoLite2-City.mmdb', - ); - $city = $cityReader->city($_SERVER['REMOTE_ADDR']); - - $location = [ - 'countryCode' => empty($city->country->isoCode) - ? 'N/A' - : $city->country->isoCode, - 'regionCode' => empty($city->subdivisions[0]->isoCode) - ? 'N/A' - : $city->subdivisions[0]->isoCode, - 'latitude' => round($city->location->latitude, 3), - 'longitude' => round($city->location->longitude, 3), - ]; - } catch (\Exception $e) { - // If things go wrong the show must go on and the user must be able to download the file - } - $session->set('location', $location); - } -} - -/** - * Set user player in session variable, for analytics purpose - */ -function set_user_session_player() -{ - $session = \Config\Services::session(); - $session->start(); - - if (!$session->has('player')) { - $playerFound = null; - $userAgent = $_SERVER['HTTP_USER_AGENT']; - - try { - $playerFound = \Opawg\UserAgentsPhp\UserAgents::find($userAgent); - } catch (\Exception $e) { - // If things go wrong the show must go on and the user must be able to download the file - } - if ($playerFound) { - $session->set('player', $playerFound); - } else { - $session->set('player', [ - 'app' => '- unknown -', - 'device' => '', - 'os' => '', - 'bot' => 0, - ]); - // Add to unknown list - try { - $db = \Config\Database::connect(); - $procedureNameAnalyticsUnknownUseragents = $db->prefixTable( - 'analytics_unknown_useragents', - ); - $db->query("CALL $procedureNameAnalyticsUnknownUseragents(?)", [ - $userAgent, - ]); - } catch (\Exception $e) { - // If things go wrong the show must go on and the user must be able to download the file - } - } - } -} - -/** - * Set user browser in session variable, for analytics purpose - */ -function set_user_session_browser() -{ - $session = \Config\Services::session(); - $session->start(); - - if (!$session->has('browser')) { - $browserName = '- Other -'; - try { - $whichbrowser = new \WhichBrowser\Parser(getallheaders()); - $browserName = $whichbrowser->browser->name; - } catch (\Exception $e) { - $browserName = '- Could not get browser name -'; - } - if ($browserName == null) { - $browserName = '- Could not get browser name -'; - } - $session->set('browser', $browserName); - } -} - -/** - * Set user referer in session variable, for analytics purpose - */ -function set_user_session_referer() -{ - $session = \Config\Services::session(); - $session->start(); - - $newreferer = isset($_SERVER['HTTP_REFERER']) - ? $_SERVER['HTTP_REFERER'] - : '- Direct -'; - $newreferer = - parse_url($newreferer, PHP_URL_HOST) == - parse_url(current_url(false), PHP_URL_HOST) - ? '- Direct -' - : $newreferer; - if (!$session->has('referer') or $newreferer != '- Direct -') { - $session->set('referer', $newreferer); - } -} - -/** - * Set user entry page in session variable, for analytics purpose - */ -function set_user_session_entry_page() -{ - $session = \Config\Services::session(); - $session->start(); - - $entryPage = $_SERVER['REQUEST_URI']; - if (!$session->has('entryPage')) { - $session->set('entryPage', $entryPage); - } -} - -function webpage_hit($podcast_id) -{ - $session = \Config\Services::session(); - $session->start(); - - if (!$session->get('denyListIp')) { - $db = \Config\Database::connect(); - - $referer = $session->get('referer'); - $domain = empty(parse_url($referer, PHP_URL_HOST)) - ? '- Direct -' - : parse_url($referer, PHP_URL_HOST); - parse_str(parse_url($referer, PHP_URL_QUERY), $queries); - $keywords = empty($queries['q']) ? null : $queries['q']; - - $procedureName = $db->prefixTable('analytics_website'); - $db->query("call $procedureName(?,?,?,?,?,?)", [ - $podcast_id, - $session->get('browser'), - $session->get('entryPage'), - $referer, - $domain, - $keywords, - ]); - } -} - -/** - * Counting podcast episode downloads for analytics purposes - * ✅ No IP address is ever stored on the server. - * ✅ Only aggregate data is stored in the database. - * We follow IAB Podcast Measurement Technical Guidelines Version 2.0: - * https://iabtechlab.com/standards/podcast-measurement-guidelines/ - * https://iabtechlab.com/wp-content/uploads/2017/12/Podcast_Measurement_v2-Dec-20-2017.pdf - * ✅ Rolling 24-hour window - * ✅ Castopod does not do pre-load - * ✅ IP deny list https://github.com/client9/ipcat - * ✅ User-agent Filtering https://github.com/opawg/user-agents - * ✅ RSS User-agent https://github.com/opawg/podcast-rss-useragents - * ✅ Ignores 2 bytes range "Range: 0-1" (performed by official Apple iOS Podcast app) - * ✅ In case of partial content, adds up all requests to check >1mn was downloaded - * ✅ Identifying Uniques is done with a combination of IP Address and User Agent - * @param int $podcastId The podcast ID - * @param int $episodeId The Episode ID - * @param int $bytesThreshold The minimum total number of bytes that must be downloaded so that an episode is counted (>1mn) - * @param int $fileSize The podcast complete file size - * @param string $serviceName The name of the service that had fetched the RSS feed - * - * @return void - */ -function podcast_hit( - $podcastId, - $episodeId, - $bytesThreshold, - $fileSize, - $duration, - $publicationDate, - $serviceName -) { - $session = \Config\Services::session(); - $session->start(); - - // We try to count (but if things went wrong the show should go on and the user should be able to download the file): - try { - // If the user IP is denied it's probably a bot: - if ($session->get('denyListIp')) { - $session->get('player')['bot'] = true; - } - //We get the HTTP header field `Range`: - $httpRange = isset($_SERVER['HTTP_RANGE']) - ? $_SERVER['HTTP_RANGE'] - : null; - - // We create a sha1 hash for this IP_Address+User_Agent+Episode_ID (used to count only once multiple episode downloads): - $episodeHashId = - '_IpUaEp_' . - sha1( - $_SERVER['REMOTE_ADDR'] . - '_' . - $_SERVER['HTTP_USER_AGENT'] . - '_' . - $episodeId, - ); - // Was this episode downloaded in the past 24h: - $downloadedBytes = cache($episodeHashId); - // Rolling window is 24 hours (86400 seconds): - $rollingTTL = 86400; - if ($downloadedBytes) { - // In case it was already downloaded, TTL should be adjusted (rolling window is 24h since 1st download): - $rollingTTL = - cache()->getMetadata($episodeHashId)['expire'] - time(); - } else { - // If it was never downloaded that means that zero byte were downloaded: - $downloadedBytes = 0; - } - // If the number of downloaded bytes was previously below the 1mn threshold we go on: - // (Otherwise it means that this was already counted, therefore we don't do anything) - if ($downloadedBytes < $bytesThreshold) { - // If HTTP_RANGE is null we are downloading the complete file: - if (!$httpRange) { - $downloadedBytes = $fileSize; - } else { - // [0-1] bytes range requests are used (by Apple) to check that file exists and that 206 partial content is working. - // We don't count these requests: - if ($httpRange != 'bytes=0-1') { - // We calculate how many bytes are being downloaded based on HTTP_RANGE values: - $ranges = explode(',', substr($httpRange, 6)); - foreach ($ranges as $range) { - $parts = explode('-', $range); - $downloadedBytes += empty($parts[1]) - ? $fileSize - : $parts[1] - (empty($parts[0]) ? 0 : $parts[0]); - } - } - } - // We save the number of downloaded bytes for this user and this episode: - cache()->save($episodeHashId, $downloadedBytes, $rollingTTL); - - // If more that 1mn was downloaded, that's a hit, we send that to the database: - if ($downloadedBytes >= $bytesThreshold) { - $db = \Config\Database::connect(); - $procedureName = $db->prefixTable('analytics_podcasts'); - - $age = intdiv(time() - $publicationDate, 86400); - - // We create a sha1 hash for this IP_Address+User_Agent+Podcast_ID (used to count unique listeners): - $listenerHashId = - '_IpUaPo_' . - sha1( - $_SERVER['REMOTE_ADDR'] . - '_' . - $_SERVER['HTTP_USER_AGENT'] . - '_' . - $podcastId, - ); - $newListener = 1; - // Has this listener already downloaded an episode today: - $downloadsByUser = cache($listenerHashId); - // We add one download - if ($downloadsByUser) { - $newListener = 0; - $downloadsByUser++; - } else { - $downloadsByUser = 1; - } - // Listener count is calculated from 00h00 to 23h59: - $midnightTTL = strtotime('tomorrow') - time(); - // We save the download count for this user until midnight: - cache()->save($listenerHashId, $downloadsByUser, $midnightTTL); - - $db->query( - "CALL $procedureName(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);", - [ - $podcastId, - $episodeId, - $session->get('location')['countryCode'], - $session->get('location')['regionCode'], - $session->get('location')['latitude'], - $session->get('location')['longitude'], - $serviceName, - $session->get('player')['app'], - $session->get('player')['device'], - $session->get('player')['os'], - $session->get('player')['bot'], - $fileSize, - $duration, - $age, - $newListener, - ], - ); - } - } - } catch (\Exception $e) { - // If things go wrong the show must go on and the user must be able to download the file - log_message('critical', $e); - } -} diff --git a/app/Helpers/url_helper.php b/app/Helpers/url_helper.php index 43e287f992..9e80311dca 100644 --- a/app/Helpers/url_helper.php +++ b/app/Helpers/url_helper.php @@ -88,7 +88,7 @@ if (!function_exists('extract_params_from_episode_uri')) { preg_match( '/@(?P<podcastName>[a-zA-Z0-9\_]{1,32})\/episodes\/(?P<episodeSlug>[a-zA-Z0-9\-]{1,191})/', $episodeUri->getPath(), - $matches + $matches, ); if ( diff --git a/app/Libraries/Analytics/Config/Analytics.php b/app/Libraries/Analytics/Config/Analytics.php new file mode 100644 index 0000000000..fa5ff6a4f5 --- /dev/null +++ b/app/Libraries/Analytics/Config/Analytics.php @@ -0,0 +1,38 @@ +<?php + +namespace Analytics\Config; + +use CodeIgniter\Config\BaseConfig; + +class Analytics extends BaseConfig +{ + /** + * Gateway to analytic routes. + * By default, all analytics routes will be under `/analytics` path + * + * @var string + */ + public $gateway = 'analytics'; + + /** + * -------------------------------------------------------------------- + * Route filters options + * -------------------------------------------------------------------- + */ + public $routeFilters = [ + 'analytics-full-data' => '', + 'analytics-data' => '', + 'analytics-filtered-data' => '', + ]; + + /** + * get the full enclosure url + * + * @param string $filename + * @return string + */ + public function getEnclosureUrl(string $enclosureUri) + { + return base_url($enclosureUri); + } +} diff --git a/app/Libraries/Analytics/Config/Routes.php b/app/Libraries/Analytics/Config/Routes.php new file mode 100644 index 0000000000..62de878c13 --- /dev/null +++ b/app/Libraries/Analytics/Config/Routes.php @@ -0,0 +1,74 @@ +<?php + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +/** + * Analytics routes file + */ + +$routes->addPlaceholder( + 'class', + '\bPodcastByCountry|\bPodcastByEpisode|\bPodcastByHour|\bPodcastByPlayer|\bPodcastByRegion|\bPodcastByService|\bPodcast|\bWebsiteByBrowser|\bWebsiteByEntryPage|\bWebsiteByReferer', +); +$routes->addPlaceholder( + 'filter', + '\bWeekly|\bYearly|\bByDay|\bByWeekday|\bByMonth|\bByAppWeekly|\bByAppYearly|\bByOsWeekly|\bByDeviceWeekly|\bBots|\bByServiceWeekly|\bBandwidthByDay|\bUniqueListenersByDay|\bUniqueListenersByMonth|\bTotalListeningTimeByDay|\bTotalListeningTimeByMonth|\bByDomainWeekly|\bByDomainYearly', +); + +$routes->group('', ['namespace' => 'Analytics\Controllers'], function ( + $routes +) { + $routes->group(config('Analytics')->gateway . '/(:num)/(:class)', function ( + $routes + ) { + $routes->get('/', 'AnalyticsController::getData/$1/$2', [ + 'as' => 'analytics-full-data', + 'filter' => config('Analytics')->routeFilters[ + 'analytics-full-data' + ], + ]); + + $routes->get('(:filter)', 'AnalyticsController::getData/$1/$2/$3', [ + 'as' => 'analytics-data', + 'filter' => config('Analytics')->routeFilters['analytics-data'], + ]); + + $routes->get( + '(:filter)/(:num)', + 'AnalyticsController::getData/$1/$2/$3/$4', + [ + 'as' => 'analytics-filtered-data', + 'filter' => config('Analytics')->routeFilters[ + 'analytics-filtered-data' + ], + ], + ); + }); + + // Route for podcast audio file analytics (/audio/pack(podcast_id,episode_id,bytes_threshold,filesize,duration,date)/podcast_folder/filename.mp3) + $routes->head( + 'audio/(:base64)/(:any)', + 'EpisodeAnalyticsController::hit/$1/$2', + [ + 'as' => 'episode-analytics-hit', + ], + ); + $routes->get( + 'audio/(:base64)/(:any)', + 'EpisodeAnalyticsController::hit/$1/$2', + [ + 'as' => 'episode-analytics-hit', + ], + ); +}); + +// Show the Unknown UserAgents +$routes->get('.well-known/unknown-useragents', 'UnknownUserAgentsController'); +$routes->get( + '.well-known/unknown-useragents/(:num)', + 'UnknownUserAgentsController/$1', +); diff --git a/app/Libraries/Analytics/Controllers/AnalyticsController.php b/app/Libraries/Analytics/Controllers/AnalyticsController.php new file mode 100644 index 0000000000..d80f03d5cf --- /dev/null +++ b/app/Libraries/Analytics/Controllers/AnalyticsController.php @@ -0,0 +1,54 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Analytics\Controllers; + +use CodeIgniter\Controller; + +class AnalyticsController extends Controller +{ + /** + * @var string + */ + protected $className; + + /** + * @var string + */ + protected $methodName; + + public function _remap($method, ...$params) + { + if (!isset($params[1])) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + + $this->className = model('Analytics' . $params[1] . 'Model'); + $this->methodName = 'getData' . (empty($params[2]) ? '' : $params[2]); + + return $this->$method( + $params[0], + isset($params[3]) ? $params[3] : null, + ); + } + + public function getData($podcastId, $episodeId) + { + $analytics_model = new $this->className(); + $methodName = $this->methodName; + if ($episodeId) { + return $this->response->setJSON( + $analytics_model->$methodName($podcastId, $episodeId), + ); + } else { + return $this->response->setJSON( + $analytics_model->$methodName($podcastId), + ); + } + } +} diff --git a/app/Controllers/Analytics.php b/app/Libraries/Analytics/Controllers/EpisodeAnalyticsController.php similarity index 83% rename from app/Controllers/Analytics.php rename to app/Libraries/Analytics/Controllers/EpisodeAnalyticsController.php index c687cb65c1..7e372d9ae9 100644 --- a/app/Controllers/Analytics.php +++ b/app/Libraries/Analytics/Controllers/EpisodeAnalyticsController.php @@ -1,18 +1,16 @@ <?php /** - * Class Analytics - * Creates Analytics controller * @copyright 2020 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ */ -namespace App\Controllers; +namespace Analytics\Controllers; use CodeIgniter\Controller; -class Analytics extends Controller +class EpisodeAnalyticsController extends Controller { /** * An array of helpers to be loaded automatically upon @@ -23,6 +21,10 @@ class Analytics extends Controller */ protected $helpers = ['analytics']; + /** + * @var \Analytics\Config\Analytics + */ + protected $config; /** * Constructor. */ @@ -43,12 +45,13 @@ class Analytics extends Controller set_user_session_deny_list_ip(); set_user_session_location(); set_user_session_player(); + + $this->config = config('Analytics'); } // Add one hit to this episode: - public function hit($base64EpisodeData, ...$filename) + public function hit($base64EpisodeData, ...$enclosureUri) { - helper('media', 'analytics'); $session = \Config\Services::session(); $session->start(); $serviceName = ''; @@ -62,7 +65,7 @@ class Analytics extends Controller $episodeData = unpack( 'IpodcastId/IepisodeId/IbytesThreshold/IfileSize/Iduration/IpublicationDate', - base64_url_decode($base64EpisodeData) + base64_url_decode($base64EpisodeData), ); podcast_hit( @@ -72,8 +75,9 @@ class Analytics extends Controller $episodeData['fileSize'], $episodeData['duration'], $episodeData['publicationDate'], - $serviceName + $serviceName, ); - return redirect()->to(media_base_url($filename)); + + return redirect()->to($this->config->getEnclosureUrl($enclosureUri)); } } diff --git a/app/Controllers/UnknownUserAgents.php b/app/Libraries/Analytics/Controllers/UnknownUserAgentsController.php similarity index 70% rename from app/Controllers/UnknownUserAgents.php rename to app/Libraries/Analytics/Controllers/UnknownUserAgentsController.php index 94718725aa..c26648189e 100644 --- a/app/Controllers/UnknownUserAgents.php +++ b/app/Libraries/Analytics/Controllers/UnknownUserAgentsController.php @@ -6,15 +6,15 @@ * @link https://castopod.org/ */ -namespace App\Controllers; +namespace Analytics\Controllers; use CodeIgniter\Controller; -class UnknownUserAgents extends Controller +class UnknownUserAgentsController extends Controller { public function index($lastKnownId = 0) { - $model = new \App\Models\UnknownUserAgentsModel(); + $model = model('UnknownUserAgentsModel'); return $this->response->setJSON($model->getUserAgents($lastKnownId)); } diff --git a/app/Database/Migrations/2020-06-08-120000_add_analytics_podcasts.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php similarity index 97% rename from app/Database/Migrations/2020-06-08-120000_add_analytics_podcasts.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php index 35821439f1..e89b1d19c9 100644 --- a/app/Database/Migrations/2020-06-08-120000_add_analytics_podcasts.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_episode.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php similarity index 97% rename from app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_episode.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php index 39dba61062..72d9b861c0 100644 --- a/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_episode.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_hour.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php similarity index 97% rename from app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_hour.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php index ae2d85e3c0..4e898f4134 100644 --- a/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_hour.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/app/Database/Migrations/2020-06-08-140000_add_analytics_podcasts_by_player.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php similarity index 97% rename from app/Database/Migrations/2020-06-08-140000_add_analytics_podcasts_by_player.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php index a1ab3174c3..424ede824a 100644 --- a/app/Database/Migrations/2020-06-08-140000_add_analytics_podcasts_by_player.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/app/Database/Migrations/2020-06-08-150000_add_analytics_podcasts_by_country.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php similarity index 97% rename from app/Database/Migrations/2020-06-08-150000_add_analytics_podcasts_by_country.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php index ce728e9671..d2a14f2279 100644 --- a/app/Database/Migrations/2020-06-08-150000_add_analytics_podcasts_by_country.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/app/Database/Migrations/2020-06-08-160000_add_analytics_podcasts_by_region.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php similarity index 97% rename from app/Database/Migrations/2020-06-08-160000_add_analytics_podcasts_by_region.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php index 009894fdf0..ee55120deb 100644 --- a/app/Database/Migrations/2020-06-08-160000_add_analytics_podcasts_by_region.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/app/Database/Migrations/2020-06-08-160000_add_podcasts_platforms.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-160000_add_podcasts_platforms.php similarity index 97% rename from app/Database/Migrations/2020-06-08-160000_add_podcasts_platforms.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-160000_add_podcasts_platforms.php index 68df0a8130..dbfd70e659 100644 --- a/app/Database/Migrations/2020-06-08-160000_add_podcasts_platforms.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-160000_add_podcasts_platforms.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/app/Database/Migrations/2020-06-08-170000_add_analytics_website_by_browser.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php similarity index 97% rename from app/Database/Migrations/2020-06-08-170000_add_analytics_website_by_browser.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php index 891a76e729..5a542c4978 100644 --- a/app/Database/Migrations/2020-06-08-170000_add_analytics_website_by_browser.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/app/Database/Migrations/2020-06-08-180000_add_analytics_website_by_referer.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php similarity index 97% rename from app/Database/Migrations/2020-06-08-180000_add_analytics_website_by_referer.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php index 0fa2fa707c..4571142946 100644 --- a/app/Database/Migrations/2020-06-08-180000_add_analytics_website_by_referer.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/app/Database/Migrations/2020-06-08-190000_add_analytics_website_by_entry_page.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php similarity index 97% rename from app/Database/Migrations/2020-06-08-190000_add_analytics_website_by_entry_page.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php index 366b75af08..8e331a6e17 100644 --- a/app/Database/Migrations/2020-06-08-190000_add_analytics_website_by_entry_page.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_unknown_useragents.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php similarity index 93% rename from app/Database/Migrations/2020-06-08-210000_add_analytics_unknown_useragents.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php index fcce6f4966..2ff7fcea7e 100644 --- a/app/Database/Migrations/2020-06-08-210000_add_analytics_unknown_useragents.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; @@ -38,10 +38,10 @@ class AddAnalyticsUnknownUseragents extends Migration $this->forge->addPrimaryKey('id'); // `created_at` and `updated_at` are created with SQL because Model class won’t be used for insertion (Procedure will be used instead) $this->forge->addField( - '`created_at` timestamp NOT NULL DEFAULT current_timestamp()' + '`created_at` timestamp NOT NULL DEFAULT current_timestamp()', ); $this->forge->addField( - '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()' + '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()', ); $this->forge->createTable('analytics_unknown_useragents'); } diff --git a/app/Database/Migrations/2020-06-11-210000_add_analytics_podcasts_procedure.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php similarity index 98% rename from app/Database/Migrations/2020-06-11-210000_add_analytics_podcasts_procedure.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php index f1792a6ced..ca579b3a5a 100644 --- a/app/Database/Migrations/2020-06-11-210000_add_analytics_podcasts_procedure.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/app/Database/Migrations/2020-06-11-210000_add_analytics_unknown_useragents_procedure.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_unknown_useragents_procedure.php similarity index 96% rename from app/Database/Migrations/2020-06-11-210000_add_analytics_unknown_useragents_procedure.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_unknown_useragents_procedure.php index 39e8d3aa28..5f6a65a934 100644 --- a/app/Database/Migrations/2020-06-11-210000_add_analytics_unknown_useragents_procedure.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_unknown_useragents_procedure.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/app/Database/Migrations/2020-06-11-210000_add_analytics_website_procedure.php b/app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_website_procedure.php similarity index 97% rename from app/Database/Migrations/2020-06-11-210000_add_analytics_website_procedure.php rename to app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_website_procedure.php index 01e89391f8..c4e5ac8ce8 100644 --- a/app/Database/Migrations/2020-06-11-210000_add_analytics_website_procedure.php +++ b/app/Libraries/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_website_procedure.php @@ -9,7 +9,7 @@ * @link https://castopod.org/ */ -namespace App\Database\Migrations; +namespace Analytics\Database\Migrations; use CodeIgniter\Database\Migration; diff --git a/app/Entities/AnalyticsPodcasts.php b/app/Libraries/Analytics/Entities/AnalyticsPodcasts.php similarity index 94% rename from app/Entities/AnalyticsPodcasts.php rename to app/Libraries/Analytics/Entities/AnalyticsPodcasts.php index b8e74b8f51..1c05c27598 100644 --- a/app/Entities/AnalyticsPodcasts.php +++ b/app/Libraries/Analytics/Entities/AnalyticsPodcasts.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Entities; +namespace Analytics\Entities; use CodeIgniter\Entity; diff --git a/app/Entities/AnalyticsPodcastsByCountry.php b/app/Libraries/Analytics/Entities/AnalyticsPodcastsByCountry.php similarity index 95% rename from app/Entities/AnalyticsPodcastsByCountry.php rename to app/Libraries/Analytics/Entities/AnalyticsPodcastsByCountry.php index d8b80de465..0f03599a69 100644 --- a/app/Entities/AnalyticsPodcastsByCountry.php +++ b/app/Libraries/Analytics/Entities/AnalyticsPodcastsByCountry.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Entities; +namespace Analytics\Entities; use CodeIgniter\Entity; diff --git a/app/Entities/AnalyticsPodcastsByEpisode.php b/app/Libraries/Analytics/Entities/AnalyticsPodcastsByEpisode.php similarity index 93% rename from app/Entities/AnalyticsPodcastsByEpisode.php rename to app/Libraries/Analytics/Entities/AnalyticsPodcastsByEpisode.php index 783bf2d54e..d2e570da47 100644 --- a/app/Entities/AnalyticsPodcastsByEpisode.php +++ b/app/Libraries/Analytics/Entities/AnalyticsPodcastsByEpisode.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Entities; +namespace Analytics\Entities; use CodeIgniter\Entity; diff --git a/app/Entities/AnalyticsPodcastsByHour.php b/app/Libraries/Analytics/Entities/AnalyticsPodcastsByHour.php similarity index 93% rename from app/Entities/AnalyticsPodcastsByHour.php rename to app/Libraries/Analytics/Entities/AnalyticsPodcastsByHour.php index 32dde6f347..6dc44330e6 100644 --- a/app/Entities/AnalyticsPodcastsByHour.php +++ b/app/Libraries/Analytics/Entities/AnalyticsPodcastsByHour.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Entities; +namespace Analytics\Entities; use CodeIgniter\Entity; diff --git a/app/Entities/AnalyticsPodcastsByPlayer.php b/app/Libraries/Analytics/Entities/AnalyticsPodcastsByPlayer.php similarity index 94% rename from app/Entities/AnalyticsPodcastsByPlayer.php rename to app/Libraries/Analytics/Entities/AnalyticsPodcastsByPlayer.php index e19f8360bc..1bd636ee31 100644 --- a/app/Entities/AnalyticsPodcastsByPlayer.php +++ b/app/Libraries/Analytics/Entities/AnalyticsPodcastsByPlayer.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Entities; +namespace Analytics\Entities; use CodeIgniter\Entity; diff --git a/app/Entities/AnalyticsPodcastsByRegion.php b/app/Libraries/Analytics/Entities/AnalyticsPodcastsByRegion.php similarity index 95% rename from app/Entities/AnalyticsPodcastsByRegion.php rename to app/Libraries/Analytics/Entities/AnalyticsPodcastsByRegion.php index de0a9b768e..7f62f57002 100644 --- a/app/Entities/AnalyticsPodcastsByRegion.php +++ b/app/Libraries/Analytics/Entities/AnalyticsPodcastsByRegion.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Entities; +namespace Analytics\Entities; use CodeIgniter\Entity; diff --git a/app/Entities/AnalyticsPodcastsByService.php b/app/Libraries/Analytics/Entities/AnalyticsPodcastsByService.php similarity index 91% rename from app/Entities/AnalyticsPodcastsByService.php rename to app/Libraries/Analytics/Entities/AnalyticsPodcastsByService.php index d34d57ac5c..d73eaeff53 100644 --- a/app/Entities/AnalyticsPodcastsByService.php +++ b/app/Libraries/Analytics/Entities/AnalyticsPodcastsByService.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Entities; +namespace Analytics\Entities; use CodeIgniter\Entity; @@ -32,7 +32,7 @@ class AnalyticsPodcastsByService extends Entity public function getLabels() { return \Opawg\UserAgentsPhp\UserAgentsRSS::getName( - $this->attributes['labels'] + $this->attributes['labels'], ) ?? $this->attributes['labels']; } } diff --git a/app/Entities/AnalyticsUnknownUseragents.php b/app/Libraries/Analytics/Entities/AnalyticsUnknownUseragents.php similarity index 93% rename from app/Entities/AnalyticsUnknownUseragents.php rename to app/Libraries/Analytics/Entities/AnalyticsUnknownUseragents.php index cff8088e05..b5a9dd3e76 100644 --- a/app/Entities/AnalyticsUnknownUseragents.php +++ b/app/Libraries/Analytics/Entities/AnalyticsUnknownUseragents.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Entities; +namespace Analytics\Entities; use CodeIgniter\Entity; diff --git a/app/Entities/AnalyticsWebsiteByBrowser.php b/app/Libraries/Analytics/Entities/AnalyticsWebsiteByBrowser.php similarity index 93% rename from app/Entities/AnalyticsWebsiteByBrowser.php rename to app/Libraries/Analytics/Entities/AnalyticsWebsiteByBrowser.php index 7b170f2efa..d753e9e733 100644 --- a/app/Entities/AnalyticsWebsiteByBrowser.php +++ b/app/Libraries/Analytics/Entities/AnalyticsWebsiteByBrowser.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Entities; +namespace Analytics\Entities; use CodeIgniter\Entity; diff --git a/app/Entities/AnalyticsWebsiteByEntryPage.php b/app/Libraries/Analytics/Entities/AnalyticsWebsiteByEntryPage.php similarity index 94% rename from app/Entities/AnalyticsWebsiteByEntryPage.php rename to app/Libraries/Analytics/Entities/AnalyticsWebsiteByEntryPage.php index d12c9bdb0c..1e1c6d9896 100644 --- a/app/Entities/AnalyticsWebsiteByEntryPage.php +++ b/app/Libraries/Analytics/Entities/AnalyticsWebsiteByEntryPage.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Entities; +namespace Analytics\Entities; use CodeIgniter\Entity; diff --git a/app/Entities/AnalyticsWebsiteByReferer.php b/app/Libraries/Analytics/Entities/AnalyticsWebsiteByReferer.php similarity index 93% rename from app/Entities/AnalyticsWebsiteByReferer.php rename to app/Libraries/Analytics/Entities/AnalyticsWebsiteByReferer.php index 30b0b2bffe..4fdf50b10b 100644 --- a/app/Entities/AnalyticsWebsiteByReferer.php +++ b/app/Libraries/Analytics/Entities/AnalyticsWebsiteByReferer.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Entities; +namespace Analytics\Entities; use CodeIgniter\Entity; diff --git a/app/Libraries/Analytics/Helpers/analytics_helper.php b/app/Libraries/Analytics/Helpers/analytics_helper.php new file mode 100644 index 0000000000..227a7ef6bf --- /dev/null +++ b/app/Libraries/Analytics/Helpers/analytics_helper.php @@ -0,0 +1,451 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +use CodeIgniter\Router\Exceptions\RouterException; + +if (!function_exists('base64_url_encode')) { + /** + * Encode Base64 for URLs + */ + function base64_url_encode($input) + { + return strtr(base64_encode($input), '+/=', '._-'); + } +} + +if (!function_exists('base64_url_decode')) { + /** + * Decode Base64 from URL + */ + function base64_url_decode($input) + { + return base64_decode(strtr($input, '._-', '+/=')); + } +} + +if (!function_exists('generate_episode_analytics_url')) { + /** + * Builds the episode analytics url that redirects to the enclosure url + * after analytics hit. + * + * @param int $podcastId + * @param int $episodeId + * @param string $enclosureUri + * @param int $enclosureDuration + * @param int $enclosureFilesize + * @param int $enclosureHeadersize + * @param \CodeIgniter\I18n\Time $publicationDate + * + * @return string + * @throws RouterException + */ + function generate_episode_analytics_url( + $podcastId, + $episodeId, + $enclosureUri, + $enclosureDuration, + $enclosureFilesize, + $enclosureHeadersize, + $publicationDate + ) { + return url_to( + 'episode-analytics-hit', + base64_url_encode( + pack( + 'I*', + $podcastId, + $episodeId, + // bytes_threshold: number of bytes that must be downloaded for an episode to be counted in download analytics + // - if file is shorter than 60sec, then it's enclosure_filesize + // - if file is longer than 60 seconds then it's enclosure_headersize + 60 seconds + $enclosureDuration <= 60 + ? $enclosureFilesize + : $enclosureHeadersize + + floor( + (($enclosureFilesize - $enclosureHeadersize) / + $enclosureDuration) * + 60, + ), + $enclosureFilesize, + $enclosureDuration, + strtotime($publicationDate), + ), + ), + $enclosureUri, + ); + } +} + +if (!function_exists('set_user_session_deny_list_ip')) { + /** + * Set user country in session variable, for analytic purposes + */ + function set_user_session_deny_list_ip() + { + $session = \Config\Services::session(); + $session->start(); + + if (!$session->has('denyListIp')) { + $session->set( + 'denyListIp', + \Podlibre\Ipcat\IpDb::find($_SERVER['REMOTE_ADDR']) != null, + ); + } + } +} + +if (!function_exists('set_user_session_location')) { + /** + * Set user country in session variable, for analytic purposes + */ + function set_user_session_location() + { + $session = \Config\Services::session(); + $session->start(); + + $location = [ + 'countryCode' => 'N/A', + 'regionCode' => 'N/A', + 'latitude' => null, + 'longitude' => null, + ]; + + // Finds location: + if (!$session->has('location')) { + try { + $cityReader = new \GeoIp2\Database\Reader( + WRITEPATH . 'uploads/GeoLite2-City/GeoLite2-City.mmdb', + ); + $city = $cityReader->city($_SERVER['REMOTE_ADDR']); + + $location = [ + 'countryCode' => empty($city->country->isoCode) + ? 'N/A' + : $city->country->isoCode, + 'regionCode' => empty($city->subdivisions[0]->isoCode) + ? 'N/A' + : $city->subdivisions[0]->isoCode, + 'latitude' => round($city->location->latitude, 3), + 'longitude' => round($city->location->longitude, 3), + ]; + } catch (\Exception $e) { + // If things go wrong the show must go on and the user must be able to download the file + } + $session->set('location', $location); + } + } +} + +if (!function_exists('set_user_session_player')) { + /** + * Set user player in session variable, for analytic purposes + */ + function set_user_session_player() + { + $session = \Config\Services::session(); + $session->start(); + + if (!$session->has('player')) { + $playerFound = null; + $userAgent = $_SERVER['HTTP_USER_AGENT']; + + try { + $playerFound = \Opawg\UserAgentsPhp\UserAgents::find( + $userAgent, + ); + } catch (\Exception $e) { + // If things go wrong the show must go on and the user must be able to download the file + } + if ($playerFound) { + $session->set('player', $playerFound); + } else { + $session->set('player', [ + 'app' => '- unknown -', + 'device' => '', + 'os' => '', + 'bot' => 0, + ]); + // Add to unknown list + try { + $db = \Config\Database::connect(); + $procedureNameAnalyticsUnknownUseragents = $db->prefixTable( + 'analytics_unknown_useragents', + ); + $db->query( + "CALL $procedureNameAnalyticsUnknownUseragents(?)", + [$userAgent], + ); + } catch (\Exception $e) { + // If things go wrong the show must go on and the user must be able to download the file + } + } + } + } +} + +if (!function_exists('set_user_session_browser')) { + /** + * Set user browser in session variable, for analytic purposes + * + * @return void + */ + function set_user_session_browser() + { + $session = \Config\Services::session(); + $session->start(); + + if (!$session->has('browser')) { + $browserName = '- Other -'; + try { + $whichbrowser = new \WhichBrowser\Parser(getallheaders()); + $browserName = $whichbrowser->browser->name; + } catch (\Exception $e) { + $browserName = '- Could not get browser name -'; + } + if ($browserName == null) { + $browserName = '- Could not get browser name -'; + } + $session->set('browser', $browserName); + } + } +} + +if (!function_exists('set_user_session_referer')) { + /** + * Set user referer in session variable, for analytic purposes + * + * @return void + */ + function set_user_session_referer() + { + $session = \Config\Services::session(); + $session->start(); + + $newreferer = isset($_SERVER['HTTP_REFERER']) + ? $_SERVER['HTTP_REFERER'] + : '- Direct -'; + $newreferer = + parse_url($newreferer, PHP_URL_HOST) == + parse_url(current_url(false), PHP_URL_HOST) + ? '- Direct -' + : $newreferer; + if (!$session->has('referer') or $newreferer != '- Direct -') { + $session->set('referer', $newreferer); + } + } +} + +if (!function_exists('set_user_session_entry_page')) { + /** + * Set user entry page in session variable, for analytic purposes + * + * @return void + */ + function set_user_session_entry_page() + { + $session = \Config\Services::session(); + $session->start(); + + $entryPage = $_SERVER['REQUEST_URI']; + if (!$session->has('entryPage')) { + $session->set('entryPage', $entryPage); + } + } +} + +if (!function_exists('webpage_hit')) { + /** + * + * @param integer $podcastId + * @return void + */ + function webpage_hit($podcastId) + { + $session = \Config\Services::session(); + $session->start(); + + if (!$session->get('denyListIp')) { + $db = \Config\Database::connect(); + + $referer = $session->get('referer'); + $domain = empty(parse_url($referer, PHP_URL_HOST)) + ? '- Direct -' + : parse_url($referer, PHP_URL_HOST); + parse_str(parse_url($referer, PHP_URL_QUERY), $queries); + $keywords = empty($queries['q']) ? null : $queries['q']; + + $procedureName = $db->prefixTable('analytics_website'); + $db->query("call $procedureName(?,?,?,?,?,?)", [ + $podcastId, + $session->get('browser'), + $session->get('entryPage'), + $referer, + $domain, + $keywords, + ]); + } + } +} + +if (!function_exists('podcast_hit')) { + /** + * Counting podcast episode downloads for analytic purposes + * ✅ No IP address is ever stored on the server. + * ✅ Only aggregate data is stored in the database. + * We follow IAB Podcast Measurement Technical Guidelines Version 2.0: + * https://iabtechlab.com/standards/podcast-measurement-guidelines/ + * https://iabtechlab.com/wp-content/uploads/2017/12/Podcast_Measurement_v2-Dec-20-2017.pdf + * ✅ Rolling 24-hour window + * ✅ Castopod does not do pre-load + * ✅ IP deny list https://github.com/client9/ipcat + * ✅ User-agent Filtering https://github.com/opawg/user-agents + * ✅ RSS User-agent https://github.com/opawg/podcast-rss-useragents + * ✅ Ignores 2 bytes range "Range: 0-1" (performed by official Apple iOS Podcast app) + * ✅ In case of partial content, adds up all requests to check >1mn was downloaded + * ✅ Identifying Uniques is done with a combination of IP Address and User Agent + * @param integer $podcastId The podcast ID + * @param integer $episodeId The Episode ID + * @param integer $bytesThreshold The minimum total number of bytes that must be downloaded so that an episode is counted (>1mn) + * @param integer $fileSize The podcast complete file size + * @param string $serviceName The name of the service that had fetched the RSS feed + * + * @return void + */ + function podcast_hit( + $podcastId, + $episodeId, + $bytesThreshold, + $fileSize, + $duration, + $publicationDate, + $serviceName + ) { + $session = \Config\Services::session(); + $session->start(); + + // We try to count (but if things went wrong the show should go on and the user should be able to download the file): + try { + // If the user IP is denied it's probably a bot: + if ($session->get('denyListIp')) { + $session->get('player')['bot'] = true; + } + //We get the HTTP header field `Range`: + $httpRange = isset($_SERVER['HTTP_RANGE']) + ? $_SERVER['HTTP_RANGE'] + : null; + + // We create a sha1 hash for this IP_Address+User_Agent+Episode_ID (used to count only once multiple episode downloads): + $episodeHashId = + '_IpUaEp_' . + sha1( + $_SERVER['REMOTE_ADDR'] . + '_' . + $_SERVER['HTTP_USER_AGENT'] . + '_' . + $episodeId, + ); + // Was this episode downloaded in the past 24h: + $downloadedBytes = cache($episodeHashId); + // Rolling window is 24 hours (86400 seconds): + $rollingTTL = 86400; + if ($downloadedBytes) { + // In case it was already downloaded, TTL should be adjusted (rolling window is 24h since 1st download): + $rollingTTL = + cache()->getMetadata($episodeHashId)['expire'] - time(); + } else { + // If it was never downloaded that means that zero byte were downloaded: + $downloadedBytes = 0; + } + // If the number of downloaded bytes was previously below the 1mn threshold we go on: + // (Otherwise it means that this was already counted, therefore we don't do anything) + if ($downloadedBytes < $bytesThreshold) { + // If HTTP_RANGE is null we are downloading the complete file: + if (!$httpRange) { + $downloadedBytes = $fileSize; + } else { + // [0-1] bytes range requests are used (by Apple) to check that file exists and that 206 partial content is working. + // We don't count these requests: + if ($httpRange != 'bytes=0-1') { + // We calculate how many bytes are being downloaded based on HTTP_RANGE values: + $ranges = explode(',', substr($httpRange, 6)); + foreach ($ranges as $range) { + $parts = explode('-', $range); + $downloadedBytes += empty($parts[1]) + ? $fileSize + : $parts[1] - + (empty($parts[0]) ? 0 : $parts[0]); + } + } + } + // We save the number of downloaded bytes for this user and this episode: + cache()->save($episodeHashId, $downloadedBytes, $rollingTTL); + + // If more that 1mn was downloaded, that's a hit, we send that to the database: + if ($downloadedBytes >= $bytesThreshold) { + $db = \Config\Database::connect(); + $procedureName = $db->prefixTable('analytics_podcasts'); + + $age = intdiv(time() - $publicationDate, 86400); + + // We create a sha1 hash for this IP_Address+User_Agent+Podcast_ID (used to count unique listeners): + $listenerHashId = + '_IpUaPo_' . + sha1( + $_SERVER['REMOTE_ADDR'] . + '_' . + $_SERVER['HTTP_USER_AGENT'] . + '_' . + $podcastId, + ); + $newListener = 1; + // Has this listener already downloaded an episode today: + $downloadsByUser = cache($listenerHashId); + // We add one download + if ($downloadsByUser) { + $newListener = 0; + $downloadsByUser++; + } else { + $downloadsByUser = 1; + } + // Listener count is calculated from 00h00 to 23h59: + $midnightTTL = strtotime('tomorrow') - time(); + // We save the download count for this user until midnight: + cache()->save( + $listenerHashId, + $downloadsByUser, + $midnightTTL, + ); + + $db->query( + "CALL $procedureName(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);", + [ + $podcastId, + $episodeId, + $session->get('location')['countryCode'], + $session->get('location')['regionCode'], + $session->get('location')['latitude'], + $session->get('location')['longitude'], + $serviceName, + $session->get('player')['app'], + $session->get('player')['device'], + $session->get('player')['os'], + $session->get('player')['bot'], + $fileSize, + $duration, + $age, + $newListener, + ], + ); + } + } + } catch (\Exception $e) { + // If things go wrong the show must go on and the user must be able to download the file + log_message('critical', $e); + } + } +} diff --git a/app/Models/AnalyticsPodcastByCountryModel.php b/app/Libraries/Analytics/Models/AnalyticsPodcastByCountryModel.php similarity index 95% rename from app/Models/AnalyticsPodcastByCountryModel.php rename to app/Libraries/Analytics/Models/AnalyticsPodcastByCountryModel.php index af76704cf3..13b9fdf8c2 100644 --- a/app/Models/AnalyticsPodcastByCountryModel.php +++ b/app/Libraries/Analytics/Models/AnalyticsPodcastByCountryModel.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Models; +namespace Analytics\Models; use CodeIgniter\Model; @@ -18,7 +18,7 @@ class AnalyticsPodcastByCountryModel extends Model protected $allowedFields = []; - protected $returnType = \App\Entities\AnalyticsPodcastsByCountry::class; + protected $returnType = \Analytics\Entities\AnalyticsPodcastsByCountry::class; protected $useSoftDeletes = false; protected $useTimestamps = false; diff --git a/app/Libraries/Analytics/Models/AnalyticsPodcastByEpisodeModel.php b/app/Libraries/Analytics/Models/AnalyticsPodcastByEpisodeModel.php new file mode 100644 index 0000000000..ed53ce2270 --- /dev/null +++ b/app/Libraries/Analytics/Models/AnalyticsPodcastByEpisodeModel.php @@ -0,0 +1,90 @@ +<?php + +/** + * Class AnalyticsPodcastByEpisodeModel + * Model for analytics_podcasts_by_episodes table in database + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Analytics\Models; + +use CodeIgniter\Model; + +class AnalyticsPodcastByEpisodeModel extends Model +{ + protected $table = 'analytics_podcasts_by_episode'; + + protected $allowedFields = []; + + protected $returnType = \Analytics\Entities\AnalyticsPodcastsByEpisode::class; + protected $useSoftDeletes = false; + + protected $useTimestamps = false; + + /** + * @param int $podcastId + * @param int $episodeId + * + * @return array + */ + public function getDataByDay(int $podcastId, int $episodeId): array + { + if ( + !($found = cache( + "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_day", + )) + ) { + $found = $this->select('date as labels') + ->selectSum('hits', 'values') + ->where([ + 'episode_id' => $episodeId, + 'podcast_id' => $podcastId, + 'age <' => 60, + ]) + ->groupBy('labels') + ->orderBy('labels', 'ASC') + ->findAll(); + + cache()->save( + "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_day", + $found, + 600, + ); + } + return $found; + } + + /** + * @param int $podcastId + * @param int $episodeId + * + * @return array + */ + public function getDataByMonth(int $podcastId, int $episodeId = null): array + { + if ( + !($found = cache( + "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_month", + )) + ) { + $found = $this->select('DATE_FORMAT(date,"%Y-%m-01") as labels') + ->selectSum('hits', 'values') + ->where([ + 'episode_id' => $episodeId, + 'podcast_id' => $podcastId, + ]) + ->groupBy('labels') + ->orderBy('labels', 'ASC') + ->findAll(); + + cache()->save( + "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_month", + $found, + 600, + ); + } + return $found; + } +} diff --git a/app/Models/AnalyticsPodcastByHourModel.php b/app/Libraries/Analytics/Models/AnalyticsPodcastByHourModel.php similarity index 92% rename from app/Models/AnalyticsPodcastByHourModel.php rename to app/Libraries/Analytics/Models/AnalyticsPodcastByHourModel.php index df43d0383e..4b5df27044 100644 --- a/app/Models/AnalyticsPodcastByHourModel.php +++ b/app/Libraries/Analytics/Models/AnalyticsPodcastByHourModel.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Models; +namespace Analytics\Models; use CodeIgniter\Model; @@ -18,7 +18,7 @@ class AnalyticsPodcastByHourModel extends Model protected $allowedFields = []; - protected $returnType = \App\Entities\AnalyticsPodcastsByHour::class; + protected $returnType = \Analytics\Entities\AnalyticsPodcastsByHour::class; protected $useSoftDeletes = false; protected $useTimestamps = false; diff --git a/app/Models/AnalyticsPodcastByPlayerModel.php b/app/Libraries/Analytics/Models/AnalyticsPodcastByPlayerModel.php similarity index 98% rename from app/Models/AnalyticsPodcastByPlayerModel.php rename to app/Libraries/Analytics/Models/AnalyticsPodcastByPlayerModel.php index 668ca123e9..237843448e 100644 --- a/app/Models/AnalyticsPodcastByPlayerModel.php +++ b/app/Libraries/Analytics/Models/AnalyticsPodcastByPlayerModel.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Models; +namespace Analytics\Models; use CodeIgniter\Model; @@ -18,7 +18,7 @@ class AnalyticsPodcastByPlayerModel extends Model protected $allowedFields = []; - protected $returnType = \App\Entities\AnalyticsPodcastsByPlayer::class; + protected $returnType = \Analytics\Entities\AnalyticsPodcastsByPlayer::class; protected $useSoftDeletes = false; protected $useTimestamps = false; diff --git a/app/Models/AnalyticsPodcastByRegionModel.php b/app/Libraries/Analytics/Models/AnalyticsPodcastByRegionModel.php similarity index 93% rename from app/Models/AnalyticsPodcastByRegionModel.php rename to app/Libraries/Analytics/Models/AnalyticsPodcastByRegionModel.php index be9a81c076..76a2f2dcf9 100644 --- a/app/Models/AnalyticsPodcastByRegionModel.php +++ b/app/Libraries/Analytics/Models/AnalyticsPodcastByRegionModel.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Models; +namespace Analytics\Models; use CodeIgniter\Model; @@ -18,7 +18,7 @@ class AnalyticsPodcastByRegionModel extends Model protected $allowedFields = []; - protected $returnType = \App\Entities\AnalyticsPodcastsByRegion::class; + protected $returnType = \Analytics\Entities\AnalyticsPodcastsByRegion::class; protected $useSoftDeletes = false; protected $useTimestamps = false; diff --git a/app/Models/AnalyticsPodcastByServiceModel.php b/app/Libraries/Analytics/Models/AnalyticsPodcastByServiceModel.php similarity index 93% rename from app/Models/AnalyticsPodcastByServiceModel.php rename to app/Libraries/Analytics/Models/AnalyticsPodcastByServiceModel.php index 9170f8618f..a3039ae936 100644 --- a/app/Models/AnalyticsPodcastByServiceModel.php +++ b/app/Libraries/Analytics/Models/AnalyticsPodcastByServiceModel.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Models; +namespace Analytics\Models; use CodeIgniter\Model; @@ -18,7 +18,7 @@ class AnalyticsPodcastByServiceModel extends Model protected $allowedFields = []; - protected $returnType = \App\Entities\AnalyticsPodcastsByService::class; + protected $returnType = \Analytics\Entities\AnalyticsPodcastsByService::class; protected $useSoftDeletes = false; protected $useTimestamps = false; diff --git a/app/Models/AnalyticsPodcastModel.php b/app/Libraries/Analytics/Models/AnalyticsPodcastModel.php similarity index 98% rename from app/Models/AnalyticsPodcastModel.php rename to app/Libraries/Analytics/Models/AnalyticsPodcastModel.php index b8e84fb114..a621bae20f 100644 --- a/app/Models/AnalyticsPodcastModel.php +++ b/app/Libraries/Analytics/Models/AnalyticsPodcastModel.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Models; +namespace Analytics\Models; use CodeIgniter\Model; @@ -18,7 +18,7 @@ class AnalyticsPodcastModel extends Model protected $allowedFields = []; - protected $returnType = \App\Entities\AnalyticsPodcasts::class; + protected $returnType = \Analytics\Entities\AnalyticsPodcasts::class; protected $useSoftDeletes = false; protected $useTimestamps = false; diff --git a/app/Models/AnalyticsUnknownUseragentsModel.php b/app/Libraries/Analytics/Models/AnalyticsUnknownUseragentsModel.php similarity index 82% rename from app/Models/AnalyticsUnknownUseragentsModel.php rename to app/Libraries/Analytics/Models/AnalyticsUnknownUseragentsModel.php index 8dae7486e6..c002668beb 100644 --- a/app/Models/AnalyticsUnknownUseragentsModel.php +++ b/app/Libraries/Analytics/Models/AnalyticsUnknownUseragentsModel.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Models; +namespace Analytics\Models; use CodeIgniter\Model; @@ -19,7 +19,7 @@ class AnalyticsUnknownUseragentsModel extends Model protected $allowedFields = []; - protected $returnType = \App\Entities\AnalyticsUnknownUseragents::class; + protected $returnType = \Analytics\Entities\AnalyticsUnknownUseragents::class; protected $useSoftDeletes = false; protected $useTimestamps = false; diff --git a/app/Models/AnalyticsWebsiteByBrowserModel.php b/app/Libraries/Analytics/Models/AnalyticsWebsiteByBrowserModel.php similarity index 92% rename from app/Models/AnalyticsWebsiteByBrowserModel.php rename to app/Libraries/Analytics/Models/AnalyticsWebsiteByBrowserModel.php index b7b7c13263..efdfc95f1f 100644 --- a/app/Models/AnalyticsWebsiteByBrowserModel.php +++ b/app/Libraries/Analytics/Models/AnalyticsWebsiteByBrowserModel.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Models; +namespace Analytics\Models; use CodeIgniter\Model; @@ -18,7 +18,7 @@ class AnalyticsWebsiteByBrowserModel extends Model protected $allowedFields = []; - protected $returnType = \App\Entities\AnalyticsWebsiteByBrowser::class; + protected $returnType = \Analytics\Entities\AnalyticsWebsiteByBrowser::class; protected $useSoftDeletes = false; protected $useTimestamps = false; diff --git a/app/Models/AnalyticsWebsiteByEntryPageModel.php b/app/Libraries/Analytics/Models/AnalyticsWebsiteByEntryPageModel.php similarity index 92% rename from app/Models/AnalyticsWebsiteByEntryPageModel.php rename to app/Libraries/Analytics/Models/AnalyticsWebsiteByEntryPageModel.php index 220719d409..95931b426e 100644 --- a/app/Models/AnalyticsWebsiteByEntryPageModel.php +++ b/app/Libraries/Analytics/Models/AnalyticsWebsiteByEntryPageModel.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Models; +namespace Analytics\Models; use CodeIgniter\Model; @@ -18,7 +18,7 @@ class AnalyticsWebsiteByEntryPageModel extends Model protected $allowedFields = []; - protected $returnType = \App\Entities\AnalyticsWebsiteByEntryPage::class; + protected $returnType = \Analytics\Entities\AnalyticsWebsiteByEntryPage::class; protected $useSoftDeletes = false; protected $useTimestamps = false; diff --git a/app/Models/AnalyticsWebsiteByRefererModel.php b/app/Libraries/Analytics/Models/AnalyticsWebsiteByRefererModel.php similarity index 96% rename from app/Models/AnalyticsWebsiteByRefererModel.php rename to app/Libraries/Analytics/Models/AnalyticsWebsiteByRefererModel.php index aed2f46bc6..65ee8dd2be 100644 --- a/app/Models/AnalyticsWebsiteByRefererModel.php +++ b/app/Libraries/Analytics/Models/AnalyticsWebsiteByRefererModel.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Models; +namespace Analytics\Models; use CodeIgniter\Model; @@ -18,7 +18,7 @@ class AnalyticsWebsiteByRefererModel extends Model protected $allowedFields = []; - protected $returnType = \App\Entities\AnalyticsWebsiteByReferer::class; + protected $returnType = \Analytics\Entities\AnalyticsWebsiteByReferer::class; protected $useSoftDeletes = false; protected $useTimestamps = false; diff --git a/app/Models/UnknownUserAgentsModel.php b/app/Libraries/Analytics/Models/UnknownUserAgentsModel.php similarity index 95% rename from app/Models/UnknownUserAgentsModel.php rename to app/Libraries/Analytics/Models/UnknownUserAgentsModel.php index 5afabf5dc9..1f531f456d 100644 --- a/app/Models/UnknownUserAgentsModel.php +++ b/app/Libraries/Analytics/Models/UnknownUserAgentsModel.php @@ -8,7 +8,7 @@ * @link https://castopod.org/ */ -namespace App\Models; +namespace Analytics\Models; use CodeIgniter\Model; diff --git a/app/Models/AnalyticsPodcastByEpisodeModel.php b/app/Models/AnalyticsPodcastByEpisodeModel.php deleted file mode 100644 index 15725032d5..0000000000 --- a/app/Models/AnalyticsPodcastByEpisodeModel.php +++ /dev/null @@ -1,145 +0,0 @@ -<?php - -/** - * Class AnalyticsPodcastByEpisodeModel - * Model for analytics_podcasts_by_episodes table in database - * @copyright 2020 Podlibre - * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 - * @link https://castopod.org/ - */ - -namespace App\Models; - -use CodeIgniter\Model; - -class AnalyticsPodcastByEpisodeModel extends Model -{ - protected $table = 'analytics_podcasts_by_episode'; - - protected $allowedFields = []; - - protected $returnType = \App\Entities\AnalyticsPodcastsByEpisode::class; - protected $useSoftDeletes = false; - - protected $useTimestamps = false; - - /** - * @param int $podcastId, $episodeId - * - * @return array - */ - public function getDataByDay(int $podcastId, int $episodeId = null): array - { - if (!$episodeId) { - if ( - !($found = cache( - "{$podcastId}_analytics_podcast_by_episode_by_day", - )) - ) { - $lastEpisodes = (new EpisodeModel()) - ->select('id, season_number, number, title') - ->orderBy('id', 'DESC') - ->where(['podcast_id' => $podcastId]) - ->findAll(5); - - $found = $this->select('age AS X'); - - $letter = 97; - foreach ($lastEpisodes as $episode) { - $found = $found - ->selectSum( - '(CASE WHEN episode_id=' . - $episode->id . - ' THEN hits END)', - '' . chr($letter) . 'Y', - ) - ->select( - '"' . - (empty($episode->season_number) - ? '' - : $episode->season_number) . - (empty($episode->number) - ? '' - : '-' . $episode->number . '/ ') . - $episode->title . - '" AS ' . - chr($letter) . - 'Value', - ); - $letter++; - } - - $found = $found - ->where([ - 'podcast_id' => $podcastId, - 'age <' => 60, - ]) - ->groupBy('X') - ->orderBy('X', 'ASC') - ->findAll(); - - cache()->save( - "{$podcastId}_analytics_podcast_by_episode_by_day", - $found, - 600, - ); - } - return $found; - } else { - if ( - !($found = cache( - "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_day", - )) - ) { - $found = $this->select('date as labels') - ->selectSum('hits', 'values') - ->where([ - 'episode_id' => $episodeId, - 'podcast_id' => $podcastId, - 'age <' => 60, - ]) - ->groupBy('labels') - ->orderBy('labels', 'ASC') - ->findAll(); - - cache()->save( - "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_day", - $found, - 600, - ); - } - return $found; - } - } - - /** - * @param int $podcastId, $episodeId - * - * @return array - */ - public function getDataByMonth(int $podcastId, int $episodeId = null): array - { - if ( - !($found = cache( - "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_month", - )) - ) { - $found = $this->select('DATE_FORMAT(date,"%Y-%m-01") as labels') - ->selectSum('hits', 'values') - ->where([ - 'episode_id' => $episodeId, - 'podcast_id' => $podcastId, - ]) - ->groupBy('labels') - ->orderBy('labels', 'ASC') - ->findAll(); - - cache()->save( - "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_month", - $found, - 600, - ); - } - return $found; - } -} diff --git a/app/Views/admin/podcast/analytics/index.php b/app/Views/admin/podcast/analytics/index.php index 54b9544389..69b32b2e6e 100644 --- a/app/Views/admin/podcast/analytics/index.php +++ b/app/Views/admin/podcast/analytics/index.php @@ -15,7 +15,7 @@ 'analytics-data', $podcast->id, 'Podcast', - 'ByDay' + 'ByDay', ) ?>"></div> </div> @@ -25,7 +25,7 @@ 'analytics-data', $podcast->id, 'Podcast', - 'ByMonth' + 'ByMonth', ) ?>"></div> </div> @@ -35,17 +35,7 @@ 'analytics-data', $podcast->id, 'Podcast', - 'BandwidthByDay' -) ?>"></div> -</div> - -<div class="mb-12 text-center"> -<h2><?= lang('Charts.episodes_by_day') ?></h2> -<div class="chart-xy" id="by-age-graph" data-chart-type="xy-series-chart" data-chart-url="<?= route_to( - 'analytics-data', - $podcast->id, - 'PodcastByEpisode', - 'ByDay' + 'BandwidthByDay', ) ?>"></div> </div> diff --git a/docs/setup-development.md b/docs/setup-development.md index f39ba6b85e..6423818d7c 100644 --- a/docs/setup-development.md +++ b/docs/setup-development.md @@ -95,9 +95,9 @@ Go to project's root folder and run: docker-compose up -d # See all running processes (you should see 3 processes running) -docker ps +docker-compose ps -# Alternatively, you can check all processes (you should see composer with an Exited status) +# Alternatively, you can check all docker processes (you should see composer and npm with an Exited status) docker ps -a ``` @@ -146,17 +146,20 @@ docker-compose run --rm app php spark db:seed LanguageSeeder docker-compose run --rm app php spark db:seed PlatformSeeder # Populates all Authentication data (roles definition…) docker-compose run --rm app php spark db:seed AuthSeeder -# Populates test data (login: admin / password: AGUehL3P) -docker-compose run --rm app php spark db:seed TestSeeder ``` 3. (optionnal) Populate the database with test data: ```bash +# Populates test data (login: admin / password: AGUehL3P) docker-compose run --rm app php spark db:seed TestSeeder +# Populates with fake podcast analytics +docker-compose run --rm app php spark db:seed FakePodcastsAnalyticsSeeder +# Populates with fake website analytics +docker-compose run --rm app php spark db:seed FakeWebsiteAnalyticsSeeder ``` -This will add an active superadmin user with the following credentials: +TestSeeder will add an active superadmin user with the following credentials: - username: **admin** - password: **AGUehL3P** @@ -205,8 +208,8 @@ To see your changes, go to: - [localhost:8080](http://localhost:8080/) for the castopod app - [localhost:8888](http://localhost:8888/) for the phpmyadmin interface: - - **Username**: podlibre - - **Password**: castopod + - username: **podlibre** + - password: **castopod** --- @@ -216,19 +219,22 @@ To see your changes, go to: ```bash # monitor the app container -docker logs --tail 50 --follow --timestamps castopod_app +docker-compose logs --tail 50 --follow --timestamps app # monitor the mariadb container -docker logs --tail 50 --follow --timestamps castopod_mariadb +docker-compose logs --tail 50 --follow --timestamps mariadb # monitor the phpmyadmin container -docker logs --tail 50 --follow --timestamps castopod_phpmyadmin +docker-compose logs --tail 50 --follow --timestamps phpmyadmin # restart docker containers docker-compose restart # Destroy all containers, opposite of `up` command docker-compose down + +# Rebuild app container +docker-compose build app ``` Check [docker](https://docs.docker.com/engine/reference/commandline/docker/) and -- GitLab