From a76724a8cfee700f6874f86b35616d61facc664e Mon Sep 17 00:00:00 2001 From: Yassine Doghri Date: Thu, 13 Apr 2023 11:45:03 +0000 Subject: [PATCH] fix(s3): add proxy to serve images from s3 to client refs #321 --- .gitignore | 6 +- app/Config/Fediverse.php | 4 +- app/Controllers/CreditsController.php | 4 +- app/Controllers/FeedController.php | 2 +- app/Controllers/WebmanifestController.php | 6 +- app/Entities/Person.php | 17 +--- app/Entities/Podcast.php | 16 +--- app/Helpers/id3_helper.php | 2 +- app/Helpers/misc_helper.php | 89 ++++++++++++++++++ app/Helpers/rss_helper.php | 4 +- app/Helpers/seo_helper.php | 4 +- app/Libraries/MediaClipper/VideoClipper.php | 2 +- app/Models/PodcastModel.php | 4 +- .../Admin/Controllers/SettingsController.php | 65 ++++++++----- modules/Fediverse/Config/Fediverse.php | 4 +- modules/Media/Config/Routes.php | 16 ++++ modules/Media/Controllers/MediaController.php | 26 +++++ modules/Media/Entities/BaseMedia.php | 28 +----- modules/Media/Entities/Image.php | 13 ++- modules/Media/Entities/Transcript.php | 12 +-- modules/Media/FileManagers/FS.php | 41 ++++++-- .../FileManagers/FileManagerInterface.php | 5 +- modules/Media/FileManagers/S3.php | 54 +++++++---- modules/Media/Helpers/filesystem_helper.php | 17 +--- modules/Media/Helpers/media_helper.php | 31 ------ public/{media => }/castopod-avatar.jpg | Bin .../{media => }/castopod-avatar_medium.webp | Bin .../castopod-avatar_thumbnail.webp | Bin public/{media => }/castopod-avatar_tiny.webp | Bin public/{media => }/castopod-banner-amber.jpg | Bin .../castopod-banner-amber_federation.jpg | Bin .../castopod-banner-amber_medium.webp | Bin .../castopod-banner-amber_small.webp | Bin .../{media => }/castopod-banner-crimson.jpg | Bin .../castopod-banner-crimson_federation.jpg | Bin .../castopod-banner-crimson_medium.webp | Bin .../castopod-banner-crimson_small.webp | Bin .../{media => }/castopod-banner-jacaranda.jpg | Bin .../castopod-banner-jacaranda_federation.jpg | Bin .../castopod-banner-jacaranda_medium.webp | Bin .../castopod-banner-jacaranda_small.webp | Bin public/{media => }/castopod-banner-lake.jpg | Bin .../castopod-banner-lake_federation.jpg | Bin .../castopod-banner-lake_medium.webp | Bin .../castopod-banner-lake_small.webp | Bin public/{media => }/castopod-banner-onyx.jpg | Bin .../castopod-banner-onyx_federation.jpg | Bin .../castopod-banner-onyx_medium.webp | Bin .../castopod-banner-onyx_small.webp | Bin public/{media => }/castopod-banner-pine.jpg | Bin .../castopod-banner-pine_federation.jpg | Bin .../castopod-banner-pine_medium.webp | Bin .../castopod-banner-pine_small.webp | Bin public/media/persons/index.html | 9 ++ public/media/podcasts/index.html | 9 ++ public/media/site/index.html | 9 ++ themes/cp_admin/_layout.php | 5 +- themes/cp_admin/episode/persons.php | 2 +- themes/cp_admin/person/_card.php | 2 +- themes/cp_admin/person/view.php | 2 +- themes/cp_admin/podcast/edit.php | 2 +- themes/cp_admin/podcast/persons.php | 2 +- themes/cp_admin/settings/general.php | 4 +- themes/cp_app/_persons_modal.php | 2 +- themes/cp_app/embed.php | 5 +- themes/cp_app/episode/_layout.php | 9 +- themes/cp_app/home.php | 5 +- themes/cp_app/pages/_layout.php | 5 +- themes/cp_app/pages/map.php | 5 +- themes/cp_app/podcast/_layout.php | 7 +- themes/cp_app/podcast/about.php | 2 +- themes/cp_app/podcast/follow.php | 7 +- themes/cp_app/podcast/unlock.php | 7 +- themes/cp_app/post/remote_action.php | 5 +- 74 files changed, 353 insertions(+), 224 deletions(-) create mode 100644 modules/Media/Config/Routes.php create mode 100644 modules/Media/Controllers/MediaController.php rename public/{media => }/castopod-avatar.jpg (100%) rename public/{media => }/castopod-avatar_medium.webp (100%) rename public/{media => }/castopod-avatar_thumbnail.webp (100%) rename public/{media => }/castopod-avatar_tiny.webp (100%) rename public/{media => }/castopod-banner-amber.jpg (100%) rename public/{media => }/castopod-banner-amber_federation.jpg (100%) rename public/{media => }/castopod-banner-amber_medium.webp (100%) rename public/{media => }/castopod-banner-amber_small.webp (100%) rename public/{media => }/castopod-banner-crimson.jpg (100%) rename public/{media => }/castopod-banner-crimson_federation.jpg (100%) rename public/{media => }/castopod-banner-crimson_medium.webp (100%) rename public/{media => }/castopod-banner-crimson_small.webp (100%) rename public/{media => }/castopod-banner-jacaranda.jpg (100%) rename public/{media => }/castopod-banner-jacaranda_federation.jpg (100%) rename public/{media => }/castopod-banner-jacaranda_medium.webp (100%) rename public/{media => }/castopod-banner-jacaranda_small.webp (100%) rename public/{media => }/castopod-banner-lake.jpg (100%) rename public/{media => }/castopod-banner-lake_federation.jpg (100%) rename public/{media => }/castopod-banner-lake_medium.webp (100%) rename public/{media => }/castopod-banner-lake_small.webp (100%) rename public/{media => }/castopod-banner-onyx.jpg (100%) rename public/{media => }/castopod-banner-onyx_federation.jpg (100%) rename public/{media => }/castopod-banner-onyx_medium.webp (100%) rename public/{media => }/castopod-banner-onyx_small.webp (100%) rename public/{media => }/castopod-banner-pine.jpg (100%) rename public/{media => }/castopod-banner-pine_federation.jpg (100%) rename public/{media => }/castopod-banner-pine_medium.webp (100%) rename public/{media => }/castopod-banner-pine_small.webp (100%) diff --git a/.gitignore b/.gitignore index 45fa5204..75d7aa6e 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 cb10bc1a..f42d4558 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 1d41afd2..ca243a95 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 290de8c4..ee0ad37e 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 653bcaae..f1e6449b 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 f0f27721..5ce4d8f0 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 313fa085..3894b102 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 584a07d5..e0564f29 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 e4a5dc93..06b0aff3 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 c4329f77..7da6baa2 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 6d535739..3099965d 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 86ebb758..f3315fd2 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 0590b434..8fd51be4 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 d68e3394..e6215e17 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); } + $icoTempFilePath = WRITEPATH . 'temp/img_favicon.ico'; + + // generate ico + $ico_lib = new PHP_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); - // 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")); + // 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 382153a8..f40fec37 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 00000000..19766ba1 --- /dev/null +++ b/modules/Media/Config/Routes.php @@ -0,0 +1,16 @@ +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 00000000..65a8f5db --- /dev/null +++ b/modules/Media/Controllers/MediaController.php @@ -0,0 +1,26 @@ +serve(implode('/', $key)); + } +} diff --git a/modules/Media/Entities/BaseMedia.php b/modules/Media/Entities/BaseMedia.php index d293ef6b..905a0c21 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|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 9f45bacc..123cc203 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 4ad7cdcb..a969cff1 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 141f690d..63c1f707 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 3d1d2ac4..5b2dd078 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 b5856faf..fe5b5e3d 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 0cff140c..9a6d00c8 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 ($newExtension === null) { + $newExtension = pathinfo($path, PATHINFO_EXTENSION); } - if ($fileKeyInfo['dirname'] === '/') { - return '/' . $fileKeyInfo['filename']; - } - - 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 fab6123d..68eb3aa9 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 e69de29b..eebf8ecb 100644 --- a/public/media/persons/index.html +++ b/public/media/persons/index.html @@ -0,0 +1,9 @@ + + + + 403 Forbidden + + +

