From 3234500e2d967438ad140f65da801a543f43775d Mon Sep 17 00:00:00 2001 From: Yassine Doghri Date: Wed, 28 Sep 2022 15:02:09 +0000 Subject: [PATCH] feat: add premium podcasts to manage subscriptions for premium episodes closes #193 --- app/Config/Autoload.php | 1 + app/Config/Filters.php | 5 + app/Controllers/BaseController.php | 2 +- app/Controllers/FeedController.php | 23 +- app/Controllers/PostController.php | 2 +- .../2020-05-30-101500_add_podcasts.php | 5 + .../2020-06-05-170000_add_episodes.php | 5 + app/Database/Seeds/AuthSeeder.php | 6 + app/Entities/Episode.php | 36 +- app/Entities/Media/BaseMedia.php | 23 + app/Entities/Podcast.php | 43 +- app/Helpers/media_helper.php | 2 +- app/Helpers/rss_helper.php | 23 +- app/Helpers/url_helper.php | 16 + app/Models/EpisodeModel.php | 10 + app/Models/PodcastModel.php | 1 + app/Resources/icons/exchange-dollar.svg | 6 + app/Resources/icons/lock-unlock.svg | 6 + app/Resources/icons/lock.svg | 6 + app/Resources/js/app.ts | 2 + .../js/modules/play-episode-button.ts | 3 +- app/Resources/styles/custom.css | 4 + app/Views/Components/Button.php | 6 +- app/Views/Components/DropdownMenu.php | 2 +- app/Views/Components/Icon.php | 7 +- .../Admin/Controllers/EpisodeController.php | 2 + .../Admin/Controllers/PodcastController.php | 2 + .../Admin/Controllers/SettingsController.php | 8 + modules/Admin/Language/en/Breadcrumb.php | 1 + modules/Admin/Language/en/Episode.php | 2 + modules/Admin/Language/en/Podcast.php | 3 + .../Admin/Language/en/PodcastNavigation.php | 3 + modules/Admin/Language/en/Settings.php | 2 + .../EpisodeAnalyticsController.php | 47 +- ...7-12-01-000000_add_analytics_podcasts.php} | 0 ...000_add_analytics_podcasts_by_episode.php} | 0 ...020000_add_analytics_podcasts_by_hour.php} | 0 ...0000_add_analytics_podcasts_by_player.php} | 0 ...000_add_analytics_podcasts_by_country.php} | 0 ...0000_add_analytics_podcasts_by_region.php} | 0 ...0000_add_analytics_website_by_browser.php} | 0 ...0000_add_analytics_website_by_referer.php} | 0 ...0_add_analytics_website_by_entry_page.php} | 0 ...0000_add_analytics_unknown_useragents.php} | 0 ...add_analytics_podcasts_by_subscription.php | 55 +++ ...10000_add_analytics_podcasts_procedure.php | 9 +- .../AnalyticsPodcastsBySubscription.php | 40 ++ .../Analytics/Helpers/analytics_helper.php | 12 +- .../AnalyticsPodcastBySubscriptionModel.php | 61 +++ .../Install/Controllers/InstallController.php | 2 + modules/PremiumPodcasts/Config/Routes.php | 139 ++++++ modules/PremiumPodcasts/Config/Services.php | 26 + .../Controllers/LockController.php | 118 +++++ .../Controllers/SubscriptionController.php | 447 ++++++++++++++++++ .../2022-07-07-120000_add_subscriptions.php | 80 ++++ .../PremiumPodcasts/Entities/Subscription.php | 123 +++++ .../Filters/PodcastUnlockFilter.php | 86 ++++ .../Helpers/premium_podcasts_helper.php | 35 ++ .../Language/en/PremiumPodcasts.php | 34 ++ .../Language/en/Subscription.php | 100 ++++ .../Models/SubscriptionModel.php | 141 ++++++ modules/PremiumPodcasts/PremiumPodcasts.php | 114 +++++ phpstan.neon | 1 + public/media/podcasts/index.html | 9 - tailwind.config.js | 3 + themes/cp_admin/_layout.php | 11 +- themes/cp_admin/_partials/_nav_aside.php | 2 +- themes/cp_admin/episode/_card.php | 11 +- themes/cp_admin/episode/_sidebar.php | 5 +- themes/cp_admin/episode/create.php | 7 + themes/cp_admin/episode/edit.php | 6 +- themes/cp_admin/episode/list.php | 6 + themes/cp_admin/podcast/_card.php | 28 +- themes/cp_admin/podcast/_sidebar.php | 9 +- themes/cp_admin/podcast/create.php | 5 + themes/cp_admin/podcast/edit.php | 5 + themes/cp_admin/settings/general.php | 7 +- themes/cp_admin/subscription/add.php | 35 ++ themes/cp_admin/subscription/delete.php | 29 ++ themes/cp_admin/subscription/edit.php | 39 ++ .../subscription/email/_credentials_list.php | 4 + .../cp_admin/subscription/email/_footer.php | 6 + .../subscription/email/_how_to_use.php | 8 + themes/cp_admin/subscription/email/edited.php | 18 + .../cp_admin/subscription/email/removed.php | 7 + themes/cp_admin/subscription/email/reset.php | 13 + .../cp_admin/subscription/email/resumed.php | 7 + .../cp_admin/subscription/email/suspended.php | 11 + .../cp_admin/subscription/email/welcome.php | 23 + themes/cp_admin/subscription/list.php | 130 +++++ themes/cp_admin/subscription/suspend.php | 36 ++ themes/cp_admin/subscription/view.php | 19 + themes/cp_app/episode/_layout.php | 6 +- themes/cp_app/episode/_partials/card.php | 35 +- .../cp_app/episode/_partials/preview_card.php | 33 +- themes/cp_app/home.php | 11 +- themes/cp_app/podcast/_layout.php | 3 +- .../podcast/_partials/premium_banner.php | 46 ++ themes/cp_app/podcast/_partials/sidebar.php | 2 +- themes/cp_app/podcast/activity.php | 4 +- themes/cp_app/podcast/unlock.php | 105 ++++ 101 files changed, 2572 insertions(+), 110 deletions(-) create mode 100644 app/Resources/icons/exchange-dollar.svg create mode 100644 app/Resources/icons/lock-unlock.svg create mode 100644 app/Resources/icons/lock.svg rename modules/Analytics/Database/Migrations/{2017-12-01-120000_add_analytics_podcasts.php => 2017-12-01-000000_add_analytics_podcasts.php} (100%) rename modules/Analytics/Database/Migrations/{2017-12-01-130000_add_analytics_podcasts_by_episode.php => 2017-12-01-010000_add_analytics_podcasts_by_episode.php} (100%) rename modules/Analytics/Database/Migrations/{2017-12-01-130000_add_analytics_podcasts_by_hour.php => 2017-12-01-020000_add_analytics_podcasts_by_hour.php} (100%) rename modules/Analytics/Database/Migrations/{2017-12-01-140000_add_analytics_podcasts_by_player.php => 2017-12-01-030000_add_analytics_podcasts_by_player.php} (100%) rename modules/Analytics/Database/Migrations/{2017-12-01-150000_add_analytics_podcasts_by_country.php => 2017-12-01-040000_add_analytics_podcasts_by_country.php} (100%) rename modules/Analytics/Database/Migrations/{2017-12-01-160000_add_analytics_podcasts_by_region.php => 2017-12-01-050000_add_analytics_podcasts_by_region.php} (100%) rename modules/Analytics/Database/Migrations/{2017-12-01-170000_add_analytics_website_by_browser.php => 2017-12-01-060000_add_analytics_website_by_browser.php} (100%) rename modules/Analytics/Database/Migrations/{2017-12-01-180000_add_analytics_website_by_referer.php => 2017-12-01-070000_add_analytics_website_by_referer.php} (100%) rename modules/Analytics/Database/Migrations/{2017-12-01-190000_add_analytics_website_by_entry_page.php => 2017-12-01-080000_add_analytics_website_by_entry_page.php} (100%) rename modules/Analytics/Database/Migrations/{2017-12-01-200000_add_analytics_unknown_useragents.php => 2017-12-01-090000_add_analytics_unknown_useragents.php} (100%) create mode 100644 modules/Analytics/Database/Migrations/2017-12-01-100000_add_analytics_podcasts_by_subscription.php create mode 100644 modules/Analytics/Entities/AnalyticsPodcastsBySubscription.php create mode 100644 modules/Analytics/Models/AnalyticsPodcastBySubscriptionModel.php create mode 100644 modules/PremiumPodcasts/Config/Routes.php create mode 100644 modules/PremiumPodcasts/Config/Services.php create mode 100644 modules/PremiumPodcasts/Controllers/LockController.php create mode 100644 modules/PremiumPodcasts/Controllers/SubscriptionController.php create mode 100644 modules/PremiumPodcasts/Database/Migrations/2022-07-07-120000_add_subscriptions.php create mode 100644 modules/PremiumPodcasts/Entities/Subscription.php create mode 100644 modules/PremiumPodcasts/Filters/PodcastUnlockFilter.php create mode 100644 modules/PremiumPodcasts/Helpers/premium_podcasts_helper.php create mode 100644 modules/PremiumPodcasts/Language/en/PremiumPodcasts.php create mode 100644 modules/PremiumPodcasts/Language/en/Subscription.php create mode 100644 modules/PremiumPodcasts/Models/SubscriptionModel.php create mode 100644 modules/PremiumPodcasts/PremiumPodcasts.php delete mode 100644 public/media/podcasts/index.html create mode 100644 themes/cp_admin/subscription/add.php create mode 100644 themes/cp_admin/subscription/delete.php create mode 100644 themes/cp_admin/subscription/edit.php create mode 100644 themes/cp_admin/subscription/email/_credentials_list.php create mode 100644 themes/cp_admin/subscription/email/_footer.php create mode 100644 themes/cp_admin/subscription/email/_how_to_use.php create mode 100644 themes/cp_admin/subscription/email/edited.php create mode 100644 themes/cp_admin/subscription/email/removed.php create mode 100644 themes/cp_admin/subscription/email/reset.php create mode 100644 themes/cp_admin/subscription/email/resumed.php create mode 100644 themes/cp_admin/subscription/email/suspended.php create mode 100644 themes/cp_admin/subscription/email/welcome.php create mode 100644 themes/cp_admin/subscription/list.php create mode 100644 themes/cp_admin/subscription/suspend.php create mode 100644 themes/cp_admin/subscription/view.php create mode 100644 themes/cp_app/podcast/_partials/premium_banner.php create mode 100644 themes/cp_app/podcast/unlock.php diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index 8c5f6f01..f314d3d2 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -51,6 +51,7 @@ class Autoload extends AutoloadConfig 'Modules\Fediverse' => ROOTPATH . 'modules/Fediverse/', 'Modules\WebSub' => ROOTPATH . 'modules/WebSub/', 'Modules\Api\Rest\V1' => ROOTPATH . 'modules/Api/Rest/V1', + 'Modules\PremiumPodcasts' => ROOTPATH . 'modules/PremiumPodcasts/', 'Config' => APPPATH . 'Config/', 'ViewComponents' => APPPATH . 'Libraries/ViewComponents/', 'ViewThemes' => APPPATH . 'Libraries/ViewThemes/', diff --git a/app/Config/Filters.php b/app/Config/Filters.php index 8d893b74..54fa52b3 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -14,6 +14,7 @@ use Modules\Api\Rest\V1\Filters\ApiFilter; use Modules\Auth\Filters\PermissionFilter; use Modules\Fediverse\Filters\AllowCorsFilter; use Modules\Fediverse\Filters\FediverseFilter; +use Modules\PremiumPodcasts\Filters\PodcastUnlockFilter; use Myth\Auth\Filters\LoginFilter; use Myth\Auth\Filters\RoleFilter; @@ -36,6 +37,7 @@ class Filters extends BaseConfig 'fediverse' => FediverseFilter::class, 'allow-cors' => AllowCorsFilter::class, 'rest-api' => ApiFilter::class, + 'podcast-unlock' => PodcastUnlockFilter::class, ]; /** @@ -87,6 +89,9 @@ class Filters extends BaseConfig 'login' => [ 'before' => [config('Admin')->gateway . '*', config('Analytics')->gateway . '*'], ], + 'podcast-unlock' => [ + 'before' => ['*@*/episodes/*'], + ], ]; } } diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index fb3d273c..fd82bc08 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -28,7 +28,7 @@ abstract class BaseController extends Controller ResponseInterface $response, LoggerInterface $logger ): void { - $this->helpers = array_merge($this->helpers, ['auth', 'svg', 'components', 'misc', 'seo']); + $this->helpers = array_merge($this->helpers, ['auth', 'svg', 'components', 'misc', 'seo', 'premium_podcasts']); // Do Not Edit This Line parent::initController($request, $response, $logger); diff --git a/app/Controllers/FeedController.php b/app/Controllers/FeedController.php index 6f970ee2..e323ede8 100644 --- a/app/Controllers/FeedController.php +++ b/app/Controllers/FeedController.php @@ -10,19 +10,21 @@ declare(strict_types=1); namespace App\Controllers; +use App\Entities\Podcast; use App\Models\EpisodeModel; use App\Models\PodcastModel; use CodeIgniter\Controller; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\ResponseInterface; use Exception; +use Modules\PremiumPodcasts\Models\SubscriptionModel; use Opawg\UserAgentsPhp\UserAgentsRSS; class FeedController extends Controller { public function index(string $podcastHandle): ResponseInterface { - helper('rss'); + helper(['rss', 'premium_podcasts']); $podcast = (new PodcastModel())->where('handle', $podcastHandle) ->first(); @@ -43,11 +45,24 @@ class FeedController extends Controller $serviceSlug = $service['slug']; } - $cacheName = - "podcast#{$podcast->id}_feed" . ($service ? "_{$serviceSlug}" : ''); + $subscription = null; + $token = $this->request->getGet('token'); + if ($token) { + $subscription = (new SubscriptionModel())->validateSubscription($podcastHandle, $token); + } + + $cacheName = implode( + '_', + array_filter([ + "podcast#{$podcast->id}", + 'feed', + $service ? $serviceSlug : null, + $subscription !== null ? 'unlocked' : null, + ]), + ); if (! ($found = cache($cacheName))) { - $found = get_rss_feed($podcast, $serviceSlug); + $found = get_rss_feed($podcast, $serviceSlug, $subscription, $token); // The page cache is set to expire after next episode publication or a decade by default so it is deleted manually upon podcast update $secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode( diff --git a/app/Controllers/PostController.php b/app/Controllers/PostController.php index 6e6f033e..3db4ab27 100644 --- a/app/Controllers/PostController.php +++ b/app/Controllers/PostController.php @@ -40,7 +40,7 @@ class PostController extends FediversePostController /** * @var string[] */ - protected $helpers = ['auth', 'fediverse', 'svg', 'components', 'misc', 'seo']; + protected $helpers = ['auth', 'fediverse', 'svg', 'components', 'misc', 'seo', 'premium_podcasts']; public function _remap(string $method, string ...$params): mixed { diff --git a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php index 61f3b86a..c5093868 100644 --- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php +++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php @@ -169,6 +169,11 @@ class AddPodcasts extends Migration 'constraint' => 512, 'null' => true, ], + 'is_premium_by_default' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], 'created_by' => [ 'type' => 'INT', 'unsigned' => true, diff --git a/app/Database/Migrations/2020-06-05-170000_add_episodes.php b/app/Database/Migrations/2020-06-05-170000_add_episodes.php index 08669301..8228c9a5 100644 --- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php +++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php @@ -129,6 +129,11 @@ class AddEpisodes extends Migration 'unsigned' => true, 'default' => 0, ], + 'is_premium' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], 'created_by' => [ 'type' => 'INT', 'unsigned' => true, diff --git a/app/Database/Seeds/AuthSeeder.php b/app/Database/Seeds/AuthSeeder.php index 365726e6..32181d18 100644 --- a/app/Database/Seeds/AuthSeeder.php +++ b/app/Database/Seeds/AuthSeeder.php @@ -154,6 +154,12 @@ class AuthSeeder extends Seeder 'description' => 'Edit a podcast', 'has_permission' => ['podcast_admin'], ], + [ + 'name' => 'manage_subscriptions', + 'description' => + 'Add / edit / remove podcast subscriptions', + 'has_permission' => ['podcast_admin'], + ], [ 'name' => 'manage_contributors', 'description' => diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 62dedeaf..d58059c2 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -73,16 +73,17 @@ use RuntimeException; * @property int $posts_count * @property int $comments_count * @property EpisodeComment[]|null $comments + * @property bool $is_premium * @property int $created_by * @property int $updated_by - * @property string $publication_status; - * @property Time|null $published_at; - * @property Time $created_at; - * @property Time $updated_at; + * @property string $publication_status + * @property Time|null $published_at + * @property Time $created_at + * @property Time $updated_at * - * @property Person[] $persons; - * @property Soundbite[] $soundbites; - * @property string $embed_url; + * @property Person[] $persons + * @property Soundbite[] $soundbites + * @property string $embed_url */ class Episode extends Entity { @@ -168,6 +169,7 @@ class Episode extends Entity 'is_published_on_hubs' => 'boolean', 'posts_count' => 'integer', 'comments_count' => 'integer', + 'is_premium' => 'boolean', 'created_by' => 'integer', 'updated_by' => 'integer', ]; @@ -233,7 +235,7 @@ class Episode extends Entity (new MediaModel('audio'))->updateMedia($this->getAudio()); } else { $audio = new Audio([ - 'file_name' => $this->attributes['slug'], + 'file_name' => pathinfo($file->getRandomName(), PATHINFO_FILENAME), 'file_directory' => 'podcasts/' . $this->getPodcast()->handle, 'language_code' => $this->getPodcast() ->language_code, @@ -337,16 +339,20 @@ class Episode extends Entity { helper('analytics'); - // remove 'podcasts/' from audio file path - $strippedAudioPath = substr($this->getAudio()->file_path, 9); - return generate_episode_analytics_url( $this->podcast_id, $this->id, - $strippedAudioPath, - $this->audio->duration, - $this->audio->file_size, - $this->audio->header_size, + $this->getPodcast() + ->handle, + $this->attributes['slug'], + $this->getAudio() + ->file_extension, + $this->getAudio() + ->duration, + $this->getAudio() + ->file_size, + $this->getAudio() + ->header_size, $this->published_at, ); } diff --git a/app/Entities/Media/BaseMedia.php b/app/Entities/Media/BaseMedia.php index 3c0c88ee..4bb9b0a1 100644 --- a/app/Entities/Media/BaseMedia.php +++ b/app/Entities/Media/BaseMedia.php @@ -114,4 +114,27 @@ class BaseMedia extends Entity $mediaModel = new MediaModel(); return $mediaModel->delete($this->id); } + + public function rename(): bool + { + $newFilePath = $this->file_directory . '/' . (new File(''))->getRandomName() . '.' . $this->file_extension; + + $db = db_connect(); + $db->transStart(); + + if (! (new MediaModel())->update($this->id, [ + 'file_path' => $newFilePath, + ])) { + return false; + } + + if (! rename(media_path($this->file_path), media_path($newFilePath))) { + $db->transRollback(); + return false; + } + + $db->transComplete(); + + return true; + } } diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php index 7e269bcd..9472a5c7 100644 --- a/app/Entities/Podcast.php +++ b/app/Entities/Podcast.php @@ -30,6 +30,8 @@ use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension; use League\CommonMark\Extension\SmartPunct\SmartPunctExtension; use League\CommonMark\MarkdownConverter; use Modules\Auth\Entities\User; +use Modules\PremiumPodcasts\Entities\Subscription; +use Modules\PremiumPodcasts\Models\SubscriptionModel; use RuntimeException; /** @@ -79,14 +81,17 @@ use RuntimeException; * @property string|null $partner_image_url * @property int $created_by * @property int $updated_by - * @property string $publication_status; - * @property Time|null $published_at; - * @property Time $created_at; - * @property Time $updated_at; + * @property string $publication_status + * @property bool $is_premium_by_default + * @property bool $is_premium + * @property Time|null $published_at + * @property Time $created_at + * @property Time $updated_at * * @property Episode[] $episodes * @property Person[] $persons * @property User[] $contributors + * @property Subscription[] $subscriptions * @property Platform[] $podcasting_platforms * @property Platform[] $social_platforms * @property Platform[] $funding_platforms @@ -130,6 +135,11 @@ class Podcast extends Entity */ protected ?array $contributors = null; + /** + * @var Subscription[]|null + */ + protected ?array $subscriptions = null; + /** * @var Platform[]|null */ @@ -182,6 +192,7 @@ class Podcast extends Entity 'is_blocked' => 'boolean', 'is_completed' => 'boolean', 'is_locked' => 'boolean', + 'is_premium_by_default' => 'boolean', 'imported_feed_url' => '?string', 'new_feed_url' => '?string', 'location_name' => '?string', @@ -380,6 +391,24 @@ class Podcast extends Entity return $this->category; } + /** + * Returns all podcast subscriptions + * + * @return Subscription[] + */ + public function getSubscriptions(): array + { + if ($this->id === null) { + throw new RuntimeException('Podcasts must be created before getting subscriptions.'); + } + + if ($this->subscriptions === null) { + $this->subscriptions = (new SubscriptionModel())->getPodcastSubscriptions($this->id); + } + + return $this->subscriptions; + } + /** * Returns all podcast contributors * @@ -652,4 +681,10 @@ class Podcast extends Entity return $this; } + + public function getIsPremium(): bool + { + // podcast is premium if at least one of its episodes is set as premium + return (new EpisodeModel())->doesPodcastHavePremiumEpisodes($this->id); + } } diff --git a/app/Helpers/media_helper.php b/app/Helpers/media_helper.php index 088096b4..f77d8ef9 100644 --- a/app/Helpers/media_helper.php +++ b/app/Helpers/media_helper.php @@ -18,7 +18,7 @@ if (! function_exists('save_media')) { /** * Saves a file to the corresponding podcast folder in `public/media` */ - function save_media(File | UploadedFile $file, string $folder = '', string $filename = ''): string + function save_media(File | UploadedFile $file, string $folder = '', string $filename = null): string { if (($extension = $file->getExtension()) !== '') { $filename = $filename . '.' . $extension; diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index c1b123c1..16845ae9 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -13,6 +13,7 @@ use App\Entities\Podcast; use App\Libraries\SimpleRSSElement; use CodeIgniter\I18n\Time; use Config\Mimes; +use Modules\PremiumPodcasts\Entities\Subscription; if (! function_exists('get_rss_feed')) { /** @@ -21,8 +22,12 @@ if (! function_exists('get_rss_feed')) { * @param string $serviceSlug The name of the service that fetches the RSS feed for future reference when the audio file is eventually downloaded * @return string rss feed as xml */ - function get_rss_feed(Podcast $podcast, string $serviceSlug = ''): string - { + function get_rss_feed( + Podcast $podcast, + string $serviceSlug = '', + Subscription $subscription = null, + string $token = null + ): string { $episodes = $podcast->episodes; $itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd'; @@ -267,16 +272,22 @@ if (! function_exists('get_rss_feed')) { } foreach ($episodes as $episode) { + if ($episode->is_premium && $subscription === null) { + continue; + } + $item = $channel->addChild('item'); $item->addChild('title', $episode->title, null, false); $enclosure = $item->addChild('enclosure'); + $enclosureParams = implode('&', array_filter([ + $episode->is_premium ? 'token=' . $token : null, + $serviceSlug !== '' ? '_from=' . urlencode($serviceSlug) : null, + ])); + $enclosure->addAttribute( 'url', - $episode->audio_analytics_url . - ($serviceSlug === '' - ? '' - : '?_from=' . urlencode($serviceSlug)), + $episode->audio_analytics_url . ($enclosureParams === '' ? '' : '?' . $enclosureParams), ); $enclosure->addAttribute('length', (string) $episode->audio->file_size); $enclosure->addAttribute('type', $episode->audio->file_mimetype); diff --git a/app/Helpers/url_helper.php b/app/Helpers/url_helper.php index 4e1b41a3..abddd00d 100644 --- a/app/Helpers/url_helper.php +++ b/app/Helpers/url_helper.php @@ -31,6 +31,22 @@ if (! function_exists('host_url')) { //-------------------------------------------------------------------- +/** + * Return the host URL to use in views + */ +if (! function_exists('current_domain')) { + /** + * Returns instance's domain name + */ + function current_domain(): string + { + $uri = current_url(true); + return $uri->getHost() . ($uri->getPort() ? ':' . $uri->getPort() : ''); + } +} + +//-------------------------------------------------------------------- + if (! function_exists('extract_params_from_episode_uri')) { /** * Returns podcast name and episode slug from episode string diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index 6ceb231b..dcf40ba5 100644 --- a/app/Models/EpisodeModel.php +++ b/app/Models/EpisodeModel.php @@ -85,6 +85,7 @@ class EpisodeModel extends Model 'is_published_on_hubs', 'posts_count', 'comments_count', + 'is_premium', 'published_at', 'created_by', 'updated_by', @@ -413,6 +414,15 @@ class EpisodeModel extends Model return $data; } + public function doesPodcastHavePremiumEpisodes(int $podcastId): bool + { + return $this->builder() + ->where([ + 'podcast_id' => $podcastId, + 'is_premium' => true, + ])->countAllResults() > 0; + } + /** * @param mixed[] $data * diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php index 8a5fd8c6..af3098d2 100644 --- a/app/Models/PodcastModel.php +++ b/app/Models/PodcastModel.php @@ -64,6 +64,7 @@ class PodcastModel extends Model 'partner_id', 'partner_link_url', 'partner_image_url', + 'is_premium_by_default', 'published_at', 'created_by', 'updated_by', diff --git a/app/Resources/icons/exchange-dollar.svg b/app/Resources/icons/exchange-dollar.svg new file mode 100644 index 00000000..85cc6af0 --- /dev/null +++ b/app/Resources/icons/exchange-dollar.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/Resources/icons/lock-unlock.svg b/app/Resources/icons/lock-unlock.svg new file mode 100644 index 00000000..0ea4517c --- /dev/null +++ b/app/Resources/icons/lock-unlock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Resources/icons/lock.svg b/app/Resources/icons/lock.svg new file mode 100644 index 00000000..a54f3e42 --- /dev/null +++ b/app/Resources/icons/lock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Resources/js/app.ts b/app/Resources/js/app.ts index f1093cda..7b944f47 100644 --- a/app/Resources/js/app.ts +++ b/app/Resources/js/app.ts @@ -1,3 +1,5 @@ import Dropdown from "./modules/Dropdown"; +import Tooltip from "./modules/Tooltip"; Dropdown(); +Tooltip(); diff --git a/app/Resources/js/modules/play-episode-button.ts b/app/Resources/js/modules/play-episode-button.ts index e9e7e1c9..3eee7af5 100644 --- a/app/Resources/js/modules/play-episode-button.ts +++ b/app/Resources/js/modules/play-episode-button.ts @@ -131,8 +131,7 @@ export class PlayEpisodeButton extends LitElement { private _showPlayer(): void { this._castopodAudioPlayer.style.display = ""; - document.body.classList.add("pb-[105px]"); - document.body.classList.add("sm:pb-[52px]"); + document.body.classList.add("pb-[105px]", "sm:pb-[52px]"); } private _flushLastPlayButton(playingEpisodeButton: PlayEpisodeButton): void { diff --git a/app/Resources/styles/custom.css b/app/Resources/styles/custom.css index fc539d27..8cfb46bc 100644 --- a/app/Resources/styles/custom.css +++ b/app/Resources/styles/custom.css @@ -28,6 +28,10 @@ border-radius: max(0px, min(1rem, calc((100vw - 1rem - 100%) * 9999))); } + .rounded-conditional-full { + border-radius: max(0px, min(9999px, calc((100vw - 1rem - 100%) * 9999))); + } + .backdrop-gradient { background-image: linear-gradient( 180deg, diff --git a/app/Views/Components/Button.php b/app/Views/Components/Button.php index a79f7848..292adf48 100644 --- a/app/Views/Components/Button.php +++ b/app/Views/Components/Button.php @@ -28,7 +28,7 @@ class Button extends Component public function render(): string { $baseClass = - 'flex-shrink-0 inline-flex items-center justify-center font-semibold shadow-xs rounded-full focus:ring-accent'; + 'gap-x-2 flex-shrink-0 inline-flex items-center justify-center font-semibold shadow-xs rounded-full focus:ring-accent'; $variantClass = [ 'default' => 'text-black bg-gray-300 hover:bg-gray-400', @@ -84,14 +84,14 @@ class Button extends Component if ($this->iconLeft !== '') { $this->slot = (new Icon([ 'glyph' => $this->iconLeft, - 'class' => 'mr-2 opacity-75' . ' ' . $iconSize[$this->size], + 'class' => 'opacity-75' . ' ' . $iconSize[$this->size], ]))->render() . $this->slot; } if ($this->iconRight !== '') { $this->slot .= (new Icon([ 'glyph' => $this->iconRight, - 'class' => 'ml-2 opacity-75' . ' ' . $iconSize[$this->size], + 'class' => 'opacity-75' . ' ' . $iconSize[$this->size], ]))->render(); } diff --git a/app/Views/Components/DropdownMenu.php b/app/Views/Components/DropdownMenu.php index 4b097134..bed2220f 100644 --- a/app/Views/Components/DropdownMenu.php +++ b/app/Views/Components/DropdownMenu.php @@ -53,7 +53,7 @@ class DropdownMenu extends Component return <<attributes['class'] !== '') { - return str_replace('attributes['glyph']); + $attributes = stringify_attributes($this->attributes); - return $svgContents; + return str_replace(' $this->request->getPost('type'), 'is_blocked' => $this->request->getPost('block') === 'yes', 'custom_rss_string' => $this->request->getPost('custom_rss'), + 'is_premium' => $this->request->getPost('premium') === 'yes', 'created_by' => user_id(), 'updated_by' => user_id(), 'published_at' => null, @@ -308,6 +309,7 @@ class EpisodeController extends BaseController $this->episode->type = $this->request->getPost('type'); $this->episode->is_blocked = $this->request->getPost('block') === 'yes'; $this->episode->custom_rss_string = $this->request->getPost('custom_rss'); + $this->episode->is_premium = $this->request->getPost('premium') === 'yes'; $this->episode->updated_by = (int) user_id(); $this->episode->setAudio($this->request->getFile('audio_file')); diff --git a/modules/Admin/Controllers/PodcastController.php b/modules/Admin/Controllers/PodcastController.php index e0813877..7e9726af 100644 --- a/modules/Admin/Controllers/PodcastController.php +++ b/modules/Admin/Controllers/PodcastController.php @@ -238,6 +238,7 @@ class PodcastController extends BaseController 'is_blocked' => $this->request->getPost('block') === 'yes', 'is_completed' => $this->request->getPost('complete') === 'yes', 'is_locked' => $this->request->getPost('lock') === 'yes', + 'is_premium_by_default' => $this->request->getPost('premium_by_default') === 'yes', 'created_by' => user_id(), 'updated_by' => user_id(), 'published_at' => null, @@ -351,6 +352,7 @@ class PodcastController extends BaseController $this->podcast->is_completed = $this->request->getPost('complete') === 'yes'; $this->podcast->is_locked = $this->request->getPost('lock') === 'yes'; + $this->podcast->is_premium_by_default = $this->request->getPost('premium_by_default') === 'yes'; $this->podcast->updated_by = (int) user_id(); // republish on websub hubs upon edit diff --git a/modules/Admin/Controllers/SettingsController.php b/modules/Admin/Controllers/SettingsController.php index 75625460..8fd936f4 100644 --- a/modules/Admin/Controllers/SettingsController.php +++ b/modules/Admin/Controllers/SettingsController.php @@ -288,6 +288,14 @@ class SettingsController extends BaseController cache()->clean(); } + if ($this->request->getPost('rename_episodes_files') === 'yes') { + $allAudio = (new MediaModel('audio'))->getAllOfType(); + + foreach ($allAudio as $audio) { + $audio->rename(); + } + } + return redirect('settings-general')->with('message', lang('Settings.housekeeping.runSuccess')); } diff --git a/modules/Admin/Language/en/Breadcrumb.php b/modules/Admin/Language/en/Breadcrumb.php index 24bece01..4b4cd3a0 100644 --- a/modules/Admin/Language/en/Breadcrumb.php +++ b/modules/Admin/Language/en/Breadcrumb.php @@ -14,6 +14,7 @@ return [ ->gateway => 'Home', 'podcasts' => 'podcasts', 'episodes' => 'episodes', + 'subscriptions' => 'subscriptions', 'contributors' => 'contributors', 'pages' => 'pages', 'settings' => 'settings', diff --git a/modules/Admin/Language/en/Episode.php b/modules/Admin/Language/en/Episode.php index ba0922f5..f800ee95 100644 --- a/modules/Admin/Language/en/Episode.php +++ b/modules/Admin/Language/en/Episode.php @@ -109,6 +109,8 @@ return [ 'bonus' => 'Bonus', 'bonus_hint' => 'Extra content for the show (for example, behind the scenes info or interviews with the cast) or cross-promotional content for another show', ], + 'premium_title' => 'Premium', + 'premium' => 'Episode must only be accessible to premium subscribers', 'parental_advisory' => [ 'label' => 'Parental advisory', 'hint' => 'Does the episode contain explicit content?', diff --git a/modules/Admin/Language/en/Podcast.php b/modules/Admin/Language/en/Podcast.php index 19a022b5..426b763b 100644 --- a/modules/Admin/Language/en/Podcast.php +++ b/modules/Admin/Language/en/Podcast.php @@ -107,6 +107,9 @@ return [ 'monetization_section_title' => 'Monetization', 'monetization_section_subtitle' => 'Earn money thanks to your audience.', + '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.', '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/Admin/Language/en/PodcastNavigation.php b/modules/Admin/Language/en/PodcastNavigation.php index b6195731..b4d7ddc0 100644 --- a/modules/Admin/Language/en/PodcastNavigation.php +++ b/modules/Admin/Language/en/PodcastNavigation.php @@ -25,6 +25,9 @@ return [ 'podcast-analytics-players' => 'Players', 'podcast-analytics-listening-time' => 'Listening time', 'podcast-analytics-time-periods' => 'Time periods', + 'premium' => 'Premium', + 'subscription-list' => 'All subscriptions', + 'subscription-add' => 'Add subscription', 'contributors' => 'Contributors', 'contributor-list' => 'All contributors', 'contributor-add' => 'Add contributor', diff --git a/modules/Admin/Language/en/Settings.php b/modules/Admin/Language/en/Settings.php index 345976be..4a70dcba 100644 --- a/modules/Admin/Language/en/Settings.php +++ b/modules/Admin/Language/en/Settings.php @@ -35,6 +35,8 @@ return [ 'reset_counts_helper' => 'This option will recalculate and reset all data counts (number of followers, posts, comments, …).', 'rewrite_media' => 'Rewrite media metadata', 'rewrite_media_helper' => 'This option will delete all superfluous media files and recreate them (images, audio files, transcripts, chapters, …)', + 'rename_episodes_files' => 'Rename episode audio files', + 'rename_episodes_files_hint' => 'This option will rename all episodes audio files to a random string of characters. Use this if one of your private episodes link was leaked as this will effectively hide it.', 'clear_cache' => 'Clear all cache', 'clear_cache_helper' => 'This option will flush redis cache or writable/cache files.', 'run' => 'Run housekeeping', diff --git a/modules/Analytics/Controllers/EpisodeAnalyticsController.php b/modules/Analytics/Controllers/EpisodeAnalyticsController.php index 5d7ec140..e1244d51 100644 --- a/modules/Analytics/Controllers/EpisodeAnalyticsController.php +++ b/modules/Analytics/Controllers/EpisodeAnalyticsController.php @@ -10,12 +10,16 @@ declare(strict_types=1); namespace Modules\Analytics\Controllers; +use App\Entities\Episode; +use App\Models\EpisodeModel; use CodeIgniter\Controller; +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 @@ -48,14 +52,14 @@ class EpisodeAnalyticsController extends Controller $this->config = config('Analytics'); } - public function hit(string $base64EpisodeData, string ...$audioPath): RedirectResponse + public function hit(string $base64EpisodeData, string ...$audioPath): RedirectResponse|ResponseInterface { $session = Services::session(); $session->start(); $serviceName = ''; - if (isset($_GET['_from'])) { - $serviceName = $_GET['_from']; + 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 -') { @@ -67,6 +71,40 @@ class EpisodeAnalyticsController extends Controller base64_url_decode($base64EpisodeData), ); + if (! $episodeData) { + throw PageNotFoundException::forPageNotFound(); + } + + // check if episode is premium? + $episode = (new EpisodeModel())->getEpisodeById($episodeData['episodeId']); + + if (! $episode instanceof Episode) { + return $this->response->setStatusCode(404); + } + + $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'], @@ -75,8 +113,9 @@ class EpisodeAnalyticsController extends Controller $episodeData['duration'], $episodeData['publicationDate'], $serviceName, + $subscription !== null ? $subscription->id : null ); - return redirect()->to($this->config->getAudioUrl(['podcasts', ...$audioPath])); + return redirect()->to($this->config->getAudioUrl($episode->audio->file_path)); } } diff --git a/modules/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php b/modules/Analytics/Database/Migrations/2017-12-01-000000_add_analytics_podcasts.php similarity index 100% rename from modules/Analytics/Database/Migrations/2017-12-01-120000_add_analytics_podcasts.php rename to modules/Analytics/Database/Migrations/2017-12-01-000000_add_analytics_podcasts.php diff --git a/modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php b/modules/Analytics/Database/Migrations/2017-12-01-010000_add_analytics_podcasts_by_episode.php similarity index 100% rename from modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_episode.php rename to modules/Analytics/Database/Migrations/2017-12-01-010000_add_analytics_podcasts_by_episode.php diff --git a/modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php b/modules/Analytics/Database/Migrations/2017-12-01-020000_add_analytics_podcasts_by_hour.php similarity index 100% rename from modules/Analytics/Database/Migrations/2017-12-01-130000_add_analytics_podcasts_by_hour.php rename to modules/Analytics/Database/Migrations/2017-12-01-020000_add_analytics_podcasts_by_hour.php diff --git a/modules/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php b/modules/Analytics/Database/Migrations/2017-12-01-030000_add_analytics_podcasts_by_player.php similarity index 100% rename from modules/Analytics/Database/Migrations/2017-12-01-140000_add_analytics_podcasts_by_player.php rename to modules/Analytics/Database/Migrations/2017-12-01-030000_add_analytics_podcasts_by_player.php diff --git a/modules/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php b/modules/Analytics/Database/Migrations/2017-12-01-040000_add_analytics_podcasts_by_country.php similarity index 100% rename from modules/Analytics/Database/Migrations/2017-12-01-150000_add_analytics_podcasts_by_country.php rename to modules/Analytics/Database/Migrations/2017-12-01-040000_add_analytics_podcasts_by_country.php diff --git a/modules/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php b/modules/Analytics/Database/Migrations/2017-12-01-050000_add_analytics_podcasts_by_region.php similarity index 100% rename from modules/Analytics/Database/Migrations/2017-12-01-160000_add_analytics_podcasts_by_region.php rename to modules/Analytics/Database/Migrations/2017-12-01-050000_add_analytics_podcasts_by_region.php diff --git a/modules/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php b/modules/Analytics/Database/Migrations/2017-12-01-060000_add_analytics_website_by_browser.php similarity index 100% rename from modules/Analytics/Database/Migrations/2017-12-01-170000_add_analytics_website_by_browser.php rename to modules/Analytics/Database/Migrations/2017-12-01-060000_add_analytics_website_by_browser.php diff --git a/modules/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php b/modules/Analytics/Database/Migrations/2017-12-01-070000_add_analytics_website_by_referer.php similarity index 100% rename from modules/Analytics/Database/Migrations/2017-12-01-180000_add_analytics_website_by_referer.php rename to modules/Analytics/Database/Migrations/2017-12-01-070000_add_analytics_website_by_referer.php diff --git a/modules/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php b/modules/Analytics/Database/Migrations/2017-12-01-080000_add_analytics_website_by_entry_page.php similarity index 100% rename from modules/Analytics/Database/Migrations/2017-12-01-190000_add_analytics_website_by_entry_page.php rename to modules/Analytics/Database/Migrations/2017-12-01-080000_add_analytics_website_by_entry_page.php diff --git a/modules/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php b/modules/Analytics/Database/Migrations/2017-12-01-090000_add_analytics_unknown_useragents.php similarity index 100% rename from modules/Analytics/Database/Migrations/2017-12-01-200000_add_analytics_unknown_useragents.php rename to modules/Analytics/Database/Migrations/2017-12-01-090000_add_analytics_unknown_useragents.php diff --git a/modules/Analytics/Database/Migrations/2017-12-01-100000_add_analytics_podcasts_by_subscription.php b/modules/Analytics/Database/Migrations/2017-12-01-100000_add_analytics_podcasts_by_subscription.php new file mode 100644 index 00000000..24fd9783 --- /dev/null +++ b/modules/Analytics/Database/Migrations/2017-12-01-100000_add_analytics_podcasts_by_subscription.php @@ -0,0 +1,55 @@ +forge->addField([ + 'podcast_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'episode_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'subscription_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'date' => [ + 'type' => 'DATE', + ], + 'hits' => [ + 'type' => 'INT', + 'unsigned' => true, + 'default' => 1, + ], + ]); + + $this->forge->addPrimaryKey(['podcast_id', 'episode_id', 'subscription_id', 'date']); + // `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()'); + $this->forge->addField( + '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()', + ); + $this->forge->createTable('analytics_podcasts_by_subscription'); + } + + public function down(): void + { + $this->forge->dropTable('analytics_podcasts_by_subscription'); + } +} diff --git a/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php b/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php index 06db0787..c87a0193 100644 --- a/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php +++ b/modules/Analytics/Database/Migrations/2017-12-01-210000_add_analytics_podcasts_procedure.php @@ -38,7 +38,8 @@ class AddAnalyticsPodcastsProcedure extends Migration IN `p_filesize` INT UNSIGNED, IN `p_duration` DECIMAL(8,3) UNSIGNED, IN `p_age` INT UNSIGNED, - IN `p_new_listener` TINYINT(1) UNSIGNED + IN `p_new_listener` TINYINT(1) UNSIGNED, + IN `p_subscription_id` INT UNSIGNED ) MODIFIES SQL DATA DETERMINISTIC SQL SECURITY INVOKER @@ -69,6 +70,12 @@ class AddAnalyticsPodcastsProcedure extends Migration INSERT INTO `{$prefix}analytics_podcasts_by_region`(`podcast_id`, `country_code`, `region_code`, `latitude`, `longitude`, `date`) VALUES (p_podcast_id, p_country_code, p_region_code, p_latitude, p_longitude, @current_date) ON DUPLICATE KEY UPDATE `hits`=`hits`+1; + + IF `p_subscription_id` THEN + INSERT INTO `{$prefix}analytics_podcasts_by_subscription`(`podcast_id`, `episode_id`, `subscription_id`, `date`) + VALUES (p_podcast_id, p_episode_id, p_subscription_id, @current_date) + ON DUPLICATE KEY UPDATE `hits`=`hits`+1; + END IF; END IF; INSERT INTO `{$prefix}analytics_podcasts_by_player`(`podcast_id`, `service`, `app`, `device`, `os`, `is_bot`, `date`) VALUES (p_podcast_id, p_service, p_app, p_device, p_os, p_bot, @current_date) diff --git a/modules/Analytics/Entities/AnalyticsPodcastsBySubscription.php b/modules/Analytics/Entities/AnalyticsPodcastsBySubscription.php new file mode 100644 index 00000000..c48ba98b --- /dev/null +++ b/modules/Analytics/Entities/AnalyticsPodcastsBySubscription.php @@ -0,0 +1,40 @@ + + */ + protected $casts = [ + 'podcast_id' => 'integer', + 'episode_id' => 'integer', + 'subscription_id' => 'integer', + 'hits' => 'integer', + ]; +} diff --git a/modules/Analytics/Helpers/analytics_helper.php b/modules/Analytics/Helpers/analytics_helper.php index 1c76df0b..351c8da7 100644 --- a/modules/Analytics/Helpers/analytics_helper.php +++ b/modules/Analytics/Helpers/analytics_helper.php @@ -41,7 +41,9 @@ if (! function_exists('generate_episode_analytics_url')) { function generate_episode_analytics_url( int $podcastId, int $episodeId, - string $audioPath, + string $podcastHandle, + string $episodeSlug, + string $audioExtension, float $audioDuration, int $audioFileSize, int $audioFileHeaderSize, @@ -66,7 +68,7 @@ if (! function_exists('generate_episode_analytics_url')) { $publicationDate->getTimestamp(), ), ), - $audioPath, + $podcastHandle . '/' . $episodeSlug . '.' . $audioExtension, ); } } @@ -263,7 +265,8 @@ if (! function_exists('podcast_hit')) { int $fileSize, float $duration, int $publicationTime, - string $serviceName + string $serviceName, + ?int $subscriptionId, ): void { $session = Services::session(); $session->start(); @@ -353,7 +356,7 @@ if (! function_exists('podcast_hit')) { ->save($podcastListenerHashId, $downloadsByUser, $secondsToMidnight); $db->query( - "CALL {$procedureName}(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);", + "CALL {$procedureName}(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);", [ $podcastId, $episodeId, @@ -370,6 +373,7 @@ if (! function_exists('podcast_hit')) { $duration, $age, $newListener, + $subscriptionId, ], ); } diff --git a/modules/Analytics/Models/AnalyticsPodcastBySubscriptionModel.php b/modules/Analytics/Models/AnalyticsPodcastBySubscriptionModel.php new file mode 100644 index 00000000..d845a08f --- /dev/null +++ b/modules/Analytics/Models/AnalyticsPodcastBySubscriptionModel.php @@ -0,0 +1,61 @@ +builder() + ->selectSum('hits', 'total_hits') + ->where([ + 'podcast_id' => $podcastId, + 'subscription_id' => $subscriptionId, + ]) + ->where('`date` >= UTC_TIMESTAMP() - INTERVAL 3 month', null, false) + ->get() + ->getResultArray())[0]['total_hits']; + + cache() + ->save($cacheName, $found, 600); + } + + return $found; + } +} diff --git a/modules/Install/Controllers/InstallController.php b/modules/Install/Controllers/InstallController.php index 1d51623e..0f80f081 100644 --- a/modules/Install/Controllers/InstallController.php +++ b/modules/Install/Controllers/InstallController.php @@ -259,6 +259,8 @@ class InstallController extends Controller ->latest(); $migrations->setNamespace('Modules\Auth') ->latest(); + $migrations->setNamespace('Modules\PremiumPodcasts') + ->latest(); $migrations->setNamespace('Modules\Analytics') ->latest(); } diff --git a/modules/PremiumPodcasts/Config/Routes.php b/modules/PremiumPodcasts/Config/Routes.php new file mode 100644 index 00000000..f657acf4 --- /dev/null +++ b/modules/PremiumPodcasts/Config/Routes.php @@ -0,0 +1,139 @@ +addPlaceholder('podcastHandle', '[a-zA-Z0-9\_]{1,32}'); + +// Admin routes for subscriptions +$routes->group( + config('Admin') + ->gateway, + [ + 'namespace' => 'Modules\PremiumPodcasts\Controllers', + ], + static function ($routes): void { + $routes->group('podcasts/(:num)/subscriptions', static function ($routes): void { + $routes->get('/', 'SubscriptionController::list/$1', [ + 'as' => 'subscription-list', + 'filter' => + 'permission:podcasts-view,podcast-manage_subscriptions', + ]); + $routes->get('add', 'SubscriptionController::add/$1', [ + 'as' => 'subscription-add', + 'filter' => 'permission:podcast-manage_subscriptions', + ]); + $routes->post( + 'add', + 'SubscriptionController::attemptAdd/$1', + [ + 'filter' => + 'permission:podcast-manage_subscriptions', + ], + ); + $routes->post('save-link', 'SubscriptionController::attemptLinkSave/$1', [ + 'as' => 'subscription-link-save', + 'filter' => 'permission:podcast-manage_subscriptions', + ]); + // Subscription + $routes->group('(:num)', static function ($routes): void { + $routes->get('/', 'SubscriptionController::view/$1/$2', [ + 'as' => 'subscription-view', + 'filter' => + 'permission:podcast-manage_subscriptions', + ]); + $routes->get( + 'edit', + 'SubscriptionController::edit/$1/$2', + [ + 'as' => 'subscription-edit', + 'filter' => + 'permission:podcast-manage_subscriptions', + ], + ); + $routes->post( + 'edit', + 'SubscriptionController::attemptEdit/$1/$2', + [ + 'as' => 'subscription-edit', + 'filter' => + 'permission:podcast-manage_subscriptions', + ], + ); + $routes->get( + 'regenerate-token', + 'SubscriptionController::regenerateToken/$1/$2', + [ + 'as' => 'subscription-regenerate-token', + 'filter' => + 'permission:podcast-manage_subscriptions', + ] + ); + $routes->get( + 'suspend', + 'SubscriptionController::suspend/$1/$2', + [ + 'as' => 'subscription-suspend', + 'filter' => + 'permission:podcast-manage_subscriptions', + ], + ); + $routes->post( + 'suspend', + 'SubscriptionController::attemptSuspend/$1/$2', + [ + 'filter' => + 'permission:podcast-manage_subscriptions', + ], + ); + $routes->get( + 'resume', + 'SubscriptionController::resume/$1/$2', + [ + 'as' => 'subscription-resume', + 'filter' => + 'permission:podcast-manage_subscriptions', + ], + ); + $routes->get( + 'remove', + 'SubscriptionController::remove/$1/$2', + [ + 'as' => 'subscription-remove', + 'filter' => + 'permission:podcast-manage_subscriptions', + ], + ); + $routes->post( + 'remove', + 'SubscriptionController::attemptRemove/$1/$2', + [ + 'filter' => + 'permission:podcast-manage_subscriptions', + ], + ); + }); + }); + } +); + +$routes->group( + '@(:podcastHandle)', + [ + 'namespace' => 'Modules\PremiumPodcasts\Controllers', + ], + static function ($routes): void { + $routes->get('unlock', 'LockController/$1', [ + 'as' => 'premium-podcast-unlock', + ]); + $routes->post('unlock', 'LockController::attemptUnlock/$1', [ + 'as' => 'premium-podcast-unlock', + ]); + $routes->get('lock', 'LockController::attemptLock/$1', [ + 'as' => 'premium-podcast-lock', + ]); + } +); diff --git a/modules/PremiumPodcasts/Config/Services.php b/modules/PremiumPodcasts/Config/Services.php new file mode 100644 index 00000000..c8fb03b4 --- /dev/null +++ b/modules/PremiumPodcasts/Config/Services.php @@ -0,0 +1,26 @@ +setSubscriptionModel($subscriptionModel); + } +} diff --git a/modules/PremiumPodcasts/Controllers/LockController.php b/modules/PremiumPodcasts/Controllers/LockController.php new file mode 100644 index 00000000..8d57947e --- /dev/null +++ b/modules/PremiumPodcasts/Controllers/LockController.php @@ -0,0 +1,118 @@ +premiumPodcasts = service('premium_podcasts'); + } + + public function _remap(string $method, string ...$params): mixed + { + if ($params === []) { + throw PageNotFoundException::forPageNotFound(); + } + + if (($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) === null) { + throw PageNotFoundException::forPageNotFound(); + } + + $this->podcast = $podcast; + + return $this->{$method}(); + } + + public function index(): string + { + $locale = service('request') + ->getLocale(); + $cacheName = + "page_podcast#{$this->podcast->id}_{$locale}_unlock" . + (can_user_interact() ? '_authenticated' : ''); + + if (! ($cachedView = cache($cacheName))) { + $data = [ + // TODO: metatags for locked premium podcasts + 'metatags' => '', + 'podcast' => $this->podcast, + ]; + + helper('form'); + + if (can_user_interact()) { + return view('podcast/unlock', $data); + } + + // The page cache is set to a decade so it is deleted manually upon podcast update + return view('podcast/unlock', $data, [ + 'cache' => DECADE, + 'cache_name' => $cacheName, + ]); + } + + return $cachedView; + } + + public function attemptUnlock(): RedirectResponse + { + $rules = [ + 'token' => 'required', + ]; + + if (! $this->validate($rules)) { + return redirect()->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $token = (string) $this->request->getPost('token'); + + // attempt unlocking the podcast with the token + if (! $this->premiumPodcasts->unlock($this->podcast->handle, $token)) { + // bad key or subscription is not active + return redirect()->back() + ->withInput() + ->with('error', lang('PremiumPodcasts.messages.unlockBadAttempt')); + } + + $redirectURL = session('redirect_url') ?? site_url('/'); + unset($_SESSION['redirect_url']); + + return redirect()->to($redirectURL) + ->withCookies() + ->with('message', lang('PremiumPodcasts.messages.unlockSuccess')); + } + + public function attemptLock(): RedirectResponse + { + $this->premiumPodcasts->lock($this->podcast->handle); + + $redirectURL = session('redirect_url') ?? site_url('/'); + unset($_SESSION['redirect_url']); + + return redirect()->to($redirectURL) + ->withCookies() + ->with('message', lang('PremiumPodcasts.messages.lockSuccess')); + } +} diff --git a/modules/PremiumPodcasts/Controllers/SubscriptionController.php b/modules/PremiumPodcasts/Controllers/SubscriptionController.php new file mode 100644 index 00000000..d5147f23 --- /dev/null +++ b/modules/PremiumPodcasts/Controllers/SubscriptionController.php @@ -0,0 +1,447 @@ +getPodcastById((int) $params[0])) === null) { + throw PageNotFoundException::forPageNotFound(); + } + + $this->podcast = $podcast; + + if (count($params) <= 1) { + return $this->{$method}(); + } + + if (($this->subscription = (new SubscriptionModel())->getSubscriptionById((int) $params[1])) === null) { + throw PageNotFoundException::forPageNotFound(); + } + + return $this->{$method}(); + } + + public function list(): string + { + $data = [ + 'podcast' => $this->podcast, + ]; + + helper('form'); + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + ]); + return view('subscription/list', $data); + } + + public function attemptLinkSave(): RedirectResponse + { + $rules = [ + 'subscription_link' => 'valid_url_strict|permit_empty', + ]; + + if (! $this->validate($rules)) { + return redirect()->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + if (($subscriptionLink = $this->request->getPost('subscription_link')) === '') { + service('settings') + ->forget('Subscription.link', 'podcast:' . $this->podcast->id); + + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'message', + lang('Subscription.messages.linkRemoveSuccess') + ); + } + + service('settings') + ->set('Subscription.link', $subscriptionLink, 'podcast:' . $this->podcast->id); + + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'message', + lang('Subscription.messages.linkSaveSuccess') + ); + } + + public function view(): string + { + $data = [ + 'podcast' => $this->podcast, + 'subscription' => $this->subscription, + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + 1 => '#' . $this->subscription->id, + ]); + return view('subscription/view', $data); + } + + public function add(): string + { + helper('form'); + + $data = [ + 'podcast' => $this->podcast, + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + ]); + return view('subscription/add', $data); + } + + public function attemptAdd(): RedirectResponse + { + helper('text'); + + $expiresAt = null; + $expirationDate = $this->request->getPost('expiration_date'); + if ($expirationDate) { + $expiresAt = Time::createFromFormat( + 'Y-m-d H:i', + $expirationDate, + $this->request->getPost('client_timezone'), + )->setTimezone(app_timezone()); + } + + $newSubscription = new Subscription([ + 'podcast_id' => $this->podcast->id, + 'email' => $this->request->getPost('email'), + 'token' => hash('sha256', $rawToken = random_string('alnum', 8)), + 'expires_at' => $expiresAt, + 'created_by' => user_id(), + 'updated_by' => user_id(), + ]); + + $db = db_connect(); + $db->transStart(); + + $subscriptionModel = new SubscriptionModel(); + if (! $subscriptionModel->insert($newSubscription)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $subscriptionModel->errors()); + } + + /** @var Email $email */ + $email = service('email'); + + if (! $email->setTo($newSubscription->email) + ->setSubject(lang('Subscription.emails.welcome_subject', [ + 'podcastTitle' => $this->podcast->title, + ], $this->podcast->language_code)) + ->setMessage(view('subscription/email/welcome', [ + 'subscription' => $newSubscription, + 'token' => $rawToken, + ]))->setMailType('html') + ->send()) { + $db->transRollback(); + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'errors', + [lang('Subscription.messages.addError'), $email->printDebugger([])] + ); + } + + $db->transComplete(); + + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'message', + lang('Subscription.messages.addSuccess', [ + 'subscriber' => $newSubscription->email, + ]) + ); + } + + public function regenerateToken(): RedirectResponse + { + helper('text'); + + $this->subscription->token = hash('sha256', $rawToken = random_string('alnum', 8)); + $this->subscription->updated_by = user_id(); + + $db = db_connect(); + + $db->transStart(); + + $subscriptionModel = new SubscriptionModel(); + if (! $subscriptionModel->update($this->subscription->id, $this->subscription)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $subscriptionModel->errors()); + } + + /** @var Email $email */ + $email = service('email'); + + if (! $email->setTo($this->subscription->email) + ->setSubject(lang('Subscription.emails.reset_subject', [], $this->podcast->language_code)) + ->setMessage(view('subscription/email/reset', [ + 'subscription' => $this->subscription, + 'token' => $rawToken, + ]))->setMailType('html') + ->send()) { + $db->transRollback(); + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'errors', + [lang('Subscription.messages.regenerateTokenError'), $email->printDebugger([])] + ); + } + + $db->transComplete(); + + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'message', + lang('Subscription.messages.regenerateTokenSuccess', [ + 'subscriber' => $this->subscription->email, + ]) + ); + } + + public function edit(): string + { + helper('form'); + + $data = [ + 'podcast' => $this->podcast, + 'subscription' => $this->subscription, + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + 1 => '#' . $this->subscription->id, + ]); + return view('subscription/edit', $data); + } + + public function attemptEdit(): RedirectResponse + { + $expiresAt = null; + $expirationDate = $this->request->getPost('expiration_date'); + if ($expirationDate) { + $expiresAt = Time::createFromFormat( + 'Y-m-d H:i', + $expirationDate, + $this->request->getPost('client_timezone'), + )->setTimezone(app_timezone()); + } + + $this->subscription->expires_at = $expiresAt; + + $db = db_connect(); + $db->transStart(); + + $subscriptionModel = new SubscriptionModel(); + if (! $subscriptionModel->update($this->subscription->id, $this->subscription)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $subscriptionModel->errors()); + } + + /** @var Email $email */ + $email = service('email'); + + if (! $email->setTo($this->subscription->email) + ->setSubject(lang('Subscription.emails.edited_subject', [], $this->podcast->language_code)) + ->setMessage(view('subscription/email/edited', [ + 'subscription' => $this->subscription, + ]))->setMailType('html') + ->send()) { + $db->transRollback(); + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'errors', + [lang('Subscription.messages.editError'), $email->printDebugger([])] + ); + } + + $db->transComplete(); + + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'message', + lang('Subscription.messages.editSuccess', [ + 'subscriber' => $this->subscription->email, + ]) + ); + } + + public function suspend(): string + { + helper('form'); + + $data = [ + 'podcast' => $this->podcast, + 'subscription' => $this->subscription, + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + 1 => '#' . $this->subscription->id, + ]); + return view('subscription/suspend', $data); + } + + public function attemptSuspend(): RedirectResponse + { + $db = db_connect(); + $db->transStart(); + + $this->subscription->suspend($this->request->getPost('reason')); + $subscriptionModel = new SubscriptionModel(); + if (! $subscriptionModel->update($this->subscription->id, $this->subscription)) { + return redirect() + ->back() + ->with('errors', $subscriptionModel->errors()); + } + + /** @var Email $email */ + $email = service('email'); + + if (! $email->setTo($this->subscription->email) + ->setSubject(lang('Subscription.emails.suspended_subject', [], $this->podcast->language_code)) + ->setMessage(view('subscription/email/suspended', [ + 'subscription' => $this->subscription, + ]))->setMailType('html') + ->send()) { + $db->transRollback(); + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'errors', + [lang('Subscription.messages.suspendError'), $email->printDebugger([])] + ); + } + + $db->transComplete(); + + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'messages', + lang('Subscription.messages.suspendSuccess', [ + 'subscriber' => $this->subscription->email, + ]) + ); + } + + public function resume(): RedirectResponse + { + $db = db_connect(); + $db->transStart(); + + $this->subscription->resume(); + + $subscriptionModel = new SubscriptionModel(); + if (! $subscriptionModel->update($this->subscription->id, $this->subscription)) { + return redirect() + ->back() + ->with('errors', $subscriptionModel->errors()); + } + + /** @var Email $email */ + $email = service('email'); + + if (! $email->setTo($this->subscription->email) + ->setSubject(lang('Subscription.emails.resumed_subject', [], $this->podcast->language_code)) + ->setMessage(view('subscription/email/resumed', [ + 'subscription' => $this->subscription, + ]))->setMailType('html') + ->send()) { + $db->transRollback(); + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'errors', + [lang('Subscription.messages.resumeError'), $email->printDebugger([])] + ); + } + + $db->transComplete(); + + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'message', + lang('Subscription.messages.resumeSuccess', [ + 'subscriber' => $this->subscription->email, + ]) + ); + } + + public function remove(): string + { + helper('form'); + + $data = [ + 'podcast' => $this->podcast, + 'subscription' => $this->subscription, + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + 1 => '#' . $this->subscription->id, + ]); + return view('subscription/delete', $data); + } + + public function attemptRemove(): RedirectResponse + { + $db = db_connect(); + $db->transStart(); + + (new SubscriptionModel())->delete($this->subscription->id); + + /** @var Email $email */ + $email = service('email'); + + if (! $email->setTo($this->subscription->email) + ->setSubject(lang('Subscription.emails.removed_subject', [], $this->podcast->language_code)) + ->setMessage(view('subscription/email/removed', [ + 'subscription' => $this->subscription, + ]))->setMailType('html') + ->send()) { + $db->transRollback(); + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'errors', + [lang('Subscription.messages.removeError'), $email->printDebugger([])] + ); + } + + $db->transComplete(); + + return redirect()->route('subscription-list', [$this->podcast->id])->with( + 'messages', + lang('Subscription.messages.removeSuccess', [ + 'subscriber' => $this->subscription->email, + ]) + ); + } +} diff --git a/modules/PremiumPodcasts/Database/Migrations/2022-07-07-120000_add_subscriptions.php b/modules/PremiumPodcasts/Database/Migrations/2022-07-07-120000_add_subscriptions.php new file mode 100644 index 00000000..ae4b91ab --- /dev/null +++ b/modules/PremiumPodcasts/Database/Migrations/2022-07-07-120000_add_subscriptions.php @@ -0,0 +1,80 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'unsigned' => true, + 'auto_increment' => true, + ], + 'podcast_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'email' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'token' => [ + 'type' => 'VARCHAR', + 'constraint' => 64, + ], + 'status' => [ + 'type' => 'ENUM', + 'constraint' => ['active', 'suspended'], + 'default' => 'active', + ], + 'status_message' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'expires_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_by' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'updated_by' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + ], + 'updated_at' => [ + 'type' => 'DATETIME', + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['podcast_id', 'email']); + $this->forge->addUniqueKey('token'); + $this->forge->addForeignKey('podcast_id', 'podcasts', 'id', '', 'CASCADE'); + $this->forge->addForeignKey('created_by', 'users', 'id'); + $this->forge->addForeignKey('updated_by', 'users', 'id'); + $this->forge->createTable('subscriptions'); + } + + public function down(): void + { + $this->forge->dropTable('subscriptions'); + } +} diff --git a/modules/PremiumPodcasts/Entities/Subscription.php b/modules/PremiumPodcasts/Entities/Subscription.php new file mode 100644 index 00000000..3ea10454 --- /dev/null +++ b/modules/PremiumPodcasts/Entities/Subscription.php @@ -0,0 +1,123 @@ + + */ + protected $casts = [ + 'id' => 'integer', + 'podcast_id' => 'integer', + 'email' => 'string', + 'token' => 'string', + 'status' => 'string', + 'status_message' => '?string', + 'created_by' => 'integer', + 'updated_by' => 'integer', + ]; + + public function getStatus(): string + { + return ($this->expires_at !== null && $this->expires_at->isBefore( + Time::now() + )) ? 'expired' : $this->attributes['status']; + } + + /** + * Suspend a subscription. + * + * @return $this + */ + public function suspend(string $reason): static + { + $this->attributes['status'] = 'suspended'; + $this->attributes['status_message'] = $reason; + + return $this; + } + + /** + * Resumes a subscription / unSuspend. + * + * @return $this + */ + public function resume(): static + { + $this->attributes['status'] = 'active'; + $this->attributes['status_message'] = null; + + return $this; + } + + /** + * Checks to see if a subscription has been suspended. + */ + public function isSuspended(): bool + { + return isset($this->attributes['status']) && $this->attributes['status'] === 'suspended'; + } + + /** + * Returns the subscription's podcast + */ + public function getPodcast(): ?Podcast + { + if ($this->podcast_id === null) { + throw new RuntimeException('Subscription must have a podcast_id before getting podcast.'); + } + + if (! $this->podcast instanceof Podcast) { + $this->podcast = (new PodcastModel())->getPodcastById($this->podcast_id); + } + + return $this->podcast; + } + + public function getDownloadsLast3Months(): int + { + return (new AnalyticsPodcastBySubscriptionModel())->getNumberOfDownloadsLast3Months( + $this->podcast_id, + $this->id + ); + } +} diff --git a/modules/PremiumPodcasts/Filters/PodcastUnlockFilter.php b/modules/PremiumPodcasts/Filters/PodcastUnlockFilter.php new file mode 100644 index 00000000..a46348e5 --- /dev/null +++ b/modules/PremiumPodcasts/Filters/PodcastUnlockFilter.php @@ -0,0 +1,86 @@ +setHost('') + ->setScheme('') + ->stripQuery('token'); + + $config = config(App::class); + if ($config->forceGlobalSecureRequests) { + // Remove "https:/" + $current = substr($current, 7); + } + + /** @var Router $router */ + $router = service('router'); + $routerParams = $router->params(); + + if ($routerParams === []) { + return; + } + + // no need to go through the unlock form if user is connected + /** @var AuthenticationBase $auth */ + $auth = service('authentication'); + if ($auth->isLoggedIn()) { + return; + } + + // Make sure this isn't already a premium podcast route + if ($current === route_to('premium-podcast-unlock', $routerParams[0])) { + return; + } + + // Make sure that public episodes are still accessible + if ($routerParams >= 2 && ($episode = (new EpisodeModel())->getEpisodeBySlug( + $routerParams[0], + $routerParams[1] + )) && ! $episode->is_premium) { + return; + } + + // if podcast is locked then send to the unlock form + /** @var PremiumPodcasts $premiumPodcasts */ + $premiumPodcasts = service('premium_podcasts'); + if (! $premiumPodcasts->check($routerParams[0])) { + session()->set('redirect_url', current_url()); + + return redirect()->route('premium-podcast-unlock', [$routerParams[0]]); + } + } + + /** + * @param array|null $arguments + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void + { + } +} diff --git a/modules/PremiumPodcasts/Helpers/premium_podcasts_helper.php b/modules/PremiumPodcasts/Helpers/premium_podcasts_helper.php new file mode 100644 index 00000000..46794c85 --- /dev/null +++ b/modules/PremiumPodcasts/Helpers/premium_podcasts_helper.php @@ -0,0 +1,35 @@ +check($podcastHandle); + } +} + +if (! function_exists('subscription')) { + /** + * Returns the Subscription instance for the currently active subscription. + */ + function subscription(string $podcastHandle): ?Subscription + { + /** @var PremiumPodcasts $premiumPodcast */ + $premiumPodcast = service('premium_podcasts'); + $premiumPodcast->check($podcastHandle); + + return $premiumPodcast->subscription($podcastHandle); + } +} diff --git a/modules/PremiumPodcasts/Language/en/PremiumPodcasts.php b/modules/PremiumPodcasts/Language/en/PremiumPodcasts.php new file mode 100644 index 00000000..18c0dd4e --- /dev/null +++ b/modules/PremiumPodcasts/Language/en/PremiumPodcasts.php @@ -0,0 +1,34 @@ + 'Podcast contains premium episodes', + 'episode_is_premium' => 'Episode is premium, only available to premium subscribers', + 'unlock_episode' => 'This episode is for premium subscribers only. Click to unlock it!', + 'banner_unlock' => 'This podcast contains premium episodes, only available to premium subscribers.', + 'banner_lock' => 'Podcast is unlocked, enjoy the premium episodes!', + 'subscribe' => 'Subscribe', + 'lock' => 'Lock', + 'unlock' => 'Unlock', + 'unlock_form' => [ + 'title' => 'Premium content', + 'subtitle' => 'This podcast contains locked premium episodes! Do you have the key to unlock them?', + 'token' => 'Enter your key', + 'token_hint' => 'If you are subscribed to {podcastTitle}, you may copy the key that was sent to you via email and paste it here.', + 'submit' => 'Unlock all episodes!', + 'call_to_action' => 'Unlock all episodes of {podcastTitle}:', + 'subscribe_cta' => 'Subscribe now!', + ], + 'messages' => [ + 'unlockSuccess' => 'Podcast was successfully unlocked! Enjoy the premium episodes!', + 'unlockBadAttempt' => 'Your key does not seem to be working…', + 'lockSuccess' => 'Podcast was successfully locked!', + ], +]; diff --git a/modules/PremiumPodcasts/Language/en/Subscription.php b/modules/PremiumPodcasts/Language/en/Subscription.php new file mode 100644 index 00000000..7371c8ab --- /dev/null +++ b/modules/PremiumPodcasts/Language/en/Subscription.php @@ -0,0 +1,100 @@ + 'Podcast subscriptions', + 'add' => 'New subscription', + 'view' => 'View subscription', + 'edit' => 'Edit subscription', + 'regenerate_token' => 'Regenerate token', + 'suspend' => 'Suspend subscription', + 'resume' => 'Resume subscription', + 'delete' => 'Delete subscription', + 'status' => [ + 'active' => 'Active', + 'suspended' => 'Suspended', + 'expired' => 'Expired', + ], + 'list' => [ + 'number' => 'Number', + 'email' => 'Email', + 'expiration_date' => 'Expiration date', + 'unlimited' => 'Unlimited', + 'downloads' => 'Downloads', + 'status' => 'Status', + ], + 'form' => [ + 'email' => 'Email', + 'expiration_date' => 'Expiration date', + 'expiration_date_hint' => 'The date and time at which the subscription expires. Leave empty for an unlimited subscription.', + 'submit_add' => 'Add subscription', + 'submit_edit' => 'Edit subscription', + ], + 'form_link_add' => [ + 'link' => 'Subscription page link', + 'link_hint' => 'This will add a call to action in the website inviting listeners to subscribe to the podcast.', + 'submit' => 'Save link', + ], + 'suspend_form' => [ + 'disclaimer' => 'Suspending the subscription will restrict the subscriber from having access to the premium content. You will still be able to lift the suspension afterwards.', + 'reason' => 'Reason', + 'reason_placeholder' => 'Why are you suspending the subscription?', + "submit" => 'Suspend subscription', + ], + 'delete_form' => [ + 'disclaimer' => 'Deleting {subscriber}\'s subscription will remove all analytics data associated with it.', + 'understand' => 'I understand, remove the subscription permanently', + 'submit' => 'Remove subscription', + ], + 'messages' => [ + 'addSuccess' => 'New subscription added! A welcome email was sent to {subscriber}.', + 'addError' => 'Subscription could not be added.', + 'editSuccess' => 'Subscription expiry date was updated! An email was sent to {subscriber}.', + 'editError' => 'Subscription could not be edited.', + 'regenerateTokenSuccess' => 'Token regenerated! An email was sent to {subscriber} with the new token.', + 'regenerateTokenError' => 'Token could not be regenerated.', + 'removeSuccess' => 'Subscription was canceled! An email was sent to {subscriber} to tell him.', + 'removeError' => 'Subscription could not be canceled.', + 'suspendSuccess' => 'Subscription was suspended! An email was sent to {subscriber}.', + 'suspendError' => 'Subscription could not be suspended.', + 'resumeSuccess' => 'Subscription was resumed! An email was sent to {subscriber}.', + 'resumeError' => 'Subscription could not be resumed.', + 'linkSaveSuccess' => 'Subscription link was saved successfully! It will appear in the website as a Call To Action!', + 'linkRemoveSuccess' => 'Subscription link was removed successfully!', + ], + 'emails' => [ + 'greeting' => 'Hey,', + 'token' => 'Your token: {0}', + 'unique_feed_link' => 'Your unique feed link: {0}', + 'how_to_use' => 'How to use?', + 'two_ways' => 'You have two ways of unlocking the premium episodes:', + 'import_into_app' => 'Copy your unique feed url inside your favourite podcast app (import it as a private feed to prevent exposing your credentials).', + 'go_to_website' => 'Go to {podcastWebsite}\'s website and unlock the podcast with your token.', + 'welcome_subject' => 'Welcome to {podcastTitle}', + 'welcome' => 'You have subscribed to {podcastTitle}, thank you and welcome aboard!', + 'welcome_token_title' => 'Here are your credentials to unlock the podcast\'s premium episodes:', + 'welcome_expires' => 'Your subscription was set to expire on {0}.', + 'welcome_never_expires' => 'Your subscription was set to never expire.', + 'reset_subject' => 'Your token was reset!', + 'reset_token' => 'Your access to {podcastTitle} has been reset!', + 'reset_token_title' => 'New credentials have been generated for you to unlock the podcast\'s premium episodes:', + 'edited_subject' => 'Your subscription has been updated!', + 'edited_expires' => 'Your subscription for {podcastTitle} was set to expire on {expiresAt}.', + 'edited_never_expires' => 'Your subscription for {podcastTitle} was set to never expire!', + 'suspended_subject' => 'Your subscription has been suspended!', + 'suspended' => 'Your subscription for {podcastTitle} has been suspended! You can no longer access the podcast\'s premium episodes.', + 'suspended_reason' => 'That is for the following reason: {0}', + 'resumed_subject' => 'Your subscription has been resumed!', + 'resumed' => 'Your subscription for {podcastTitle} has been resumed! You may access the podcast\'s premium episodes again.', + 'removed_subject' => 'Your subscription has been removed!', + 'removed' => 'Your subscription for {podcastTitle} has been removed! You no longer have access to the podcast\'s premium episodes.', + 'footer' => '{castopod} hosted on {host}', + ], +]; diff --git a/modules/PremiumPodcasts/Models/SubscriptionModel.php b/modules/PremiumPodcasts/Models/SubscriptionModel.php new file mode 100644 index 00000000..5f79271a --- /dev/null +++ b/modules/PremiumPodcasts/Models/SubscriptionModel.php @@ -0,0 +1,141 @@ +find($subscriptionId); + + cache() + ->save($cacheName, $found, DECADE); + } + + return $found; + } + + /** + * @return Subscription[] + */ + public function getPodcastSubscriptions(int $podcastId): array + { + $cacheName = "podcast#{$podcastId}_subscriptions"; + if (! ($found = cache($cacheName))) { + $found = $this->where('podcast_id', $podcastId) + ->findAll(); + + cache() + ->save($cacheName, $found, DECADE); + } + + return $found; + } + + /** + * @param string $token plain-text token to be encrypted and matched against encrypted tokens in database + */ + public function validateSubscription(int|string $podcastIdOrHandle, string $token): ?Subscription + { + $subscriptionModel = $this; + + if (is_int($podcastIdOrHandle)) { + $this->where('id', $podcastIdOrHandle); + } else { + $this->select('subscriptions.*') + ->where('handle', $podcastIdOrHandle) + ->join('podcasts', 'podcasts.id = subscriptions.podcast_id'); + } + + return $subscriptionModel + ->where([ + 'token' => hash('sha256', $token), + 'status' => 'active', + 'expires_at' => null, + ]) + ->orWhere('`expires_at` > UTC_TIMESTAMP()', null, false) + ->first(); + } + + /** + * @param mixed[] $data + * + * @return mixed[] + */ + protected function clearCache(array $data): array + { + $subscription = (new self())->find(is_array($data['id']) ? $data['id'][0] : $data['id']); + + cache() + ->delete("subscription#{$subscription->id}"); + cache() + ->delete("podcast#{$subscription->podcast_id}_subscriptions"); + + return $data; + } +} diff --git a/modules/PremiumPodcasts/PremiumPodcasts.php b/modules/PremiumPodcasts/PremiumPodcasts.php new file mode 100644 index 00000000..e1469d97 --- /dev/null +++ b/modules/PremiumPodcasts/PremiumPodcasts.php @@ -0,0 +1,114 @@ + + */ + protected $subscriptions = []; + + public function setSubscriptionModel(SubscriptionModel $subscriptionModel): self + { + $this->subscriptionModel = $subscriptionModel; + + return $this; + } + + public function unlock(string $podcastHandle, string $token): bool + { + $subscription = $this->subscriptionModel->validateSubscription($podcastHandle, $token); + + if (! $subscription instanceof Subscription) { + $this->subscriptions[$podcastHandle] = null; + + return false; + } + + $this->subscriptions[$podcastHandle] = $subscription; + + $session = session(); + $session->set("{$podcastHandle}:subscription", $subscription); + + Events::trigger('unlock', $podcastHandle, $subscription); + + return true; + } + + public function lock(string $podcastHandle): bool + { + if (! $this->isUnlocked($podcastHandle)) { + return true; + } + + $this->subscriptions[$podcastHandle] = null; + + unset($_SESSION["{$podcastHandle}:subscription"]); + + Events::trigger('lock', $podcastHandle); + + return true; + } + + public function isUnlocked(string $podcastHandle): bool + { + if (array_key_exists( + $podcastHandle, + $this->subscriptions + ) && ($this->subscriptions[$podcastHandle] instanceof Subscription)) { + return true; + } + + if ($subscription = session()->get("{$podcastHandle}:subscription")) { + $this->subscriptions[$podcastHandle] = $subscription; + + return true; + } + + return false; + } + + public function check(string $podcastHandle): bool + { + // check if locked, no need to go any further + if (! $this->isUnlocked($podcastHandle)) { + return false; + } + + // Store the current subscription object + $this->subscriptions[$podcastHandle] = $this->subscriptionModel->getSubscriptionById( + $this->subscriptions[$podcastHandle]->id + ); + + if (! $this->subscriptions[$podcastHandle] instanceof Subscription) { + return false; + } + + // lock podcast if subscription is not active + if ($this->subscriptions[$podcastHandle]->status !== 'active') { + $this->lock($podcastHandle); + + return false; + } + + // All good! + return true; + } + + /** + * Returns the Subscription instance for the current logged in user. + */ + public function subscription(string $podcastHandle): ?Subscription + { + return $this->isUnlocked($podcastHandle) ? $this->subscriptions[$podcastHandle] : null; + } +} diff --git a/phpstan.neon b/phpstan.neon index 5f89045a..cbedb485 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -10,6 +10,7 @@ parameters: - app/Helpers - modules/Analytics/Helpers - modules/Fediverse/Helpers + - modules/PremiumPodcasts/Helpers - vendor/codeigniter4/framework/system/Helpers - vendor/myth/auth/src/Helpers excludePaths: diff --git a/public/media/podcasts/index.html b/public/media/podcasts/index.html deleted file mode 100644 index eebf8ecb..00000000 --- a/public/media/podcasts/index.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - 403 Forbidden - - -

Directory access is forbidden.

- - diff --git a/tailwind.config.js b/tailwind.config.js index 00ffd68a..0da9244a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -142,6 +142,9 @@ module.exports = { }, }, }, + zIndex: { + 60: 60, + }, }, }, variants: {}, diff --git a/themes/cp_admin/_layout.php b/themes/cp_admin/_layout.php index 5d25b970..78e41ca0 100644 --- a/themes/cp_admin/_layout.php +++ b/themes/cp_admin/_layout.php @@ -31,8 +31,15 @@
-
- renderSection('pageTitle') ?> +
+ is_premium) || (isset($podcast) && $podcast->is_premium)): ?> +
+ + renderSection('pageTitle') ?> +
+ + renderSection('pageTitle') ?> + renderSection('headerLeft') ?>
renderSection('headerRight') ?>
diff --git a/themes/cp_admin/_partials/_nav_aside.php b/themes/cp_admin/_partials/_nav_aside.php index 2730b01e..79ff2a87 100644 --- a/themes/cp_admin/_partials/_nav_aside.php +++ b/themes/cp_admin/_partials/_nav_aside.php @@ -1,4 +1,4 @@ - +