From 14d7d078225cdc8980759273a5dc4163d9f84b06 Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Mon, 13 Jun 2022 16:30:34 +0000
Subject: [PATCH] fix: replace deletedField with published_at for episodes

- remove delete_at field + soft delete for media and pages
- update CodeIgniter4 to 4.2.0 + update all starter files
- explicitly use builder() when creating queries from model
---
 app/Config/App.php                            |  2 +-
 app/Config/Constants.php                      | 21 +++++-
 app/Config/ContentSecurityPolicy.php          | 15 ++++
 app/Config/Database.php                       |  4 +-
 app/Config/Events.php                         | 10 +--
 app/Config/Feature.php                        |  7 +-
 app/Config/Filters.php                        |  5 +-
 app/Config/Mimes.php                          |  1 +
 app/Config/Publisher.php                      | 11 +++
 app/Config/Routes.php                         |  9 ++-
 app/Config/Security.php                       | 19 +++++
 app/Config/Validation.php                     |  3 +-
 app/Config/View.php                           | 11 +++
 app/Controllers/BaseController.php            |  2 +-
 .../2020-05-29-120000_add_media.php           |  4 -
 .../2020-08-17-150000_add_pages.php           |  4 -
 app/Entities/Episode.php                      |  2 +-
 app/Entities/EpisodeComment.php               |  5 +-
 app/Entities/Media/BaseMedia.php              |  4 +-
 app/Entities/Podcast.php                      |  3 +-
 app/Models/ClipModel.php                      |  3 +-
 app/Models/EpisodeCommentModel.php            | 21 ++++--
 app/Models/EpisodeModel.php                   | 33 ++++++---
 app/Models/LikeModel.php                      |  4 +-
 app/Models/MediaModel.php                     |  5 +-
 app/Models/PageModel.php                      |  2 +-
 app/Models/PersonModel.php                    | 16 ++--
 app/Models/PodcastModel.php                   | 13 ++--
 app/Views/errors/html/error_exception.php     | 12 +--
 builds                                        |  2 +-
 composer.json                                 |  6 +-
 composer.lock                                 | 16 ++--
 env                                           |  9 ++-
 modules/Admin/Controllers/BaseController.php  |  2 +-
 .../Admin/Controllers/PodcastController.php   | 17 ++++-
 phpstan.neon                                  |  3 -
 public/index.php                              | 36 +++++++--
 spark                                         | 73 ++++++++++++-------
 tests/README.md                               | 11 ++-
 themes/cp_app/episode/_layout.php             |  8 +-
 .../episode/_partials/comment_actions.php     |  2 +-
 .../_partials/comment_reply_actions.php       |  2 +-
 42 files changed, 297 insertions(+), 141 deletions(-)

diff --git a/app/Config/App.php b/app/Config/App.php
index ae52af66da..fe5c59a8a7 100644
--- a/app/Config/App.php
+++ b/app/Config/App.php
@@ -293,7 +293,7 @@ class App extends BaseConfig
      * (empty string) means default SameSite attribute set by browsers (`Lax`)
      * will be set on cookies. If set to `None`, `$cookieSecure` must also be set.
      *
-     * @deprecated use Config\Cookie::$samesite property instead.
+     * @deprecated `Config\Cookie` $samesite property is used.
      */
     public string $cookieSameSite = 'Lax';
 
diff --git a/app/Config/Constants.php b/app/Config/Constants.php
index e685c5d2ed..462eea3cb2 100644
--- a/app/Config/Constants.php
+++ b/app/Config/Constants.php
@@ -52,9 +52,9 @@ defined('MINUTE') || define('MINUTE', 60);
 defined('HOUR') || define('HOUR', 3600);
 defined('DAY') || define('DAY', 86400);
 defined('WEEK') || define('WEEK', 604800);
-defined('MONTH') || define('MONTH', 2592000);
-defined('YEAR') || define('YEAR', 31536000);
-defined('DECADE') || define('DECADE', 315360000);
+defined('MONTH') || define('MONTH', 2_592_000);
+defined('YEAR') || define('YEAR', 31_536_000);
+defined('DECADE') || define('DECADE', 315_360_000);
 
 /*
  | --------------------------------------------------------------------------
@@ -91,3 +91,18 @@ defined('EXIT_USER_INPUT') || define('EXIT_USER_INPUT', 7); // invalid user inpu
 defined('EXIT_DATABASE') || define('EXIT_DATABASE', 8); // database error
 defined('EXIT__AUTO_MIN') || define('EXIT__AUTO_MIN', 9); // lowest automatically-assigned error code
 defined('EXIT__AUTO_MAX') || define('EXIT__AUTO_MAX', 125); // highest automatically-assigned error code
+
+/**
+ * @deprecated Use \CodeIgniter\Events\Events::PRIORITY_LOW instead.
+ */
+define('EVENT_PRIORITY_LOW', 200);
+
+/**
+ * @deprecated Use \CodeIgniter\Events\Events::PRIORITY_NORMAL instead.
+ */
+define('EVENT_PRIORITY_NORMAL', 100);
+
+/**
+ * @deprecated Use \CodeIgniter\Events\Events::PRIORITY_HIGH instead.
+ */
+define('EVENT_PRIORITY_HIGH', 10);
diff --git a/app/Config/ContentSecurityPolicy.php b/app/Config/ContentSecurityPolicy.php
index 4467086773..301f773174 100644
--- a/app/Config/ContentSecurityPolicy.php
+++ b/app/Config/ContentSecurityPolicy.php
@@ -145,4 +145,19 @@ class ContentSecurityPolicy extends BaseConfig
      * @var string|string[]|null
      */
     public string | array | null $sandbox = null;
+
+    /**
+     * Nonce tag for style
+     */
+    public string $styleNonceTag = '{csp-style-nonce}';
+
+    /**
+     * Nonce tag for script
+     */
+    public string $scriptNonceTag = '{csp-script-nonce}';
+
+    /**
+     * Replace nonce tag automatically
+     */
+    public bool $autoNonce = true;
 }
diff --git a/app/Config/Database.php b/app/Config/Database.php
index e0813199a5..7042ccf8cd 100644
--- a/app/Config/Database.php
+++ b/app/Config/Database.php
@@ -61,8 +61,9 @@ class Database extends Config
         'database' => ':memory:',
         'DBDriver' => 'SQLite3',
         'DBPrefix' => 'db_',
+        // Needed to ensure we're working correctly with prefixes live. DO NOT REMOVE FOR CI DEVS
         'pConnect' => false,
-        'DBDebug' => ENVIRONMENT !== 'production',
+        'DBDebug' => (ENVIRONMENT !== 'production'),
         'charset' => 'utf8',
         'DBCollat' => 'utf8_general_ci',
         'swapPre' => '',
@@ -71,6 +72,7 @@ class Database extends Config
         'strictOn' => false,
         'failover' => [],
         'port' => 3306,
+        'foreignKeys' => true,
     ];
 
     //--------------------------------------------------------------------