Directory access is forbidden.

+ + diff --git a/public/media/podcasts/index.html b/public/media/podcasts/index.html index e69de29b..eebf8ecb 100644 --- a/public/media/podcasts/index.html +++ b/public/media/podcasts/index.html @@ -0,0 +1,9 @@ + + + + 403 Forbidden + + +

Directory access is forbidden.

+ + diff --git a/public/media/site/index.html b/public/media/site/index.html index e69de29b..eebf8ecb 100644 --- a/public/media/site/index.html +++ b/public/media/site/index.html @@ -0,0 +1,9 @@ + + + + 403 Forbidden + + +

Directory access is forbidden.

+ + diff --git a/themes/cp_admin/_layout.php b/themes/cp_admin/_layout.php index d23a48a4..2cca6f23 100644 --- a/themes/cp_admin/_layout.php +++ b/themes/cp_admin/_layout.php @@ -15,9 +15,8 @@ $isEpisodeArea = isset($podcast) && isset($episode); <?= $this->renderSection('title') ?> | Castopod Admin - - + + ' /> diff --git a/themes/cp_admin/episode/persons.php b/themes/cp_admin/episode/persons.php index 05e0a619..d5deb5ba 100644 --- a/themes/cp_admin/episode/persons.php +++ b/themes/cp_admin/episode/persons.php @@ -58,7 +58,7 @@ return '
' . '' . esc($person->full_name) . '' . + '">' . esc($person->full_name) . '' . '
' . esc($person->full_name) . implode( diff --git a/themes/cp_admin/person/_card.php b/themes/cp_admin/person/_card.php index 20408bb5..afd7e23f 100644 --- a/themes/cp_admin/person/_card.php +++ b/themes/cp_admin/person/_card.php @@ -2,7 +2,7 @@
- <?= esc($person->full_name) ?> + <?= esc($person->full_name) ?>

