diff --git a/.gitignore b/.gitignore
index 45fa520426b9cf00a7b273a4d2d91b234df5618b..75d7aa6e9445d22490bf1cf1da8ac06d1e9d795d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -143,16 +143,20 @@ node_modules
 
 # public folder
 public/*
-public/media/site
 !public/media
 !public/.htaccess
 !public/favicon.ico
 !public/icon*
+!public/castopod-banner*
+!public/castopod-avatar*
 !public/index.php
 !public/robots.txt
 !public/.well-known
 !public/.well-known/GDPR.yml
 
+public/assets/*
+!public/assets/index.html
+
 # public media folder
 !public/media/podcasts
 !public/media/persons
diff --git a/app/Config/Fediverse.php b/app/Config/Fediverse.php
index cb10bc1afc1e17541ea174befd2996c2a00fb256..f42d45585ed81060c795ce90fde99d515b0739a5 100644
--- a/app/Config/Fediverse.php
+++ b/app/Config/Fediverse.php
@@ -23,7 +23,7 @@ class Fediverse extends FediverseBaseConfig
      */
     public string $noteObject = NoteObject::class;
 
-    public string $defaultAvatarImagePath = 'media/castopod-avatar_thumbnail.webp';
+    public string $defaultAvatarImagePath = 'castopod-avatar_thumbnail.webp';
 
     public string $defaultAvatarImageMimetype = 'image/webp';
 
@@ -52,7 +52,7 @@ class Fediverse extends FediverseBaseConfig
 
         helper('media');
 
-        $this->defaultCoverImagePath = media_path($defaultBannerPath . '_federation.' . $extension);
+        $this->defaultCoverImagePath = $defaultBannerPath . '_federation.' . $extension;
         $this->defaultCoverImageMimetype = $defaultBanner['mimetype'];
     }
 }