diff --git a/app/Config/Events.php b/app/Config/Events.php
index 9fd5f31189..f8567024b6 100644
--- a/app/Config/Events.php
+++ b/app/Config/Events.php
@@ -39,9 +39,7 @@ Events::on('pre_system', function () {
             ob_end_flush();
         }
 
-        ob_start(function ($buffer) {
-            return $buffer;
-        });
+        ob_start(static fn ($buffer) => $buffer);
     }
 
     /*
@@ -132,11 +130,11 @@ Events::on('on_post_add', function ($post): void {
 
     if ($post->episode_id !== null) {
         if ($isReply) {
-            model(EpisodeModel::class, false)
+            model(EpisodeModel::class, false)->builder()
                 ->where('id', $post->episode_id)
                 ->increment('comments_count');
         } else {
-            model(EpisodeModel::class, false)
+            model(EpisodeModel::class, false)->builder()
                 ->where('id', $post->episode_id)
                 ->increment('posts_count');
         }
@@ -161,7 +159,7 @@ Events::on('on_post_remove', function ($post): void {
     }
 
     if ($episodeId = $post->episode_id) {
-        model(EpisodeModel::class, false)
+        model(EpisodeModel::class, false)->builder()
             ->where('id', $episodeId)
             ->decrement('posts_count');
     }
diff --git a/app/Config/Feature.php b/app/Config/Feature.php
index 25659ddd0c..1f381b3c23 100644
--- a/app/Config/Feature.php
+++ b/app/Config/Feature.php
@@ -12,7 +12,7 @@ use CodeIgniter\Config\BaseConfig;
 class Feature extends BaseConfig
 {
     /**
-     * Enable multiple filters for a route or not
+     * Enable multiple filters for a route or not.
      *
      * If you enable this:
      *   - CodeIgniter\CodeIgniter::handleRequest() uses:
@@ -24,4 +24,9 @@ class Feature extends BaseConfig
      *     - CodeIgniter\Router\RouteCollection::getFiltersForRoute(), instead of getFilterForRoute()
      */
     public bool $multipleFilters = false;
+
+    /**
+     * Use improved new auto routing instead of the default legacy version.
+     */
+    public bool $autoRoutesImproved = false;
 }
diff --git a/app/Config/Filters.php b/app/Config/Filters.php
index fdc14d7957..f7088509bd 100644
--- a/app/Config/Filters.php
+++ b/app/Config/Filters.php
@@ -59,7 +59,10 @@ class Filters extends BaseConfig
     /**
      * List of filter aliases that works on a particular HTTP method (GET, POST, etc.).
      *
-     * Example: 'post' => ['csrf', 'throttle']
+     * Example: 'post' => ['foo', 'bar']
+     *
+     * If you use this, you should disable auto-routing because auto-routing permits any HTTP method to access a
+     * controller. Accessing the controller with a method you don’t expect could bypass the filter.
      *
      * @var array<string, string[]>
      */
diff --git a/app/Config/Mimes.php b/app/Config/Mimes.php
index bf94916e07..b47fc9a5a8 100644
--- a/app/Config/Mimes.php
+++ b/app/Config/Mimes.php
@@ -169,6 +169,7 @@ class Mimes
         'mj2' => ['image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'],
         'mjp2' => ['image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'],
         'png' => ['image/png', 'image/x-png'],
+        'webp' => 'image/webp',
         'tif' => 'image/tiff',
         'tiff' => 'image/tiff',
         'css' => ['text/css', 'text/plain'],
diff --git a/app/Config/Publisher.php b/app/Config/Publisher.php
index 39018572c7..61e752fa63 100644
--- a/app/Config/Publisher.php
+++ b/app/Config/Publisher.php
@@ -14,4 +14,15 @@ use CodeIgniter\Config\Publisher as BasePublisher;
  */
 class Publisher extends BasePublisher
 {
+    /**
+     * A list of allowed destinations with a (pseudo-)regex of allowed files for each destination. Attempts to publish
+     * to directories not in this list will result in a PublisherException. Files that do no fit the pattern will cause
+     * copy/merge to fail.
+     *
+     * @var array<string,string>
+     */
+    public $restrictions = [
+        ROOTPATH => '*',
+        FCPATH => '#\.(s?css|js|map|html?|xml|json|webmanifest|ttf|eot|woff2?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i',
+    ];
 }
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 1432ceda8e..0bb3fb1d83 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -9,7 +9,7 @@ $routes = Services::routes();
 
 // Load the system's routing file first, so that the app and ENVIRONMENT
 // can override as needed.
