<?php
namespace App\Service\Content;
use App\Dictionary\ContentStatus;
use App\Dictionary\ContentType;
use App\Dictionary\ImageFilter;
use App\Entity\Category;
use App\Entity\Content;
use App\Entity\ContentImageset;
use App\Entity\ContentImagesetImage;
use App\Entity\Member;
use App\Entity\MemberContent;
use App\Event\Content\ImagesetImageDeleteEvent;
use App\Exception\Content\ContentStatusException;
use App\Lib\Content\Imagine\PixelateFilter;
use App\Lib\Content\PreviewPhoto;
use App\Repository\ContentImagesetImageRepository;
use App\Repository\ContentImagesetRepository;
use App\Response\EntityRemoved;
use App\Service\Media\FileUploader\ImagesetImageUploader;
use App\Service\Media\StorageLayer;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Imagine\Image\Box;
use Imagine\Image\ImageInterface;
use Imagine\Image\ImagineInterface;
use Imagine\Image\ManipulatorInterface;
use League\Flysystem\FileExistsException;
use League\Flysystem\FileNotFoundException;
use Liip\ImagineBundle\Service\FilterService;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\FileBag;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class ImagesetService extends AbstractContentService
{
/**
* @var string
*/
public const SECRET_KEY = 'KdkjFdZj3oijiejopJWjp93jIJouh34KPol3B';
/**
* @var ContentImagesetRepository
*/
protected $repo;
protected ContentImagesetImageRepository $imageRepo;
protected ImagesetImageUploader $uploader;
protected ContentService $contentService;
protected FilterService $filterService;
protected ImagineInterface $imagine;
protected LoggerInterface $logger;
protected StorageLayer $storageLayer;
protected PixelateFilter $pixelateFilter;
/**
* ImagesetService constructor.
*/
public function __construct(ContentImagesetRepository $repo, ContentImagesetImageRepository $imageRepository,
ImagesetImageUploader $uploader,
ContentService $contentService, FilterService $filterService,
EventDispatcherInterface $dispatcher, ImagineInterface $imagine,
LoggerInterface $logger, StorageLayer $storageLayer)
{
parent::__construct($repo, $dispatcher);
$this->logger = $logger;
$this->contentService = $contentService;
$this->filterService = $filterService;
$this->imagine = $imagine;
$this->imageRepo = $imageRepository;
$this->storageLayer = $storageLayer;
$this->setUploader($uploader);
$this->pixelateFilter = new PixelateFilter($this->imagine);
}
public function getImagesetRepo(): ContentImagesetRepository
{
return $this->repo;
}
public function getUploader(): ImagesetImageUploader
{
return $this->uploader;
}
public function setUploader(ImagesetImageUploader $uploader): self
{
$this->uploader = $uploader;
return $this;
}
/**
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function storeContentImageset(ContentImageset $entity): ContentImageset
{
$entity->setPics($entity->getImages()->count());
return $this->storeEntity($entity);
}
public function findBySlug(string $slug, bool $ignoreContentStatus = false): ?ContentImageset
{
if ($ignoreContentStatus) {
return $this->getRepository()->findOneBy(['slug' => $slug]);
}
$qb = $this->getRepository()->getBaseImagesetQueryBuilder([]);
$qb->andWhere('i.slug = :slug');
$qb->setParameter(':slug', $slug);
return $qb->getQuery()->getOneOrNullResult();
}
public function findByPublicPath(string $publicPath): ?ContentImageset
{
return $this->getRepository()->findOneBy(['public_path' => $publicPath]);
}
public function mayAddImagesToContentImageset(ContentImageset $imageset): bool
{
$content = $imageset->getContent();
if (ContentType::IMAGESET !== $content->getType()) {
return false;
}
return ContentStatus::ACTIVE !== $content->getStatus();
}
public function createImagesetImageForSet(ContentImageset $contentImageset): ContentImagesetImage
{
if (!$this->mayAddImagesToContentImageset($contentImageset)) {
throw new ContentStatusException('Content already published.');
}
$image = new ContentImagesetImage();
$contentImageset->addImagesetImage($image);
return $image;
}
public function applyValuesToImageEntity(ParameterBag $bag, FileBag $fileBag, ContentImagesetImage $entity): ContentImagesetImage
{
try {
$defaultPriority = $entity->getContentImageset()->getImages()->count() + 1;
} catch (\Throwable $t) {
$defaultPriority = 1;
}
$entity->setPriority($bag->getInt('priority', $defaultPriority));
if ($fileBag->has('file')) {
$fileName = $this->getUploader()->upload($fileBag->get('file'));
if (null === $fileName) {
throw new \Exception('writing file failed');
}
}
return $entity;
}
/**
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function deleteImagesetImage(ContentImagesetImage $image): EntityRemoved
{
// deletes files
$this->getEventDispatcher()->dispatch(new ImagesetImageDeleteEvent($image));
return new EntityRemoved($this->imageRepo->deleteEntity($image->getId(), true), $image->getId());
}
public function deleteImageFiltersInStorage(ContentImagesetImage $image, array $filters = [], ?bool $blur = null): void
{
if (empty($filters)) {
$filters = [ImageFilter::PREVIMG_SMALL, ImageFilter::PREVIMG_BIG, ImageFilter::PHOTOS, ImageFilter::THUMBS];
}
$filesToDelete = [];
foreach ($filters as $filter) {
if (null === $blur) {
$url = $this->getUrlForImagesetImage($image, false, $filter);
$filesToDelete[] = substr($url, strpos($url, '/media/'));
$url = $this->getUrlForImagesetImage($image, true, $filter);
$filesToDelete[] = substr($url, strpos($url, '/media/'));
} else {
$url = $this->getUrlForImagesetImage($image, $blur, $filter);
$filesToDelete[] = substr($url, strpos($url, '/media/'));
}
}
foreach ($filesToDelete as $filepath) {
if (empty($filepath)) {
continue;
}
try {
$this->storageLayer->getMediacacheStorage()->delete($filepath);
} catch (FileNotFoundException $e) {
$this->logger->warning('Not found when deleting imageset image: '.$e->getMessage().PHP_EOL.$e->getTraceAsString());
}
}
}
public function getImagesetImageById(int $id): ?ContentImagesetImage
{
return $this->imageRepo->find($id);
}
/**
* @return ContentImagesetImage[]
*/
public function getImagesetImagesById(array $ids): array
{
return $this->imageRepo->findBy(['id' => $ids]);
}
public function getImagesetImagesByMemberContent(array $memberContents): array
{
$finalImages = [];
$imagesetImageIds = [];
/** @var MemberContent $mc */
foreach ($memberContents as $mc) {
$imageId = $mc->getImagesetImageId();
// Add all images for old memberContent where imagesetImageId is null
if (null === $imageId) {
$allImages = $mc->getContent()->getImageset()->getImages();
foreach ($allImages as $image) {
$finalImages[$image->getId()] = $image;
}
continue;
}
// Add single image purchase
if (!isset($finalImages[$imageId])) {
$imagesetImageIds[$mc->getImagesetImageId()] = $mc->getImagesetImageId();
}
}
return array_merge($finalImages, $this->getImagesetImagesById($imagesetImageIds));
}
public function getImagesetImageByPosition(ContentImageset $imageset, int $position): ?ContentImagesetImage
{
return $this->imageRepo->findOneBy([
'content_imageset' => $imageset,
'priority' => $position,
]);
}
public function getActiveImagesetsByCategory(Category $category, int $page, int $limit): Paginator
{
$qb = $this->repo->getActiveImagesetsByCategoryQueryBuilder($category);
$qb->setMaxResults($limit);
$qb->setFirstResult($page * $limit - $limit);
return new Paginator($qb->getQuery());
}
public function getImagesetsByMember(Member $member, int $page, array $statuses, int $limit): Paginator
{
return $this->repo->getImagesetsByMember($member, $page, $statuses, $limit);
}
public function getFilenameHash(Content $content, ContentImagesetImage $image, bool $blur = false): string
{
$secret = $this->getSecret($content);
$hash2 = md5($content->getId().'/'.$image->getId().$secret.($blur ? 'A' : 'B'));
return substr($hash2, 0, 8);
}
protected function getSecret(Content $content): string
{
return self::SECRET_KEY.$content->getCreatedAt()->format('Ym');
}
public function getFoldernameHash(Content $content): string
{
$hash = md5($content->getId().$this->getSecret($content));
return substr($hash, -2).'/'.substr($hash, -4, 2).'/'.$content->getId();
}
/**
* @return string
*/
public function getFullHash(Content $content, ContentImagesetImage $image, bool $blur)
{
return $this->getFoldernameHash($content).'/'.$image->getId().'.'.$this->getFilenameHash($content, $image, $blur);
}
public function getSourceFile(int $content, int $image): string
{
return $this->getSourceFilepath($content).'/'.$this->getSourceFilename($image);
}
public function getSourceFilename(int $imageId): string
{
return $imageId.'.large.jpg';
}
public function getSourceFilepath(int $content): string
{
$paddedId = str_pad($content, 7, '0', STR_PAD_LEFT);
$parts = [
substr($paddedId, 0, 2),
substr($paddedId, 2, 2),
substr($paddedId, 4, 2),
$paddedId,
];
return implode('/', $parts);
}
public function makeHash(Content $content, ContentImagesetImage $image, bool $blur = false)
{
$secret = $this->getSecret($content);
$hash2 = md5($content->getId().'/'.$image->getId().$secret.($blur ? 'A' : 'B'));
return substr($hash2, 0, 8);
}
public function getUrlForImagesetImage(ContentImagesetImage $image, bool $blur, string $filter = 'previmg_big'): ?string
{
$path = $this->getPathForUrl($image, $blur);
if (null === $path || ($blur && ImageFilter::PHOTOS === $filter)) {
return null;
}
return sprintf('%s/mediacache/media/%s/%s', $_ENV['APP_HOST_ASSETS'], $filter, $path);
}
/**
* Will always overwrite blurred file.
*/
public function getUrlForImagesetImageWithProcessing(ContentImagesetImage $imagesetImage, bool $blur, string $filter, bool $deleteBeforeGenerate = false): ?string
{
$path = $this->getPathForUrl($imagesetImage, $blur);
if (null === $path || ($blur && ImageFilter::PHOTOS === $filter)) {
return null;
}
try {
if ($deleteBeforeGenerate) {
try {
$this->storageLayer->getMediacacheStorage()->delete('/media/'.$filter.'/'.$path);
} catch (FileNotFoundException $fee) {
$this->logger->debug('info: '.$fee->getMessage().PHP_EOL.$fee->getTraceAsString());
}
}
\Imagick::setResourceLimit(\Imagick::RESOURCETYPE_THREAD, 8);
// getUrlOfFilteredImage() triggers liip-imagine filters
$url = $this->filterService->getUrlOfFilteredImage($path, $filter);
// Photos-filter does not need blurring and thumbs is already done by ImagesetThumbnailDataLoader.
if ($blur && in_array($filter, [ImageFilter::PREVIMG_SMALL, ImageFilter::PREVIMG_BIG], true)) {
$readPath = sprintf('/media/%s/%s', $filter, $path);
$resource = $this->storageLayer->getMediacacheStorage()->readStream($readPath);
$image = $this->imagine->read($resource);
$image = $this->pixelateImagesetImage($image);
// Always overwrite blurred file
try {
$this->storageLayer->getMediacacheStorage()->write($readPath, $image->get('webp'));
} catch (FileExistsException $fee) {
$this->storageLayer->getMediacacheStorage()->update($readPath, $image->get('webp'));
}
}
return $url;
} catch (\Exception $e) {
$this->logger->error('1st msg: '.$e->getMessage().' 2nd code: '.$e->getCode().PHP_EOL.$e->getTraceAsString());
$this->logger->error('2nd msg: '.$e->getPrevious()?->getMessage().' 2nd code: '.$e->getPrevious()?->getCode().PHP_EOL);
}
return null;
}
private function pixelateImagesetImage(ImageInterface $image): ImageInterface
{
return $this->pixelateFilter->applyForImagesetImage($image);
}
private function getPathForUrl(ContentImagesetImage $image, bool $blur): ?string
{
$set = $image->getContentImageset();
$hash = $this->makeHash($set->getContent(), $image, $blur);
$path = $set->getPublicPath();
if (empty($path)) {
return null;
}
return $path.'/'.$image->getId().'.'.$hash.'.jpg';
}
public function bustCacheForImagesetImage(ContentImagesetImage $image, string $filter): bool
{
$set = $image->getContentImageset();
$path = $this->getFoldernameHash($set->getContent());
// blurred
$hash = $this->getFilenameHash($set->getContent(), $image, true);
$filename = $image->getId().'.'.$hash.'.jpg';
$this->filterService->bustCache($path.'/'.$filename, $filter);
// original
$hash = $this->getFilenameHash($set->getContent(), $image, false);
$filename = $image->getId().'.'.$hash.'.jpg';
$this->filterService->bustCache($path.'/'.$filename, $filter);
return true;
}
/**
* @throws FileExistsException
* @throws FileNotFoundException
*/
public function resizePreviewPhoto(PreviewPhoto $photo): bool
{
$path = $photo->getStoragePath();
if (!$this->storageLayer->hasPublicFile($path)) {
return false;
}
$input = $this->storageLayer->readPublicFileStream($path);
$image = $this->imagine->read($input);
if ($image->getSize()->getWidth() <= 640 && $image->getSize()->getHeight() < 360) {
return false;
}
$size = new Box(640, 360);
$thumb = $image->thumbnail($size, ManipulatorInterface::THUMBNAIL_OUTBOUND);
$this->storageLayer->overwritePublicFile($path, $thumb->get('png'));
return true;
}
public function getImagesetImageIdByWebPath(string $path): int
{
return (int) explode('.', basename($path))[0];
}
public function getImagesetByWebpath(string $path): ContentImageset
{
if (in_array(substr($path, -4), ['.jpg', '.png']) || in_array(substr($path, -5), ['.jpeg', '.webp'])) {
$path = dirname($path);
}
return $this->findByPublicPath($path);
}
}