diff --git a/app/Controllers/CreditsController.php b/app/Controllers/CreditsController.php
index 1d41afd23f1d2442b35192a6b9af90cd9f433163..ca243a954960a5b8d89158dac6e61cbc668a51e7 100644
--- a/app/Controllers/CreditsController.php
+++ b/app/Controllers/CreditsController.php
@@ -52,7 +52,7 @@ class CreditsController extends BaseController
                             $personId => [
                                 'full_name' => $credit->person->full_name,
                                 'thumbnail_url' =>
-                                    $credit->person->avatar->thumbnail_url,
+                                    get_avatar_url($credit->person, 'thumbnail'),
                                 'information_url' =>
                                     $credit->person->information_url,
                                 'roles' => [
@@ -90,7 +90,7 @@ class CreditsController extends BaseController
                     $credits[$personGroup]['persons'][$personId] = [
                         'full_name' => $credit->person->full_name,
                         'thumbnail_url' =>
-                            $credit->person->avatar->thumbnail_url,
+                            get_avatar_url($credit->person, 'thumbnail'),
                         'information_url' => $credit->person->information_url,
                         'roles' => [
                             $personRole => [
diff --git a/app/Controllers/FeedController.php b/app/Controllers/FeedController.php
index 290de8c476515f3176090a01f98be2cb0fd5892e..ee0ad37e50afb88795ca012aa0cdb40501449a0b 100644
--- a/app/Controllers/FeedController.php
+++ b/app/Controllers/FeedController.php
@@ -23,7 +23,7 @@ class FeedController extends Controller
 {
     public function index(string $podcastHandle): ResponseInterface
     {
-        helper(['rss', 'premium_podcasts']);
+        helper(['rss', 'premium_podcasts', 'misc']);
 
         $podcast = (new PodcastModel())->where('handle', $podcastHandle)
             ->first();
diff --git a/app/Controllers/WebmanifestController.php b/app/Controllers/WebmanifestController.php
index 653bcaae6cb4b57da66eae14d804b15b51d4c17b..f1e6449b43900ea3d4f2dad9034012d2dc13df2e 100644
--- a/app/Controllers/WebmanifestController.php
+++ b/app/Controllers/WebmanifestController.php
@@ -61,14 +61,12 @@ class WebmanifestController extends Controller
             'background_color' => self::THEME_COLORS[service('settings')->get('App.theme')]['background'],
             'icons' => [
                 [
-                    'src' => service('settings')
-                        ->get('App.siteIcon')['192'],
+                    'src' => get_site_icon_url('192'),
                     'type' => 'image/png',
                     'sizes' => '192x192',
                 ],
                 [
-                    'src' => service('settings')
-                        ->get('App.siteIcon')['512'],
+                    'src' => get_site_icon_url('512'),
                     'type' => 'image/png',
                     'sizes' => '512x512',
                 ],
diff --git a/app/Entities/Person.php b/app/Entities/Person.php
index f0f27721027547f71c4e26bf6f4e7043dd830567..5ce4d8f0debdd62ab608b7da83d8bdd765804f0b 100644
--- a/app/Entities/Person.php
+++ b/app/Entities/Person.php
@@ -84,21 +84,10 @@ class Person extends Entity
         return $this;
     }
 
-    public function getAvatar(): Image
+    public function getAvatar(): ?Image
     {
-        if ($this->attributes['avatar_id'] === null) {
-            helper('media');
-            return new Image([
-                'file_key' => config('Images')
-                    ->avatarDefaultPath,
-                'file_mimetype' => config('Images')
-                    ->avatarDefaultMimeType,
-                'file_size' => 0,
-                'file_metadata' => [
-                    'sizes' => config('Images')
-                        ->personAvatarSizes,
-                ],
-            ], 'fs');
+        if ($this->avatar_id === null) {
+            return null;
         }
 
         if ($this->avatar === null) {
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 313fa0853c8a4bbab315901cdb9028a747dde6cf..3894b102c8a679316eac4895a3201c243c7241af 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -294,22 +294,10 @@ class Podcast extends Entity
         return $this;
     }
 
-    public function getBanner(): Image
+    public function getBanner(): ?Image
     {
         if ($this->banner_id === null) {
-            $defaultBanner = config('Images')
-                ->podcastBannerDefaultPaths[service('settings')->get('App.theme')] ?? config(
-                    'Images'
-                )->podcastBannerDefaultPaths['default'];
-            return new Image([
-                'file_key' => $defaultBanner['path'],
-                'file_mimetype' => $defaultBanner['mimetype'],
-                'file_size' => 0,
-                'file_metadata' => [
-                    'sizes' => config('Images')
-->podcastBannerSizes,
-                ],
-            ], 'fs');
+            return null;
         }
 
         if (! $this->banner instanceof Image) {
diff --git a/app/Helpers/id3_helper.php b/app/Helpers/id3_helper.php
index 584a07d550c37c091b53e799cabbc440e9c45106..e0564f291f5ac4ba472c82a25c1d7c9e1d264f30 100644
--- a/app/Helpers/id3_helper.php
+++ b/app/Helpers/id3_helper.php
@@ -33,7 +33,7 @@ if (! function_exists('write_audio_file_tags')) {
         /** @var FileManagerInterface $fileManager */
         $fileManager = service('file_manager');
 
-        $APICdata = $fileManager->getFileContents($episode->cover->id3_key);
+        $APICdata = (string) $fileManager->getFileContents($episode->cover->id3_key);
 
         // TODO: variables used for podcast specific tags
         // $podcastUrl = $episode->podcast->link;
diff --git a/app/Helpers/misc_helper.php b/app/Helpers/misc_helper.php
index e4a5dc931e0618c6079b5f622e4087f9a8f85b89..06b0aff3c0edba7cdaf1d46da5c06c9e96022d40 100644
--- a/app/Helpers/misc_helper.php
+++ b/app/Helpers/misc_helper.php
@@ -2,6 +2,9 @@
 
 declare(strict_types=1);
 
+use App\Entities\Person;
+use App\Entities\Podcast;
+
 /**
  * @copyright  2020 Ad Aures
  * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
@@ -297,3 +300,89 @@ if (! function_exists('format_bytes')) {
         return round($bytes, $precision) . $units[$pow];
     }
 }
+
+
+if (! function_exists('get_site_icon_url')) {
+    function get_site_icon_url(string $size): string
+    {
+        if (config('App')->siteIcon['ico'] === service('settings')->get('App.siteIcon')['ico']) {
+            // return default site icon url
+            return base_url(service('settings')->get('App.siteIcon')[$size]);
+        }
+
+        return service('file_manager')->getUrl(service('settings')->get('App.siteIcon')[$size]);
+    }
+}
+
+
+if (! function_exists('get_podcast_banner')) {
+    function get_podcast_banner_url(Podcast $podcast, string $size): string
+    {
+        if ($podcast->banner === null) {
+            $defaultBanner = config('Images')
+                ->podcastBannerDefaultPaths[service('settings')->get('App.theme')] ?? config(
+                    'Images'
+                )->podcastBannerDefaultPaths['default'];
+
+            $sizes = config('Images')
+->podcastBannerSizes;
+
+            $sizeConfig = $sizes[$size];
+            helper('filesystem');
+
+            // return default site icon url
+            return base_url(
+                change_file_path($defaultBanner['path'], '_' . $size, $sizeConfig['extension'] ?? null)
+            );
+        }
+
+        $sizeKey = $size . '_url';
+        return $podcast->banner->{$sizeKey};
+    }
+}
+
+if (! function_exists('get_podcast_banner_mimetype')) {
+    function get_podcast_banner_mimetype(Podcast $podcast, string $size): string
+    {
+        if ($podcast->banner === null) {
+            $sizes = config('Images')
+->podcastBannerSizes;
+
+            $sizeConfig = $sizes[$size];
+            helper('filesystem');
+
+            // return default site icon url
+            return array_key_exists('mimetype', $sizeConfig) ? $sizeConfig['mimetype'] : config(
+                'Images'
+            )->podcastBannerDefaultMimeType;
+        }
+
+        $mimetype = $size . '_mimetype';
+        return $podcast->banner->{$mimetype};
+    }
+}
+
+if (! function_exists('get_avatar_url')) {
+    function get_avatar_url(Person $person, string $size): string
+    {
+        if ($person->avatar === null) {
+            $defaultAvatar = config('Images')
+->avatarDefaultPath;
+
+            $sizes = config('Images')
+->personAvatarSizes;
+
+            $sizeConfig = $sizes[$size];
+
+            helper('filesystem');
+
+            // return default site icon url
+            return base_url(
+                change_file_path($defaultAvatar['path'], '_' . $size, $sizeConfig['extension'] ?? null)
+            );
+        }
+
+        $sizeKey = $size . '_url';
+        return $person->avatar->{$sizeKey};
+    }
+}
diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php
index c4329f7783218512aac18ad01e1c2521bee9f637..7da6baa294952933e4031512f70c466d7b93aeae 100644
--- a/app/Helpers/rss_helper.php
+++ b/app/Helpers/rss_helper.php
@@ -204,7 +204,7 @@ if (! function_exists('get_rss_feed')) {
             foreach ($person->roles as $role) {
                 $personElement = $channel->addChild('person', $person->full_name, $podcastNamespace);
 
-                $personElement->addAttribute('img', $person->avatar->medium_url);
+                $personElement->addAttribute('img', get_avatar_url($person, 'medium'));
 
                 if ($person->information_url !== null) {
                     $personElement->addAttribute('href', $person->information_url);
@@ -388,7 +388,7 @@ if (! function_exists('get_rss_feed')) {
                         esc(lang("PersonsTaxonomy.persons.{$role->group}.label", [], 'en')),
                     );
 
-                    $personElement->addAttribute('img', $person->avatar->medium_url);
+                    $personElement->addAttribute('img', get_avatar_url($person, 'medium'));
 
                     if ($person->information_url !== null) {
                         $personElement->addAttribute('href', $person->information_url);
diff --git a/app/Helpers/seo_helper.php b/app/Helpers/seo_helper.php
index 6d535739c63db7f4f2b28bda3331146f0b9d8fde..3099965d622381eac01ae18ab45031770617e793 100644
--- a/app/Helpers/seo_helper.php
+++ b/app/Helpers/seo_helper.php
@@ -273,7 +273,7 @@ if (! function_exists('get_home_metatags')) {
         $metatags
             ->title(service('settings')->get('App.siteName'))
             ->description(esc(service('settings')->get('App.siteDescription')))
-            ->image(service('settings')->get('App.siteIcon')['512'])
+            ->image(get_site_icon_url('512'))
             ->canonical((string) current_url())
             ->og('site_name', esc(service('settings')->get('App.siteName')));
 
@@ -292,7 +292,7 @@ if (! function_exists('get_page_metatags')) {
                 )->get('App.siteName')
             )
             ->description(esc(service('settings')->get('App.siteDescription')))
-            ->image(service('settings')->get('App.siteIcon')['512'])
+            ->image(get_site_icon_url('512'))
             ->canonical((string) current_url())
             ->og('site_name', esc(service('settings')->get('App.siteName')));
 
diff --git a/app/Libraries/MediaClipper/VideoClipper.php b/app/Libraries/MediaClipper/VideoClipper.php
index 86ebb7587c4eb38e8803c6ad693e38b1a653452a..f3315fd2eff9ed28b1b0d8e951d383e54456ae49 100644
--- a/app/Libraries/MediaClipper/VideoClipper.php
+++ b/app/Libraries/MediaClipper/VideoClipper.php
@@ -134,7 +134,7 @@ class VideoClipper
         /** @var FileManagerInterface $fileManager */
         $fileManager = service('file_manager');
 
-        $jsonTranscriptString = $fileManager->getFileContents($jsonFileKey);
+        $jsonTranscriptString = (string) $fileManager->getFileContents($jsonFileKey);
         if ($jsonTranscriptString === '') {
             throw new Exception('Cannot get transcript json contents.');
         }
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index 0590b4340506d73e204bd478ea7e1bda6e4962f0..8fd51be4e12032d02fae9c8b940768b1f6a11a7d 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -469,8 +469,8 @@ class PodcastModel extends Model
                 $actor->summary = $podcast->description_html;
                 $actor->avatar_image_url = $podcast->cover->federation_url;
                 $actor->avatar_image_mimetype = $podcast->cover->federation_mimetype;
-                $actor->cover_image_url = $podcast->banner->federation_url;
-                $actor->cover_image_mimetype = $podcast->banner->federation_mimetype;
+                $actor->cover_image_url = get_podcast_banner_url($podcast, 'federation');
+                $actor->cover_image_mimetype = get_podcast_banner_mimetype($podcast, 'federation');
 
                 if ($actor->hasChanged()) {
                     $actorModel->update($actor->id, $actor);
diff --git a/modules/Admin/Controllers/SettingsController.php b/modules/Admin/Controllers/SettingsController.php
index d68e339467654ae6d3a9de446954c5fa519a4e40..e6215e17809f34637886a5b1bfa3b36c86b4f71b 100644
--- a/modules/Admin/Controllers/SettingsController.php
+++ b/modules/Admin/Controllers/SettingsController.php
@@ -17,10 +17,10 @@ use App\Models\EpisodeModel;
 use App\Models\PersonModel;
 use App\Models\PodcastModel;
 use App\Models\PostModel;
+use CodeIgniter\Files\File;
 use CodeIgniter\HTTP\RedirectResponse;
 use Modules\Media\Entities\Audio;
 use Modules\Media\FileManagers\FileManagerInterface;
-use Modules\Media\FileManagers\FS;
 use Modules\Media\Models\MediaModel;
 use PHP_ICO;
 
@@ -58,47 +58,60 @@ class SettingsController extends BaseController
 
         $siteIconFile = $this->request->getFile('site_icon');
         if ($siteIconFile !== null && $siteIconFile->isValid()) {
-            helper(['filesystem', 'media']);
+            /** @var FileManagerInterface $fileManager */
+            $fileManager = service('file_manager');
 
             // delete site folder in media before repopulating it
-            delete_files(media_path_absolute('/site'));
-
-            // save original in disk
-            $originalFilename = (new FS(config('Media')))->save(
-                $siteIconFile,
-                'site/icon.' . $siteIconFile->getExtension()
-            );
+            $fileManager->deleteAll('site');
 
             // convert jpeg image to png if not
             if ($siteIconFile->getClientMimeType() !== 'image/png') {
-                service('image')->withFile(media_path_absolute($originalFilename))
+                $tempFilePath = tempnam(WRITEPATH . 'temp', 'img_');
+                service('image')
+                    ->withFile($siteIconFile->getRealPath())
                     ->convert(IMAGETYPE_JPEG)
-                    ->save(media_path_absolute('/site/icon.png'));
+                    ->save($tempFilePath);
+
+                @unlink($siteIconFile->getRealPath());
+
+                $siteIconFile = new File($tempFilePath, true);
             }
 
-            // generate random hash to use as a suffix to renew browser cache
-            $randomHash = substr(bin2hex(random_bytes(18)), 0, 8);
+            $icoTempFilePath = WRITEPATH . 'temp/img_favicon.ico';
 
             // generate ico
             $ico_lib = new PHP_ICO();
-            $ico_lib->add_image(media_path_absolute('/site/icon.png'), [[32, 32], [64, 64]]);
-            $ico_lib->save_ico(media_path_absolute("/site/favicon.{$randomHash}.ico"));
+            $ico_lib->add_image($siteIconFile->getRealPath(), [[32, 32], [64, 64]]);
+            $ico_lib->save_ico($icoTempFilePath);
+
+            // generate random hash to use as a suffix to renew browser cache
+            $randomHash = substr(bin2hex(random_bytes(18)), 0, 8);
+
+            // save ico
+            $fileManager->save(new File($icoTempFilePath, true), "site/favicon.{$randomHash}.ico");
 
             // resize original to needed sizes
             foreach ([64, 180, 192, 512] as $size) {
+                $tempFilePath = tempnam(WRITEPATH . 'temp', 'img_');
                 service('image')
-                    ->withFile(media_path_absolute('/site/icon.png'))
+                    ->withFile($siteIconFile->getRealPath())
                     ->resize($size, $size)
-                    ->save(media_path_absolute("/site/icon-{$size}.{$randomHash}.png"));
+                    ->save($tempFilePath);
+
+                // save sizes to
+                $fileManager->save(new File($tempFilePath), "site/icon-{$size}.{$randomHash}.png");
             }
 
+            // save original as png
+            $fileManager->save($siteIconFile, 'site/icon.png');
+
             service('settings')
                 ->set('App.siteIcon', [
-                    'ico' => '/' . media_path("/site/favicon.{$randomHash}.ico"),
-                    '64' => '/' . media_path("/site/icon-64.{$randomHash}.png"),
-                    '180' => '/' . media_path("/site/icon-180.{$randomHash}.png"),
-                    '192' => '/' . media_path("/site/icon-192.{$randomHash}.png"),
-                    '512' => '/' . media_path("/site/icon-512.{$randomHash}.png"),
+                    'ico' => "site/favicon.{$randomHash}.ico",
+                    '64' => "site/icon-64.{$randomHash}.png",
+                    '180' => "site/icon-180.{$randomHash}.png",
+                    '192' => "site/icon-192.{$randomHash}.png",
+                    '512' => "site/icon-512.{$randomHash}.png",
                 ]);
         }
 
@@ -107,9 +120,11 @@ class SettingsController extends BaseController
 
     public function deleteIcon(): RedirectResponse
     {
-        helper(['filesystem', 'media']);
-        // delete site folder in media
-        delete_files(media_path_absolute('/site'));
+        /** @var FileManagerInterface $fileManager */
+        $fileManager = service('file_manager');
+
+        // delete site folder
+        $fileManager->deleteAll('site');
 
         service('settings')
             ->forget('App.siteIcon');
diff --git a/modules/Fediverse/Config/Fediverse.php b/modules/Fediverse/Config/Fediverse.php
index 382153a871a6f6c374c7bab48669cfea29aa79d8..f40fec3757bd145d9393a1fd18343c7f3e59321a 100644
--- a/modules/Fediverse/Config/Fediverse.php
+++ b/modules/Fediverse/Config/Fediverse.php
@@ -30,11 +30,11 @@ class Fediverse extends BaseConfig
      * Default avatar and cover images
      * --------------------------------------------------------------------
      */
-    public string $defaultAvatarImagePath = 'media/avatar-default.jpg';
+    public string $defaultAvatarImagePath = 'avatar-default.jpg';
 
     public string $defaultAvatarImageMimetype = 'image/jpeg';
 
-    public string $defaultCoverImagePath = 'media/banner-default.jpg';
+    public string $defaultCoverImagePath = 'banner-default.jpg';
 
     public string $defaultCoverImageMimetype = 'image/jpeg';
 
diff --git a/modules/Media/Config/Routes.php b/modules/Media/Config/Routes.php
new file mode 100644
index 0000000000000000000000000000000000000000..19766ba1c2385b2534efcbc623bd23051354612b
--- /dev/null
+++ b/modules/Media/Config/Routes.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright  2023 Ad Aures
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+$routes = service('routes');
+
+$routes->get('static/(:any)', 'MediaController::serve/$1', [
+    'as' => 'media-serve',
+    'namespace' => 'Modules\Media\Controllers',
+]);
diff --git a/modules/Media/Controllers/MediaController.php b/modules/Media/Controllers/MediaController.php
new file mode 100644
index 0000000000000000000000000000000000000000..65a8f5dbe647ec454e72931b62c5f1a69dbf3f62
--- /dev/null
+++ b/modules/Media/Controllers/MediaController.php
@@ -0,0 +1,26 @@
+<?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\Media\Controllers;
+
+use CodeIgniter\Controller;
+use CodeIgniter\HTTP\Response;
+use Modules\Media\FileManagers\FileManagerInterface;
+
+class MediaController extends Controller
+{
+    public function serve(string ...$key): Response
+    {
+        /** @var FileManagerInterface $fileManager */
+        $fileManager = service('file_manager');
+
+        return $fileManager->serve(implode('/', $key));
+    }
+}
diff --git a/modules/Media/Entities/BaseMedia.php b/modules/Media/Entities/BaseMedia.php
index d293ef6b0ee79eb04722a4db53338cb8fdcf0bc3..905a0c21f6b7f122456cd3c388be30d66d3d6a68 100644
--- a/modules/Media/Entities/BaseMedia.php
+++ b/modules/Media/Entities/BaseMedia.php
@@ -12,9 +12,6 @@ namespace Modules\Media\Entities;
 
 use CodeIgniter\Entity\Entity;
 use CodeIgniter\Files\File;
-use Modules\Media\FileManagers\FileManagerInterface;
-use Modules\Media\FileManagers\FS;
-use Modules\Media\FileManagers\S3;
 use Modules\Media\Models\MediaModel;
 
 /**
@@ -58,28 +55,13 @@ class BaseMedia extends Entity
         'updated_by' => 'integer',
     ];
 
-    protected FileManagerInterface $fileManager;
-
     /**
      * @param array<string, mixed>|null $data
-     * @param 'fs'|'s3'|null $fileManager
      */
-    public function __construct(array $data = null, string $fileManager = null)
+    public function __construct(array $data = null)
     {
         parent::__construct($data);
 
-        if ($fileManager !== null) {
-            $this->fileManager = match ($fileManager) {
-                'fs' => new FS(config('Media')),
-                's3' => new S3(config('Media'))
-            };
-        } else {
-            /** @var FileManagerInterface $fileManagerService */
-            $fileManagerService = service('file_manager');
-
-            $this->fileManager = $fileManagerService;
-        }
-
         $this->initFileProperties();
     }
 
@@ -92,7 +74,7 @@ class BaseMedia extends Entity
                 'extension' => $extension,
             ] = pathinfo($this->file_key);
 
-            $this->attributes['file_url'] = $this->fileManager->getUrl($this->file_key);
+            $this->attributes['file_url'] = service('file_manager')->getUrl($this->file_key);
             $this->attributes['file_name'] = $filename;
             $this->attributes['file_directory'] = $dirname;
             $this->attributes['file_extension'] = $extension;
@@ -120,14 +102,14 @@ class BaseMedia extends Entity
             return false;
         }
 
-        $this->attributes['file_key'] = $this->fileManager->save($this->attributes['file'], $this->file_key);
+        $this->attributes['file_key'] = service('file_manager')->save($this->attributes['file'], $this->file_key);
 
         return true;
     }
 
     public function deleteFile(): bool
     {
-        return $this->fileManager->delete($this->file_key);
+        return service('file_manager')->delete($this->file_key);
     }
 
     public function rename(): bool
@@ -143,7 +125,7 @@ class BaseMedia extends Entity
             return false;
         }
 
-        if (! $this->fileManager->rename($this->file_key, $newFileKey)) {
+        if (! service('file_manager')->rename($this->file_key, $newFileKey)) {
             $db->transRollback();
             return false;
         }
diff --git a/modules/Media/Entities/Image.php b/modules/Media/Entities/Image.php
index 9f45bacc7fc0f571349798a25ed9afe7cac9ebd2..123cc203edfb0167311da8736cfae91a0b0ae3ea 100644
--- a/modules/Media/Entities/Image.php
+++ b/modules/Media/Entities/Image.php
@@ -42,14 +42,12 @@ class Image extends BaseMedia
     {
         helper('filesystem');
 
-        $fileKeyWithoutExt = path_without_ext($this->file_key);
-
         foreach ($this->sizes as $name => $size) {
             $extension = array_key_exists('extension', $size) ? $size['extension'] : $this->file_extension;
             $mimetype = array_key_exists('mimetype', $size) ? $size['mimetype'] : $this->file_mimetype;
 
-            $this->{$name . '_key'} = $fileKeyWithoutExt . '_' . $name . '.' . $extension;
-            $this->{$name . '_url'} = $this->fileManager->getUrl($this->{$name . '_key'});
+            $this->{$name . '_key'} = change_file_path($this->file_key, '_' . $name, $extension);
+            $this->{$name . '_url'} = service('file_manager')->getUrl($this->{$name . '_key'});
             $this->{$name . '_mimetype'} = $mimetype;
         }
 
@@ -126,7 +124,8 @@ class Image extends BaseMedia
 
             // download image temporarily to generate sizes from
             $tempImagePath = (string) tempnam(WRITEPATH . 'temp', 'img_');
-            $imageContent = $this->fileManager->getFileContents($this->file_key);
+            $imageContent = (string) service('file_manager')
+                ->getFileContents($this->file_key);
             file_put_contents($tempImagePath, $imageContent);
 
             $this->attributes['file'] = new File($tempImagePath, true);
@@ -144,7 +143,7 @@ class Image extends BaseMedia
 
             $newImage = new File($tempFilePath, true);
 
-            $this->fileManager
+            service('file_manager')
                 ->save($newImage, $this->{$name . '_key'});
         }
 
@@ -159,7 +158,7 @@ class Image extends BaseMedia
         foreach (array_keys($this->sizes) as $name) {
             $pathProperty = $name . '_key';
 
-            if (! $this->fileManager->delete($this->{$pathProperty})) {
+            if (! service('file_manager')->delete($this->{$pathProperty})) {
                 return false;
             }
         }
diff --git a/modules/Media/Entities/Transcript.php b/modules/Media/Entities/Transcript.php
index 4ad7cdcb7a83da1bb7bbcf30dc45ce6a4ea5b595..a969cff1f22c9693f6ac849384dae68b201da881 100644
--- a/modules/Media/Entities/Transcript.php
+++ b/modules/Media/Entities/Transcript.php
@@ -29,7 +29,7 @@ class Transcript extends BaseMedia
             helper('media');
 
             $this->json_key = $this->file_metadata['json_key'];
-            $this->json_url = $this->fileManager
+            $this->json_url = service('file_manager')
                 ->getUrl($this->json_key);
         }
     }
@@ -42,12 +42,8 @@ class Transcript extends BaseMedia
 
         helper('filesystem');
 
-        $fileKeyWithoutExt = path_without_ext($this->file_key);
-
-        $jsonfileKey = $fileKeyWithoutExt . '.json';
-
         // set metadata (generated json file path)
-        $this->json_key = $jsonfileKey;
+        $this->json_key = change_file_path($this->file_key, '', 'json');
         $metadata['json_key'] = $this->json_key;
 
         $this->attributes['file_metadata'] = json_encode($metadata, JSON_INVALID_UTF8_IGNORE);
@@ -71,7 +67,7 @@ class Transcript extends BaseMedia
         }
 
         if ($this->json_key) {
-            return $this->fileManager->delete($this->json_key);
+            return service('file_manager')->delete($this->json_key);
         }
 
         return true;
@@ -96,7 +92,7 @@ class Transcript extends BaseMedia
 
         $newTranscriptJson = new File($tempFilePath, true);
 
-        $this->fileManager
+        service('file_manager')
             ->save($newTranscriptJson, $this->json_key);
 
         return true;
diff --git a/modules/Media/FileManagers/FS.php b/modules/Media/FileManagers/FS.php
index 141f690d60fb619097cddeb2d5ad94ec40c2f8ad..63c1f707321f94fbef6c8b339bd1afd090459487 100644
--- a/modules/Media/FileManagers/FS.php
+++ b/modules/Media/FileManagers/FS.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Modules\Media\FileManagers;
 
 use CodeIgniter\Files\File;
+use CodeIgniter\HTTP\Response;
 use Exception;
 use Modules\Media\Config\Media as MediaConfig;
 
@@ -27,7 +28,7 @@ class FS implements FileManagerInterface
             $path = $path . '.' . $extension;
         }
 
-        $mediaRoot = media_path_absolute();
+        $mediaRoot = $this->media_path_absolute();
 
         if (! file_exists(dirname($mediaRoot . '/' . $path))) {
             mkdir(dirname($mediaRoot . '/' . $path), 0777, true);
@@ -51,7 +52,7 @@ class FS implements FileManagerInterface
     {
         helper('media');
 
-        return @unlink(media_path_absolute($key));
+        return @unlink($this->media_path_absolute($key));
     }
 
     public function getUrl(string $key): string
@@ -70,21 +71,21 @@ class FS implements FileManagerInterface
     {
         helper('media');
 
-        return rename(media_path_absolute($oldKey), media_path_absolute($newKey));
+        return rename($this->media_path_absolute($oldKey), $this->media_path_absolute($newKey));
     }
 
-    public function getFileContents(string $key): string
+    public function getFileContents(string $key): string|false
     {
         helper('media');
 
-        return (string) file_get_contents(media_path_absolute($key));
+        return file_get_contents($this->media_path_absolute($key));
     }
 
     public function getFileInput(string $key): string
     {
         helper('media');
 
-        return media_path_absolute($key);
+        return $this->media_path_absolute($key);
     }
 
     public function deletePodcastImageSizes(string $podcastHandle): bool
@@ -113,12 +114,12 @@ class FS implements FileManagerInterface
         if ($pattern === '*') {
             helper('filesystem');
 
-            return delete_files(media_path_absolute($prefix), true);
+            return delete_files($this->media_path_absolute($prefix), true);
         }
 
         $prefix = rtrim($prefix, '/') . '/';
 
-        $imagePaths = glob(media_path_absolute($prefix . $pattern));
+        $imagePaths = glob($this->media_path_absolute($prefix . $pattern));
 
         if (! $imagePaths) {
             return true;
@@ -135,6 +136,28 @@ class FS implements FileManagerInterface
     {
         helper('media');
 
-        return is_really_writable(media_path_absolute());
+        return is_really_writable($this->media_path_absolute());
+    }
+
+    public function serve(string $key): Response
+    {
+        return redirect()->to($this->getUrl($key));
+    }
+
+    /**
+     * Prefixes the absolute storage directory to the media path of a given uri
+     *
+     * @param  string|string[] $uri URI string or array of URI segments
+     */
+    private function media_path_absolute(string | array $uri = ''): string
+    {
+        // convert segment array to string
+        if (is_array($uri)) {
+            $uri = implode('/', $uri);
+        }
+
+        $uri = trim($uri, '/');
+
+        return config('Media')->storage . '/' . config('Media')->root . '/' . $uri;
     }
 }
diff --git a/modules/Media/FileManagers/FileManagerInterface.php b/modules/Media/FileManagers/FileManagerInterface.php
index 3d1d2ac499d463b229129c2282c4e64fe2469f18..5b2dd07881513fb3a60fade041941325d133b1ae 100644
--- a/modules/Media/FileManagers/FileManagerInterface.php
+++ b/modules/Media/FileManagers/FileManagerInterface.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Modules\Media\FileManagers;
 
 use CodeIgniter\Files\File;
+use CodeIgniter\HTTP\Response;
 
 interface FileManagerInterface
 {
@@ -16,7 +17,7 @@ interface FileManagerInterface
 
     public function rename(string $oldKey, string $newKey): bool;
 
-    public function getFileContents(string $key): string;
+    public function getFileContents(string $key): string|false;
 
     public function getFileInput(string $key): string;
 
@@ -27,4 +28,6 @@ interface FileManagerInterface
     public function deleteAll(string $prefix, string $pattern = '*'): bool;
 
     public function isHealthy(): bool;
+
+    public function serve(string $key): Response;
 }
diff --git a/modules/Media/FileManagers/S3.php b/modules/Media/FileManagers/S3.php
index b5856faf7ab2965846636f260412a4d274e698b3..fe5b5e3d47e296566087ee0ef8727762f4a3fce7 100644
--- a/modules/Media/FileManagers/S3.php
+++ b/modules/Media/FileManagers/S3.php
@@ -6,8 +6,9 @@ namespace Modules\Media\FileManagers;
 
 use Aws\Credentials\Credentials;
 use Aws\S3\S3Client;
+use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\Files\File;
-use CodeIgniter\HTTP\URI;
+use CodeIgniter\HTTP\Response;
 use Exception;
 use Modules\Media\Config\Media as MediaConfig;
 
@@ -35,6 +36,7 @@ class S3 implements FileManagerInterface
                 'Bucket' => $this->config->s3['bucket'],
                 'Key' => $this->prefixKey($key),
                 'SourceFile' => $file,
+                'ContentType' => $file->getMimeType(),
             ]);
         } catch (Exception) {
             return false;
@@ -62,16 +64,7 @@ class S3 implements FileManagerInterface
 
     public function getUrl(string $key): string
     {
-        $url = new URI((string) $this->config->s3['endpoint']);
-
-        if ($this->config->s3['pathStyleEndpoint'] === true) {
-            $url->setPath($this->config->s3['bucket'] . '/' . $this->prefixKey($key));
-            return (string) $url;
-        }
-
-        $url->setHost($this->config->s3['bucket'] . '.' . $url->getHost());
-        $url->setPath($this->prefixKey($key));
-        return (string) $url;
+        return url_to('media-serve', $key);
     }
 
     public function rename(string $oldKey, string $newKey): bool
@@ -91,12 +84,16 @@ class S3 implements FileManagerInterface
         return $this->delete($oldKey);
     }
 
-    public function getFileContents(string $key): string
+    public function getFileContents(string $key): string|false
     {
-        $result = $this->s3->getObject([
-            'Bucket' => $this->config->s3['bucket'],
-            'Key' => $this->prefixKey($key),
-        ]);
+        try {
+            $result = $this->s3->getObject([
+                'Bucket' => $this->config->s3['bucket'],
+                'Key' => $this->prefixKey($key),
+            ]);
+        } catch (Exception) {
+            return false;
+        }
 
         return (string) $result->get('Body');
     }
@@ -186,6 +183,31 @@ class S3 implements FileManagerInterface
         return true;
     }
 
+    public function serve(string $key): Response
+    {
+        $response = service('response');
+
+        try {
+            $result = $this->s3->getObject([
+                'Bucket' => $this->config->s3['bucket'],
+                'Key' => $this->prefixKey($key),
+            ]);
+        } catch (Exception) {
+            throw new PageNotFoundException();
+        }
+
+        // Remove Cache-Control header before redefining it
+        header_remove('Cache-Control');
+
+        return $response->setCache([
+            'max-age' => DECADE,
+            'last-modified' => $result->get('LastModified'),
+            'etag' => $result->get('ETag'),
+            'public' => true,
+        ])->setContentType($result->get('ContentType'))
+            ->setBody((string) $result->get('Body')->getContents());
+    }
+
     private function prefixKey(string $key): string
     {
         if ($this->config->s3['keyPrefix'] === '') {
diff --git a/modules/Media/Helpers/filesystem_helper.php b/modules/Media/Helpers/filesystem_helper.php
index 0cff140c7bdd58c02ca7395d2bf9a4aea5dd9a9b..9a6d00c8a58073e3a3e579279f41f53bc7857fe7 100644
--- a/modules/Media/Helpers/filesystem_helper.php
+++ b/modules/Media/Helpers/filesystem_helper.php
@@ -8,20 +8,13 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
-
-if (! function_exists('path_without_ext')) {
-    function path_without_ext(string $path): string
+if (! function_exists('add_suffix_to_path')) {
+    function change_file_path(string $path, string $suffix = '', ?string $newExtension = null): string
     {
-        $fileKeyInfo = pathinfo($path);
-
-        if ($fileKeyInfo['dirname'] === '.' && ! str_starts_with($path, '.')) {
-            return $fileKeyInfo['filename'];
-        }
-
-        if ($fileKeyInfo['dirname'] === '/') {
-            return '/' . $fileKeyInfo['filename'];
+        if ($newExtension === null) {
+            $newExtension = pathinfo($path, PATHINFO_EXTENSION);
         }
 
-        return implode('/', [$fileKeyInfo['dirname'], $fileKeyInfo['filename']]);
+        return preg_replace('~\.[^.]+$~', '', $path) . $suffix . '.' . $newExtension;
     }
 }
diff --git a/modules/Media/Helpers/media_helper.php b/modules/Media/Helpers/media_helper.php
index fab6123dba4bd72b5af01443bb93fc84c0b4e237..68eb3aa999938ce901d920f1d4776835bd4924b8 100644
--- a/modules/Media/Helpers/media_helper.php
+++ b/modules/Media/Helpers/media_helper.php
@@ -63,34 +63,3 @@ if (! function_exists('download_file')) {
         return new File($tmpfileKey);
     }
 }
-
-if (! function_exists('media_path')) {
-    /**
-     * Prefixes the root media path to a given uri
-     *
-     * @param  string|string[] $uri URI string or array of URI segments
-     */
-    function media_path(string | array $uri = ''): string
-    {
-        // convert segment array to string
-        if (is_array($uri)) {
-            $uri = implode('/', $uri);
-        }
-
-        $uri = trim($uri, '/');
-
-        return config('Media')->root . '/' . $uri;
-    }
-}
-
-if (! function_exists('media_path_absolute')) {
-    /**
-     * Prefixes the absolute storage directory to the media path of a given uri
-     *
-     * @param  string|string[] $uri URI string or array of URI segments
-     */
-    function media_path_absolute(string | array $uri = ''): string
-    {
-        return config('Media')->storage . '/' . media_path($uri);
-    }
-}
diff --git a/public/media/castopod-avatar.jpg b/public/castopod-avatar.jpg
similarity index 100%
rename from public/media/castopod-avatar.jpg
rename to public/castopod-avatar.jpg
diff --git a/public/media/castopod-avatar_medium.webp b/public/castopod-avatar_medium.webp
similarity index 100%
rename from public/media/castopod-avatar_medium.webp
rename to public/castopod-avatar_medium.webp
diff --git a/public/media/castopod-avatar_thumbnail.webp b/public/castopod-avatar_thumbnail.webp
similarity index 100%
rename from public/media/castopod-avatar_thumbnail.webp
rename to public/castopod-avatar_thumbnail.webp
diff --git a/public/media/castopod-avatar_tiny.webp b/public/castopod-avatar_tiny.webp
similarity index 100%
rename from public/media/castopod-avatar_tiny.webp
rename to public/castopod-avatar_tiny.webp
diff --git a/public/media/castopod-banner-amber.jpg b/public/castopod-banner-amber.jpg
similarity index 100%
rename from public/media/castopod-banner-amber.jpg
rename to public/castopod-banner-amber.jpg
diff --git a/public/media/castopod-banner-amber_federation.jpg b/public/castopod-banner-amber_federation.jpg
similarity index 100%
rename from public/media/castopod-banner-amber_federation.jpg
rename to public/castopod-banner-amber_federation.jpg
diff --git a/public/media/castopod-banner-amber_medium.webp b/public/castopod-banner-amber_medium.webp
similarity index 100%
rename from public/media/castopod-banner-amber_medium.webp
rename to public/castopod-banner-amber_medium.webp
diff --git a/public/media/castopod-banner-amber_small.webp b/public/castopod-banner-amber_small.webp
similarity index 100%
rename from public/media/castopod-banner-amber_small.webp
rename to public/castopod-banner-amber_small.webp
diff --git a/public/media/castopod-banner-crimson.jpg b/public/castopod-banner-crimson.jpg
similarity index 100%
rename from public/media/castopod-banner-crimson.jpg
rename to public/castopod-banner-crimson.jpg
diff --git a/public/media/castopod-banner-crimson_federation.jpg b/public/castopod-banner-crimson_federation.jpg
similarity index 100%
rename from public/media/castopod-banner-crimson_federation.jpg
rename to public/castopod-banner-crimson_federation.jpg
diff --git a/public/media/castopod-banner-crimson_medium.webp b/public/castopod-banner-crimson_medium.webp
similarity index 100%
rename from public/media/castopod-banner-crimson_medium.webp
rename to public/castopod-banner-crimson_medium.webp
diff --git a/public/media/castopod-banner-crimson_small.webp b/public/castopod-banner-crimson_small.webp
similarity index 100%
rename from public/media/castopod-banner-crimson_small.webp
rename to public/castopod-banner-crimson_small.webp
diff --git a/public/media/castopod-banner-jacaranda.jpg b/public/castopod-banner-jacaranda.jpg
similarity index 100%
rename from public/media/castopod-banner-jacaranda.jpg
rename to public/castopod-banner-jacaranda.jpg
diff --git a/public/media/castopod-banner-jacaranda_federation.jpg b/public/castopod-banner-jacaranda_federation.jpg
similarity index 100%
rename from public/media/castopod-banner-jacaranda_federation.jpg
rename to public/castopod-banner-jacaranda_federation.jpg
diff --git a/public/media/castopod-banner-jacaranda_medium.webp b/public/castopod-banner-jacaranda_medium.webp
similarity index 100%
rename from public/media/castopod-banner-jacaranda_medium.webp
rename to public/castopod-banner-jacaranda_medium.webp
diff --git a/public/media/castopod-banner-jacaranda_small.webp b/public/castopod-banner-jacaranda_small.webp
similarity index 100%
rename from public/media/castopod-banner-jacaranda_small.webp
rename to public/castopod-banner-jacaranda_small.webp
diff --git a/public/media/castopod-banner-lake.jpg b/public/castopod-banner-lake.jpg
similarity index 100%
rename from public/media/castopod-banner-lake.jpg
rename to public/castopod-banner-lake.jpg
diff --git a/public/media/castopod-banner-lake_federation.jpg b/public/castopod-banner-lake_federation.jpg
similarity index 100%
rename from public/media/castopod-banner-lake_federation.jpg
rename to public/castopod-banner-lake_federation.jpg
diff --git a/public/media/castopod-banner-lake_medium.webp b/public/castopod-banner-lake_medium.webp
similarity index 100%
rename from public/media/castopod-banner-lake_medium.webp
rename to public/castopod-banner-lake_medium.webp
diff --git a/public/media/castopod-banner-lake_small.webp b/public/castopod-banner-lake_small.webp
similarity index 100%
rename from public/media/castopod-banner-lake_small.webp
rename to public/castopod-banner-lake_small.webp
diff --git a/public/media/castopod-banner-onyx.jpg b/public/castopod-banner-onyx.jpg
similarity index 100%
rename from public/media/castopod-banner-onyx.jpg
rename to public/castopod-banner-onyx.jpg
diff --git a/public/media/castopod-banner-onyx_federation.jpg b/public/castopod-banner-onyx_federation.jpg
similarity index 100%
rename from public/media/castopod-banner-onyx_federation.jpg
rename to public/castopod-banner-onyx_federation.jpg
diff --git a/public/media/castopod-banner-onyx_medium.webp b/public/castopod-banner-onyx_medium.webp
similarity index 100%
rename from public/media/castopod-banner-onyx_medium.webp
rename to public/castopod-banner-onyx_medium.webp
diff --git a/public/media/castopod-banner-onyx_small.webp b/public/castopod-banner-onyx_small.webp
similarity index 100%
rename from public/media/castopod-banner-onyx_small.webp
rename to public/castopod-banner-onyx_small.webp
diff --git a/public/media/castopod-banner-pine.jpg b/public/castopod-banner-pine.jpg
similarity index 100%
rename from public/media/castopod-banner-pine.jpg
rename to public/castopod-banner-pine.jpg
diff --git a/public/media/castopod-banner-pine_federation.jpg b/public/castopod-banner-pine_federation.jpg
similarity index 100%
rename from public/media/castopod-banner-pine_federation.jpg
rename to public/castopod-banner-pine_federation.jpg
diff --git a/public/media/castopod-banner-pine_medium.webp b/public/castopod-banner-pine_medium.webp
similarity index 100%
rename from public/media/castopod-banner-pine_medium.webp
rename to public/castopod-banner-pine_medium.webp
diff --git a/public/media/castopod-banner-pine_small.webp b/public/castopod-banner-pine_small.webp
similarity index 100%
rename from public/media/castopod-banner-pine_small.webp
rename to public/castopod-banner-pine_small.webp
diff --git a/public/media/persons/index.html b/public/media/persons/index.html
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..eebf8ecb2b2bdf794e1a23e04bc129e3aaacaeb4 100644
--- a/public/media/persons/index.html
+++ b/public/media/persons/index.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>403 Forbidden</title>
+  </head>
+  <body>
+    <p>Directory access is forbidden.</p>
+  </body>
+</html>
diff --git a/public/media/podcasts/index.html b/public/media/podcasts/index.html
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..eebf8ecb2b2bdf794e1a23e04bc129e3aaacaeb4 100644
--- a/public/media/podcasts/index.html
+++ b/public/media/podcasts/index.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>403 Forbidden</title>
+  </head>
+  <body>
+    <p>Directory access is forbidden.</p>
+  </body>
+</html>
diff --git a/public/media/site/index.html b/public/media/site/index.html
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..eebf8ecb2b2bdf794e1a23e04bc129e3aaacaeb4 100644
--- a/public/media/site/index.html
+++ b/public/media/site/index.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>403 Forbidden</title>
+  </head>
+  <body>
+    <p>Directory access is forbidden.</p>
+  </body>
+</html>
diff --git a/themes/cp_admin/_layout.php b/themes/cp_admin/_layout.php
index d23a48a463dcb6daa7f9bfbcfad0ed2d04f00e00..2cca6f23fb86c2a0f3554c7244220bce734f09b6 100644
--- a/themes/cp_admin/_layout.php
+++ b/themes/cp_admin/_layout.php
@@ -15,9 +15,8 @@ $isEpisodeArea = isset($podcast) && isset($episode);
     <title><?= $this->renderSection('title') ?> | Castopod Admin</title>
     <meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience."/>
     <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="icon" type="image/x-icon" href="<?= get_site_icon_url('ico') ?>" />
+    <link rel="apple-touch-icon" href="<?= get_site_icon_url('180') ?>">
     <link rel="manifest" href="<?= route_to('webmanifest') ?>">
 
     <link rel='stylesheet' type='text/css' href='<?= route_to('themes-colors-css') ?>' />
diff --git a/themes/cp_admin/episode/persons.php b/themes/cp_admin/episode/persons.php
index 05e0a619cabb71f3cafde59a8ffbc5e0dc266129..d5deb5ba1183756e4c4dc1be910a2532155416b5 100644
--- a/themes/cp_admin/episode/persons.php
+++ b/themes/cp_admin/episode/persons.php
@@ -58,7 +58,7 @@
                 return '<div class="flex">' .
                     '<a href="' .
                     route_to('person-view', $person->id) .
-                    '"><img src="' . $person->avatar->thumbnail_url . '" alt="' . esc($person->full_name) . '" class="object-cover w-16 rounded-full aspect-square" loading="lazy" /></a>' .
+                    '"><img src="' . get_avatar_url($person, 'thumbnail') . '" alt="' . esc($person->full_name) . '" class="object-cover w-16 rounded-full aspect-square" loading="lazy" /></a>' .
                     '<div class="flex flex-col ml-3">' .
                     esc($person->full_name) .
                     implode(
diff --git a/themes/cp_admin/person/_card.php b/themes/cp_admin/person/_card.php
index 20408bb517906addfdf4fc9455ba94de1a6a9b3d..afd7e23f79c296f4d2517602eb15da73078c1fa1 100644
--- a/themes/cp_admin/person/_card.php
+++ b/themes/cp_admin/person/_card.php
@@ -2,7 +2,7 @@
     <a href="<?= route_to('person-view', $person->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 alt="<?= esc($person->full_name) ?>" src="<?= $person->avatar->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" />
+            <img alt="<?= esc($person->full_name) ?>" src="<?= get_avatar_url($person, 'medium') ?>" 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>
         <div class="absolute z-20">
             <h2 class="px-4 py-2 font-semibold leading-tight"><?= esc($person->full_name) ?></h2>
diff --git a/themes/cp_admin/person/view.php b/themes/cp_admin/person/view.php
index 4956b3cca3c24260d8a970acb7380e604278f7eb..e07efd7719b6b1ace2da418cd8e6e523e12ea31c 100644
--- a/themes/cp_admin/person/view.php
+++ b/themes/cp_admin/person/view.php
@@ -17,7 +17,7 @@
 
 <div class="flex flex-wrap gap-2">
     <img
-        src="<?= $person->avatar->medium_url ?>"
+        src="<?= get_avatar_url($person, 'medium') ?>"
         alt="<?= esc($person->full_name) ?>"
         class="object-cover w-full max-w-xs rounded aspect-square"
         loading="lazy"
diff --git a/themes/cp_admin/podcast/edit.php b/themes/cp_admin/podcast/edit.php
index 794e446c507c0e6c6c8c369a1ca2d47b878704ea..3b05c882366d2377e898fe01ff4ed4d329da9ee6 100644
--- a/themes/cp_admin/podcast/edit.php
+++ b/themes/cp_admin/podcast/edit.php
@@ -25,7 +25,7 @@
     <?php if ($podcast->banner_id !== null): ?>
         <a href="<?= route_to('podcast-banner-delete', $podcast->id) ?>" class="absolute p-1 text-red-700 bg-red-100 border-2 rounded-full hover:text-red-900 border-contrast focus:ring-accent top-2 right-2" title="<?= lang('Podcast.form.banner_delete') ?>" data-tooltip="bottom"><?= icon('delete-bin') ?></a>
     <?php endif; ?>
-    <img src="<?= $podcast->banner->small_url ?>" alt="" class="w-full aspect-[3/1] bg-header" loading="lazy" />
+    <img src="<?= get_podcast_banner_url($podcast, 'small') ?>" alt="" class="w-full aspect-[3/1] bg-header" loading="lazy" />
     <div class="flex px-4 py-2">
         <img src="<?= $podcast->cover->thumbnail_url ?>" alt="<?= esc($podcast->title) ?>"
             class="w-16 h-16 mr-4 -mt-8 rounded-full ring-2 ring-background-elevated aspect-square" loading="lazy" />
diff --git a/themes/cp_admin/podcast/persons.php b/themes/cp_admin/podcast/persons.php
index 7b127d630bed9e3896765cab1c62eb4bad535231..c19f061efd07522da86368ff8a328454db112fc5 100644
--- a/themes/cp_admin/podcast/persons.php
+++ b/themes/cp_admin/podcast/persons.php
@@ -55,7 +55,7 @@
                 return '<div class="flex">' .
                     '<a href="' .
                     route_to('person-view', $person->id) .
-                    '"><img src="' . $person->avatar->thumbnail_url . '" alt="' . esc($person->full_name) . '" class="object-cover w-16 h-16 rounded-full aspect-square" loading="lazy" /></a>' .
+                    '"><img src="' . get_avatar_url($person, 'thumbnail') . '" alt="' . esc($person->full_name) . '" class="object-cover w-16 h-16 rounded-full aspect-square" loading="lazy" /></a>' .
                     '<div class="flex flex-col ml-3">' .
                     esc($person->full_name) .
                     implode(
diff --git a/themes/cp_admin/settings/general.php b/themes/cp_admin/settings/general.php
index dd2ce565f577f9053b4fc5518927b683db58a914..182d59a74e2f7cf953320d9c05efa64f0e34b021 100644
--- a/themes/cp_admin/settings/general.php
+++ b/themes/cp_admin/settings/general.php
@@ -46,7 +46,7 @@
         <?php if (config('App')->siteIcon['ico'] !== service('settings')->get('App.siteIcon')['ico']): ?>
         <div class="relative ml-2">
             <a href="<?= route_to('settings-instance-delete-icon') ?>" class="absolute p-1 text-red-700 bg-red-100 border-2 rounded-full hover:text-red-900 border-contrast -top-3 -right-3 focus:ring-accent" title="<?= lang('Settings.instance.site_icon_delete') ?>" data-tooltip="top"><?= icon('delete-bin') ?></a>
-            <img src="<?= service('settings')->get('App.siteIcon')['64'] ?>" alt="<?= esc(service('settings')->get('App.siteName')) ?> Favicon" class="w-10 h-10 aspect-square" loading="lazy" />
+            <img src="<?= get_site_icon_url('64') ?>" alt="<?= esc(service('settings')->get('App.siteName')) ?> Favicon" class="w-10 h-10 aspect-square" loading="lazy" />
         </div>
         <?php endif; ?>
     </div>
@@ -62,7 +62,7 @@
 
 <Forms.Section
     title="<?= lang('Settings.images.title') ?>"
-    subtitle="<?= lang('Settings.images.subtitle') ?>" >
+    subtitle="<?= lang('Settings.images.subtitle') ?>">
 
     <Button variant="primary" type="submit" iconLeft="refresh"><?= lang('Settings.images.regenerate') ?></Button>
 
diff --git a/themes/cp_app/_persons_modal.php b/themes/cp_app/_persons_modal.php
index 237fefeeee0d6bcedbaa46054a1adac3f0024741..847f46d2b1b47d12fd2fc688afff925eebb18970 100644
--- a/themes/cp_app/_persons_modal.php
+++ b/themes/cp_app/_persons_modal.php
@@ -17,7 +17,7 @@
         <div class="flex flex-col items-start p-4 gap-y-4">
             <?php foreach ($persons as $person): ?>
                 <div class="flex gap-x-2">
-                    <img src="<?= $person->avatar->thumbnail_url ?>" alt="<?= esc($person->full_name) ?>" class="object-cover w-10 rounded-full bg-header aspect-square" loading="lazy" />
+                    <img src="<?= get_avatar_url($person, 'thumbnail') ?>" alt="<?= esc($person->full_name) ?>" class="object-cover w-10 rounded-full bg-header aspect-square" loading="lazy" />
                     <div class="flex flex-col">
                         <h4 class="text-sm font-semibold">
                             <?php if ($person->information_url): ?>
diff --git a/themes/cp_app/embed.php b/themes/cp_app/embed.php
index afc1c05de3a7ce85666e6c58f7746df9576a8a8e..f18322d6f8f411356c2ed6bb4a853bd0b269b1bf 100644
--- a/themes/cp_app/embed.php
+++ b/themes/cp_app/embed.php
@@ -9,9 +9,8 @@
     <meta name="description" content="<?= esc(
         $episode->description,
     ) ?>" />
-    <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="icon" type="image/x-icon" href="<?= get_site_icon_url('ico') ?>" />
+    <link rel="apple-touch-icon" href="<?= get_site_icon_url('180') ?>">
     <link rel='stylesheet' type='text/css' href='<?= route_to('themes-colors-css') ?>' />
     <?= service('vite')
         ->asset('styles/index.css', 'css') ?>
diff --git a/themes/cp_app/episode/_layout.php b/themes/cp_app/episode/_layout.php
index 8bf65fd319fe412811e2098eb692a1a09a43b79c..730ec143dabf6882eab7e0463c8a0bf80d0aa9c6 100644
--- a/themes/cp_app/episode/_layout.php
+++ b/themes/cp_app/episode/_layout.php
@@ -7,9 +7,8 @@
 <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="icon" type="image/x-icon" href="<?= get_site_icon_url('ico') ?>" />
+    <link rel="apple-touch-icon" href="<?= get_site_icon_url('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>
@@ -78,7 +77,7 @@
         </div>
     </nav>
     <header class="relative z-50 flex flex-col col-start-2 px-8 pt-8 pb-4 overflow-hidden bg-accent-base/75 gap-y-4">
-        <div class="absolute top-0 left-0 w-full h-full bg-center bg-no-repeat bg-cover blur-lg mix-blend-overlay filter grayscale" style="background-image: url('<?= $episode->podcast->banner->small_url ?>');"></div>
+        <div class="absolute top-0 left-0 w-full h-full bg-center bg-no-repeat bg-cover blur-lg mix-blend-overlay filter grayscale" style="background-image: url('<?= get_podcast_banner_url($episode->podcast, 'small') ?>');"></div>
         <div class="absolute top-0 left-0 w-full h-full bg-gradient-to-t from-background-header to-transparent"></div>
         <div class="z-10 flex flex-col items-start gap-y-2 gap-x-4 sm:flex-row">
             <div class="relative flex-shrink-0">
@@ -97,7 +96,7 @@
                         <span class="inline-flex flex-row-reverse">
                             <?php $i = 0; ?>
                             <?php foreach ($episode->persons as $person): ?>
-                                <img src="<?= $person->avatar->thumbnail_url ?>" alt="<?= esc($person->full_name) ?>" class="object-cover w-8 h-8 -ml-4 border-2 rounded-full aspect-square border-background-header last:ml-0" loading="lazy" />
+                                <img src="<?= get_avatar_url($person, 'thumbnail') ?>" alt="<?= esc($person->full_name) ?>" class="object-cover w-8 h-8 -ml-4 border-2 rounded-full aspect-square border-background-header last:ml-0" loading="lazy" />
                                 <?php $i++;
                                 if ($i === 3) {
                                     break;
diff --git a/themes/cp_app/home.php b/themes/cp_app/home.php
index aa6eedda52cd6ef09724e76744424dbfb664cc60..73dc8cff4a7b088955bbe89c8959f9ab96995bb5 100644
--- a/themes/cp_app/home.php
+++ b/themes/cp_app/home.php
@@ -6,9 +6,8 @@
 <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="icon" type="image/x-icon" href="<?= get_site_icon_url('ico') ?>" />
+    <link rel="apple-touch-icon" href="<?= get_site_icon_url('180') ?>">
     <link rel="manifest" href="<?= route_to('webmanifest') ?>">
     <meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>">
     <script>
diff --git a/themes/cp_app/pages/_layout.php b/themes/cp_app/pages/_layout.php
index 466d9a94073af708d73a7a7e2feb479cbf0aa6f4..a87db84ffe217b985fefc5771901aa7b08b82c41 100644
--- a/themes/cp_app/pages/_layout.php
+++ b/themes/cp_app/pages/_layout.php
@@ -6,9 +6,8 @@
 <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="icon" type="image/x-icon" href="<?= get_site_icon_url('ico') ?>" />
+    <link rel="apple-touch-icon" href="<?= get_site_icon_url('180') ?>">
     <link rel="manifest" href="<?= route_to('webmanifest') ?>">
     <meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>">
     <script>
diff --git a/themes/cp_app/pages/map.php b/themes/cp_app/pages/map.php
index a0629f7bd85c6345a58f4583bb44b947361dc3ea..a7b5bbddd09a7bafc86412e960259c2c7f07bbb8 100644
--- a/themes/cp_app/pages/map.php
+++ b/themes/cp_app/pages/map.php
@@ -11,9 +11,8 @@
             ->get('App.siteName')),
     ]) ?>"/>
     <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="icon" type="image/x-icon" href="<?= get_site_icon_url('ico') ?>" />
+    <link rel="apple-touch-icon" href="<?= get_site_icon_url('180') ?>">
     <link rel="manifest" href="<?= route_to('webmanifest') ?>">
     <meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>">
     <script>
diff --git a/themes/cp_app/podcast/_layout.php b/themes/cp_app/podcast/_layout.php
index 372002ab8e76b1ea6630253d883ddd456b91ebb6..c420f5cf2c0c8d51ce047404e4c6225cef4e372d 100644
--- a/themes/cp_app/podcast/_layout.php
+++ b/themes/cp_app/podcast/_layout.php
@@ -7,9 +7,8 @@
 <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="icon" type="image/x-icon" href="<?= get_site_icon_url('ico') ?>" />
+    <link rel="apple-touch-icon" href="<?= get_site_icon_url('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>
@@ -43,7 +42,7 @@
         </div>
     <?php endif; ?>
 
-    <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 ?>');">
+    <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('<?= get_podcast_banner_url($podcast, 'medium') ?>');">
         <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" />
diff --git a/themes/cp_app/podcast/about.php b/themes/cp_app/podcast/about.php
index 7909f3ba7e0ac1d153c19ced8604498a8d78d276..b42507fb7dedc9cf688c6cf78ef9f2679e8f691f 100644
--- a/themes/cp_app/podcast/about.php
+++ b/themes/cp_app/podcast/about.php
@@ -21,7 +21,7 @@
                 <span class="inline-flex flex-row-reverse">
                     <?php $i = 0; ?>
                     <?php foreach ($podcast->persons as $person): ?>
-                        <img src="<?= $person->avatar->thumbnail_url ?>" alt="<?= esc($person->full_name) ?>" class="object-cover w-8 -ml-4 border-2 rounded-full aspect-square bg-header border-background-base last:ml-0" loading="lazy" />
+                        <img src="<?= get_avatar_url($person, 'thumbnail') ?>" alt="<?= esc($person->full_name) ?>" class="object-cover w-8 -ml-4 border-2 rounded-full aspect-square bg-header border-background-base last:ml-0" loading="lazy" />
                         <?php $i++;
                         if ($i === 3) {
                             break;
diff --git a/themes/cp_app/podcast/follow.php b/themes/cp_app/podcast/follow.php
index 142f20f2968df73c138f7c1b2f1f4e87a1f32f1c..7438a1e2e2804e4a6ea23a0471e38227c7d5741a 100644
--- a/themes/cp_app/podcast/follow.php
+++ b/themes/cp_app/podcast/follow.php
@@ -7,9 +7,8 @@
 <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="icon" type="image/x-icon" href="<?= get_site_icon_url('ico') ?>" />
+    <link rel="apple-touch-icon" href="<?= get_site_icon_url('180') ?>">
     <link rel="manifest" href="<?= route_to('podcast-webmanifest', esc($actor->podcast->handle)) ?>">
     <meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>">
     <script>
@@ -38,7 +37,7 @@
             'Fediverse.follow.subtitle',
         ) ?></h1>
         <div class="flex flex-col w-full max-w-xs -mt-24 overflow-hidden shadow bg-elevated rounded-xl">
-            <img src="<?= $actor->podcast->banner->small_url ?>" alt="" class="w-full aspect-[3/1] bg-header" loading="lazy" />
+            <img src="<?= get_podcast_banner_url($actor->podcast, 'small') ?>" alt="" class="w-full aspect-[3/1] bg-header" loading="lazy" />
             <div class="flex px-4 py-2">
                 <img src="<?= $actor->avatar_image_url ?>" alt="<?= esc($actor->display_name) ?>"
                     class="w-16 h-16 mr-4 -mt-8 rounded-full ring-2 ring-background-elevated aspect-square" loading="lazy" />
diff --git a/themes/cp_app/podcast/unlock.php b/themes/cp_app/podcast/unlock.php
index aaa3d0ec61315b7cd13d967ad27e2b3600093d0a..9a5ec691aacd8292a49656ed60dcd929482b866a 100644
--- a/themes/cp_app/podcast/unlock.php
+++ b/themes/cp_app/podcast/unlock.php
@@ -7,9 +7,8 @@
 <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="icon" type="image/x-icon" href="<?= get_site_icon_url('ico') ?>" />
+    <link rel="apple-touch-icon" href="<?= get_site_icon_url('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>
@@ -70,7 +69,7 @@
         </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 ?>');">
+    <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('<?= get_podcast_banner_url($podcast, 'medium') ?>');">
         <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="z-[45] h-24 rounded-full sm:h-28 md:h-36 ring-3 ring-background-elevated aspect-square" loading="lazy" />
diff --git a/themes/cp_app/post/remote_action.php b/themes/cp_app/post/remote_action.php
index bcc84db61e352babd59ffa6e1af2808276db19fc..ec8665e3ed5eba94a4b8d25e1e3dc61ea08606aa 100644
--- a/themes/cp_app/post/remote_action.php
+++ b/themes/cp_app/post/remote_action.php
@@ -5,9 +5,8 @@
 <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="icon" type="image/x-icon" href="<?= get_site_icon_url('ico') ?>" />
+    <link rel="apple-touch-icon" href="<?= get_site_icon_url('180') ?>">
     <link rel="manifest" href="<?= route_to('podcast-webmanifest', esc($post->actor->podcast->handle)) ?>">
     <meta name="theme-color" content="<?= \App\Controllers\WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme'] ?>">
     <script>