-if (file_exists(SYSTEMPATH . 'Config/Routes.php')) {
+if (is_file(SYSTEMPATH . 'Config/Routes.php')) {
     require SYSTEMPATH . 'Config/Routes.php';
 }
 
@@ -23,6 +23,11 @@ $routes->setDefaultController('Home');
 $routes->setDefaultMethod('index');
 $routes->setTranslateURIDashes(false);
 $routes->set404Override();
+
+// The Auto Routing (Legacy) is very dangerous. It is easy to create vulnerable apps
+// where controller filters or CSRF protection are bypassed.
+// If you don't want to define all routes, please use the Auto Routing (Improved).
+// Set `$autoRoutesImproved` to true in `app/Config/Feature.php` and set the following to true.
 $routes->setAutoRoute(false);
 
 /**
@@ -303,6 +308,6 @@ $routes->group('@(:podcastHandle)', function ($routes): void {
  * You will have access to the $routes object within that file without
  * needing to reload it.
  */
-if (file_exists(APPPATH . 'Config/' . ENVIRONMENT . '/Routes.php')) {
+if (is_file(APPPATH . 'Config/' . ENVIRONMENT . '/Routes.php')) {
     require APPPATH . 'Config/' . ENVIRONMENT . '/Routes.php';
 }
diff --git a/app/Config/Security.php b/app/Config/Security.php
index 92c105d8e9..0b986a4167 100644
--- a/app/Config/Security.php
+++ b/app/Config/Security.php
@@ -83,4 +83,23 @@ class Security extends BaseConfig
      * Redirect to previous page with error on failure.
      */
     public bool $redirect = true;
+
+    /**
+     * --------------------------------------------------------------------------
+     * CSRF SameSite
+     * --------------------------------------------------------------------------
+     *
+     * Setting for CSRF SameSite cookie token.
+     *
+     * Allowed values are: None - Lax - Strict - ''.
+     *
+     * Defaults to `Lax` as recommended in this link:
+     *
+     * @see https://portswigger.net/web-security/csrf/samesite-cookies
+     *
+     * @var string
+     *
+     * @deprecated `Config\Cookie` $samesite property is used.
+     */
+    public $samesite = 'Lax';
 }
diff --git a/app/Config/Validation.php b/app/Config/Validation.php
index bcabb7f472..ae8d9e693f 100644
--- a/app/Config/Validation.php
+++ b/app/Config/Validation.php
@@ -6,13 +6,14 @@ namespace Config;
 
 use App\Validation\FileRules as AppFileRules;
 use App\Validation\Rules as AppRules;
+use CodeIgniter\Config\BaseConfig;
 use CodeIgniter\Validation\CreditCardRules;
 use CodeIgniter\Validation\FileRules;
 use CodeIgniter\Validation\FormatRules;
 use CodeIgniter\Validation\Rules;
 use Myth\Auth\Authentication\Passwords\ValidationRules as PasswordRules;
 
-class Validation
+class Validation extends BaseConfig
 {
     /**
      * Stores the classes that contain the rules that are available.
diff --git a/app/Config/View.php b/app/Config/View.php
index 0650d129ee..212f28c917 100644
--- a/app/Config/View.php
+++ b/app/Config/View.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Config;
 
 use CodeIgniter\Config\View as BaseView;
+use CodeIgniter\View\ViewDecoratorInterface;
 
 class View extends BaseView
 {
@@ -36,4 +37,14 @@ class View extends BaseView
      * @var string[]
      */
     public $plugins = [];
+
+    /**
+     * View Decorators are class methods that will be run in sequence to have a chance to alter the generated output
+     * just prior to caching the results.
+     *
+     * All classes must implement CodeIgniter\View\ViewDecoratorInterface
+     *
+     * @var class-string<ViewDecoratorInterface>[]
+     */
+    public array $decorators = [];
 }
diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php
index 312f1ec2fb..fb3d273cfd 100644
--- a/app/Controllers/BaseController.php
+++ b/app/Controllers/BaseController.php
@@ -18,7 +18,7 @@ use ViewThemes\Theme;
  *
  * For security be sure to declare any new methods as protected or private.
  */
-class BaseController extends Controller
+abstract class BaseController extends Controller
 {
     /**
      * Constructor.
diff --git a/app/Database/Migrations/2020-05-29-120000_add_media.php b/app/Database/Migrations/2020-05-29-120000_add_media.php
index af6c4aabd8..177827c5be 100644
--- a/app/Database/Migrations/2020-05-29-120000_add_media.php
+++ b/app/Database/Migrations/2020-05-29-120000_add_media.php
@@ -67,10 +67,6 @@ class AddMedia extends Migration
             'updated_at' => [
                 'type' => 'DATETIME',
             ],
-            'deleted_at' => [
-                'type' => 'DATETIME',
-                'null' => true,
-            ],
         ]);
 
         $this->forge->addKey('id', true);
diff --git a/app/Database/Migrations/2020-08-17-150000_add_pages.php b/app/Database/Migrations/2020-08-17-150000_add_pages.php
index 35eb249c90..c544cb7eb1 100644
--- a/app/Database/Migrations/2020-08-17-150000_add_pages.php
+++ b/app/Database/Migrations/2020-08-17-150000_add_pages.php
@@ -45,10 +45,6 @@ class AddPages extends Migration
             'updated_at' => [
                 'type' => 'DATETIME',
             ],
-            'deleted_at' => [
-                'type' => 'DATETIME',
-                'null' => true,
-            ],
         ]);
         $this->forge->addPrimaryKey('id');
         $this->forge->createTable('pages');
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index 3222368517..6474642ee9 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -136,7 +136,7 @@ class Episode extends Entity
     /**
      * @var string[]
      */
-    protected $dates = ['published_at', 'created_at', 'updated_at', 'deleted_at'];
+    protected $dates = ['published_at', 'created_at', 'updated_at'];
 
     /**
      * @var array<string, string>
diff --git a/app/Entities/EpisodeComment.php b/app/Entities/EpisodeComment.php
index 43769ddace..f2dda34c4c 100644
--- a/app/Entities/EpisodeComment.php
+++ b/app/Entities/EpisodeComment.php
@@ -96,12 +96,13 @@ class EpisodeComment extends UuidEntity
             throw new RuntimeException('Comment must have an actor_id before getting actor.');
         }
 
-        if ($this->actor === null) {
+        if (! $this->actor instanceof Actor) {
             // @phpstan-ignore-next-line
             $this->actor = model(ActorModel::class, false)
                 ->getActorById($this->actor_id);
         }
 
+        // @phpstan-ignore-next-line
         return $this->actor;
     }
 
@@ -135,7 +136,7 @@ class EpisodeComment extends UuidEntity
             throw new RuntimeException('Comment is not a reply.');
         }
 
-        if ($this->reply_to_comment === null) {
+        if (! $this->reply_to_comment instanceof self) {
             $this->reply_to_comment = model(EpisodeCommentModel::class, false)
                 ->getCommentById($this->in_reply_to_id);
         }
diff --git a/app/Entities/Media/BaseMedia.php b/app/Entities/Media/BaseMedia.php
index 2acf221d9f..3c0c88ee4f 100644
--- a/app/Entities/Media/BaseMedia.php
+++ b/app/Entities/Media/BaseMedia.php
@@ -38,7 +38,7 @@ class BaseMedia extends Entity
     /**
      * @var string[]
      */
-    protected $dates = ['uploaded_at', 'updated_at', 'deleted_at'];
+    protected $dates = ['uploaded_at', 'updated_at'];
 
     /**
      * @var array<string, string>
@@ -112,6 +112,6 @@ class BaseMedia extends Entity
     public function delete(): bool|BaseResult
     {
         $mediaModel = new MediaModel();
-        return $mediaModel->delete($this->id, true);
+        return $mediaModel->delete($this->id);
     }
 }
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 84e99cda07..9e58b46105 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -197,12 +197,13 @@ class Podcast extends Entity
             throw new RuntimeException('Podcast must have an actor_id before getting actor.');
         }
 
-        if ($this->actor === null) {
+        if (! $this->actor instanceof Actor) {
             // @phpstan-ignore-next-line
             $this->actor = model(ActorModel::class, false)
                 ->getActorById($this->actor_id);
         }
 
+        // @phpstan-ignore-next-line
         return $this->actor;
     }
 
diff --git a/app/Models/ClipModel.php b/app/Models/ClipModel.php
index 79cab11cdf..a47e172ec0 100644
--- a/app/Models/ClipModel.php
+++ b/app/Models/ClipModel.php
@@ -131,7 +131,7 @@ class ClipModel extends Model
 
     public function getRunningVideoClipsCount(): int
     {
-        $result = $this
+        $result = $this->builder()
             ->select('COUNT(*) as `running_count`')
             ->where([
                 'type' => 'video',
@@ -146,6 +146,7 @@ class ClipModel extends Model
     public function doesVideoClipExist(VideoClip $videoClip): int | false
     {
         $result = $this->select('id')
+            ->builder()
             ->where([
                 'podcast_id' => $videoClip->podcast_id,
                 'episode_id' => $videoClip->episode_id,
diff --git a/app/Models/EpisodeCommentModel.php b/app/Models/EpisodeCommentModel.php
index 47e4fd8d60..839099824f 100644
--- a/app/Models/EpisodeCommentModel.php
+++ b/app/Models/EpisodeCommentModel.php
@@ -13,6 +13,7 @@ namespace App\Models;
 use App\Entities\EpisodeComment;
 use App\Libraries\CommentObject;
 use CodeIgniter\Database\BaseBuilder;
+use CodeIgniter\Database\BaseResult;
 use Michalsn\Uuid\UuidModel;
 use Modules\Fediverse\Activities\CreateActivity;
 use Modules\Fediverse\Models\ActivityModel;
@@ -82,11 +83,11 @@ class EpisodeCommentModel extends UuidModel
         }
 
         if ($comment->in_reply_to_id === null) {
-            (new EpisodeModel())
+            (new EpisodeModel())->builder()
                 ->where('id', $comment->episode_id)
                 ->increment('comments_count');
         } else {
-            (new self())
+            (new self())->builder()
                 ->where('id', service('uuid')->fromString($comment->in_reply_to_id)->getBytes())
                 ->increment('replies_count');
         }
@@ -146,17 +147,19 @@ class EpisodeCommentModel extends UuidModel
     public function getEpisodeComments(int $episodeId): array
     {
         // TODO: merge with replies from posts linked to episode linked
-        $episodeComments = $this->select('*, 0 as is_from_post')
+        $episodeCommentsBuilder = $this->builder();
+        $episodeComments = $episodeCommentsBuilder->select('*, 0 as is_from_post')
             ->where([
                 'episode_id' => $episodeId,
                 'in_reply_to_id' => null,
             ])
             ->getCompiledSelect();
 
-        $episodePostsReplies = $this->db->table(config('Fediverse')->tablesPrefix . 'posts')
-            ->select(
-                'id, uri, episode_id, actor_id, in_reply_to_id, message, message_html, favourites_count as likes_count, replies_count, published_at as created_at, created_by, 1 as is_from_post'
-            )
+        $postModel = new PostModel();
+        $episodePostsRepliesBuilder = $postModel->builder();
+        $episodePostsReplies = $episodePostsRepliesBuilder->select(
+            'id, uri, episode_id, actor_id, in_reply_to_id, message, message_html, favourites_count as likes_count, replies_count, published_at as created_at, created_by, 1 as is_from_post'
+        )
             ->whereIn('in_reply_to_id', function (BaseBuilder $builder) use (&$episodeId): BaseBuilder {
                 return $builder->select('id')
                     ->from(config('Fediverse')->tablesPrefix . 'posts')
@@ -168,6 +171,7 @@ class EpisodeCommentModel extends UuidModel
             ->where('`created_at` <= UTC_TIMESTAMP()', null, false)
             ->getCompiledSelect();
 
+        /** @var BaseResult $allEpisodeComments */
         $allEpisodeComments = $this->db->query(
             $episodeComments . ' UNION ' . $episodePostsReplies . ' ORDER BY created_at ASC'
         );
@@ -211,7 +215,8 @@ class EpisodeCommentModel extends UuidModel
 
     public function resetRepliesCount(): int | false
     {
-        $commentsRepliesCount = $this->select('episode_comments.id, COUNT(*) as `replies_count`')
+        $commentsRepliesCount = $this->builder()
+            ->select('episode_comments.id, COUNT(*) as `replies_count`')
             ->join('episode_comments as c2', 'episode_comments.id = c2.in_reply_to_id')
             ->groupBy('episode_comments.id')
             ->get()
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index 3b20c07ba9..d5a3555c60 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -11,6 +11,7 @@ declare(strict_types=1);
 namespace App\Models;
 
 use App\Entities\Episode;
+use CodeIgniter\Database\BaseResult;
 use CodeIgniter\I18n\Time;
 use CodeIgniter\Model;
 
@@ -264,7 +265,8 @@ class EpisodeModel extends Model
      */
     public function getSecondsToNextUnpublishedEpisode(int $podcastId): int | false
     {
-        $result = $this->select('TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(), `published_at`) as timestamp_diff')
+        $result = $this->builder()
+            ->select('TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(), `published_at`) as timestamp_diff')
             ->where([
                 'podcast_id' => $podcastId,
             ])
@@ -280,10 +282,11 @@ class EpisodeModel extends Model
 
     public function getCurrentSeasonNumber(int $podcastId): ?int
     {
-        $result = $this->select('MAX(season_number) as current_season_number')
+        $result = $this->builder()
+            ->select('MAX(season_number) as current_season_number')
             ->where([
                 'podcast_id' => $podcastId,
-                $this->deletedField => null,
+                'published_at IS NOT' => null,
             ])
             ->get()
             ->getResultArray();
@@ -293,11 +296,12 @@ class EpisodeModel extends Model
 
     public function getNextEpisodeNumber(int $podcastId, ?int $seasonNumber): int
     {
-        $result = $this->select('MAX(number) as next_episode_number')
+        $result = $this->builder()
+            ->select('MAX(number) as next_episode_number')
             ->where([
                 'podcast_id' => $podcastId,
                 'season_number' => $seasonNumber,
-                $this->deletedField => null,
+                'published_at IS NOT' => null,
             ])->get()
             ->getResultArray();
 
@@ -309,13 +313,13 @@ class EpisodeModel extends Model
      */
     public function getPodcastStats(int $podcastId): array
     {
-        $result = $this->select(
-            'COUNT(DISTINCT season_number) as number_of_seasons, COUNT(*) as number_of_episodes, MIN(published_at) as first_published_at'
-        )
+        $result = $this->builder()
+            ->select(
+                'COUNT(DISTINCT season_number) as number_of_seasons, COUNT(*) as number_of_episodes, MIN(published_at) as first_published_at'
+            )
             ->where([
                 'podcast_id' => $podcastId,
                 'published_at IS NOT' => null,
-                $this->deletedField => null,
             ])->get()
             ->getResultArray();
 
@@ -333,13 +337,16 @@ class EpisodeModel extends Model
 
     public function resetCommentsCount(): int | false
     {
-        $episodeCommentsCount = $this->select('episodes.id, COUNT(*) as `comments_count`')
+        $episodeCommentsBuilder = $this->builder();
+        $episodeCommentsCount = $episodeCommentsBuilder->select('episodes.id, COUNT(*) as `comments_count`')
             ->join('episode_comments', 'episodes.id = episode_comments.episode_id')
             ->where('in_reply_to_id', null)
             ->groupBy('episodes.id')
             ->getCompiledSelect();
 
-        $episodePostsRepliesCount = $this
+        $postModel = new PostModel();
+        $episodePostsRepliesBuilder = $postModel->builder();
+        $episodePostsRepliesCount = $episodePostsRepliesBuilder
             ->select('episodes.id, COUNT(*) as `comments_count`')
             ->join(
                 config('Fediverse')
@@ -350,6 +357,7 @@ class EpisodeModel extends Model
             ->groupBy('episodes.id')
             ->getCompiledSelect();
 
+        /** @var BaseResult $query */
         $query = $this->db->query(
             'SELECT `id`, SUM(`comments_count`) as `comments_count` FROM (' . $episodeCommentsCount . ' UNION ALL ' . $episodePostsRepliesCount . ') x GROUP BY `id`'
         );
@@ -365,7 +373,8 @@ class EpisodeModel extends Model
 
     public function resetPostsCount(): int | false
     {
-        $episodePostsCount = $this->select('episodes.id, COUNT(*) as `posts_count`')
+        $episodePostsCount = $this->builder()
+            ->select('episodes.id, COUNT(*) as `posts_count`')
             ->join(
                 config('Fediverse')
                     ->tablesPrefix . 'posts',
diff --git a/app/Models/LikeModel.php b/app/Models/LikeModel.php
index 8462d4c33e..f4eebe36ec 100644
--- a/app/Models/LikeModel.php
+++ b/app/Models/LikeModel.php
@@ -56,7 +56,7 @@ class LikeModel extends UuidModel
             'comment_id' => $comment->id,
         ]);
 
-        (new EpisodeCommentModel())
+        (new EpisodeCommentModel())->builder()
             ->where('id', service('uuid')->fromString($comment->id)->getBytes())
             ->increment('likes_count');
 
@@ -91,7 +91,7 @@ class LikeModel extends UuidModel
     {
         $this->db->transStart();
 
-        (new EpisodeCommentModel())
+        (new EpisodeCommentModel())->builder()
             ->where('id', service('uuid') ->fromString($comment->id) ->getBytes())
             ->decrement('likes_count');
 
diff --git a/app/Models/MediaModel.php b/app/Models/MediaModel.php
index 0357eb675b..dc0f1f5b96 100644
--- a/app/Models/MediaModel.php
+++ b/app/Models/MediaModel.php
@@ -36,7 +36,7 @@ class MediaModel extends Model
     /**
      * @var bool
      */
-    protected $useSoftDeletes = true;
+    protected $useSoftDeletes = false;
 
     /**
      * @var bool
@@ -121,6 +121,7 @@ class MediaModel extends Model
                 'id' => $mediaId,
             ]);
 
+            /** @var object $result */
             $result = $builder->first();
             $mediaClass = $this->returnType;
             $found = new $mediaClass($result->toArray(false, true));
@@ -176,7 +177,7 @@ class MediaModel extends Model
     {
         $media->deleteFile();
 
-        return $this->delete($media->id, true);
+        return $this->delete($media->id);
     }
 
     /**
diff --git a/app/Models/PageModel.php b/app/Models/PageModel.php
index 664196f292..62772b59fa 100644
--- a/app/Models/PageModel.php
+++ b/app/Models/PageModel.php
@@ -38,7 +38,7 @@ class PageModel extends Model
     /**
      * @var bool
      */
-    protected $useSoftDeletes = true;
+    protected $useSoftDeletes = false;
 
     /**
      * @var bool
diff --git a/app/Models/PersonModel.php b/app/Models/PersonModel.php
index a01bb233e6..00d065a0dc 100644
--- a/app/Models/PersonModel.php
+++ b/app/Models/PersonModel.php
@@ -110,7 +110,7 @@ class PersonModel extends Model
             $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_person#{$personId}_roles";
 
             if (! ($found = cache($cacheName))) {
-                $found = $this
+                $found = $this->builder()
                     ->select('episodes_persons.person_group as group, episodes_persons.person_role as role')
                     ->join('episodes_persons', 'persons.id = episodes_persons.person_id')
                     ->where('persons.id', $personId)
@@ -122,7 +122,7 @@ class PersonModel extends Model
             $cacheName = "podcast#{$podcastId}_person#{$personId}_roles";
 
             if (! ($found = cache($cacheName))) {
-                $found = $this
+                $found = $this->builder()
                     ->select('podcasts_persons.person_group as group, podcasts_persons.person_role as role')
                     ->join('podcasts_persons', 'persons.id = podcasts_persons.person_id')
                     ->where('persons.id', $personId)
@@ -210,15 +210,15 @@ class PersonModel extends Model
     {
         $cacheName = "podcast#{$podcastId}_episode#{$episodeId}_persons";
         if (! ($found = cache($cacheName))) {
-            $found = $this
+            $this->builder()
                 ->select(
                     'persons.*, episodes_persons.podcast_id as podcast_id, episodes_persons.episode_id as episode_id'
                 )
                 ->distinct()
                 ->join('episodes_persons', 'persons.id = episodes_persons.person_id')
                 ->where('episodes_persons.episode_id', $episodeId)
-                ->orderby('persons.full_name')
-                ->findAll();
+                ->orderby('persons.full_name');
+            $found = $this->findAll();
 
             cache()
                 ->save($cacheName, $found, DECADE);
@@ -234,13 +234,13 @@ class PersonModel extends Model
     {
         $cacheName = "podcast#{$podcastId}_persons";
         if (! ($found = cache($cacheName))) {
-            $found = $this
+            $this->builder()
                 ->select('persons.*, podcasts_persons.podcast_id as podcast_id')
                 ->distinct()
                 ->join('podcasts_persons', 'persons.id=podcasts_persons.person_id')
                 ->where('podcasts_persons.podcast_id', $podcastId)
-                ->orderby('persons.full_name')
-                ->findAll();
+                ->orderby('persons.full_name');
+            $found = $this->findAll();
 
             cache()
                 ->save($cacheName, $found, DECADE);
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index b28c9e2d15..e9f618e367 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -175,9 +175,10 @@ class PodcastModel extends Model
 
             $fediverseTablePrefix = $prefix . config('Fediverse')
                 ->tablesPrefix;
-            $this->select(
-                'podcasts.*, MAX(' . $fediverseTablePrefix . 'posts.published_at' . ') as max_published_at'
-            )
+            $this->builder()
+                ->select(
+                    'podcasts.*, MAX(' . $fediverseTablePrefix . 'posts.published_at' . ') as max_published_at'
+                )
                 ->join(
                     $fediverseTablePrefix . 'posts',
                     $fediverseTablePrefix . 'posts.actor_id = podcasts.actor_id',
@@ -302,11 +303,12 @@ class PodcastModel extends Model
         if (! ($found = cache($cacheName))) {
             $episodeModel = new EpisodeModel();
             $found = $episodeModel
+                ->builder()
                 ->select('YEAR(published_at) as year, count(*) as number_of_episodes')
                 ->where([
                     'podcast_id' => $podcastId,
                     'season_number' => null,
-                    $episodeModel->deletedField => null,
+                    'published_at IS NOT' => null,
                 ])
                 ->where('`published_at` <= UTC_TIMESTAMP()', null, false)
                 ->groupBy('year')
@@ -338,11 +340,12 @@ class PodcastModel extends Model
         if (! ($found = cache($cacheName))) {
             $episodeModel = new EpisodeModel();
             $found = $episodeModel
+                ->builder()
                 ->select('season_number, count(*) as number_of_episodes')
                 ->where([
                     'podcast_id' => $podcastId,
                     'season_number is not' => null,
-                    $episodeModel->deletedField => null,
+                    'published_at IS NOT' => null,
                 ])
                 ->where('`published_at` <= UTC_TIMESTAMP()', null, false)
                 ->groupBy('season_number')
diff --git a/app/Views/errors/html/error_exception.php b/app/Views/errors/html/error_exception.php
index 9e9e9a9af9..01a72cf20d 100644
--- a/app/Views/errors/html/error_exception.php
+++ b/app/Views/errors/html/error_exception.php
@@ -32,7 +32,7 @@ $error_id = uniqid('error', true); ?>
 
 	<!-- Source -->
 	<div class="container">
-		<p><b><?= esc(static::cleanPath($file, $line)) ?></b> at line <b><?= esc($line) ?></b></p>
+		<p><b><?= esc(clean_path($file, $line)) ?></b> at line <b><?= esc($line) ?></b></p>
 
 		<?php if (is_file($file)) : ?>
 			<div class="source">
@@ -66,9 +66,9 @@ $error_id = uniqid('error', true); ?>
 							<?php if (isset($row['file']) && is_file($row['file'])) :?>
 								<?php
                                 if (isset($row['function']) && in_array($row['function'], ['include', 'include_once', 'require', 'require_once'], true)) {
-                                    echo esc($row['function'] . ' ' . static::cleanPath($row['file']));
+                                    echo esc($row['function'] . ' ' . clean_path($row['file']));
                                 } else {
-                                    echo esc(static::cleanPath($row['file']) . ' : ' . $row['line']);
+                                    echo esc(clean_path($row['file']) . ' : ' . $row['line']);
                                 }
                                 ?>
 							<?php else : ?>
@@ -201,7 +201,7 @@ $error_id = uniqid('error', true); ?>
 						</tr>
 						<tr>
 							<td>HTTP Method</td>
-							<td><?= esc($request->getMethod(true)) ?></td>
+							<td><?= esc(strtoupper($request->getMethod())) ?></td>
 						</tr>
 						<tr>
 							<td>IP Address</td>
@@ -316,7 +316,7 @@ $error_id = uniqid('error', true); ?>
 				<table>
 					<tr>
 						<td style="width: 15em">Response Status</td>
-						<td><?= esc($response->getStatusCode() . ' - ' . $response->getReason()) ?></td>
+						<td><?= esc($response->getStatusCode() . ' - ' . $response->getReasonPhrase()) ?></td>
 					</tr>
 				</table>
 
@@ -352,7 +352,7 @@ $error_id = uniqid('error', true); ?>
 
 				<ol>
 				<?php foreach ($files as $file) :?>
-					<li><?= esc(static::cleanPath($file)) ?></li>
+					<li><?= esc(clean_path($file)) ?></li>
 				<?php endforeach ?>
 				</ol>
 			</div>
diff --git a/builds b/builds
index 0b10a150ac..cc2ca0851a 100644
--- a/builds
+++ b/builds
@@ -39,7 +39,7 @@ if (is_file($file)) {
             if ($dev) {
                 $array['minimum-stability'] = 'dev';
                 $array['prefer-stable']     = true;
-                $array['repositories']      = $array['repositories'] ?? [];
+                $array['repositories'] ??= [];
 
                 $found = false;
 
diff --git a/composer.json b/composer.json
index 14cbca1c7b..a3257bb57a 100644
--- a/composer.json
+++ b/composer.json
@@ -7,7 +7,7 @@
   "license": "AGPL-3.0-or-later",
   "require": {
     "php": "^8.0",
-    "codeigniter4/framework": "^4",
+    "codeigniter4/framework": "^4.2.0",
     "james-heinrich/getid3": "^2.0.x-dev",
     "whichbrowser/parser": "^v2.1.1",
     "geoip2/geoip2": "^v2.12.2",
@@ -35,10 +35,6 @@
     "symplify/coding-standard": "^10.1"
   },
   "autoload": {
-    "psr-4": {
-      "App\\": "app",
-      "Config\\": "app/Config"
-    },
     "exclude-from-classmap": [
       "**/Database/Migrations/**"
     ]
diff --git a/composer.lock b/composer.lock
index aa1b0a3771..86d6633975 100644
--- a/composer.lock
+++ b/composer.lock
@@ -173,16 +173,16 @@
     },
     {
       "name": "codeigniter4/framework",
-      "version": "v4.1.9",
+      "version": "v4.2.0",
       "source": {
         "type": "git",
         "url": "https://github.com/codeigniter4/framework.git",
-        "reference": "4ec623a6b8269dd09f570ab514e5864276bb7f56"
+        "reference": "29f0e9eb2442eba41f4e9832b6695c7584e096e0"
       },
       "dist": {
         "type": "zip",
-        "url": "https://api.github.com/repos/codeigniter4/framework/zipball/4ec623a6b8269dd09f570ab514e5864276bb7f56",
-        "reference": "4ec623a6b8269dd09f570ab514e5864276bb7f56",
+        "url": "https://api.github.com/repos/codeigniter4/framework/zipball/29f0e9eb2442eba41f4e9832b6695c7584e096e0",
+        "reference": "29f0e9eb2442eba41f4e9832b6695c7584e096e0",
         "shasum": ""
       },
       "require": {
@@ -190,15 +190,15 @@
         "ext-intl": "*",
         "ext-json": "*",
         "ext-mbstring": "*",
-        "kint-php/kint": "^4.0",
+        "kint-php/kint": "^4.1.1",
         "laminas/laminas-escaper": "^2.9",
-        "php": "^7.3 || ^8.0",
+        "php": "^7.4 || ^8.0",
         "psr/log": "^1.1"
       },
       "require-dev": {
         "codeigniter/coding-standard": "^1.1",
         "fakerphp/faker": "^1.9",
-        "friendsofphp/php-cs-fixer": "^3.1",
+        "friendsofphp/php-cs-fixer": "3.6.*",
         "mikey179/vfsstream": "^1.6",
         "nexusphp/cs-config": "^3.3",
         "phpunit/phpunit": "^9.1",
@@ -223,7 +223,7 @@
         "slack": "https://codeigniterchat.slack.com",
         "source": "https://github.com/codeigniter4/CodeIgniter4"
       },
-      "time": "2022-02-26T00:51:52+00:00"
+      "time": "2022-06-03T14:04:14+00:00"
     },
     {
       "name": "codeigniter4/settings",
diff --git a/env b/env
index 84a59a84e8..67faaee5b5 100644
--- a/env
+++ b/env
@@ -21,12 +21,14 @@
 #--------------------------------------------------------------------
 
 # app.baseURL = ''
+# If you have trouble with `.`, you could also use `_`.
+# app_baseURL = ''
 # app.forceGlobalSecureRequests = false
 
 # app.sessionDriver = 'CodeIgniter\Session\Handlers\FileHandler'
 # app.sessionCookieName = 'ci_session'
 # app.sessionExpiration = 7200
-# app.sessionSavePath = NULL
+# app.sessionSavePath = null
 # app.sessionMatchIP = false
 # app.sessionTimeToUpdate = 300
 # app.sessionRegenerateDestroy = false
@@ -60,7 +62,7 @@
 # contentsecuritypolicy.scriptSrc = 'self'
 # contentsecuritypolicy.styleSrc = 'self'
 # contentsecuritypolicy.imageSrc = 'self'
-# contentsecuritypolicy.base_uri = null
+# contentsecuritypolicy.baseURI = null
 # contentsecuritypolicy.childSrc = null
 # contentsecuritypolicy.connectSrc = 'self'
 # contentsecuritypolicy.fontSrc = null
@@ -73,6 +75,9 @@
 # contentsecuritypolicy.reportURI = null
 # contentsecuritypolicy.sandbox = false
 # contentsecuritypolicy.upgradeInsecureRequests = false
+# contentsecuritypolicy.styleNonceTag = '{csp-style-nonce}'
+# contentsecuritypolicy.scriptNonceTag = '{csp-script-nonce}'
+# contentsecuritypolicy.autoNonce = true
 
 #--------------------------------------------------------------------
 # COOKIE
diff --git a/modules/Admin/Controllers/BaseController.php b/modules/Admin/Controllers/BaseController.php
index a38b8e8457..667460a1f2 100644
--- a/modules/Admin/Controllers/BaseController.php
+++ b/modules/Admin/Controllers/BaseController.php
@@ -19,7 +19,7 @@ use ViewThemes\Theme;
  * For security be sure to declare any new methods as protected or private.
  */
 
-class BaseController extends Controller
+abstract class BaseController extends Controller
 {
     /**
      * Constructor.
diff --git a/modules/Admin/Controllers/PodcastController.php b/modules/Admin/Controllers/PodcastController.php
index aef52ed50b..8a18b7b395 100644
--- a/modules/Admin/Controllers/PodcastController.php
+++ b/modules/Admin/Controllers/PodcastController.php
@@ -513,11 +513,15 @@ class PodcastController extends BaseController
                 'type' => 'cover',
                 'file' => $this->podcast->cover,
             ],
+        ];
+
+        if ($this->podcast->banner_id !== null) {
+            $podcastMediaList[] =
             [
                 'type' => 'banner',
                 'file' => $this->podcast->banner,
-            ],
-        ];
+            ];
+        }
 
         foreach ($podcastMediaList as $podcastMedia) {
             if ($podcastMedia['file'] !== null && ! $podcastMedia['file']->delete()) {
@@ -566,6 +570,15 @@ class PodcastController extends BaseController
             }
         }
 
+        if ($this->podcast->actor_id === interact_as_actor_id()) {
+            //set interact to the most recently created podcast actor
+            $mostRecentPodcast = (new PodcastModel())->orderBy('created_at', 'desc')
+                ->first();
+            if ($mostRecentPodcast !== null) {
+                set_interact_as_actor($mostRecentPodcast->actor_id);
+            }
+        }
+
         $db->transComplete();
 
         //delete podcast media files and folder
diff --git a/phpstan.neon b/phpstan.neon
index c32c499c4a..48893dbd53 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -19,8 +19,5 @@ parameters:
         - themes/*
     ignoreErrors:
         - '#Cannot access property [\$a-z_]+ on ((array\|)?object)#'
-        - '#^Call to an undefined method CodeIgniter\\Database\\BaseBuilder#'
         - '#^Call to an undefined method CodeIgniter\\Database\\ConnectionInterface#'
-        - '#^Call to an undefined method CodeIgniter\\Model#'
         - '#^Access to an undefined property App\\Entities\\Media\\Image#'
-        - '#^Access to an undefined property CodeIgniter\\Database\\BaseBuilder#'
\ No newline at end of file
diff --git a/public/index.php b/public/index.php
index 4d04f8a58b..cc758a8b11 100644
--- a/public/index.php
+++ b/public/index.php
@@ -2,11 +2,16 @@
 
 declare(strict_types=1);
 
+use CodeIgniter\Config\DotEnv;
 use Config\Paths;
+use Config\Services;
 
 // Path to the front controller (this file)
 define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR);
 
+// Ensure the current directory is pointing to the front controller's directory
+chdir(FCPATH);
+
 /*
  *---------------------------------------------------------------
  * BOOTSTRAP THE APPLICATION
@@ -16,20 +21,34 @@ define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR);
  * and fires up an environment-specific bootstrapping.
  */
 
-// Ensure the current directory is pointing to the front controller's directory
-chdir(__DIR__);
-
 // Load our paths config file
 // This is the line that might need to be changed, depending on your folder structure.
-$pathsConfig = FCPATH . '../app/Config/Paths.php';
-// ^^^ Change this if you move your application folder
-require realpath($pathsConfig) ?: $pathsConfig;
+require FCPATH . '../app/Config/Paths.php';
+// ^^^ Change this line if you move your application folder
 
 $paths = new Paths();
 
 // Location of the framework bootstrap file.
-$bootstrap = rtrim($paths->systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'bootstrap.php';
-$app = require realpath($bootstrap) ?: $bootstrap;
+require rtrim($paths->systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'bootstrap.php';
+
+// Load environment settings from .env files into $_SERVER and $_ENV
+require_once SYSTEMPATH . 'Config/DotEnv.php';
+(new DotEnv(ROOTPATH))->load();
+
+/*
+ * ---------------------------------------------------------------
+ * GRAB OUR CODEIGNITER INSTANCE
+ * ---------------------------------------------------------------
+ *
+ * The CodeIgniter class contains the core functionality to make
+ * the application run, and does all of the dirty work to get
+ * the pieces all working together.
+ */
+
+$app = Services::codeigniter();
+$app->initialize();
+$context = is_cli() ? 'php-cli' : 'web';
+$app->setContext($context);
 
 /*
  *---------------------------------------------------------------
@@ -38,4 +57,5 @@ $app = require realpath($bootstrap) ?: $bootstrap;
  * Now that everything is setup, it's time to actually fire
  * up the engines and make this app do its thang.
  */
+
 $app->run();
diff --git a/spark b/spark
index f62aeddb29..225422aace 100644
--- a/spark
+++ b/spark
@@ -1,6 +1,15 @@
 #!/usr/bin/env php
 <?php
 
+/**
+ * This file is part of CodeIgniter 4 framework.
+ *
+ * (c) CodeIgniter Foundation <admin@codeigniter.com>
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
 /*
  * --------------------------------------------------------------------
  * CodeIgniter command-line tools
@@ -12,8 +21,28 @@
  * this class mainly acts as a passthru to the framework itself.
  */
 
+// Refuse to run when called from php-cgi
+if (strpos(PHP_SAPI, 'cgi') === 0) {
+    exit("The cli tool is not supported when running php-cgi. It needs php-cli to function!\n\n");
+}
+
+// We want errors to be shown when using it from the CLI.
+error_reporting(-1);
+ini_set('display_errors', '1');
+
+/**
+ * @var bool
+ *
+ * @deprecated No longer in use. `CodeIgniter` has `$context` property.
+ */
 define('SPARKED', true);
 
+// Path to the front controller
+define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR);
+
+// Ensure the current directory is pointing to the front controller's directory
+chdir(FCPATH);
+
 /*
  *---------------------------------------------------------------
  * BOOTSTRAP THE APPLICATION
@@ -23,47 +52,39 @@ define('SPARKED', true);
  * and fires up an environment-specific bootstrapping.
  */
 
-// Refuse to run when called from php-cgi
-if (strpos(PHP_SAPI, 'cgi') === 0)
-{
-	die("The cli tool is not supported when running php-cgi. It needs php-cli to function!\n\n");
-}
-
-// Path to the front controller
-define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR);
-
 // Load our paths config file
-$pathsConfig = 'app/Config/Paths.php';
+// This is the line that might need to be changed, depending on your folder structure.
+require FCPATH . '../app/Config/Paths.php';
 // ^^^ Change this line if you move your application folder
-require realpath($pathsConfig) ?: $pathsConfig;
 
 $paths = new Config\Paths();
 
-// Ensure the current directory is pointing to the front controller's directory
-chdir(FCPATH);
+// Location of the framework bootstrap file.
+require rtrim($paths->systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'bootstrap.php';
 
-$bootstrap = rtrim($paths->systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'bootstrap.php';
-$app       = require realpath($bootstrap) ?: $bootstrap;
+// Load environment settings from .env files into $_SERVER and $_ENV
+require_once SYSTEMPATH . 'Config/DotEnv.php';
+(new CodeIgniter\Config\DotEnv(ROOTPATH))->load();
+
+// Grab our CodeIgniter
+$app = Config\Services::codeigniter();
+$app->initialize();
+$app->setContext('spark');
 
 // Grab our Console
 $console = new CodeIgniter\CLI\Console($app);
 
-// We want errors to be shown when using it from the CLI.
-error_reporting(-1);
-ini_set('display_errors', '1');
-
 // Show basic information before we do anything else.
-if (is_int($suppress = array_search('--no-header', $_SERVER['argv'], true)))
-{
-	unset($_SERVER['argv'][$suppress]); // @codeCoverageIgnore
-	$suppress = true;
+if (is_int($suppress = array_search('--no-header', $_SERVER['argv'], true))) {
+    unset($_SERVER['argv'][$suppress]); // @codeCoverageIgnore
+    $suppress = true;
 }
 
 $console->showHeader($suppress);
 
 // fire off the command in the main framework.
 $response = $console->run();
-if ($response->getStatusCode() >= 300)
-{
-	exit($response->getStatusCode());
+
+if ($response->getStatusCode() >= 300) {
+    exit($response->getStatusCode());
 }
diff --git a/tests/README.md b/tests/README.md
index 3452c2d1ca..99812ccdfe 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -8,12 +8,12 @@ test your application. Those details can be found in the documentation.
 ## Resources
 
 - [CodeIgniter 4 User Guide on Testing](https://codeigniter4.github.io/userguide/testing/index.html)
-- [PHPUnit docs](https://phpunit.readthedocs.io/en/8.5/index.html)
+- [PHPUnit docs](https://phpunit.de/documentation.html)
 
 ## Requirements
 
 It is recommended to use the latest version of PHPUnit. At the time of this
-writing we are running version 8.5.13. Support for this has been built into the
+writing we are running version 9.x. Support for this has been built into the
 **composer.json** file that ships with CodeIgniter and can easily be installed
 via [Composer](https://getcomposer.org/) if you don't already have it installed
 globally.
@@ -34,7 +34,8 @@ A number of the tests use a running database. In order to set up the database
 edit the details for the `tests` group in **app/Config/Database.php** or
 **phpunit.xml**. Make sure that you provide a database engine that is currently
 running on your machine. More details on a test database setup are in the
-_Docs>>Testing>>Testing Your Database_ section of the documentation.
+[Testing Your Database](https://codeigniter4.github.io/userguide/testing/database.html)
+section of the documentation.
 
 If you want to run the tests without using live database you can exclude
 @DatabaseLive group. Or make a copy of **phpunit.dist.xml** - call it
@@ -48,6 +49,10 @@ the main directory.
 
     > ./phpunit
 
+If you are using Windows, use the following command.
+
+    > vendor\bin\phpunit
+
 You can limit tests to those within a single test directory by specifying the
 directory name after phpunit.
 
diff --git a/themes/cp_app/episode/_layout.php b/themes/cp_app/episode/_layout.php
index fd6b02a740..075a28ad4f 100644
--- a/themes/cp_app/episode/_layout.php
+++ b/themes/cp_app/episode/_layout.php
@@ -44,13 +44,13 @@
     <?php endif; ?>
 
     <nav class="flex items-center justify-between h-10 col-start-2 text-white bg-header">
-        <a href="<?= route_to('podcast-episodes', esc($podcast->handle)) ?>" class="flex items-center flex-1 h-full min-w-0 px-2 gap-x-2 focus:ring-accent focus:ring-inset" title="<?= lang('Episode.back_to_episodes', [
+        <a href="<?= route_to('podcast-episodes', esc($podcast->handle)) ?>" class="flex items-center h-full min-w-0 px-2 gap-x-2 focus:ring-accent focus:ring-inset" title="<?= lang('Episode.back_to_episodes', [
             'podcast' => esc($podcast->title),
         ]) ?>">
-            <?= icon('arrow-left', 'text-lg') ?>
-            <div class="flex items-center flex-1 min-w-0 gap-x-2">
+            <?= icon('arrow-left', 'text-lg flex-shrink-0') ?>
+            <div class="flex items-center min-w-0 gap-x-2">
                 <img class="w-8 h-8 rounded-full" src="<?= $episode->podcast->cover->tiny_url ?>" alt="<?= esc($episode->podcast->title) ?>" loading="lazy" />
-                <div class="flex flex-col flex-1 overflow-hidden">
+                <div class="flex flex-col overflow-hidden">
                     <span class="text-sm font-semibold leading-none truncate"><?= esc($episode->podcast->title) ?></span>
                     <span class="text-xs"><?= lang('Podcast.followers', [
                         'numberOfFollowers' => $podcast->actor->followers_count,
diff --git a/themes/cp_app/episode/_partials/comment_actions.php b/themes/cp_app/episode/_partials/comment_actions.php
index 01d062a4ea..0272c7694a 100644
--- a/themes/cp_app/episode/_partials/comment_actions.php
+++ b/themes/cp_app/episode/_partials/comment_actions.php
@@ -1,6 +1,6 @@
 <footer>
     <?php if (can_user_interact()): ?>
-        <form action="<?= route_to('episode-comment-attempt-like', esc(interact_as_actor()->username), esc($comment->episode->slug), $comment->id) ?>" method="POST" class="flex items-center gap-x-4">
+        <form action="<?= route_to('episode-comment-attempt-like', esc($comment->episode->podcast->handle), esc($comment->episode->slug), $comment->id) ?>" method="POST" class="flex items-center gap-x-4">
             <?= csrf_field() ?>
             <button type="submit" name="action" class="inline-flex items-center hover:underline group" title="<?= lang(
     'Comment.likes',
diff --git a/themes/cp_app/episode/_partials/comment_reply_actions.php b/themes/cp_app/episode/_partials/comment_reply_actions.php
index c906279698..837f73d55e 100644
--- a/themes/cp_app/episode/_partials/comment_reply_actions.php
+++ b/themes/cp_app/episode/_partials/comment_reply_actions.php
@@ -1,6 +1,6 @@
 <footer>
     <?php if (can_user_interact()): ?>
-        <form action="<?= route_to('episode-comment-attempt-like', esc(interact_as_actor()->username), esc($reply->episode->slug), $reply->id) ?>" method="POST" class="flex items-center gap-x-4">
+        <form action="<?= route_to('episode-comment-attempt-like', esc($reply->episode->podcast->handle), esc($reply->episode->slug), $reply->id) ?>" method="POST" class="flex items-center gap-x-4">
             <?= csrf_field() ?>
 
             <button type="submit" name="action" class="inline-flex items-center hover:underline group" title="<?= lang(
-- 
GitLab