From 9e1e5d2e862d6a3345d11ca7f96b955c76bfa013 Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Mon, 12 Jul 2021 18:40:22 +0000
Subject: [PATCH] feat(activitypub): add Podcast actor and PodcastEpisode
 object with comments

---
 app/Config/ActivityPub.php                    |  3 -
 app/Config/Routes.php                         | 38 ++++++++
 app/Controllers/EpisodeController.php         | 61 +++++++++++++
 app/Controllers/PodcastController.php         | 57 ++++++++++++
 app/Entities/Episode.php                      | 23 ++++-
 .../Controllers/StatusController.php          |  2 +-
 .../ActivityPub/Objects/NoteObject.php        |  2 +-
 .../Objects/OrderedCollectionObject.php       |  2 +-
 app/Libraries/Analytics/AnalyticsTrait.php    |  2 +-
 app/Libraries/PodcastActor.php                | 34 +++++--
 app/Libraries/PodcastEpisode.php              | 89 +++++++++++++++++++
 app/Models/StatusModel.php                    | 18 ++++
 app/Views/admin/podcast/latest_episodes.php   |  2 +-
 13 files changed, 316 insertions(+), 17 deletions(-)
 create mode 100644 app/Libraries/PodcastEpisode.php

diff --git a/app/Config/ActivityPub.php b/app/Config/ActivityPub.php
index 00bb648211..45e1f1a5fa 100644
--- a/app/Config/ActivityPub.php
+++ b/app/Config/ActivityPub.php
@@ -6,7 +6,6 @@ namespace Config;
 
 use ActivityPub\Config\ActivityPub as ActivityPubBase;
 use App\Libraries\NoteObject;
