From 3234500e2d967438ad140f65da801a543f43775d Mon Sep 17 00:00:00 2001 From: Yassine Doghri <yassine@doghri.fr> 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 8c5f6f0138..f314d3d20c 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 8d893b746d..54fa52b3b4 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 fb3d273cfd..fd82bc085b 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 6f970ee278..e323ede85d 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 6e6f033e69..3db4ab275c 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 61f3b86a70..c5093868cd 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 0866930190..8228c9a51c 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 365726e6f7..32181d1879 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 62dedeafb2..d58059c235 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 3c0c88ee4f..4bb9b0a19b 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 7e269bcd71..9472a5c707 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 088096b414..f77d8ef917 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 c1b123c1b9..16845ae916 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 4e1b41a3aa..abddd00dca 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 6ceb231b75..dcf40ba5b3 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 8a5fd8c647..af3098d220 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 0000000000..85cc6af064 --- /dev/null +++ b/app/Resources/icons/exchange-dollar.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <g> + <path fill="none" d="M0 0h24v24H0z"/> + <path d="M5.373 4.51A9.962 9.962 0 0 1 12 2c5.523 0 10 4.477 10 10a9.954 9.954 0 0 1-1.793 5.715L17.5 12H20A8 8 0 0 0 6.274 6.413l-.9-1.902zm13.254 14.98A9.962 9.962 0 0 1 12 22C6.477 22 2 17.523 2 12c0-2.125.663-4.095 1.793-5.715L6.5 12H4a8 8 0 0 0 13.726 5.587l.9 1.902zM8.5 14H14a.5.5 0 1 0 0-1h-4a2.5 2.5 0 1 1 0-5h1V7h2v1h2.5v2H10a.5.5 0 1 0 0 1h4a2.5 2.5 0 1 1 0 5h-1v1h-2v-1H8.5v-2z"/> + </g> +</svg> \ 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 0000000000..0ea4517c22 --- /dev/null +++ b/app/Resources/icons/lock-unlock.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <g> + <path fill="none" d="M0 0h24v24H0z"/> + <path d="M7 10h13a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V11a1 1 0 0 1 1-1h1V9a7 7 0 0 1 13.262-3.131l-1.789.894A5 5 0 0 0 7 9v1zm3 5v2h4v-2h-4z"/> + </g> +</svg> diff --git a/app/Resources/icons/lock.svg b/app/Resources/icons/lock.svg new file mode 100644 index 0000000000..a54f3e4239 --- /dev/null +++ b/app/Resources/icons/lock.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <g> + <path fill="none" d="M0 0h24v24H0z"/> + <path d="M18 8h2a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h2V7a6 6 0 1 1 12 0v1zm-7 7.732V18h2v-2.268a2 2 0 1 0-2 0zM16 8V7a4 4 0 1 0-8 0v1h8z"/> + </g> +</svg> diff --git a/app/Resources/js/app.ts b/app/Resources/js/app.ts index f1093cda32..7b944f47df 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 e9e7e1c9aa..3eee7af509 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 fc539d2781..8cfb46bccb 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 a79f7848db..292adf4817 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 4b097134ff..bed2220f91 100644 --- a/app/Views/Components/DropdownMenu.php +++ b/app/Views/Components/DropdownMenu.php @@ -53,7 +53,7 @@ class DropdownMenu extends Component return <<<HTML <nav id="{$this->id}" - class="absolute z-50 flex flex-col py-2 rounded-lg whitespace-nowrap text-skin-base border-contrast bg-elevated border-3" + class="absolute flex flex-col py-2 rounded-lg z-60 whitespace-nowrap text-skin-base border-contrast bg-elevated border-3" aria-labelledby="{$this->labelledby}" data-dropdown="menu" data-dropdown-placement="{$this->placement}" diff --git a/app/Views/Components/Icon.php b/app/Views/Components/Icon.php index 257ebb7b71..f926eb45e7 100644 --- a/app/Views/Components/Icon.php +++ b/app/Views/Components/Icon.php @@ -19,10 +19,9 @@ class Icon extends Component return '□'; } - if ($this->attributes['class'] !== '') { - return str_replace('<svg', '<svg class="' . $this->attributes['class'] . '"', $svgContents); - } + unset($this->attributes['glyph']); + $attributes = stringify_attributes($this->attributes); - return $svgContents; + return str_replace('<svg', '<svg ' . $attributes, $svgContents); } } diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php index d58be7ba42..9e5e906f70 100644 --- a/modules/Admin/Controllers/EpisodeController.php +++ b/modules/Admin/Controllers/EpisodeController.php @@ -193,6 +193,7 @@ class EpisodeController extends BaseController 'type' => $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 e08138773e..7e9726affa 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 7562546038..8fd936f4b9 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 24bece0140..4b4cd3a0bf 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 ba0922f519..f800ee95eb 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 19a022b563..426b763b8b 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 b619573154..b4d7ddc089 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 345976be7f..4a70dcbaa0 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 5d7ec14024..e1244d5194 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 0000000000..24fd97836a --- /dev/null +++ b/modules/Analytics/Database/Migrations/2017-12-01-100000_add_analytics_podcasts_by_subscription.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2022 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\Analytics\Database\Migrations; + +use CodeIgniter\Database\Migration; + +class AddAnalyticsPodcastsBySubscription extends Migration +{ + public function up(): void + { + $this->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 06db078734..c87a0193ed 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 0000000000..c48ba98b46 --- /dev/null +++ b/modules/Analytics/Entities/AnalyticsPodcastsBySubscription.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2022 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\Analytics\Entities; + +use CodeIgniter\Entity\Entity; + +/** + * @property int $podcast_id + * @property int $episode_id + * @property int $subscription_id + * @property Time $date + * @property int $hits + * @property Time $created_at + * @property Time $updated_at + */ +class AnalyticsPodcastsBySubscription extends Entity +{ + /** + * @var string[] + */ + protected $dates = ['date', 'created_at', 'updated_at']; + + /** + * @var array<string, string> + */ + 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 1c76df0b08..351c8da77b 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 0000000000..d845a08f9a --- /dev/null +++ b/modules/Analytics/Models/AnalyticsPodcastBySubscriptionModel.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2022 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\Analytics\Models; + +use CodeIgniter\Model; +use Modules\Analytics\Entities\AnalyticsPodcastsBySubscription; + +class AnalyticsPodcastBySubscriptionModel extends Model +{ + /** + * @var string + */ + protected $table = 'analytics_podcasts_by_subscription'; + + /** + * @var string + */ + protected $returnType = AnalyticsPodcastsBySubscription::class; + + /** + * @var bool + */ + protected $useSoftDeletes = false; + + /** + * @var bool + */ + protected $useTimestamps = false; + + public function getNumberOfDownloadsLast3Months(int $podcastId, int $subscriptionId): int + { + $cacheName = "{$podcastId}_{$subscriptionId}_analytics_podcast_by_subscription"; + + if ( + ! ($found = cache($cacheName)) + ) { + $found = (int) ($this->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 1d51623ebb..0f80f081a8 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 0000000000..f657acf46f --- /dev/null +++ b/modules/PremiumPodcasts/Config/Routes.php @@ -0,0 +1,139 @@ +<?php + +declare(strict_types=1); + +namespace Modules\PremiumPodcasts\Config; + +$routes = service('routes'); + +$routes->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 0000000000..c8fb03b49f --- /dev/null +++ b/modules/PremiumPodcasts/Config/Services.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace Modules\PremiumPodcasts\Config; + +use Config\Services as BaseService; +use Modules\PremiumPodcasts\Models\SubscriptionModel; +use Modules\PremiumPodcasts\PremiumPodcasts; + +class Services extends BaseService +{ + public static function premium_podcasts(?SubscriptionModel $subscriptionModel = null, bool $getShared = true) + { + if ($getShared) { + return self::getSharedInstance('premium_podcasts', $subscriptionModel); + } + + $premiumPodcasts = new PremiumPodcasts(); + + $subscriptionModel ??= model(SubscriptionModel::class); + + return $premiumPodcasts + ->setSubscriptionModel($subscriptionModel); + } +} diff --git a/modules/PremiumPodcasts/Controllers/LockController.php b/modules/PremiumPodcasts/Controllers/LockController.php new file mode 100644 index 0000000000..8d57947eca --- /dev/null +++ b/modules/PremiumPodcasts/Controllers/LockController.php @@ -0,0 +1,118 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2022 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\PremiumPodcasts\Controllers; + +use App\Controllers\BaseController; +use App\Entities\Podcast; +use App\Models\PodcastModel; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\RedirectResponse; +use Modules\PremiumPodcasts\PremiumPodcasts; + +class LockController extends BaseController +{ + protected Podcast $podcast; + + protected PremiumPodcasts $premiumPodcasts; + + public function __construct() + { + $this->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 0000000000..d5147f231d --- /dev/null +++ b/modules/PremiumPodcasts/Controllers/SubscriptionController.php @@ -0,0 +1,447 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2022 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\PremiumPodcasts\Controllers; + +use App\Entities\Podcast; +use App\Models\PodcastModel; +use CodeIgniter\Email\Email; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\I18n\Time; +use Modules\Admin\Controllers\BaseController; +use Modules\PremiumPodcasts\Entities\Subscription; +use Modules\PremiumPodcasts\Models\SubscriptionModel; + +class SubscriptionController extends BaseController +{ + protected Podcast $podcast; + + protected Subscription $subscription; + + public function _remap(string $method, string ...$params): mixed + { + if ($params === []) { + throw PageNotFoundException::forPageNotFound(); + } + + if (($podcast = (new PodcastModel())->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 0000000000..ae4b91abd2 --- /dev/null +++ b/modules/PremiumPodcasts/Database/Migrations/2022-07-07-120000_add_subscriptions.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\PremiumPodcasts\Database\Migrations; + +use CodeIgniter\Database\Migration; + +class AddSubscriptions extends Migration +{ + public function up(): void + { + $this->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 0000000000..3ea10454c8 --- /dev/null +++ b/modules/PremiumPodcasts/Entities/Subscription.php @@ -0,0 +1,123 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2020 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\PremiumPodcasts\Entities; + +use App\Entities\Podcast; +use App\Models\PodcastModel; +use CodeIgniter\Entity\Entity; +use CodeIgniter\I18n\Time; +use Modules\Analytics\Models\AnalyticsPodcastBySubscriptionModel; +use RuntimeException; + +/** + * @property int $id + * @property int $podcast_id + * @property Podcast|null $podcast + * @property string $email + * @property string $token + * @property string $status + * @property string|null $status_message + * @property Time $expires_at + * @property int $downloads_last_3_months + * + * @property int $created_by + * @property int $updated_by + * @property Time $created_at + * @property Time $updated_at + */ +class Subscription extends Entity +{ + protected ?Podcast $podcast = null; + + /** + * @var string[] + */ + protected $dates = ['expires_at', 'created_at', 'updated_at']; + + /** + * @var array<string, string> + */ + 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 0000000000..a46348e5fa --- /dev/null +++ b/modules/PremiumPodcasts/Filters/PodcastUnlockFilter.php @@ -0,0 +1,86 @@ +<?php + +declare(strict_types=1); + +namespace Modules\PremiumPodcasts\Filters; + +use App\Models\EpisodeModel; +use CodeIgniter\Filters\FilterInterface; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Router\Router; +use Config\App; +use Modules\PremiumPodcasts\PremiumPodcasts; +use Myth\Auth\Authentication\AuthenticationBase; + +class PodcastUnlockFilter implements FilterInterface +{ + /** + * Verifies that a user is logged in, or redirects to login. + * + * @param array|null $params + * + * @return mixed + */ + public function before(RequestInterface $request, $params = null) + { + if (! function_exists('is_unlocked')) { + helper('premium_podcasts'); + } + + $current = (string) current_url(true) + ->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 0000000000..46794c85f5 --- /dev/null +++ b/modules/PremiumPodcasts/Helpers/premium_podcasts_helper.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2022 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +use Modules\PremiumPodcasts\Entities\Subscription; +use Modules\PremiumPodcasts\PremiumPodcasts; + +if (! function_exists('is_podcast_unlocked')) { + function is_unlocked(string $podcastHandle): bool + { + /** @var PremiumPodcasts $premiumPodcast */ + $premiumPodcast = service('premium_podcasts'); + return $premiumPodcast->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 0000000000..18c0dd4e4e --- /dev/null +++ b/modules/PremiumPodcasts/Language/en/PremiumPodcasts.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2022 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +return [ + 'podcast_is_premium' => '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 0000000000..7371c8ab0c --- /dev/null +++ b/modules/PremiumPodcasts/Language/en/Subscription.php @@ -0,0 +1,100 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2022 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +return [ + 'podcast_subscriptions' => '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 0000000000..5f79271ac1 --- /dev/null +++ b/modules/PremiumPodcasts/Models/SubscriptionModel.php @@ -0,0 +1,141 @@ +<?php + +declare(strict_types=1); + +/** + * Class SoundbiteModel Model for podcasts_soundbites table in database + * + * @copyright 2020 Ad Aures + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\PremiumPodcasts\Models; + +use CodeIgniter\Model; +use Modules\PremiumPodcasts\Entities\Subscription; + +class SubscriptionModel extends Model +{ + /** + * @var string + */ + protected $table = 'subscriptions'; + + /** + * @var string + */ + protected $primaryKey = 'id'; + + /** + * @var string[] + */ + protected $allowedFields = [ + 'id', + 'podcast_id', + 'email', + 'token', + 'status', + 'status_message', + 'expires_at', + 'created_by', + 'updated_by', + ]; + + /** + * @noRector + */ + protected $returnType = Subscription::class; + + /** + * @var bool + */ + protected $useSoftDeletes = false; + + /** + * @var bool + */ + protected $useTimestamps = true; + + /** + * @var string[] + */ + protected $afterUpdate = ['clearCache']; + + /** + * @var string[] + */ + protected $beforeDelete = ['clearCache']; + + public function getSubscriptionById(int $subscriptionId): ?Subscription + { + $cacheName = "subscription#{$subscriptionId}"; + if (! ($found = cache($cacheName))) { + $found = $this->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 0000000000..e1469d973d --- /dev/null +++ b/modules/PremiumPodcasts/PremiumPodcasts.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); + +namespace Modules\PremiumPodcasts; + +use CodeIgniter\Events\Events; +use Modules\PremiumPodcasts\Entities\Subscription; +use Modules\PremiumPodcasts\Models\SubscriptionModel; + +class PremiumPodcasts +{ + protected SubscriptionModel $subscriptionModel; + + /** + * @var array<string, Subscription|null> + */ + 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 5f89045a24..cbedb485d8 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 eebf8ecb2b..0000000000 --- a/public/media/podcasts/index.html +++ /dev/null @@ -1,9 +0,0 @@ -<!DOCTYPE html> -<html> - <head> - <title>403 Forbidden</title> - </head> - <body> - <p>Directory access is forbidden.</p> - </body> -</html> diff --git a/tailwind.config.js b/tailwind.config.js index 00ffd68a7d..0da9244ac5 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 5d25b97012..78e41ca00f 100644 --- a/themes/cp_admin/_layout.php +++ b/themes/cp_admin/_layout.php @@ -31,8 +31,15 @@ <div class="flex flex-col justify-end w-full -mt-4 sticky-header-inner"> <?= render_breadcrumb('text-xs items-center flex') ?> <div class="flex justify-between py-1"> - <div class="flex flex-wrap items-center overflow-x-hidden"> - <Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></Heading> + <div class="flex flex-wrap items-center"> + <?php if ((isset($episode) && $episode->is_premium) || (isset($podcast) && $podcast->is_premium)): ?> + <div class="inline-flex items-center"> + <IconButton uri="<?= route_to('subscription-list', $podcast->id) ?>" glyph="exchange-dollar" variant="secondary" class="p-0 mr-2 text-4xl border-0"><?= isset($episode) ? lang('PremiumPodcasts.episode_is_premium') : lang('PremiumPodcasts.podcast_is_premium') ?></IconButton> + <Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></Heading> + </div> + <?php else: ?> + <Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></Heading> + <?php endif; ?> <?= $this->renderSection('headerLeft') ?> </div> <div class="flex flex-shrink-0 gap-x-2"><?= $this->renderSection('headerRight') ?></div> diff --git a/themes/cp_admin/_partials/_nav_aside.php b/themes/cp_admin/_partials/_nav_aside.php index 2730b01e6f..79ff2a87f6 100644 --- a/themes/cp_admin/_partials/_nav_aside.php +++ b/themes/cp_admin/_partials/_nav_aside.php @@ -1,4 +1,4 @@ -<div data-sidebar-toggler="backdrop" role="button" tabIndex="0" aria-label="Close" class="fixed z-50 hidden w-full h-full bg-gray-800/75 md:hidden"></div> +<div data-sidebar-toggler="backdrop" role="button" tabIndex="0" aria-label="<?= lang('Common.close') ?>" class="fixed z-50 hidden w-full h-full bg-gray-800/75 md:hidden"></div> <aside data-sidebar-toggler="sidebar" data-toggle-class="-translate-x-full" data-hide-class="-translate-x-full" class="h-full max-h-[calc(100vh-40px)] sticky z-50 flex flex-col row-start-2 col-start-1 text-white transition duration-200 ease-in-out transform -translate-x-full border-r top-10 border-navigation bg-navigation md:translate-x-0"> <?php if (isset($podcast) && isset($episode)): ?> <?= $this->include('episode/_sidebar') ?> diff --git a/themes/cp_admin/episode/_card.php b/themes/cp_admin/episode/_card.php index 0d675fe901..c46b30aece 100644 --- a/themes/cp_admin/episode/_card.php +++ b/themes/cp_admin/episode/_card.php @@ -1,10 +1,17 @@ -<article class="relative flex flex-col flex-1 flex-shrink-0 w-full transition group overflow-hidden bg-elevated border-3 snap-center hover:shadow-lg focus-within:shadow-lg focus-within:ring-accent border-subtle rounded-xl min-w-[12rem] max-w-[17rem]"> +<article class="relative flex flex-col flex-1 flex-shrink-0 w-full transition group overflow-hidden bg-elevated border-3 snap-center hover:shadow-lg focus-within:shadow-lg focus-within:ring-accent rounded-xl min-w-[12rem] max-w-[17rem] <?= $episode->is_premium ? 'border-accent-base' : 'border-subtle' ?>"> <a href="<?= route_to('episode-view', $episode->podcast->id, $episode->id) ?>" class="flex flex-col justify-end w-full h-full text-white group"> <div class="absolute bottom-0 left-0 z-10 w-full h-full backdrop-gradient mix-blend-multiply"></div> <div class="w-full h-full overflow-hidden bg-header"> <img src="<?= $episode->cover->medium_url ?>" alt="<?= esc($episode->title) ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform group-focus:scale-105 group-hover:scale-105 aspect-square" loading="lazy" /> </div> - <?= publication_pill($episode->published_at, $episode->publication_status, 'absolute top-0 left-0 ml-2 mt-2 text-sm'); ?> + <?php if ($episode->is_premium): ?> + <div class="absolute top-0 left-0 inline-flex mt-2 gap-x-2"> + <Icon glyph="exchange-dollar" class="w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg text-accent-contrast bg-accent-base" /> + <?= publication_pill($episode->published_at, $episode->publication_status, 'text-sm'); ?> + </div> + <?php else: ?> + <?= publication_pill($episode->published_at, $episode->publication_status, 'absolute top-0 left-0 ml-2 mt-2 text-sm'); ?> + <?php endif; ?> <div class="absolute z-20 flex flex-col items-start px-4 py-2"> <?= episode_numbering($episode->number, $episode->season_number, 'text-xs font-semibold !no-underline px-1 bg-black/50 mr-1', true) ?> <span class="font-semibold leading-tight line-clamp-2"><?= esc($episode->title) ?></span> diff --git a/themes/cp_admin/episode/_sidebar.php b/themes/cp_admin/episode/_sidebar.php index 0993601b69..fc3291e847 100644 --- a/themes/cp_admin/episode/_sidebar.php +++ b/themes/cp_admin/episode/_sidebar.php @@ -21,7 +21,10 @@ $podcastNavigation = [ /> <span class="flex-1 w-full px-2 text-xs font-semibold truncate" title="<?= esc($podcast->title) ?>"><?= esc($podcast->title) ?></span> </a> -<div class="flex items-center px-4 py-2 border-y border-navigation"> +<div class="relative flex items-center px-4 py-2 border-y border-navigation"> + <?php if ($episode->is_premium): ?> + <Icon glyph="exchange-dollar" class="absolute pl-1 text-xl rounded-r-full rounded-tl-lg left-4 top-4 text-accent-contrast bg-accent-base" /> + <?php endif; ?> <img src="<?= $episode->cover->thumbnail_url ?>" alt="<?= esc($episode->title) ?>" diff --git a/themes/cp_admin/episode/create.php b/themes/cp_admin/episode/create.php index a35ab5a884..4953d28b38 100644 --- a/themes/cp_admin/episode/create.php +++ b/themes/cp_admin/episode/create.php @@ -107,6 +107,8 @@ isChecked="false" ><?= lang('Episode.form.parental_advisory.explicit') ?></Forms.RadioButton> </fieldset> + + </Forms.Section> @@ -131,6 +133,11 @@ </Forms.Section> +<Forms.Section title="<?= lang('Episode.form.premium_title') ?>"> + <Forms.Toggler class="mt-2" name="premium" value="yes" checked="<?= $podcast->is_premium_by_default ? 'true' : 'false' ?>"> + <?= lang('Episode.form.premium') ?></Forms.Toggler> +</Forms.Section> + <Forms.Section title="<?= lang('Episode.form.location_section_title') ?>" subtitle="<?= lang('Episode.form.location_section_subtitle') ?>" diff --git a/themes/cp_admin/episode/edit.php b/themes/cp_admin/episode/edit.php index 64a78eef3a..ab7d6a449c 100644 --- a/themes/cp_admin/episode/edit.php +++ b/themes/cp_admin/episode/edit.php @@ -113,7 +113,6 @@ </Forms.Section> - <Forms.Section title="<?= lang('Episode.form.show_notes_section_title') ?>" subtitle="<?= lang('Episode.form.show_notes_section_subtitle') ?>"> @@ -136,6 +135,11 @@ </Forms.Section> +<Forms.Section title="<?= lang('Episode.form.premium_title') ?>" > + <Forms.Toggler class="mt-2" name="premium" value="yes" checked="<?= $episode->is_premium ? 'true' : 'false' ?>"> + <?= lang('Episode.form.premium') ?></Forms.Toggler> +</Forms.Section> + <Forms.Section title="<?= lang('Episode.form.location_section_title') ?>" subtitle="<?= lang('Episode.form.location_section_subtitle') ?>" diff --git a/themes/cp_admin/episode/list.php b/themes/cp_admin/episode/list.php index da9aad1a95..ff82cdfe71 100644 --- a/themes/cp_admin/episode/list.php +++ b/themes/cp_admin/episode/list.php @@ -42,6 +42,11 @@ data_table( [ 'header' => lang('Episode.list.episode'), 'cell' => function ($episode, $podcast) { + $premiumBadge = ''; + if ($episode->is_premium) { + $premiumBadge = '<Icon glyph="exchange-dollar" class="absolute left-0 w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg top-2 text-accent-contrast bg-accent-base" />'; + } + return '<div class="flex">' . '<div class="relative flex-shrink-0 mr-2">' . '<time class="absolute px-1 text-xs font-semibold text-white rounded bottom-2 right-2 bg-black/50" datetime="PT' . round($episode->audio->duration, 3) . 'S">' . @@ -49,6 +54,7 @@ data_table( (int) $episode->audio->duration, ) . '</time>' . + $premiumBadge . '<img src="' . $episode->cover->thumbnail_url . '" alt="' . esc($episode->title) . '" class="object-cover w-20 rounded-lg shadow-inner aspect-square" loading="lazy" />' . '</div>' . '<a class="overflow-x-hidden text-sm hover:underline" href="' . route_to( diff --git a/themes/cp_admin/podcast/_card.php b/themes/cp_admin/podcast/_card.php index 1479777591..3ba7df786f 100644 --- a/themes/cp_admin/podcast/_card.php +++ b/themes/cp_admin/podcast/_card.php @@ -1,4 +1,4 @@ -<article class="relative h-full overflow-hidden transition shadow bg-elevated border-3 border-subtle group rounded-xl hover:shadow-xl focus-within:shadow-xl focus-within:ring-accent"> +<article class="relative h-full overflow-hidden transition shadow bg-elevated border-3 group rounded-xl hover:shadow-xl focus-within:shadow-xl focus-within:ring-accent <?= $podcast->is_premium ? 'border-accent-base' : 'border-subtle' ?>"> <a href="<?= route_to('podcast-view', $podcast->id) ?>" class="flex flex-col justify-end w-full h-full text-white group"> <div class="absolute bottom-0 left-0 z-10 w-full h-full backdrop-gradient mix-blend-multiply"></div> <div class="<?= 'w-full h-full overflow-hidden bg-header' . ($podcast->publication_status !== 'published' ? ' grayscale group-hover:grayscale-[60%]' : '') ?>"> @@ -6,13 +6,27 @@ alt="<?= esc($podcast->title) ?>" src="<?= $podcast->cover->medium_url ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform aspect-square group-focus:scale-105 group-hover:scale-105" loading="lazy" /> </div> - <?php if ($podcast->publication_status !== 'published'): ?> - <span class="absolute top-0 left-0 flex items-center px-1 mt-2 ml-2 text-sm font-semibold text-gray-600 border border-gray-600 rounded bg-gray-50"> - <?= lang('Podcast.draft') ?> - <?php if ($podcast->publication_status === 'scheduled'): ?> - <Icon glyph="timer" class="flex-shrink-0 ml-1 text-lg" /> + <?php if ($podcast->is_premium): ?> + <div class="absolute top-0 left-0 inline-flex mt-2 gap-x-2"> + <Icon glyph="exchange-dollar" class="w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg text-accent-contrast bg-accent-base" /> + <?php if ($podcast->publication_status !== 'published'): ?> + <span class="flex items-center px-1 text-sm font-semibold text-gray-600 border border-gray-600 rounded bg-gray-50"> + <?= lang('Podcast.draft') ?> + <?php if ($podcast->publication_status === 'scheduled'): ?> + <Icon glyph="timer" class="flex-shrink-0 ml-1 text-lg" /> + <?php endif ?> + </span> <?php endif ?> - </span> + </div> + <?php else: ?> + <?php if ($podcast->publication_status !== 'published'): ?> + <span class="absolute top-0 left-0 flex items-center px-1 mt-2 ml-2 text-sm font-semibold text-gray-600 border border-gray-600 rounded bg-gray-50"> + <?= lang('Podcast.draft') ?> + <?php if ($podcast->publication_status === 'scheduled'): ?> + <Icon glyph="timer" class="flex-shrink-0 ml-1 text-lg" /> + <?php endif ?> + </span> + <?php endif ?> <?php endif ?> <div class="absolute z-20 w-full px-4 pb-4 transition duration-75 ease-out translate-y-6 group-focus:translate-y-0 group-hover:translate-y-0"> <h2 class="font-bold leading-none truncate font-display"><?= esc($podcast->title) ?></h2> diff --git a/themes/cp_admin/podcast/_sidebar.php b/themes/cp_admin/podcast/_sidebar.php index bbe873e889..360d531cfa 100644 --- a/themes/cp_admin/podcast/_sidebar.php +++ b/themes/cp_admin/podcast/_sidebar.php @@ -9,6 +9,10 @@ $podcastNavigation = [ 'icon' => 'play-circle', 'items' => ['episode-list', 'episode-create'], ], + 'premium' => [ + 'icon' => 'exchange-dollar', + 'items' => ['subscription-list', 'subscription-add'], + ], 'analytics' => [ 'icon' => 'line-chart', 'items' => [ @@ -41,7 +45,10 @@ $counts = [ ?> -<div class="flex items-center px-4 py-2 border-b border-navigation"> +<div class="relative flex items-center px-4 py-2 border-b border-navigation"> + <?php if ($podcast->is_premium): ?> + <Icon glyph="exchange-dollar" class="absolute pl-1 text-xl rounded-r-full rounded-tl-lg left-4 top-4 text-accent-contrast bg-accent-base" /> + <?php endif; ?> <img src="<?= $podcast->cover->thumbnail_url ?>" alt="<?= esc($podcast->title) ?>" diff --git a/themes/cp_admin/podcast/create.php b/themes/cp_admin/podcast/create.php index 6cfc85b0e8..9906da3cb0 100644 --- a/themes/cp_admin/podcast/create.php +++ b/themes/cp_admin/podcast/create.php @@ -148,6 +148,11 @@ </Forms.Section> +<Forms.Section title="<?= lang('Podcast.form.premium') ?>"> + <Forms.Toggler class="mt-2" name="premium_by_default" value="yes" checked="false" hint="<?= lang('Podcast.form.premium_by_default_hint') ?>"> + <?= lang('Podcast.form.premium_by_default') ?></Forms.Toggler> +</Forms.Section> + <Forms.Section title="<?= lang('Podcast.form.location_section_title') ?>" subtitle="<?= lang('Podcast.form.location_section_subtitle') ?>" > diff --git a/themes/cp_admin/podcast/edit.php b/themes/cp_admin/podcast/edit.php index 7e90356892..6f01f9d8cf 100644 --- a/themes/cp_admin/podcast/edit.php +++ b/themes/cp_admin/podcast/edit.php @@ -169,6 +169,11 @@ </Forms.Section> +<Forms.Section title="<?= lang('Podcast.form.premium') ?>"> + <Forms.Toggler class="mt-2" name="premium_by_default" value="yes" checked="<?= $podcast->is_premium_by_default ? 'true' : 'false' ?>" hint="<?= lang('Podcast.form.premium_by_default_hint') ?>"> + <?= lang('Podcast.form.premium_by_default') ?></Forms.Toggler> +</Forms.Section> + <Forms.Section title="<?= lang('Podcast.form.location_section_title') ?>" subtitle="<?= lang('Podcast.form.location_section_subtitle') ?>" > diff --git a/themes/cp_admin/settings/general.php b/themes/cp_admin/settings/general.php index 1bafc54608..4a17d2efdd 100644 --- a/themes/cp_admin/settings/general.php +++ b/themes/cp_admin/settings/general.php @@ -77,9 +77,10 @@ title="<?= lang('Settings.housekeeping.title') ?>" subtitle="<?= lang('Settings.housekeeping.subtitle') ?>" > - <Forms.Toggler name="reset_counts" value="yes" size="small" checked="true" hint="<?= lang('Settings.housekeeping.reset_counts_helper') ?>"><?= lang('Settings.housekeeping.reset_counts') ?></Forms.Toggler> - <Forms.Toggler name="rewrite_media" value="yes" size="small" checked="true" hint="<?= lang('Settings.housekeeping.rewrite_media_helper') ?>"><?= lang('Settings.housekeeping.rewrite_media') ?></Forms.Toggler> - <Forms.Toggler name="clear_cache" value="yes" size="small" checked="true" hint="<?= lang('Settings.housekeeping.clear_cache_helper') ?>"><?= lang('Settings.housekeeping.clear_cache') ?></Forms.Toggler> + <Forms.Toggler name="reset_counts" value="yes" size="small" checked="false" hint="<?= lang('Settings.housekeeping.reset_counts_helper') ?>"><?= lang('Settings.housekeeping.reset_counts') ?></Forms.Toggler> + <Forms.Toggler name="rewrite_media" value="yes" size="small" checked="false" hint="<?= lang('Settings.housekeeping.rewrite_media_helper') ?>"><?= lang('Settings.housekeeping.rewrite_media') ?></Forms.Toggler> + <Forms.Toggler name="rename_episodes_files" value="yes" size="small" checked="false" hint="<?= lang('Settings.housekeeping.rename_episodes_files_hint') ?>"><?= lang('Settings.housekeeping.rename_episodes_files') ?></Forms.Toggler> + <Forms.Toggler name="clear_cache" value="yes" size="small" checked="false" hint="<?= lang('Settings.housekeeping.clear_cache_helper') ?>"><?= lang('Settings.housekeeping.clear_cache') ?></Forms.Toggler> <Button variant="primary" type="submit" iconLeft="home-gear"><?= lang('Settings.housekeeping.run') ?></Button> diff --git a/themes/cp_admin/subscription/add.php b/themes/cp_admin/subscription/add.php new file mode 100644 index 0000000000..0cd956bf83 --- /dev/null +++ b/themes/cp_admin/subscription/add.php @@ -0,0 +1,35 @@ +<?= $this->extend('../cp_admin/_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Subscription.add', [esc($podcast->title)]) ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Subscription.add', [esc($podcast->title)]) ?> +<?= $this->endSection() ?> + + +<?= $this->section('content') ?> + +<form method="POST" action="<?= route_to('subscription-add', $podcast->id) ?>" class="flex flex-col max-w-sm gap-y-4"> +<?= csrf_field() ?> +<input type="hidden" name="client_timezone" value="UTC" /> + +<Forms.Field + name="email" + type="email" + label="<?= lang('Subscription.form.email') ?>" + required="true" /> + +<Forms.Field + as="DatetimePicker" + name="expiration_date" + label="<?= lang('Subscription.form.expiration_date') ?>" + hint="<?= lang('Subscription.form.expiration_date_hint') ?>" +/> + +<Button type="submit" class="self-end" variant="primary"><?= lang('Subscription.form.submit_add') ?></Button> + +</form> + +<?= $this->endSection() ?> diff --git a/themes/cp_admin/subscription/delete.php b/themes/cp_admin/subscription/delete.php new file mode 100644 index 0000000000..24248509ec --- /dev/null +++ b/themes/cp_admin/subscription/delete.php @@ -0,0 +1,29 @@ +<?= $this->extend('_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Subscription.delete') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Subscription.delete') ?> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> + +<form action="<?= route_to('subscription-delete', $podcast->id, $subscription->id) ?>" method="POST" class="flex flex-col w-full max-w-xl mx-auto"> +<?= csrf_field() ?> + +<Alert variant="danger" glyph="alert" class="font-semibold"><?= lang('Subscription.delete_form.disclaimer', [ + 'subscriber' => $subscription->email, +]) ?></Alert> + +<Forms.Checkbox class="mt-2" name="understand" required="true" isChecked="false"><?= lang('Subscription.delete_form.understand') ?></Forms.Checkbox> + +<div class="flex items-center self-end mt-4 gap-x-2"> + <Button uri="<?= route_to('subscription-list', $podcast->id) ?>"><?= lang('Common.cancel') ?></Button> + <Button type="submit" variant="danger"><?= lang('Subscription.delete_form.submit') ?></Button> +</div> + +</form> + +<?= $this->endSection() ?> diff --git a/themes/cp_admin/subscription/edit.php b/themes/cp_admin/subscription/edit.php new file mode 100644 index 0000000000..761ebc2c0c --- /dev/null +++ b/themes/cp_admin/subscription/edit.php @@ -0,0 +1,39 @@ +<?= $this->extend('../cp_admin/_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Subscription.edit', [esc($podcast->title)]) ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Subscription.edit', [esc($podcast->title)]) ?> +<?= $this->endSection() ?> + + +<?= $this->section('content') ?> + +<form method="POST" action="<?= route_to('subscription-edit', $podcast->id, $subscription->id) ?>" class="flex flex-col max-w-sm gap-y-4"> +<?= csrf_field() ?> +<input type="hidden" name="client_timezone" value="UTC" /> + +<div class="px-4 py-5 bg-base sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium leading-5 text-skin-muted"> + <?= lang('Subscription.list.email') ?> + </dt> + <dd class="mt-1 text-sm leading-5 sm:mt-0 sm:col-span-2"> + <?= esc($subscription->email) ?> + </dd> +</div> + +<Forms.Field + as="DatetimePicker" + name="expiration_date" + label="<?= lang('Subscription.form.expiration_date') ?>" + hint="<?= lang('Subscription.form.expiration_date_hint') ?>" + value="<?= $subscription->expires_at ?>" +/> + +<Button type="submit" class="self-end" variant="primary"><?= lang('Subscription.form.submit_edit') ?></Button> + +</form> + +<?= $this->endSection() ?> diff --git a/themes/cp_admin/subscription/email/_credentials_list.php b/themes/cp_admin/subscription/email/_credentials_list.php new file mode 100644 index 0000000000..1e7460c3f4 --- /dev/null +++ b/themes/cp_admin/subscription/email/_credentials_list.php @@ -0,0 +1,4 @@ +<ul> + <li><?= lang('Subscription.emails.token', ['<strong>' . $token . '</strong>'], $subscription->podcast->language_code, false) ?> </li> + <li><?= lang('Subscription.emails.unique_feed_link', ['<a href="' . $subscription->podcast->feedUrl . '?token=' . $token . '">' . $subscription->podcast->feedUrl . '?token=' . $token . '</a>'], $subscription->podcast->language_code, false) ?> </li> +</ul> diff --git a/themes/cp_admin/subscription/email/_footer.php b/themes/cp_admin/subscription/email/_footer.php new file mode 100644 index 0000000000..f97a7c96b3 --- /dev/null +++ b/themes/cp_admin/subscription/email/_footer.php @@ -0,0 +1,6 @@ +<br /><br />---<br /> + +<small><?= lang('Subscription.emails.footer', [ + 'castopod' => '<a href="https://castopod.org/">Castopod</a>', + 'host' => '<a href="' . base_url('', 'https') . '">' . current_domain() . '</a>', +], $subscription->podcast->language_code, false) ?></small> diff --git a/themes/cp_admin/subscription/email/_how_to_use.php b/themes/cp_admin/subscription/email/_how_to_use.php new file mode 100644 index 0000000000..04ddb480ac --- /dev/null +++ b/themes/cp_admin/subscription/email/_how_to_use.php @@ -0,0 +1,8 @@ +<h3><?= lang('Subscription.emails.how_to_use', [], $subscription->podcast->language_code) ?></h3> +<p><?= lang('Subscription.emails.two_ways', [], $subscription->podcast->language_code) ?></p> +<ol> + <li><?= lang('Subscription.emails.import_into_app', [], $subscription->podcast->language_code) ?></li> + <li><?= lang('Subscription.emails.go_to_website', [ + 'podcastWebsite' => '<a href="' . url_to('podcast-episodes', esc($subscription->podcast->handle)) . '">' . $subscription->podcast->title . '</a>', + ], $subscription->podcast->language_code, false) ?></li> +</ol> diff --git a/themes/cp_admin/subscription/email/edited.php b/themes/cp_admin/subscription/email/edited.php new file mode 100644 index 0000000000..3b21d9d39a --- /dev/null +++ b/themes/cp_admin/subscription/email/edited.php @@ -0,0 +1,18 @@ +<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/> + +<?php if ($subscription->expires_at): ?> + <?php + $formatter = new IntlDateFormatter($subscription->podcast->language_code, IntlDateFormatter::LONG, IntlDateFormatter::LONG); + $translatedDate = $subscription->expires_at->toLocalizedString($formatter->getPattern()); + ?> + <?= lang('Subscription.emails.edited_expires', [ + 'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>', + 'expiresAt' => '<strong>' . $translatedDate . '</strong>', + ], $subscription->podcast->language_code, false) ?> +<?php else: ?> + <?= lang('Subscription.emails.edited_never_expires', [ + 'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>', + ], $subscription->podcast->language_code, false) ?> +<?php endif; ?> + +<?= $this->include('subscription/email/_footer') ?> diff --git a/themes/cp_admin/subscription/email/removed.php b/themes/cp_admin/subscription/email/removed.php new file mode 100644 index 0000000000..909067dce6 --- /dev/null +++ b/themes/cp_admin/subscription/email/removed.php @@ -0,0 +1,7 @@ +<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/> + +<?= lang('Subscription.emails.removed', [ + 'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>', +], $subscription->podcast->language_code, false) ?> + +<?= $this->include('subscription/email/_footer') ?> diff --git a/themes/cp_admin/subscription/email/reset.php b/themes/cp_admin/subscription/email/reset.php new file mode 100644 index 0000000000..dea6449f79 --- /dev/null +++ b/themes/cp_admin/subscription/email/reset.php @@ -0,0 +1,13 @@ +<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/> + +<?= lang('Subscription.emails.reset_token', [ + 'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>', +], $subscription->podcast->language_code, false) ?><br/><br/> + +<?= lang('Subscription.emails.reset_token_title', [], $subscription->podcast->language_code) ?> + +<?= $this->include('subscription/email/_credentials_list') ?> + +<?= $this->include('subscription/email/_how_to_use') ?> + +<?= $this->include('subscription/email/_footer') ?> diff --git a/themes/cp_admin/subscription/email/resumed.php b/themes/cp_admin/subscription/email/resumed.php new file mode 100644 index 0000000000..f06f08a46a --- /dev/null +++ b/themes/cp_admin/subscription/email/resumed.php @@ -0,0 +1,7 @@ +<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/> + +<?= lang('Subscription.emails.resumed', [ + 'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>', +], $subscription->podcast->language_code, false) ?> + +<?= $this->include('subscription/email/_footer') ?> diff --git a/themes/cp_admin/subscription/email/suspended.php b/themes/cp_admin/subscription/email/suspended.php new file mode 100644 index 0000000000..e59a34e032 --- /dev/null +++ b/themes/cp_admin/subscription/email/suspended.php @@ -0,0 +1,11 @@ +<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/> + +<?= lang('Subscription.emails.suspended', [ + 'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>', +], $subscription->podcast->language_code, false) ?><br/><br/> + +<?php if ($subscription->status_message): ?> + <?= lang('Subscription.emails.suspended_reason', ['<br /><br /><code>' . nl2br($subscription->status_message) . '</code>'], $subscription->podcast->language_code, false) ?> +<?php endif; ?> + +<?= $this->include('subscription/email/_footer') ?> diff --git a/themes/cp_admin/subscription/email/welcome.php b/themes/cp_admin/subscription/email/welcome.php new file mode 100644 index 0000000000..8fa9c84802 --- /dev/null +++ b/themes/cp_admin/subscription/email/welcome.php @@ -0,0 +1,23 @@ +<?= lang('Subscription.emails.greeting', [], $subscription->podcast->language_code) ?><br/><br/> + +<?= lang('Subscription.emails.welcome', [ + 'podcastTitle' => '<strong>' . $subscription->podcast->title . '</strong>', +], $subscription->podcast->language_code, false) ?><br/><br/> + +<?= lang('Subscription.emails.welcome_token_title', [], $subscription->podcast->language_code) ?> + +<?= $this->include('subscription/email/_credentials_list') ?> + +<?php if ($subscription->expires_at): ?> + <?php + $formatter = new IntlDateFormatter($subscription->podcast->language_code, IntlDateFormatter::LONG, IntlDateFormatter::LONG); + $translatedDate = $subscription->expires_at->toLocalizedString($formatter->getPattern()); + ?> + <?= lang('Subscription.emails.welcome_expires', ['<strong>' . $translatedDate . '</strong>'], $subscription->podcast->language_code, false) ?> +<?php else: ?> + <?= lang('Subscription.emails.welcome_never_expires', [], $subscription->podcast->language_code) ?> +<?php endif; ?> + +<?= $this->include('subscription/email/_how_to_use') ?> + +<?= $this->include('subscription/email/_footer') ?> diff --git a/themes/cp_admin/subscription/list.php b/themes/cp_admin/subscription/list.php new file mode 100644 index 0000000000..3d09685eed --- /dev/null +++ b/themes/cp_admin/subscription/list.php @@ -0,0 +1,130 @@ +<?= $this->extend('../cp_admin/_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Subscription.podcast_subscriptions') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Subscription.podcast_subscriptions') ?> +<?= $this->endSection() ?> + +<?= $this->section('headerRight') ?> +<Button uri="<?= route_to('subscription-add', $podcast->id) ?>" variant="primary" iconLeft="add"><?= lang('Subscription.add') ?></Button> +<?= $this->endSection() ?> + + +<?= $this->section('content') ?> + +<form method="POST" action="<?= route_to('subscription-link-save', $podcast->id) ?>" class="flex flex-col items-start max-w-sm gap-y-1"> + <?= csrf_field() ?> + <Forms.Field + class="w-full" + type="url" + name="subscription_link" + label="<?= lang('Subscription.form_link_add.link') ?>" + hint="<?= lang('Subscription.form_link_add.link_hint') ?>" + placeholder="https://…" + value="<?= service('settings') + ->get('Subscription.link', 'podcast:' . $podcast->id) ?>" /> + <Button variant="primary" type="submit"><?= lang('Subscription.form_link_add.submit') ?></Button> +</form> + +<hr class="my-6 border-subtle"> + +<?= data_table( + [ + [ + 'header' => lang('Subscription.list.number'), + 'cell' => function ($subscription) { + return '#' . $subscription->id; + }, + ], + [ + 'header' => lang('Subscription.list.email'), + 'cell' => function ($subscription) { + return esc($subscription->email); + }, + ], + [ + 'header' => lang('Subscription.list.expiration_date'), + 'cell' => function ($subscription) { + return $subscription->expires_at ? local_date($subscription->expires_at) : lang('Subscription.list.unlimited'); + }, + ], + [ + 'header' => lang('Subscription.list.downloads'), + 'cell' => function ($subscription) { + return $subscription->downloads_last_3_months; + }, + ], + [ + 'header' => lang('Subscription.list.status'), + 'cell' => function ($subscription) { + $statusMapping = [ + 'active' => 'success', + 'suspended' => 'warning', + 'expired' => 'default', + ]; + + return '<Pill variant="' . $statusMapping[$subscription->status] . '" class="lowercase">' . lang('Subscription.status.' . $subscription->status) . '</Pill>'; + }, + ], + [ + 'header' => lang('Common.actions'), + 'cell' => function ($subscription, $podcast) { + $items = [ + [ + 'type' => 'link', + 'title' => lang('Subscription.view'), + 'uri' => route_to('subscription-view', $podcast->id, $subscription->id), + ], + [ + 'type' => 'link', + 'title' => lang('Subscription.edit'), + 'uri' => route_to('subscription-edit', $podcast->id, $subscription->id), + ], + [ + 'type' => 'link', + 'title' => lang('Subscription.regenerate_token'), + 'uri' => route_to('subscription-regenerate-token', $podcast->id, $subscription->id), + ], + [ + 'type' => 'separator', + ], + [ + 'type' => 'link', + 'title' => lang('Subscription.delete'), + 'uri' => route_to('subscription-remove', $podcast->id, $subscription->id), + 'class' => 'font-semibold text-red-600', + ], + ]; + + if ($subscription->status === 'suspended') { + $suspendAction = [[ + 'type' => 'link', + 'title' => lang('Subscription.resume'), + 'uri' => route_to('subscription-resume', $podcast->id, $subscription->id), + ]]; + } else { + $suspendAction = [[ + 'type' => 'link', + 'title' => lang('Subscription.suspend'), + 'uri' => route_to('subscription-suspend', $podcast->id, $subscription->id), + ]]; + } + + array_splice($items, 3, 0, $suspendAction); + + return '<button id="more-dropdown-' . $subscription->id . '" type="button" class="inline-flex items-center p-1 rounded-full focus:ring-accent" data-dropdown="button" data-dropdown-target="more-dropdown-' . $subscription->id . '-menu" aria-haspopup="true" aria-expanded="false">' . + icon('more') . + '</button>' . + '<DropdownMenu id="more-dropdown-' . $subscription->id . '-menu" labelledby="more-dropdown-' . $subscription->id . '" offsetY="-24" items="' . esc(json_encode($items)) . '" />'; + }, + ], + ], + $podcast->subscriptions, + '', + $podcast, + ) ?> + +<?= $this->endSection() ?> diff --git a/themes/cp_admin/subscription/suspend.php b/themes/cp_admin/subscription/suspend.php new file mode 100644 index 0000000000..046ad2af32 --- /dev/null +++ b/themes/cp_admin/subscription/suspend.php @@ -0,0 +1,36 @@ +<?= $this->extend('_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Subscription.suspend') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Subscription.suspend') ?> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> + +<form action="<?= route_to('subscription-suspend', $podcast->id, $subscription->id) ?>" method="POST" class="flex flex-col w-full max-w-xl mx-auto"> +<?= csrf_field() ?> + +<Alert variant="warning" glyph="alert" class="font-semibold"><?= lang('Subscription.suspend_form.disclaimer', [ + 'email' => $subscription->email, +]) ?></Alert> + +<Forms.Field + as="Textarea" + name="reason" + label="<?= lang('Subscription.suspend_form.reason') ?>" + placeholder="<?= lang('Subscription.suspend_form.reason_placeholder') ?>" + rows="4" + class="mt-4" +/> + +<div class="flex items-center self-end mt-4 gap-x-2"> + <Button uri="<?= route_to('subscription-list', $podcast->id) ?>"><?= lang('Common.cancel') ?></Button> + <Button type="submit" variant="warning" iconLeft="pause"><?= lang('Subscription.suspend_form.submit') ?></Button> +</div> + +</form> + +<?= $this->endSection() ?> diff --git a/themes/cp_admin/subscription/view.php b/themes/cp_admin/subscription/view.php new file mode 100644 index 0000000000..b17b6acf87 --- /dev/null +++ b/themes/cp_admin/subscription/view.php @@ -0,0 +1,19 @@ +<?= $this->extend('../cp_admin/_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Subscription.view', [ + esc($subscription->id), +]) ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Subscription.view', [ + esc($subscription->id), +]) ?> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> + +<?= $subscription->email ?> + +<?= $this->endSection() ?> diff --git a/themes/cp_app/episode/_layout.php b/themes/cp_app/episode/_layout.php index 075a28ad4f..48e1a0edbe 100644 --- a/themes/cp_app/episode/_layout.php +++ b/themes/cp_app/episode/_layout.php @@ -83,6 +83,9 @@ <div class="z-10 flex flex-col items-start gap-y-2 gap-x-4 sm:flex-row"> <div class="relative flex-shrink-0"> <?= explicit_badge($episode->parental_advisory === 'explicit', 'rounded absolute left-0 bottom-0 ml-2 mb-2 bg-black/75 text-accent-contrast') ?> + <?php if ($episode->is_premium): ?> + <Icon glyph="exchange-dollar" class="absolute left-0 w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg top-2 text-accent-contrast bg-accent-base" /> + <?php endif; ?> <img src="<?= $episode->cover->medium_url ?>" alt="<?= esc($episode->title) ?>" class="flex-shrink-0 rounded-md shadow-xl h-36 aspect-square" loading="lazy" /> </div> <div class="flex flex-col items-start w-full min-w-0 text-white"> @@ -139,11 +142,12 @@ <?php endif; ?> </div> <?= $this->include('episode/_partials/navigation') ?> + <?= $this->include('podcast/_partials/premium_banner') ?> <div class="relative grid items-start flex-1 col-start-2 grid-cols-podcastMain gap-x-6"> <main class="w-full col-start-1 row-start-1 py-6 col-span-full md:col-span-1"> <?= $this->renderSection('content') ?> </main> - <div data-sidebar-toggler="backdrop" class="absolute top-0 left-0 z-10 hidden w-full h-full bg-backdrop/75 md:hidden" role="button" tabIndex="0" aria-label="Close"></div> + <div data-sidebar-toggler="backdrop" class="absolute top-0 left-0 z-10 hidden w-full h-full bg-backdrop/75 md:hidden" role="button" tabIndex="0" aria-label="<?= lang('Common.close') ?>"></div> <?= $this->include('podcast/_partials/sidebar') ?> </div> <?= view('_persons_modal', [ diff --git a/themes/cp_app/episode/_partials/card.php b/themes/cp_app/episode/_partials/card.php index 7bfd5e7c28..165b00c0f9 100644 --- a/themes/cp_app/episode/_partials/card.php +++ b/themes/cp_app/episode/_partials/card.php @@ -1,27 +1,36 @@ -<article class="flex w-full p-4 shadow bg-elevated rounded-conditional-2xl gap-x-2"> - <div class="relative"> +<article class="flex w-full p-3 shadow border-3 bg-elevated rounded-conditional-2xl gap-x-2 <?= $episode->is_premium ? 'border-accent-base' : 'border-transparent' ?>"> + <div class="relative flex-shrink-0 w-20"> <time class="absolute px-1 text-xs font-semibold text-white rounded bottom-2 right-2 bg-black/75" datetime="PT<?= round($episode->audio->duration, 3) ?>S"> <?= format_duration((int) $episode->audio->duration) ?> </time> + <?php if ($episode->is_premium): ?> + <Icon glyph="exchange-dollar" class="absolute left-0 w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg top-2 text-accent-contrast bg-accent-base" /> + <?php endif; ?> <img src="<?= $episode->cover - ->thumbnail_url ?>" alt="<?= esc($episode->title) ?>" class="object-cover w-20 rounded-lg shadow-inner aspect-square" loading="lazy" /> + ->thumbnail_url ?>" alt="<?= esc($episode->title) ?>" class="object-cover w-full rounded-lg shadow-inner aspect-square" loading="lazy" /> </div> <div class="flex items-center flex-1 gap-x-4"> <div class="flex flex-col flex-1"> - <div class="inline-flex items-center"> + <div class="flex flex-wrap items-center"> <?= episode_numbering($episode->number, $episode->season_number, 'text-xs font-semibold border-subtle text-skin-muted px-1 border mr-2 !no-underline', true) ?> <?= relative_time($episode->published_at, 'text-xs whitespace-nowrap text-skin-muted') ?> </div> <h2 class="flex-1 mt-1 font-semibold leading-tight line-clamp-2"><a class="hover:underline" href="<?= $episode->link ?>"><?= esc($episode->title) ?></a></h2> </div> - <play-episode-button - id="<?= $episode->id ?>" - imageSrc="<?= $episode->cover->thumbnail_url ?>" - title="<?= esc($episode->title) ?>" - podcast="<?= esc($episode->podcast->title) ?>" - src="<?= $episode->audio_web_url ?>" - mediaType="<?= $episode->audio->file_mimetype ?>" - playLabel="<?= lang('Common.play_episode_button.play') ?>" - playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button> + <?php if ($episode->is_premium && ! subscription($podcast->handle)): ?> + <a href="<?= route_to('episode', $episode->podcast->handle, $episode->slug) ?>" class="p-3 rounded-full bg-brand bg-accent-base text-accent-contrast hover:bg-accent-hover focus:ring-accent" title="<?= lang('PremiumPodcasts.unlock_episode') ?>" data-tooltip="bottom"> + <Icon glyph="lock" class="text-xl" /> + </a> + <?php else: ?> + <play-episode-button + id="<?= $episode->id ?>" + imageSrc="<?= $episode->cover->thumbnail_url ?>" + title="<?= esc($episode->title) ?>" + podcast="<?= esc($episode->podcast->title) ?>" + src="<?= $episode->audio_web_url ?>" + mediaType="<?= $episode->audio->file_mimetype ?>" + playLabel="<?= lang('Common.play_episode_button.play') ?>" + playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button> + <?php endif; ?> </div> </article> diff --git a/themes/cp_app/episode/_partials/preview_card.php b/themes/cp_app/episode/_partials/preview_card.php index 7bcd153029..0d425b5663 100644 --- a/themes/cp_app/episode/_partials/preview_card.php +++ b/themes/cp_app/episode/_partials/preview_card.php @@ -3,9 +3,12 @@ <time class="absolute px-1 text-sm font-semibold text-white rounded bg-black/75 bottom-2 right-2" datetime="PT<?= round($episode->audio->duration, 3) ?>S"> <?= format_duration((int) $episode->audio->duration) ?> </time> + <?php if ($episode->is_premium): ?> + <Icon glyph="exchange-dollar" class="absolute left-0 w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg top-2 text-accent-contrast bg-accent-base" /> + <?php endif; ?> <img - src="<?= $episode->cover->thumbnail_url ?>" - alt="<?= esc($episode->title) ?>" class="w-24 h-24 aspect-square" loading="lazy" /> + src="<?= $episode->cover->thumbnail_url ?>" + alt="<?= esc($episode->title) ?>" class="w-24 h-24 aspect-square" loading="lazy" /> </div> <div class="flex flex-col flex-1 px-4 py-2"> <div class="inline-flex"> @@ -14,14 +17,20 @@ </div> <a href="<?= $episode->link ?>" class="flex items-baseline font-semibold line-clamp-2" title="<?= esc($episode->title) ?>"><?= esc($episode->title) ?></a> </div> - <play-episode-button - class="mr-4" - id="<?= $index . '_' . $episode->id ?>" - imageSrc="<?= $episode->cover->thumbnail_url ?>" - title="<?= esc($episode->title) ?>" - podcast="<?= esc($episode->podcast->title) ?>" - src="<?= $episode->audio_web_url ?>" - mediaType="<?= $episode->audio->file_mimetype ?>" - playLabel="<?= lang('Common.play_episode_button.play') ?>" - playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button> + <?php if ($episode->is_premium && ! subscription($episode->podcast->handle)): ?> + <a href="<?= route_to('episode', $episode->podcast->handle, $episode->slug) ?>" class="p-3 mr-4 rounded-full bg-brand bg-accent-base text-accent-contrast hover:bg-accent-hover focus:ring-accent" title="<?= lang('PremiumPodcasts.unlock_episode') ?>" data-tooltip="bottom"> + <Icon glyph="lock" class="text-xl" /> + </a> + <?php else: ?> + <play-episode-button + class="mr-4" + id="<?= $index . '_' . $episode->id ?>" + imageSrc="<?= $episode->cover->thumbnail_url ?>" + title="<?= esc($episode->title) ?>" + podcast="<?= esc($episode->podcast->title) ?>" + src="<?= $episode->audio_web_url ?>" + mediaType="<?= $episode->audio->file_mimetype ?>" + playLabel="<?= lang('Common.play_episode_button.play') ?>" + playingLabel="<?= lang('Common.play_episode_button.playing') ?>"></play-episode-button> + <?php endif; ?> </div> \ No newline at end of file diff --git a/themes/cp_app/home.php b/themes/cp_app/home.php index 13fed8fefd..c6f62d4cff 100644 --- a/themes/cp_app/home.php +++ b/themes/cp_app/home.php @@ -76,11 +76,18 @@ <div class="grid gap-4 mt-4 grid-cols-cards"> <?php if ($podcasts): ?> <?php foreach ($podcasts as $podcast): ?> - <a href="<?= $podcast->link ?>" class="relative w-full h-full overflow-hidden transition shadow focus:ring-accent rounded-xl border-subtle hover:shadow-xl focus:shadow-xl group border-3"> + <a href="<?= $podcast->link ?>" class="relative w-full h-full overflow-hidden transition shadow focus:ring-accent rounded-xl hover:shadow-xl focus:shadow-xl group border-3 <?= $podcast->is_premium ? 'border-accent-base' : 'border-subtle' ?>"> <article class="text-white"> <div class="absolute bottom-0 left-0 z-10 w-full h-full backdrop-gradient mix-blend-multiply"></div> <div class="w-full h-full overflow-hidden bg-header"> - <?= explicit_badge($podcast->parental_advisory === 'explicit', 'absolute top-0 left-0 z-10 rounded bg-black/75 ml-2 mt-2') ?> + <?php if ($podcast->is_premium): ?> + <div class="absolute top-0 left-0 z-10 inline-flex items-center mt-2 gap-x-2"> + <Icon glyph="exchange-dollar" class="w-8 pl-2 text-2xl rounded-r-full rounded-tl-lg text-accent-contrast bg-accent-base" /> + <?= explicit_badge($podcast->parental_advisory === 'explicit', 'rounded bg-black/75') ?> + </div> + <?php else: ?> + <?= explicit_badge($podcast->parental_advisory === 'explicit', 'absolute top-0 left-0 z-10 rounded bg-black/75 ml-2 mt-2') ?> + <?php endif; ?> <img alt="<?= esc($podcast->title) ?>" src="<?= $podcast->cover->medium_url ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform bg-header aspect-square group-focus:scale-105 group-hover:scale-105" loading="lazy" /> </div> <div class="absolute bottom-0 left-0 z-20 w-full px-4 pb-2"> diff --git a/themes/cp_app/podcast/_layout.php b/themes/cp_app/podcast/_layout.php index 3b7900869f..2136465aa8 100644 --- a/themes/cp_app/podcast/_layout.php +++ b/themes/cp_app/podcast/_layout.php @@ -43,7 +43,7 @@ </div> <?php endif; ?> - <header class="relative z-50 flex flex-col-reverse justify-between w-full col-start-2 bg-top bg-no-repeat bg-cover sm:flex-row sm:items-end bg-header aspect-[3/1]" style="background-image: url('<?= $podcast->banner->medium_url ?>');"> + <header class="min-h-[200px] relative z-50 flex flex-col-reverse justify-between w-full gap-x-2 col-start-2 bg-top bg-no-repeat bg-cover sm:flex-row sm:items-end bg-header aspect-[3/1]" style="background-image: url('<?= $podcast->banner->medium_url ?>');"> <div class="absolute bottom-0 left-0 w-full h-full backdrop-gradient mix-blend-multiply"></div> <div class="z-10 flex items-center pl-4 -mb-6 md:pl-8 md:-mb-8 gap-x-4"> <img src="<?= $podcast->cover->thumbnail_url ?>" alt="<?= esc($podcast->title) ?>" class="h-24 rounded-full sm:h-28 md:h-36 ring-3 ring-background-elevated aspect-square" loading="lazy" /> @@ -77,6 +77,7 @@ </div> </header> <?= $this->include('podcast/_partials/navigation') ?> + <?= $this->include('podcast/_partials/premium_banner') ?> <div class="relative grid items-start flex-1 col-start-2 grid-cols-podcastMain gap-x-6"> <main class="w-full max-w-xl col-start-1 row-start-1 py-6 mx-auto col-span-full md:col-span-1"> <?= $this->renderSection('content') ?> diff --git a/themes/cp_app/podcast/_partials/premium_banner.php b/themes/cp_app/podcast/_partials/premium_banner.php new file mode 100644 index 0000000000..69b6e636af --- /dev/null +++ b/themes/cp_app/podcast/_partials/premium_banner.php @@ -0,0 +1,46 @@ +<?php declare(strict_types=1); + +if ($podcast->is_premium): ?> + <?php + $isUnlocked = service('premium_podcasts') + ->isUnlocked($podcast->handle); + $shownIcon = $isUnlocked ? 'lock-unlock' : 'lock'; + $hiddenIcon = $isUnlocked ? 'lock' : 'lock-unlock'; + ?> + <div class="flex flex-col items-center justify-between col-start-2 px-2 py-1 mt-2 sm:px-1 md:mt-4 rounded-conditional-full gap-y-2 sm:flex-row bg-accent-base gap-x-2 text-accent-contrast"> + <p class="inline-flex items-center text-sm md:pl-4 gap-x-2"><?= $isUnlocked ? lang('PremiumPodcasts.banner_lock') : lang('PremiumPodcasts.banner_unlock') ?></p> + <?php if ($subscriptionLink = service('settings')->get('Subscription.link', 'podcast:' . $podcast->id)): ?> + <div class="flex items-center self-end gap-x-2"> + <Button + variant="primary" + class="group" + size="small" + uri="<?= $isUnlocked ? route_to('premium-podcast-lock', $podcast->handle) : route_to('premium-podcast-unlock', $podcast->handle) ?>" + > + <Icon glyph="<?= $shownIcon ?>" class="text-sm group-focus:hidden group-hover:hidden" /> + <Icon glyph="<?= $hiddenIcon ?>" class="hidden text-sm group-focus:block group-hover:block" /> + <?= $isUnlocked ? lang('PremiumPodcasts.lock') : lang('PremiumPodcasts.unlock') ?> + </Button> + <Button + iconLeft="external-link" + target="_blank" + rel="noopener noreferrer" + variant="secondary" + size="small" + class="tracking-wider uppercase" + uri="<?= $subscriptionLink ?>"><?= lang('PremiumPodcasts.subscribe') ?></Button> + </div> + <?php else: ?> + <Button + variant="primary" + class="self-end group" + size="small" + uri="<?= $isUnlocked ? route_to('premium-podcast-lock', $podcast->handle) : route_to('premium-podcast-unlock', $podcast->handle) ?>" + > + <Icon glyph="<?= $shownIcon ?>" class="text-sm group-focus:hidden group-hover:hidden" /> + <Icon glyph="<?= $hiddenIcon ?>" class="hidden text-sm group-focus:block group-hover:block" /> + <?= $isUnlocked ? lang('PremiumPodcasts.lock') : lang('PremiumPodcasts.unlock') ?> + </Button> + <?php endif; ?> + </div> +<?php endif; ?> \ No newline at end of file diff --git a/themes/cp_app/podcast/_partials/sidebar.php b/themes/cp_app/podcast/_partials/sidebar.php index 0f178ba241..842eb22d0e 100644 --- a/themes/cp_app/podcast/_partials/sidebar.php +++ b/themes/cp_app/podcast/_partials/sidebar.php @@ -1,4 +1,4 @@ -<div data-sidebar-toggler="backdrop" class="absolute top-0 left-0 z-10 hidden w-full h-full bg-backdrop/75 md:hidden" role="button" tabIndex="0" aria-label="Close"></div> +<div data-sidebar-toggler="backdrop" class="absolute top-0 left-0 z-10 hidden w-full h-full bg-backdrop/75 md:hidden" role="button" tabIndex="0" aria-label="<?= lang('Common.close') ?>"></div> <aside id="podcast-sidebar" data-sidebar-toggler="sidebar" data-toggle-class="hidden" data-hide-class="hidden" class="z-20 hidden h-full col-span-1 col-start-2 row-start-1 p-4 py-6 shadow-2xl md:shadow-none md:block bg-base"> <div class="sticky z-10 bg-base top-12"> <a href="<?= route_to('podcast_feed', esc($podcast->handle)) ?>" class="inline-flex items-center mb-6 text-sm font-semibold focus:ring-accent text-skin-muted hover:text-skin-base group" target="_blank" rel="noopener noreferrer"> diff --git a/themes/cp_app/podcast/activity.php b/themes/cp_app/podcast/activity.php index dcca1117b2..56ab350af8 100644 --- a/themes/cp_app/podcast/activity.php +++ b/themes/cp_app/podcast/activity.php @@ -6,12 +6,12 @@ <form action="<?= route_to('post-attempt-create', esc(interact_as_actor()->username)) ?>" method="POST" class="flex p-4 shadow bg-elevated gap-x-2 rounded-conditional-2xl"> <?= csrf_field() ?> - <?= view('_message_block') ?> - + <img src="<?= interact_as_actor() ->avatar_image_url ?>" alt="<?= esc(interact_as_actor() ->display_name) ?>" class="w-10 h-10 rounded-full aspect-square" loading="lazy" /> <div class="flex flex-col flex-1 min-w-0 gap-y-2"> + <?= view('_message_block') ?> <Forms.Textarea name="message" required="true" diff --git a/themes/cp_app/podcast/unlock.php b/themes/cp_app/podcast/unlock.php new file mode 100644 index 0000000000..1e89af1513 --- /dev/null +++ b/themes/cp_app/podcast/unlock.php @@ -0,0 +1,105 @@ +<?= helper('page') ?> + +<!DOCTYPE html> +<html lang="<?= service('request') + ->getLocale() ?>"> + +<head> + <meta charset="UTF-8"/> + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> + <link rel="icon" type="image/x-icon" href="<?= service('settings') + ->get('App.siteIcon')['ico'] ?>" /> + <link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>"> + <link rel="manifest" href="<?= route_to('podcast-webmanifest', esc($podcast->handle)) ?>"> + <meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>"> + <script> + // Check that service workers are supported + if ('serviceWorker' in navigator) { + // Use the window load event to keep the page load performant + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js'); + }); + } + </script> + + <?= $metatags ?> + + <link rel='stylesheet' type='text/css' href='<?= route_to('themes-colors-css') ?>' /> + <?= service('vite') + ->asset('styles/index.css', 'css') ?> + <?= service('vite') + ->asset('js/app.ts', 'js') ?> +</head> + +<body class="overflow-hidden flex flex-col min-h-screen mx-auto md:min-h-full md:grid md:grid-cols-podcast bg-base theme-<?= service('settings') + ->get('App.theme') ?>"> + <?php if (can_user_interact()): ?> + <div class="col-span-full"> + <?= $this->include('_admin_navbar') ?> + </div> + <?php endif; ?> + + <div class="fixed z-50 flex flex-col items-center justify-center w-full h-full px-4 bg-accent-base/30 backdrop-blur-md"> + <a class="absolute w-full h-full" href="<?= current_url() === previous_url() ? route_to('podcast-activity', $podcast->handle) : previous_url() ?>"><span class="sr-only"><?= lang('Common.go_back') ?></span></a> + <form class="z-10 flex flex-col items-center w-full max-w-lg p-8 text-center rounded-lg shadow-xl bg-elevated" action="<?= route_to('premium-podcast-unlock', $podcast->handle) ?>" method="POST"> + <?= csrf_field() ?> + <Icon class="p-4 text-6xl rounded-full bg-base text-accent-base" glyph="lock" /> + <Heading tagName="h1" size="large" class="mt-2"><?= lang('PremiumPodcasts.unlock_form.title') ?></Heading> + <p class="max-w-sm text-skin-muted"><?= lang('PremiumPodcasts.unlock_form.subtitle', [ + 'podcastTitle' => esc($podcast->title), + ]) ?></p> + <?= view('_message_block') ?> + <Forms.Field + class="self-stretch mt-4 text-left" + name="token" + type="password" + label="<?= lang('PremiumPodcasts.unlock_form.token') ?>" + hint="<?= lang('PremiumPodcasts.unlock_form.token_hint', [ + 'podcastTitle' => esc($podcast->title), + ]) ?>" + required="true" + /> + <Button type="submit" variant="primary" iconLeft="lock-unlock" class="self-center mt-2"><?= lang('PremiumPodcasts.unlock_form.submit') ?></Button> + <?php if ($subscriptionLink = service('settings') + ->get('Subscription.link', 'podcast:' . $podcast->id)): ?> + <p class="max-w-xs mt-4 text-xs"> + <?= lang('PremiumPodcasts.unlock_form.call_to_action', [ + 'podcastTitle' => esc($podcast->title), + ]) ?> + <a href="<?= $subscriptionLink ?>" target="_blank" rel="noopener noreferrer" class="font-semibold underline hover:no-underline"><?= lang('PremiumPodcasts.unlock_form.subscribe_cta') ?></a> + </p> + <?php endif; ?> + </form> + </div> + + <header class="relative flex flex-col-reverse justify-between w-full col-start-2 bg-top bg-no-repeat bg-cover sm:flex-row sm:items-end bg-header aspect-[3/1]" style="background-image: url('<?= $podcast->banner->medium_url ?>');"> + <div class="absolute bottom-0 left-0 w-full h-full backdrop-gradient mix-blend-multiply"></div> + <div class="flex items-center pl-4 -mb-6 md:pl-8 md:-mb-8 gap-x-4"> + <img src="<?= $podcast->cover->thumbnail_url ?>" alt="<?= esc($podcast->title) ?>" class="h-24 rounded-full sm:h-28 md:h-36 ring-3 ring-background-elevated aspect-square" loading="lazy" /> + <div class="relative flex flex-col text-white -top-3 sm:top-0 md:top-2"> + <h1 class="text-lg font-bold leading-none line-clamp-2 md:leading-none md:text-2xl font-display"><?= esc($podcast->title) ?><span class="ml-1 font-sans text-base font-normal">@<?= esc($podcast->handle) ?></span></h1> + <div class=""> + <?= explicit_badge($podcast->parental_advisory === 'explicit', 'mr-1') ?> + <span class="text-xs"><?= lang('Podcast.followers', [ + 'numberOfFollowers' => $podcast->actor->followers_count, + ]) ?></span> + </div> + </div> + </div> + <div class="inline-flex items-center self-end mt-2 mr-2 sm:mb-4 sm:mr-4 gap-x-2"> + <?php if (in_array(true, array_column($podcast->fundingPlatforms, 'is_visible'), true)): ?> + <button class="p-2 text-red-600 bg-white rounded-full shadow hover:text-red-500 focus:ring-accent" data-toggle="funding-links" data-toggle-class="hidden" data-tooltip="bottom" title="<?= lang('Podcast.sponsor') ?>"><Icon glyph="heart"></Icon></button> + <?php endif; ?> + </div> + </header> + <?= $this->include('podcast/_partials/navigation') ?> + <div class="relative grid items-start flex-1 col-start-2 grid-cols-podcastMain gap-x-6"> + <main class="w-full max-w-xl col-start-1 row-start-1 py-6 mx-auto col-span-full md:col-span-1"></main> + <?= $this->include('podcast/_partials/sidebar') ?> + </div> + + <?php if (in_array(true, array_column($podcast->fundingPlatforms, 'is_visible'), true)): ?> + <?= $this->include('podcast/_partials/funding_links_modal') ?> + <?php endif; ?> + +</body> -- GitLab