Commit 02e4441f authored by Yassine Doghri's avatar Yassine Doghri
Browse files

feat: create optimized & resized images upon upload

- resize uploaded image to thumbnail, medium, large, feed, and id3 formats
- set image url formats where adapted in views
- set format sizes and extensions in Images config file for customization
- add validation for image uploads: `min_dims` and `is_image_squared`
- update codeigniter4 and myth-auth php packages to latest develop versions
- update npm packages to latest versions
- update public/.htaccess

closes #6
parent 40a0535f
......@@ -10,8 +10,12 @@ WORKDIR /castopod
RUN apt-get update && apt-get install -y \
libicu-dev \
libpng-dev \
libjpeg-dev \
zlib1g-dev \
&& docker-php-ext-install intl gd
&& docker-php-ext-install intl
RUN docker-php-ext-configure gd --with-jpeg-dir=/usr/include/ \
&& docker-php-ext-install gd
RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli
......@@ -19,4 +23,5 @@ RUN echo "file_uploads = On\n" \
"memory_limit = 100M\n" \
"upload_max_filesize = 100M\n" \
"post_max_size = 120M\n" \
"max_execution_time = 300\n" \
> /usr/local/etc/php/conf.d/uploads.ini
......@@ -30,4 +30,73 @@ class Images extends BaseConfig
'gd' => \CodeIgniter\Images\Handlers\GDHandler::class,
'imagick' => \CodeIgniter\Images\Handlers\ImageMagickHandler::class,
];
/**
* --------------------------------------------------------------------------
* Uploaded images resizing sizes (in px)
* --------------------------------------------------------------------------
* The sizes listed below determine the resizing of images when uploaded.
* All uploaded images are of 1:1 ratio (width and height are the same).
*/
/**
* @var integer
*/
public $thumbnailSize = 150;
/**
* @var integer
*/
public $mediumSize = 320;
/**
* @var integer
*/
public $largeSize = 1024;
/**
* Size of images linked in the rss feed (should be between 1400 and 3000)
*
* @var integer
*/
public $feedSize = 1400;
/**
* Size for ID3 tag cover art (should be between 300 and 800)
*
* @var integer
*/
public $id3Size = 500;
/**
* --------------------------------------------------------------------------
* Uploaded images naming extensions
* --------------------------------------------------------------------------
* The properties listed below set the name extensions for the resized images
*/
/**
* @var string
*/
public $thumbnailExtension = '_thumbnail';
/**
* @var string
*/
public $mediumExtension = '_medium';
/**
* @var string
*/
public $largeExtension = '_large';
/**
* @var string
*/
public $feedExtension = '_feed';
/**
* @var string
*/
public $id3Extension = '_id3';
}
......@@ -17,9 +17,9 @@ class Validation
public $ruleSets = [
\CodeIgniter\Validation\Rules::class,
\CodeIgniter\Validation\FormatRules::class,
\CodeIgniter\Validation\FileRules::class,
\CodeIgniter\Validation\CreditCardRules::class,
\App\Validation\Rules::class,
\App\Validation\FileRules::class,
\Myth\Auth\Authentication\Passwords\ValidationRules::class,
];
......
......@@ -85,7 +85,7 @@ class Episode extends BaseController
$rules = [
'enclosure' => 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]',
'image' =>
'uploaded[image]|is_image[image]|ext_in[image,jpg,png]|permit_empty',
'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
'publication_date' => 'valid_date[Y-m-d]|permit_empty',
'publication_time' =>
'regex_match[/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/]|permit_empty',
......@@ -151,7 +151,7 @@ class Episode extends BaseController
'enclosure' =>
'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]|permit_empty',
'image' =>
'uploaded[image]|is_image[image]|ext_in[image,jpg,png]|permit_empty',
'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
'publication_date' => 'valid_date[Y-m-d]|permit_empty',
'publication_time' =>
'regex_match[/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/]|permit_empty',
......
......@@ -79,7 +79,8 @@ class Podcast extends BaseController
public function attemptCreate()
{
$rules = [
'image' => 'uploaded[image]|is_image[image]|ext_in[image,jpg,png]',
'image' =>
'uploaded[image]|is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
];
if (!$this->validate($rules)) {
......@@ -162,8 +163,9 @@ class Podcast extends BaseController
helper(['media', 'misc']);
$rules = [
'name' => 'required',
'imported_feed_url' => 'required',
'imported_feed_url' => 'required|valid_url',
'season_number' => 'is_natural_no_zero|permit_empty',
'max_episodes' => 'is_natural_no_zero|permit_empty',
];
if (!$this->validate($rules)) {
......@@ -217,8 +219,6 @@ class Podcast extends BaseController
'complete' => empty($nsItunes->complete)
? false
: $nsItunes->complete == 'yes',
'episode_description_footer' => '',
'custom_html_head' => '',
'created_by' => user(),
'updated_by' => user(),
]);
......@@ -299,9 +299,10 @@ class Podcast extends BaseController
? null
: download_file($nsItunes->image->attributes()),
'explicit' => $nsItunes->explicit == 'yes',
'number' => $this->request->getPost('force_renumber')
? $itemNumber
: $nsItunes->episode,
'number' =>
$this->request->getPost('force_renumber') == 'yes'
? $itemNumber
: $nsItunes->episode,
'season_number' => empty(
$this->request->getPost('season_number')
)
......@@ -358,7 +359,7 @@ class Podcast extends BaseController
{
$rules = [
'image' =>
'uploaded[image]|is_image[image]|ext_in[image,jpg,png]|permit_empty',
'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
];
if (!$this->validate($rules)) {
......
......@@ -47,6 +47,8 @@ class Analytics extends Controller
// Add one hit to this episode:
public function hit($p_podcastId, $p_episodeId, ...$filename)
{
helper('media');
podcast_hit($p_podcastId, $p_episodeId);
return redirect()->to(media_url(implode('/', $filename)));
}
......
......@@ -25,20 +25,10 @@ class Episode extends Entity
protected $link;
/**
* @var \CodeIgniter\Files\File
* @var \App\Entities\Image
*/
protected $image;
/**
* @var string
*/
protected $image_media_path;
/**
* @var string
*/
protected $image_url;
/**
* @var \CodeIgniter\Files\File
*/
......@@ -98,33 +88,30 @@ class Episode extends Entity
(!($image instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
$image->isValid())
) {
helper('media');
// check whether the user has inputted an image and store it
$this->attributes['image_uri'] = save_podcast_media(
$image,
$this->getPodcast()->name,
$this->attributes['slug']
);
$this->image = new \App\Entities\Image(
$this->attributes['image_uri']
);
$this->image->saveSizes();
}
return $this;
}
public function getImage(): \CodeIgniter\Files\File
{
return new \CodeIgniter\Files\File($this->getImageMediaPath());
}
public function getImageMediaPath(): string
{
return media_path($this->attributes['image_uri']);
}
public function getImageUrl(): string
public function getImage(): \App\Entities\Image
{
if ($image_uri = $this->attributes['image_uri']) {
return media_url($image_uri);
return new \App\Entities\Image($image_uri);
}
return $this->getPodcast()->image_url;
return $this->getPodcast()->image;
}
/**
......
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use CodeIgniter\Entity;
class Image extends Entity
{
/**
* @var string
*/
protected $original_path;
/**
* @var string
*/
protected $original_url;
/**
* @var string
*/
protected $thumbnail_path;
/**
* @var string
*/
protected $thumbnail_url;
/**
* @var string
*/
protected $medium_path;
/**
* @var string
*/
protected $medium_url;
/**
* @var string
*/
protected $large_path;
/**
* @var string
*/
protected $large_url;
/**
* @var string
*/
protected $feed_path;
/**
* @var string
*/
protected $feed_url;
/**
* @var string
*/
protected $id3_path;
public function __construct($originalUri)
{
helper('media');
$originalPath = media_path($originalUri);
[
'filename' => $filename,
'dirname' => $dirname,
'extension' => $extension,
] = pathinfo($originalPath);
// load images extensions from config
$imageConfig = config('Images');
$thumbnailExtension = $imageConfig->thumbnailExtension;
$mediumExtension = $imageConfig->mediumExtension;
$largeExtension = $imageConfig->largeExtension;
$feedExtension = $imageConfig->feedExtension;
$id3Extension = $imageConfig->id3Extension;
$thumbnail =
$dirname . '/' . $filename . $thumbnailExtension . '.' . $extension;
$medium =
$dirname . '/' . $filename . $mediumExtension . '.' . $extension;
$large =
$dirname . '/' . $filename . $largeExtension . '.' . $extension;
$feed = $dirname . '/' . $filename . $feedExtension . '.' . $extension;
$id3 = $dirname . '/' . $filename . $id3Extension . '.' . $extension;
parent::__construct([
'original_path' => $originalPath,
'original_url' => media_url($originalUri),
'thumbnail_path' => $thumbnail,
'thumbnail_url' => base_url($thumbnail),
'medium_path' => $medium,
'medium_url' => base_url($medium),
'large_path' => $large,
'large_url' => base_url($large),
'feed_path' => $feed,
'feed_url' => base_url($feed),
'id3_path' => $id3,
]);
}
public function saveSizes()
{
// load images sizes from config
$imageConfig = config('Images');
$thumbnailSize = $imageConfig->thumbnailSize;
$mediumSize = $imageConfig->mediumSize;
$largeSize = $imageConfig->largeSize;
$feedSize = $imageConfig->feedSize;
$id3Size = $imageConfig->id3Size;
$imageService = \Config\Services::image();
$imageService
->withFile($this->attributes['original_path'])
->resize($thumbnailSize, $thumbnailSize)
->save($this->attributes['thumbnail_path']);
$imageService
->withFile($this->attributes['original_path'])
->resize($mediumSize, $mediumSize)
->save($this->attributes['medium_path']);
$imageService
->withFile($this->attributes['original_path'])
->resize($largeSize, $largeSize)
->save($this->attributes['large_path']);
$imageService
->withFile($this->attributes['original_path'])
->resize($feedSize, $feedSize)
->save($this->attributes['feed_path']);
$imageService
->withFile($this->attributes['original_path'])
->resize($id3Size, $id3Size)
->save($this->attributes['id3_path']);
}
}
......@@ -23,20 +23,10 @@ class Podcast extends Entity
protected $link;
/**
* @var \CodeIgniter\Files\File
* @var \App\Entities\Image
*/
protected $image;
/**
* @var string
*/
protected $image_media_path;
/**
* @var string
*/
protected $image_url;
/**
* @var \App\Entities\Episode[]
*/
......@@ -101,24 +91,18 @@ class Podcast extends Entity
$this->attributes['name'],
'cover'
);
return $this;
$this->image = new \App\Entities\Image(
$this->attributes['image_uri']
);
$this->image->saveSizes();
}
}
public function getImage()
{
return new \CodeIgniter\Files\File($this->getImageMediaPath());
}
public function getImageMediaPath()
{
return media_path($this->attributes['image_uri']);
return $this;
}
public function getImageUrl()
public function getImage()
{
return media_url($this->attributes['image_uri']);
return new \App\Entities\Image($this->attributes['image_uri']);
}
public function getLink()
......
......@@ -47,7 +47,7 @@ function write_enclosure_tags($episode)
$tagwriter->tagformats = ['id3v2.4'];
$tagwriter->tag_encoding = $TextEncoding;
$cover = new \CodeIgniter\Files\File($episode->image_media_path);
$cover = new \CodeIgniter\Files\File($episode->image->id3_path);
$APICdata = file_get_contents($cover->getRealPath());
......
......@@ -19,17 +19,15 @@ function save_podcast_media($file, $podcast_name, $media_name)
{
$file_name = $media_name . '.' . $file->getExtension();
if (!file_exists(config('App')->mediaRoot . '/' . $podcast_name)) {
mkdir(config('App')->mediaRoot . '/' . $podcast_name, 0777, true);
touch(config('App')->mediaRoot . '/' . $podcast_name . '/index.html');
$mediaRoot = config('App')->mediaRoot;
if (!file_exists($mediaRoot . '/' . $podcast_name)) {
mkdir($mediaRoot . '/' . $podcast_name, 0777, true);
touch($mediaRoot . '/' . $podcast_name . '/index.html');
}
// move to media folder and overwrite file if already existing
$file->move(
config('App')->mediaRoot . '/' . $podcast_name . '/',
$file_name,
true
);
$file->move($mediaRoot . '/' . $podcast_name . '/', $file_name, true);
return $podcast_name . '/' . $file_name;
}
......@@ -64,3 +62,15 @@ function media_path($uri = ''): string
return config('App')->mediaRoot . '/' . $uri;
}
/**
* Return the media base URL to use in views
*
* @param mixed $uri URI string or array of URI segments
* @param string $protocol
* @return string
*/
function media_url($uri = '', string $protocol = null): string
{
return base_url(config('App')->mediaRoot . '/' . $uri, $protocol);
}
......@@ -57,7 +57,7 @@ function get_rss_feed($podcast)
$channel->addChild('title', $podcast->title);
$channel->addChildWithCDATA('description', $podcast->description_html);
$itunes_image = $channel->addChild('image', null, $itunes_namespace);
$itunes_image->addAttribute('href', $podcast->image_url);
$itunes_image->addAttribute('href', $podcast->image->url);
$channel->addChild('language', $podcast->language);
$itunes_category = $channel->addChild('category', null, $itunes_namespace);
......@@ -106,7 +106,7 @@ function get_rss_feed($podcast)
$channel->addChild('complete', 'Yes', $itunes_namespace);
$image = $channel->addChild('image');
$image->addChild('url', $podcast->image_url);
$image->addChild('url', $podcast->image->feed_url);
$image->addChild('title', $podcast->title);
$image->addChild('link', $podcast->link);
......@@ -136,7 +136,7 @@ function get_rss_feed($podcast)
null,
$itunes_namespace
);
$episode_itunes_image->addAttribute('href', $episode->image_url);
$episode_itunes_image->addAttribute('href', $episode->image->feed_url);
$item->addChild(
'explicit',
$episode->explicit ? 'true' : 'false',
......
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
/**
* Return the media base URL to use in views
*
* @param mixed $uri URI string or array of URI segments
* @param string $protocol
* @return string
*/
function media_url($uri = '', string $protocol = null): string
{
return base_url(config('App')->mediaRoot . '/' . $uri, $protocol);
}
......@@ -12,4 +12,8 @@ return [
'mediumDate' => '{0,date,medium}',
'duration' => '{0,duration}',
'powered_by' => 'Powered by {castopod}.',
'forms' => [
'image_size_hint' =>
'Image must be squared with at least 1400px wide and tall.',
],
];
......@@ -9,4 +9,8 @@
return [
'not_in_protected_slugs' =>
'The {field} field conflicts with one of the gateway routes (admin, auth or install).',
'min_dims' =>
'{field} is either not an image, or it is not wide or tall enough.',
'is_image_squared' =>
'{field} is either not an image, or it is not squared (width and height differ).',
];
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Validation;
use CodeIgniter\Validation\FileRules as ValidationFileRules;
class FileRules extends ValidationFileRules
{
/**
* Checks an uploaded file to verify that the dimensions are within
* a specified allowable dimension.
*
* @param string|null $blank
* @param string $params
*
* @return boolean
*/
public function min_dims(string $blank = null, string $params): bool
{
// Grab the file name off the top of the $params
// after we split it.
$params = explode(',', $params);
$name = array_shift($params);
if (!($files = $this->request->getFileMultiple($name))) {
$files = [$this->request->getFile($name)];
}
foreach ($files as $file) {
if (is_null($file)) {
return false;
}
if ($file->getError() === UPLOAD_ERR_NO_FILE) {
return true;
}
// Get Parameter sizes
$minWidth = $params[0] ?? 0;
$minHeight = $params[1] ?? 0;