-use App\Libraries\PodcastActor;
 
 class ActivityPub extends ActivityPubBase
 {
@@ -15,8 +14,6 @@ class ActivityPub extends ActivityPubBase
      * ActivityPub Objects
      * --------------------------------------------------------------------
      */
-    public string $actorObject = PodcastActor::class;
-
     public string $noteObject = NoteObject::class;
 
     /**
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 41f8be7538..cb5dcf7a9e 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -697,6 +697,10 @@ $routes->group('@(:podcastName)', function ($routes): void {
                 'namespace' => 'ActivityPub\Controllers',
                 'controller-method' => 'ActorController/$1',
             ],
+            'application/podcast-activity+json' => [
+                'namespace' => 'App\Controllers',
+                'controller-method' => 'PodcastController::podcastActor/$1',
+            ],
             'application/ld+json; profile="https://www.w3.org/ns/activitystreams' => [
                 'namespace' => 'ActivityPub\Controllers',
                 'controller-method' => 'ActorController/$1',
@@ -705,10 +709,44 @@ $routes->group('@(:podcastName)', function ($routes): void {
     ]);
     $routes->get('episodes', 'PodcastController::episodes/$1', [
         'as' => 'podcast-episodes',
+        'alternate-content' => [
+            'application/activity+json' => [
+                'controller-method' => 'PodcastController::episodeCollection/$1',
+            ],
+            'application/podcast-activity+json' => [
+                'controller-method' => 'PodcastController::episodeCollection/$1',
+            ],
+            'application/ld+json; profile="https://www.w3.org/ns/activitystreams' => [
+                'controller-method' => 'PodcastController::episodeCollection/$1',
+            ],
+        ],
     ]);
     $routes->group('episodes/(:slug)', function ($routes): void {
         $routes->get('/', 'EpisodeController/$1/$2', [
             'as' => 'episode',
+            'alternate-content' => [
+                'application/activity+json' => [
+                    'controller-method' => 'EpisodeController::episodeObject/$1/$2',
+                ],
+                'application/podcast-activity+json' => [
+                    'controller-method' => 'EpisodeController::episodeObject/$1/$2',
+                ],
+                'application/ld+json; profile="https://www.w3.org/ns/activitystreams' => [
+                    'controller-method' => 'EpisodeController::episodeObject/$1/$2',
+                ],
+            ],
+        ]);
+        $routes->get('comments', 'EpisodeController::comments/$1/$2', [
+            'as' => 'episode-comments',
+            'application/activity+json' => [
+                'controller-method' => 'EpisodeController::comments/$1/$2',
+            ],
+            'application/podcast-activity+json' => [
+                'controller-method' => 'EpisodeController::comments/$1/$2',
+            ],
+            'application/ld+json; profile="https://www.w3.org/ns/activitystreams' => [
+                'controller-method' => 'EpisodeController::comments/$1/$2',
+            ],
         ]);
         $routes->get('oembed.json', 'EpisodeController::oembedJSON/$1/$2', [
             'as' => 'episode-oembed-json',
diff --git a/app/Controllers/EpisodeController.php b/app/Controllers/EpisodeController.php
index 89c6fac2b0..34922690e5 100644
--- a/app/Controllers/EpisodeController.php
+++ b/app/Controllers/EpisodeController.php
@@ -10,12 +10,18 @@ declare(strict_types=1);
 
 namespace App\Controllers;
 
+use ActivityPub\Objects\OrderedCollectionObject;
+use ActivityPub\Objects\OrderedCollectionPage;
 use Analytics\AnalyticsTrait;
 use App\Entities\Episode;
 use App\Entities\Podcast;
+use App\Libraries\NoteObject;
+use App\Libraries\PodcastEpisode;
 use App\Models\EpisodeModel;
 use App\Models\PodcastModel;
+use CodeIgniter\Database\BaseBuilder;
 use CodeIgniter\Exceptions\PageNotFoundException;
+use CodeIgniter\HTTP\Response;
 use CodeIgniter\HTTP\ResponseInterface;
 use Config\Services;
 use SimpleXMLElement;
@@ -191,4 +197,59 @@ class EpisodeController extends BaseController
 
         return $this->response->setXML((string) $oembed);
     }
+
+    /**
+     * @noRector ReturnTypeDeclarationRector
+     */
+    public function episodeObject(): Response
+    {
+        $podcastObject = new PodcastEpisode($this->episode);
+
+        return $this->response
+            ->setContentType('application/json')
+            ->setBody($podcastObject->toJSON());
+    }
+
+    /**
+     * @noRector ReturnTypeDeclarationRector
+     */
+    public function comments(): Response
+    {
+        /**
+         * get comments: aggregated replies from posts referring to the episode
+         */
+        $episodeComments = model('StatusModel')
+            ->whereIn('in_reply_to_id', function (BaseBuilder $builder): BaseBuilder {
+                return $builder->select('id')
+                    ->from('activitypub_statuses')
+                    ->where('episode_id', $this->episode->id);
+            })
+            ->where('`published_at` <= NOW()', null, false)
+            ->orderBy('published_at', 'ASC');
+
+        $pageNumber = (int) $this->request->getGet('page');
+
+        if ($pageNumber < 1) {
+            $episodeComments->paginate(12);
+            $pager = $episodeComments->pager;
+            $collection = new OrderedCollectionObject(null, $pager);
+        } else {
+            $paginatedComments = $episodeComments->paginate(12, 'default', $pageNumber);
+            $pager = $episodeComments->pager;
+
+            $orderedItems = [];
+            if ($paginatedComments !== null) {
+                foreach ($paginatedComments as $comment) {
+                    $orderedItems[] = (new NoteObject($comment))->toArray();
+                }
+            }
+
+            // @phpstan-ignore-next-line
+            $collection = new OrderedCollectionPage($pager, $orderedItems);
+        }
+
+        return $this->response
+            ->setContentType('application/activity+json')
+            ->setBody($collection->toJSON());
+    }
 }
diff --git a/app/Controllers/PodcastController.php b/app/Controllers/PodcastController.php
index c87027463d..75d50111a8 100644
--- a/app/Controllers/PodcastController.php
+++ b/app/Controllers/PodcastController.php
@@ -10,12 +10,18 @@ declare(strict_types=1);
 
 namespace App\Controllers;
 
+use ActivityPub\Objects\OrderedCollectionObject;
+use ActivityPub\Objects\OrderedCollectionPage;
 use Analytics\AnalyticsTrait;
 use App\Entities\Podcast;
