diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 803c0625..3a9f533e 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -194,6 +194,14 @@ $routes->group('@(:podcastHandle)', static function ($routes): void { $routes->get('feed', 'FeedController/$1'); }); +// audio routes +$routes->head('audio/@(:podcastHandle)/(:slug)', 'EpisodeController::audio/$1/$2', [ + 'as' => 'episode-audio', +],); +$routes->get('audio/@(:podcastHandle)/(:slug)', 'EpisodeController::audio/$1/$2', [ + 'as' => 'episode-audio', +],); + // Other pages $routes->get('/credits', 'CreditsController', [ 'as' => 'credits', diff --git a/app/Controllers/EpisodeController.php b/app/Controllers/EpisodeController.php index 5b59cf55..03551e35 100644 --- a/app/Controllers/EpisodeController.php +++ b/app/Controllers/EpisodeController.php @@ -19,12 +19,14 @@ use App\Models\PodcastModel; use App\Models\PostModel; use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\ResponseInterface; use Config\Services; use Modules\Analytics\AnalyticsTrait; use Modules\Fediverse\Objects\OrderedCollectionObject; use Modules\Fediverse\Objects\OrderedCollectionPage; +use Modules\PremiumPodcasts\Models\SubscriptionModel; use SimpleXMLElement; class EpisodeController extends BaseController @@ -329,4 +331,82 @@ class EpisodeController extends BaseController ->setHeader('Access-Control-Allow-Origin', '*') ->setBody($collection->toJSON()); } + + public function audio(): RedirectResponse | ResponseInterface + { + // check if episode is premium? + $subscription = null; + + // check if podcast is already unlocked before any token validation + if ($this->episode->is_premium && ($subscription = service('premium_podcasts')->subscription( + $this->episode->podcast->handle + )) === null) { + // look for token as GET parameter + if (($token = $this->request->getGet('token')) === null) { + return $this->response->setStatusCode(401) + ->setJSON([ + 'errors' => [ + 'status' => 401, + 'title' => 'Unauthorized', + 'detail' => 'Episode is premium, you must provide a token to unlock it.', + ], + ]); + } + + // check if there's a valid subscription for the provided token + if (($subscription = (new SubscriptionModel())->validateSubscription( + $this->episode->podcast->handle, + $token + )) === null) { + return $this->response->setStatusCode(401, 'Invalid token!') + ->setJSON([ + 'errors' => [ + 'status' => 401, + 'title' => 'Unauthorized', + 'detail' => 'Invalid token!', + ], + ]); + } + } + + $session = Services::session(); + $session->start(); + + $serviceName = ''; + if ($this->request->getGet('_from')) { + $serviceName = $this->request->getGet('_from'); + } elseif ($session->get('embed_domain') !== null) { + $serviceName = $session->get('embed_domain'); + } elseif ($session->get('referer') !== null && $session->get('referer') !== '- Direct -') { + $serviceName = parse_url((string) $session->get('referer'), PHP_URL_HOST); + } + + $audioFileSize = $this->episode->audio->file_size; + $audioFileHeaderSize = $this->episode->audio->header_size; + $audioDuration = $this->episode->audio->duration; + + // bytes_threshold: number of bytes that must be downloaded for an episode to be counted in download analytics + // - if audio is less than or equal to 60s, then take the audio file_size + // - if audio is more than 60s, then take the audio file_header_size + 60s + $bytesThreshold = $audioDuration <= 60 + ? $audioFileSize + : $audioFileHeaderSize + + (int) floor((($audioFileSize - $audioFileHeaderSize) / $audioDuration) * 60); + + helper('analytics'); + podcast_hit( + $this->episode->podcast_id, + $this->episode->id, + $bytesThreshold, + $audioFileSize, + $audioDuration, + $this->episode->published_at->getTimestamp(), + $serviceName, + $subscription !== null ? $subscription->id : null + ); + + $analyticsConfig = config('Analytics'); + + return redirect()->to($analyticsConfig->getAudioUrl($this->episode, $this->request->getGet())); + } } diff --git a/app/Controllers/FeedController.php b/app/Controllers/FeedController.php index e323ede8..290de8c4 100644 --- a/app/Controllers/FeedController.php +++ b/app/Controllers/FeedController.php @@ -3,14 +3,13 @@ declare(strict_types=1); /** - * @copyright 2020 Ad Aures + * @copyright 2022 Ad Aures * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ */ namespace App\Controllers; -use App\Entities\Podcast; use App\Models\EpisodeModel; use App\Models\PodcastModel; use CodeIgniter\Controller; diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 56d8af8a..ffe0b33e 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -44,7 +44,7 @@ use RuntimeException; * @property string $title * @property int $audio_id * @property Audio $audio - * @property string $audio_analytics_url + * @property string $audio_url * @property string $audio_web_url * @property string $audio_opengraph_url * @property string|null $description Holds text only description, striped of any markdown or html special characters @@ -93,7 +93,7 @@ class Episode extends Entity protected ?Audio $audio = null; - protected string $audio_analytics_url; + protected string $audio_url; protected string $audio_web_url; @@ -335,36 +335,19 @@ class Episode extends Entity return $this->chapters; } - public function getAudioAnalyticsUrl(): string + public function getAudioUrl(): string { - helper('analytics'); - - return generate_episode_analytics_url( - $this->podcast_id, - $this->id, - $this->getPodcast() - ->handle, - $this->attributes['slug'], - $this->getAudio() - ->file_extension, - $this->getAudio() - ->duration, - $this->getAudio() - ->file_size, - $this->getAudio() - ->header_size, - $this->published_at, - ); + return url_to('episode-audio', $this->getPodcast()->handle, $this->slug); } public function getAudioWebUrl(): string { - return $this->getAudioAnalyticsUrl() . '?_from=-+Website+-'; + return $this->getAudioUrl() . '?_from=-+Website+-'; } public function getAudioOpengraphUrl(): string { - return $this->getAudioAnalyticsUrl() . '?_from=-+Open+Graph+-'; + return $this->getAudioUrl() . '?_from=-+Open+Graph+-'; } /** diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index faee3708..28d81e31 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -286,7 +286,7 @@ if (! function_exists('get_rss_feed')) { $enclosure->addAttribute( 'url', - $episode->audio_analytics_url . ($enclosureParams === '' ? '' : '?' . $enclosureParams), + $episode->audio_url . ($enclosureParams === '' ? '' : '?' . $enclosureParams), ); $enclosure->addAttribute('length', (string) $episode->audio->file_size); $enclosure->addAttribute('type', $episode->audio->file_mimetype); diff --git a/app/Helpers/seo_helper.php b/app/Helpers/seo_helper.php index f9d870b0..6d535739 100644 --- a/app/Helpers/seo_helper.php +++ b/app/Helpers/seo_helper.php @@ -87,7 +87,7 @@ if (! function_exists('get_episode_metatags')) { 'timeRequired' => iso8601_duration($episode->audio->duration), 'duration' => iso8601_duration($episode->audio->duration), 'associatedMedia' => new Thing('MediaObject', [ - 'contentUrl' => $episode->audio->file_url, + 'contentUrl' => $episode->audio_url, ]), 'partOfSeries' => new Thing('PodcastSeries', [ 'name' => $episode->podcast->title, diff --git a/app/Libraries/PodcastEpisode.php b/app/Libraries/PodcastEpisode.php index 9b2bf169..7388808d 100644 --- a/app/Libraries/PodcastEpisode.php +++ b/app/Libraries/PodcastEpisode.php @@ -58,13 +58,13 @@ class PodcastEpisode extends ObjectType // add audio file $this->audio = [ - 'id' => $episode->audio->file_url, + 'id' => $episode->audio_url, 'type' => 'Audio', 'name' => esc($episode->title), 'size' => $episode->audio->file_size, 'duration' => $episode->audio->duration, 'url' => [ - 'href' => $episode->audio->file_url, + 'href' => $episode->audio_url, 'type' => 'Link', 'mediaType' => $episode->audio->file_mimetype, ], diff --git a/modules/Admin/Controllers/PodcastController.php b/modules/Admin/Controllers/PodcastController.php index 488fe464..e8bcf742 100644 --- a/modules/Admin/Controllers/PodcastController.php +++ b/modules/Admin/Controllers/PodcastController.php @@ -264,6 +264,10 @@ class PodcastController extends BaseController $this->request->getPost('other_categories') ?? [], ); + // OP3 + service('settings') + ->set('Analytics.enableOP3', $this->request->getPost('enable_op3') === 'yes', 'podcast:' . $newPodcastId); + $db->transComplete(); return redirect()->route('podcast-view', [$newPodcastId])->with( @@ -373,6 +377,14 @@ class PodcastController extends BaseController $this->request->getPost('other_categories') ?? [], ); + // enable/disable OP3? + service('settings') + ->set( + 'Analytics.enableOP3', + $this->request->getPost('enable_op3') === 'yes', + 'podcast:' . $this->podcast->id + ); + $db->transComplete(); return redirect()->route('podcast-edit', [$this->podcast->id])->with( diff --git a/modules/Admin/Language/en/Podcast.php b/modules/Admin/Language/en/Podcast.php index 426b763b..2d46aff5 100644 --- a/modules/Admin/Language/en/Podcast.php +++ b/modules/Admin/Language/en/Podcast.php @@ -110,6 +110,10 @@ return [ 'premium' => 'Premium', 'premium_by_default' => 'Episodes must be set as premium by default', 'premium_by_default_hint' => 'Podcast episodes will be marked as premium by default. You can still choose to set some episodes, trailers or bonuses as public.', + 'op3' => 'Open Podcast Prefix Project (OP3)', + 'op3_hint' => 'Value your analytics data with OP3, an open-source and trusted third party analytics service. Share, validate and compare your analytics data with the open podcasting ecosystem.', + 'op3_enable' => 'Enable OP3 analytics service', + 'op3_enable_hint' => 'For security reasons, premium episodes\' analytics data will not be shared with OP3.', 'payment_pointer' => 'Payment Pointer for Web Monetization', 'payment_pointer_hint' => 'This is your where you will receive money thanks to Web Monetization', diff --git a/modules/Analytics/Config/Analytics.php b/modules/Analytics/Config/Analytics.php index 6caea6b0..2d08ad04 100644 --- a/modules/Analytics/Config/Analytics.php +++ b/modules/Analytics/Config/Analytics.php @@ -4,7 +4,10 @@ declare(strict_types=1); namespace Modules\Analytics\Config; +use App\Entities\Episode; use CodeIgniter\Config\BaseConfig; +use CodeIgniter\HTTP\URI; +use Modules\Analytics\OP3; class Analytics extends BaseConfig { @@ -39,14 +42,37 @@ class Analytics extends BaseConfig public string $salt = ''; /** - * get the full audio file url + * -------------------------------------------------------------------------- + * The Open Podcast Prefix Project Config + * -------------------------------------------------------------------------- * - * @param string|string[] $audioPath + * @var array */ - public function getAudioUrl(string | array $audioPath): string - { - helper('media'); + public array $OP3 = [ + 'host' => 'https://op3.dev/', + ]; - return media_base_url($audioPath); + public bool $enableOP3 = false; + + /** + * get the full audio file url + */ + public function getAudioUrl(Episode $episode, array $params): string + { + helper(['media', 'setting']); + + $audioFileURI = new URI(media_base_url($episode->audio->file_path)); + $audioFileURI->setQueryArray($params); + + // Wrap episode url with OP3 if episode is public and OP3 is enabled on this podcast + if (! $episode->is_premium && service('settings')->get( + 'Analytics.enableOP3', + 'podcast:' . $episode->podcast_id + )) { + $op3 = new OP3($this->OP3); + $audioFileURI = new URI($op3->wrap($audioFileURI, $episode)); + } + + return (string) $audioFileURI; } } diff --git a/modules/Analytics/Config/Routes.php b/modules/Analytics/Config/Routes.php index 5285fbfd..0526f1d8 100644 --- a/modules/Analytics/Config/Routes.php +++ b/modules/Analytics/Config/Routes.php @@ -53,21 +53,12 @@ $routes->group('', [ $routes->get(config('Analytics')->gateway . '/(:class)/(:filter)', 'AnalyticsController::getData/$1/$2', [ 'as' => 'analytics-data-instance', ]); - // 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', - ], - ); + + /** + * @deprecated 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',); + $routes->get('audio/(:base64)/(:any)', 'EpisodeAnalyticsController::hit/$1/$2',); }); // Show the Unknown UserAgents diff --git a/modules/Analytics/Controllers/EpisodeAnalyticsController.php b/modules/Analytics/Controllers/EpisodeAnalyticsController.php index 8631af4b..b5ee229c 100644 --- a/modules/Analytics/Controllers/EpisodeAnalyticsController.php +++ b/modules/Analytics/Controllers/EpisodeAnalyticsController.php @@ -17,13 +17,13 @@ use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; -use Config\Services; use Modules\Analytics\Config\Analytics; -use Modules\PremiumPodcasts\Models\SubscriptionModel; use Psr\Log\LoggerInterface; class EpisodeAnalyticsController extends Controller { + public mixed $config; + /** * An array of helpers to be loaded automatically upon class instantiation. These helpers will be available to all * other controllers that extend Analytics. @@ -32,7 +32,7 @@ class EpisodeAnalyticsController extends Controller */ protected $helpers = ['analytics']; - protected Analytics $config; + protected Analytics $analyticsConfig; /** * Constructor. @@ -52,70 +52,26 @@ class EpisodeAnalyticsController extends Controller $this->config = config('Analytics'); } - public function hit(string $base64EpisodeData, string ...$audioPath): RedirectResponse|ResponseInterface + /** + * @deprecated Replaced by EpisodeController::audio method + */ + public function hit(string $base64EpisodeData, string ...$audioPath): RedirectResponse { - $session = Services::session(); - $session->start(); - - $serviceName = ''; - if ($this->request->getGet('_from')) { - $serviceName = $this->request->getGet('_from'); - } elseif ($session->get('embed_domain') !== null) { - $serviceName = $session->get('embed_domain'); - } elseif ($session->get('referer') !== null && $session->get('referer') !== '- Direct -') { - $serviceName = parse_url((string) $session->get('referer'), PHP_URL_HOST); - } - $episodeData = unpack( 'IpodcastId/IepisodeId/IbytesThreshold/IfileSize/Iduration/IpublicationDate', base64_url_decode($base64EpisodeData), ); - if (! $episodeData) { + if ($episodeData === false) { throw PageNotFoundException::forPageNotFound(); } - // check if episode is premium? $episode = (new EpisodeModel())->getEpisodeById($episodeData['episodeId']); if (! $episode instanceof Episode) { - return $this->response->setStatusCode(404); + throw PageNotFoundException::forPageNotFound(); } - $subscription = null; - - // check if podcast is already unlocked before any token validation - if ($episode->is_premium && ($subscription = service('premium_podcasts')->subscription( - $episode->podcast->handle - )) === null) { - // look for token as GET parameter - if (($token = $this->request->getGet('token')) === null) { - return $this->response->setStatusCode( - 401, - 'Episode is premium, you must provide a token to unlock it.' - ); - } - - // check if there's a valid subscription for the provided token - if (($subscription = (new SubscriptionModel())->validateSubscription( - $episode->podcast->handle, - $token - )) === null) { - return $this->response->setStatusCode(401, 'Invalid token!'); - } - } - - podcast_hit( - $episodeData['podcastId'], - $episodeData['episodeId'], - $episodeData['bytesThreshold'], - $episodeData['fileSize'], - $episodeData['duration'], - $episodeData['publicationDate'], - $serviceName, - $subscription !== null ? $subscription->id : null - ); - - return redirect()->to($this->config->getAudioUrl($episode->audio->file_path)); + return redirect()->route('episode-audio', [$episode->podcast->handle, $episode->slug]); } } diff --git a/modules/Analytics/Helpers/analytics_helper.php b/modules/Analytics/Helpers/analytics_helper.php index f5ef255a..cdd5c698 100644 --- a/modules/Analytics/Helpers/analytics_helper.php +++ b/modules/Analytics/Helpers/analytics_helper.php @@ -34,45 +34,6 @@ if (! function_exists('base64_url_decode')) { } } -if (! function_exists('generate_episode_analytics_url')) { - /** - * Builds the episode analytics url that redirects to the audio file url after analytics hit. - */ - function generate_episode_analytics_url( - int $podcastId, - int $episodeId, - string $podcastHandle, - string $episodeSlug, - string $audioExtension, - float $audioDuration, - int $audioFileSize, - int $audioFileHeaderSize, - \CodeIgniter\I18n\Time $publicationDate - ): string { - 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 audio is less than or equal to 60s, then take the audio file_size - // - if audio is more than 60s, then take the audio file_header_size + 60s - $audioDuration <= 60 - ? $audioFileSize - : $audioFileHeaderSize + - floor((($audioFileSize - $audioFileHeaderSize) / $audioDuration) * 60), - $audioFileSize, - $audioDuration, - $publicationDate->getTimestamp(), - ), - ), - $podcastHandle . '/' . $episodeSlug . '.' . $audioExtension, - ); - } -} - if (! function_exists('set_user_session_deny_list_ip')) { /** * Set user country in session variable, for analytic purposes diff --git a/modules/Analytics/OP3.php b/modules/Analytics/OP3.php new file mode 100644 index 00000000..053ab5b7 --- /dev/null +++ b/modules/Analytics/OP3.php @@ -0,0 +1,32 @@ + $config + */ + public function __construct(array $config) + { + $this->host = rtrim($config['host'], '/'); + } + + public function wrap(URI $audioURI, Episode $episode): string + { + return $this->host . '/e,pg=' . $episode->podcast->guid . '/' . $audioURI; + } +} diff --git a/themes/cp_admin/episode/soundbites_new.php b/themes/cp_admin/episode/soundbites_new.php index f5609494..b686de27 100644 --- a/themes/cp_admin/episode/soundbites_new.php +++ b/themes/cp_admin/episode/soundbites_new.php @@ -21,7 +21,7 @@ class="max-w-sm" /> -