3 namespace Wallabag\CoreBundle\Helper
;
6 use GuzzleHttp\Message\Response
;
7 use Psr\Log\LoggerInterface
;
8 use Symfony\Component\DomCrawler\Crawler
;
9 use Symfony\Component\Finder\Finder
;
10 use Symfony\Component\HttpFoundation\File\MimeType\MimeTypeExtensionGuesser
;
14 const REGENERATE_PICTURES_QUALITY
= 80;
22 public function __construct(Client
$client, $baseFolder, $wallabagUrl, LoggerInterface
$logger)
24 $this->client
= $client;
25 $this->baseFolder
= $baseFolder;
26 $this->wallabagUrl
= rtrim($wallabagUrl, '/');
27 $this->logger
= $logger;
28 $this->mimeGuesser
= new MimeTypeExtensionGuesser();
34 * Process the html and extract images URLs from it.
40 public static function extractImagesUrlsFromHtml($html)
42 $crawler = new Crawler($html);
43 $imagesCrawler = $crawler
44 ->filterXpath('//img');
45 $imagesUrls = $imagesCrawler
47 $imagesSrcsetUrls = self
::getSrcsetUrls($imagesCrawler);
49 return array_unique(array_merge($imagesUrls, $imagesSrcsetUrls));
53 * Process the html and extract image from it, save them to local and return the updated html.
55 * @param int $entryId ID of the entry
57 * @param string $url Used as a base path for relative image and folder
61 public function processHtml($entryId, $html, $url)
63 $imagesUrls = self
::extractImagesUrlsFromHtml($html);
65 $relativePath = $this->getRelativePath($entryId);
67 // download and save the image to the folder
68 foreach ($imagesUrls as $image) {
69 $imagePath = $this->processSingleImage($entryId, $image, $url, $relativePath);
71 if (false === $imagePath) {
75 // if image contains "&" and we can't find it in the html it might be because it's encoded as &
76 if (false !== stripos($image, '&') && false === stripos($html, $image)) {
77 $image = str_replace('&', '&', $image);
80 $html = str_replace($image, $imagePath, $html);
87 * Process a single image:
89 * - re-saved it (for security reason)
90 * - return the new local path.
92 * @param int $entryId ID of the entry
93 * @param string $imagePath Path to the image to retrieve
94 * @param string $url Url from where the image were found
95 * @param string $relativePath Relative local path to saved the image
97 * @return string Relative url to access the image from the web
99 public function processSingleImage($entryId, $imagePath, $url, $relativePath = null)
101 if (null === $imagePath) {
105 if (null === $relativePath) {
106 $relativePath = $this->getRelativePath($entryId);
109 $this->logger
->debug('DownloadImages: working on image: ' . $imagePath);
111 $folderPath = $this->baseFolder
. '/' . $relativePath;
114 $absolutePath = $this->getAbsoluteLink($url, $imagePath);
115 if (false === $absolutePath) {
116 $this->logger
->error('DownloadImages: Can not determine the absolute path for that image, skipping.');
122 $res = $this->client
->get($absolutePath);
123 } catch (\Exception
$e) {
124 $this->logger
->error('DownloadImages: Can not retrieve image, skipping.', ['exception' => $e]);
129 $ext = $this->getExtensionFromResponse($res, $imagePath);
130 if (false === $res) {
134 $hashImage = hash('crc32', $absolutePath);
135 $localPath = $folderPath . '/' . $hashImage . '.' . $ext;
138 $im = imagecreatefromstring($res->getBody());
139 } catch (\Exception
$e) {
144 $this->logger
->error('DownloadImages: Error while regenerating image', ['path' => $localPath]);
151 // use Imagick if available to keep GIF animation
152 if (class_exists('\\Imagick')) {
154 $imagick = new \
Imagick();
155 $imagick->readImageBlob($res->getBody());
156 $imagick->setImageFormat('gif');
157 $imagick->writeImages($localPath, true);
158 } catch (\Exception
$e) {
159 // if Imagick fail, fallback to the default solution
160 imagegif($im, $localPath);
163 imagegif($im, $localPath);
166 $this->logger
->debug('DownloadImages: Re-creating gif');
170 imagejpeg($im, $localPath, self
::REGENERATE_PICTURES_QUALITY
);
171 $this->logger
->debug('DownloadImages: Re-creating jpg');
174 imagealphablending($im, false);
175 imagesavealpha($im, true);
176 imagepng($im, $localPath, ceil(self
::REGENERATE_PICTURES_QUALITY
/ 100 * 9));
177 $this->logger
->debug('DownloadImages: Re-creating png');
182 return $this->wallabagUrl
. '/assets/images/' . $relativePath . '/' . $hashImage . '.' . $ext;
186 * Remove all images for the given entry id.
188 * @param int $entryId ID of the entry
190 public function removeImages($entryId)
192 $relativePath = $this->getRelativePath($entryId);
193 $folderPath = $this->baseFolder
. '/' . $relativePath;
195 $finder = new Finder();
198 ->ignoreDotFiles(true)
201 foreach ($finder as $file) {
202 @unlink($file->getRealPath());
209 * Get images urls from the srcset image attribute.
211 * @param Crawler $imagesCrawler
213 * @return array An array of urls
215 private static function getSrcsetUrls(Crawler
$imagesCrawler)
218 $iterator = $imagesCrawler
220 while ($iterator->valid()) {
221 $srcsetAttribute = $iterator->current()->getAttribute('srcset');
222 if ('' !== $srcsetAttribute) {
223 // Couldn't start with " OR ' OR a white space
224 // Could be one or more white space
225 // Must be one or more digits followed by w OR x
226 $pattern = "/(?:[^\"'\s]+\s*(?:\d+[wx])+)/";
227 preg_match_all($pattern, $srcsetAttribute, $matches);
228 $srcset = \
call_user_func_array('array_merge', $matches);
229 $srcsetUrls = array_map(function ($src) {
230 return trim(explode(' ', $src, 2)[0]);
232 $urls = array_merge($srcsetUrls, $urls);
241 * Setup base folder where all images are going to be saved.
243 private function setFolder()
245 // if folder doesn't exist, attempt to create one and store the folder name in property $folder
246 if (!file_exists($this->baseFolder
)) {
247 mkdir($this->baseFolder
, 0755, true);
252 * Generate the folder where we are going to save images based on the entry url.
254 * @param int $entryId ID of the entry
258 private function getRelativePath($entryId)
260 $hashId = hash('crc32', $entryId);
261 $relativePath = $hashId[0] . '/' . $hashId[1] . '/' . $hashId;
262 $folderPath = $this->baseFolder
. '/' . $relativePath;
264 if (!file_exists($folderPath)) {
265 mkdir($folderPath, 0777, true);
268 $this->logger
->debug('DownloadImages: Folder used for that Entry id', ['folder' => $folderPath, 'entryId' => $entryId]);
270 return $relativePath;
274 * Make an $url absolute based on the $base.
276 * @see Graby->makeAbsoluteStr
278 * @param string $base Base url
279 * @param string $url Url to make it absolute
281 * @return false|string
283 private function getAbsoluteLink($base, $url)
285 if (preg_match('!^https?://!i', $url)) {
290 $base = new \
SimplePie_IRI($base);
292 // remove '//' in URL path (causes URLs not to resolve properly)
293 if (isset($base->ipath
)) {
294 $base->ipath
= preg_replace('!//+!', '/', $base->ipath
);
297 if ($absolute = \SimplePie_IRI
::absolutize($base, $url)) {
298 return $absolute->get_uri();
301 $this->logger
->error('DownloadImages: Can not make an absolute link', ['base' => $base, 'url' => $url]);
307 * Retrieve and validate the extension from the response of the url of the image.
309 * @param Response $res Guzzle Response
310 * @param string $imagePath Path from the src image from the content (used for log only)
312 * @return string|false Extension name or false if validation failed
314 private function getExtensionFromResponse(Response
$res, $imagePath)
316 $ext = $this->mimeGuesser
->guess($res->getHeader('content-type'));
317 $this->logger
->debug('DownloadImages: Checking extension', ['ext' => $ext, 'header' => $res->getHeader('content-type')]);
319 // ok header doesn't have the extension, try a different way
322 'jpeg' => "\xFF\xD8\xFF",
324 'png' => "\x89\x50\x4e\x47\x0d\x0a",
326 $bytes = substr((string) $res->getBody(), 0, 8);
328 foreach ($types as $type => $header) {
329 if (0 === strpos($bytes, $header)) {
335 $this->logger
->debug('DownloadImages: Checking extension (alternative)', ['ext' => $ext]);
338 if (!\
in_array($ext, ['jpeg', 'jpg', 'gif', 'png'], true)) {
339 $this->logger
->error('DownloadImages: Processed image with not allowed extension. Skipping: ' . $imagePath);