+use App\Libraries\PodcastActor;
+use App\Libraries\PodcastEpisode;
 use App\Models\EpisodeModel;
 use App\Models\PodcastModel;
 use App\Models\StatusModel;
 use CodeIgniter\Exceptions\PageNotFoundException;
+use CodeIgniter\HTTP\RedirectResponse;
+use CodeIgniter\HTTP\Response;
 
 class PodcastController extends BaseController
 {
@@ -42,6 +48,15 @@ class PodcastController extends BaseController
         return $this->{$method}(...$params);
     }
 
+    public function podcastActor(): RedirectResponse
+    {
+        $podcastActor = new PodcastActor($this->podcast);
+
+        return $this->response
+            ->setContentType('application/activity+json')
+            ->setBody($podcastActor->toJSON());
+    }
+
     public function activity(): string
     {
         // Prevent analytics hit when authenticated
@@ -209,4 +224,46 @@ class PodcastController extends BaseController
 
         return $cachedView;
     }
+
+    /**
+     * @noRector ReturnTypeDeclarationRector
+     */
+    public function episodeCollection(): Response
+    {
+        if ($this->podcast->type === 'serial') {
+            // podcast is serial
+            $episodes = model('EpisodeModel')
+                ->where('`published_at` <= NOW()', null, false)
+                ->orderBy('season_number DESC, number ASC');
+        } else {
+            $episodes = model('EpisodeModel')
+                ->where('`published_at` <= NOW()', null, false)
+                ->orderBy('published_at', 'DESC');
+        }
+
+        $pageNumber = (int) $this->request->getGet('page');
+
+        if ($pageNumber < 1) {
+            $episodes->paginate(12);
+            $pager = $episodes->pager;
+            $collection = new OrderedCollectionObject(null, $pager);
+        } else {
+            $paginatedEpisodes = $episodes->paginate(12, 'default', $pageNumber);
+            $pager = $episodes->pager;
+
+            $orderedItems = [];
+            if ($paginatedEpisodes !== null) {
+                foreach ($paginatedEpisodes as $episode) {
+                    $orderedItems[] = (new PodcastEpisode($episode))->toArray();
+                }
+            }
+
+            // @phpstan-ignore-next-line
+            $collection = new OrderedCollectionPage($pager, $orderedItems);
+        }
+
+        return $this->response
+            ->setContentType('application/activity+json')
+            ->setBody($collection->toJSON());
+    }
 }
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index c10cbb17b0..1e22dd36b7 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -121,6 +121,11 @@ class Episode extends Entity
      */
     protected ?array $statuses = null;
 
+    /**
+     * @var Status[]|null
+     */
+    protected ?array $comments = null;
+
     protected ?Location $location = null;
 
     protected string $custom_rss_string;
@@ -387,7 +392,7 @@ class Episode extends Entity
     public function getStatuses(): array
     {
         if ($this->id === null) {
-            throw new RuntimeException('Episode must be created before getting soundbites.');
+            throw new RuntimeException('Episode must be created before getting statuses.');
         }
 
         if ($this->statuses === null) {
@@ -397,6 +402,22 @@ class Episode extends Entity
         return $this->statuses;
     }
 
+    /**
+     * @return Status[]
+     */
+    public function getComments(): array
+    {
+        if ($this->id === null) {
+            throw new RuntimeException('Episode must be created before getting comments.');
+        }
+
+        if ($this->comments === null) {
+            $this->comments = (new StatusModel())->getEpisodeComments($this->id);
+        }
+
+        return $this->comments;
+    }
+
     public function getLink(): string
     {
         return base_url(route_to('episode', $this->getPodcast() ->name, $this->attributes['slug']));
diff --git a/app/Libraries/ActivityPub/Controllers/StatusController.php b/app/Libraries/ActivityPub/Controllers/StatusController.php
index e43b433ed8..022304f271 100644
--- a/app/Libraries/ActivityPub/Controllers/StatusController.php
+++ b/app/Libraries/ActivityPub/Controllers/StatusController.php
@@ -92,7 +92,7 @@ class StatusController extends Controller
             if ($paginatedReplies !== null) {
                 foreach ($paginatedReplies as $reply) {
                     $replyObject = new $noteObjectClass($reply);
-                    $orderedItems[] = $replyObject->toJSON();
+                    $orderedItems[] = $replyObject->toArray();
                 }
             }
 
diff --git a/app/Libraries/ActivityPub/Objects/NoteObject.php b/app/Libraries/ActivityPub/Objects/NoteObject.php
index b44d67091b..067eef84e5 100644
--- a/app/Libraries/ActivityPub/Objects/NoteObject.php
+++ b/app/Libraries/ActivityPub/Objects/NoteObject.php
@@ -39,7 +39,7 @@ class NoteObject extends ObjectType
             $this->inReplyTo = $status->reply_to_status->uri;
         }
 
-        $this->replies = base_url(route_to('status-replies', $status->actor->username, $status->id));
+        $this->replies = url_to('status-replies', $status->actor->username, $status->id);
 
         $this->cc = [$status->actor->followers_url];
     }