full_name) ?>

diff --git a/themes/cp_admin/person/view.php b/themes/cp_admin/person/view.php index 4956b3cc..e07efd77 100644 --- a/themes/cp_admin/person/view.php +++ b/themes/cp_admin/person/view.php @@ -17,7 +17,7 @@
<?= esc($person->full_name) ?>banner_id !== null): ?> - +
<?= esc($podcast->title) ?> diff --git a/themes/cp_admin/podcast/persons.php b/themes/cp_admin/podcast/persons.php index 7b127d63..c19f061e 100644 --- a/themes/cp_admin/podcast/persons.php +++ b/themes/cp_admin/podcast/persons.php @@ -55,7 +55,7 @@ return '
' . '' . esc($person->full_name) . '' . + '">' . esc($person->full_name) . '' . '
' . esc($person->full_name) . implode( diff --git a/themes/cp_admin/settings/general.php b/themes/cp_admin/settings/general.php index dd2ce565..182d59a7 100644 --- a/themes/cp_admin/settings/general.php +++ b/themes/cp_admin/settings/general.php @@ -46,7 +46,7 @@ siteIcon['ico'] !== service('settings')->get('App.siteIcon')['ico']): ?>
- <?= esc(service('settings')->get('App.siteName')) ?> Favicon + <?= esc(service('settings')->get('App.siteName')) ?> Favicon
@@ -62,7 +62,7 @@ + subtitle=""> diff --git a/themes/cp_app/_persons_modal.php b/themes/cp_app/_persons_modal.php index 237fefee..847f46d2 100644 --- a/themes/cp_app/_persons_modal.php +++ b/themes/cp_app/_persons_modal.php @@ -17,7 +17,7 @@
- <?= esc($person->full_name) ?> + <?= esc($person->full_name) ?>

information_url): ?> diff --git a/themes/cp_app/embed.php b/themes/cp_app/embed.php index afc1c05d..f18322d6 100644 --- a/themes/cp_app/embed.php +++ b/themes/cp_app/embed.php @@ -9,9 +9,8 @@ - - + + ' /> asset('styles/index.css', 'css') ?> diff --git a/themes/cp_app/episode/_layout.php b/themes/cp_app/episode/_layout.php index 8bf65fd3..730ec143 100644 --- a/themes/cp_app/episode/_layout.php +++ b/themes/cp_app/episode/_layout.php @@ -7,9 +7,8 @@ - - + +