diff --git a/app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php b/app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php
index 1b2d288862..64e64c6911 100644
--- a/app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php
+++ b/app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php
@@ -28,7 +28,7 @@ class OrderedCollectionObject extends ObjectType
     protected ?string $last = null;
 
     /**
-     * @param ObjectType[] $orderedItems
+     * @param ObjectType[]|null $orderedItems
      */
     public function __construct(
         protected ?array $orderedItems = null,
diff --git a/app/Libraries/Analytics/AnalyticsTrait.php b/app/Libraries/Analytics/AnalyticsTrait.php
index 18fa9addd5..87c9d0d523 100644
--- a/app/Libraries/Analytics/AnalyticsTrait.php
+++ b/app/Libraries/Analytics/AnalyticsTrait.php
@@ -40,7 +40,7 @@ trait AnalyticsTrait
             $procedureName = $db->prefixTable('analytics_website');
             $db->query("call {$procedureName}(?,?,?,?,?,?)", [
                 $podcastId,
-                $session->get('browser'),
+                $session->get('browser') ?? '',
                 $session->get('entryPage'),
                 $referer,
                 $domain,
diff --git a/app/Libraries/PodcastActor.php b/app/Libraries/PodcastActor.php
index bde1736960..3eef392db9 100644
--- a/app/Libraries/PodcastActor.php
+++ b/app/Libraries/PodcastActor.php
@@ -10,21 +10,39 @@ declare(strict_types=1);
 
 namespace App\Libraries;
 
-use ActivityPub\Entities\Actor;
 use ActivityPub\Objects\ActorObject;
-use App\Models\PodcastModel;
+use App\Entities\Podcast;
 
 class PodcastActor extends ActorObject
 {
-    protected string $rss;
+    protected string $rssFeed;
 
-    public function __construct(Actor $actor)
+    protected string $language;
+
+    protected string $category;
+
+    protected string $episodes;
+
+    public function __construct(Podcast $podcast)
     {
-        parent::__construct($actor);
+        parent::__construct($podcast->actor);
+
+        $this->context[] = 'https://github.com/Podcastindex-org/activitypub-spec-work/blob/main/docs/1.0.md';
+
+        $this->type = 'Podcast';
+
+        $this->rssFeed = $podcast->feed_url;
+
+        $this->language = $podcast->language_code;
+
+        $category = '';
+        if ($podcast->category->parent_id !== null) {
+            $category .= $podcast->category->parent->apple_category . ' > ';
+        }
+        $category .= $podcast->category->apple_category;
 
-        $podcast = (new PodcastModel())->where('actor_id', $actor->id)
-            ->first();
+        $this->category = $category;
 
-        $this->rss = $podcast->feed_url;
+        $this->episodes = url_to('podcast-episodes', $podcast->name);
     }
 }
diff --git a/app/Libraries/PodcastEpisode.php b/app/Libraries/PodcastEpisode.php
new file mode 100644
index 0000000000..9a8a20027b
--- /dev/null
+++ b/app/Libraries/PodcastEpisode.php
@@ -0,0 +1,89 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2021 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Libraries;
+
+use ActivityPub\Core\ObjectType;
+use App\Entities\Episode;
+
+class PodcastEpisode extends ObjectType
+{
+    protected string $type = 'PodcastEpisode';
+
+    protected string $attributedTo;
+
+    protected string $comments;
+
+    /**
+     * @var array<mixed>
+     */
+    protected array $description = [];
+
+    /**
+     * @var array<string, string>
+     */
+    protected array $image = [];
+
+    /**
+     * @var array<mixed>
+     */
+    protected array $audio = [];
+
+    public function __construct(Episode $episode)
+    {
+        // TODO: clean things up with specified spec
+        $this->id = $episode->link;
+
+        $this->description = [
+            'type' => 'Note',
+            'mediaType' => 'text/markdown',
+            'content' => $episode->description_markdown,
+            'contentMap' => [
+                $episode->podcast->language_code => $episode->description_html,
+            ],
+        ];
+
+        $this->image = [
+            'type' => 'Image',
+            'mediaType' => $episode->image_mimetype,
+            'url' => $episode->image->url,
+        ];
+
+        // add audio file
+        $this->audio = [
+            'id' => $episode->audio_file_url,
+            'type' => 'Audio',
+            'name' => $episode->title,
+            'size' => $episode->audio_file_size,
+            'duration' => $episode->audio_file_duration,
+            'url' => [
+                'href' => $episode->audio_file_url,
+                'type' => 'Link',
+                'mediaType' => $episode->audio_file_mimetype,
+            ],
+            'transcript' => $episode->transcript_file_url,
+            'chapters' => $episode->chapters_file_url,
+        ];
+
+        $this->comments = url_to('episode-comments', $episode->podcast->name, $episode->slug);
+
+        if ($episode->published_at !== null) {
+            $this->published = $episode->published_at->format(DATE_W3C);
+        }
+
+        if ($episode->podcast->actor !== null) {
+            $this->attributedTo = $episode->podcast->actor->uri;
+
+            if ($episode->podcast->actor->followers_url) {
+                $this->cc = [$episode->podcast->actor->followers_url];
+            }
+        }
+    }
+}
diff --git a/app/Models/StatusModel.php b/app/Models/StatusModel.php
index 08ba72751c..132c48efd2 100644
--- a/app/Models/StatusModel.php
+++ b/app/Models/StatusModel.php
@@ -12,6 +12,7 @@ namespace App\Models;
 
 use ActivityPub\Models\StatusModel as ActivityPubStatusModel;
 use App\Entities\Status;
+use CodeIgniter\Database\BaseBuilder;
 
 class StatusModel extends ActivityPubStatusModel
 {
@@ -53,4 +54,21 @@ class StatusModel extends ActivityPubStatusModel
             ->orderBy('published_at', 'DESC')
             ->findAll();
     }
+
+    /**
+     * Retrieves all published statuses for a given episode ordered by publication date
+     *
+     * @return Status[]
+     */
+    public function getEpisodeComments(int $episodeId): array
+    {
+        return $this->whereIn('in_reply_to_id', function (BaseBuilder $builder) use (&$episodeId): BaseBuilder {
+            return $builder->select('id')
+                ->from('activitypub_statuses')
+                ->where('episode_id', $episodeId);
+        })
+            ->where('`published_at` <= NOW()', null, false)
+            ->orderBy('published_at', 'ASC')
+            ->findAll();
+    }
 }
diff --git a/app/Views/admin/podcast/latest_episodes.php b/app/Views/admin/podcast/latest_episodes.php
index 4bf5ac7df4..b36ba21373 100644
--- a/app/Views/admin/podcast/latest_episodes.php
+++ b/app/Views/admin/podcast/latest_episodes.php
@@ -10,7 +10,7 @@
         </a>
     </header>
     <?php if ($episodes): ?>
-        <div class="flex justify-between p-2 space-x-4 overflow-x-auto">
+        <div class="flex p-2 overflow-x-auto gap-x-6">
         <?php foreach ($episodes as $episode): ?>
             <article class="flex flex-col flex-shrink-0 w-56 overflow-hidden bg-white border shadow rounded-xl">
                 <img
-- 
GitLab