X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=src%2FWallabag%2FCoreBundle%2FHelper%2FContentProxy.php;h=d38811a272caa13b0ed859a99ee394c64e93b831;hb=1b220426e2e8139364b4a34678a2843c2e8bccf5;hp=2a650e9711604261fbfe74d7a56b6cf22efe515a;hpb=a05b61159e147776f63baee731b5026796e5f7ae;p=github%2Fwallabag%2Fwallabag.git diff --git a/src/Wallabag/CoreBundle/Helper/ContentProxy.php b/src/Wallabag/CoreBundle/Helper/ContentProxy.php index 2a650e97..d38811a2 100644 --- a/src/Wallabag/CoreBundle/Helper/ContentProxy.php +++ b/src/Wallabag/CoreBundle/Helper/ContentProxy.php @@ -24,8 +24,9 @@ class ContentProxy protected $mimeGuesser; protected $fetchingErrorMessage; protected $eventDispatcher; + protected $storeArticleHeaders; - public function __construct(Graby $graby, RuleBasedTagger $tagger, ValidatorInterface $validator, LoggerInterface $logger, $fetchingErrorMessage) + public function __construct(Graby $graby, RuleBasedTagger $tagger, ValidatorInterface $validator, LoggerInterface $logger, $fetchingErrorMessage, $storeArticleHeaders = false) { $this->graby = $graby; $this->tagger = $tagger; @@ -33,6 +34,7 @@ class ContentProxy $this->logger = $logger; $this->mimeGuesser = new MimeTypeExtensionGuesser(); $this->fetchingErrorMessage = $fetchingErrorMessage; + $this->storeArticleHeaders = $storeArticleHeaders; } /** @@ -51,6 +53,7 @@ class ContentProxy if ((empty($content) || false === $this->validateContent($content)) && false === $disableContentUpdate) { $fetchedContent = $this->graby->fetchContent($url); + $fetchedContent['title'] = $this->sanitizeContentTitle($fetchedContent['title'], $fetchedContent['content_type']); // when content is imported, we have information in $content // in case fetching content goes bad, we'll keep the imported information instead of overriding them @@ -63,9 +66,177 @@ class ContentProxy // so we'll be able to refetch it in the future $content['url'] = !empty($content['url']) ? $content['url'] : $url; + // In one case (at least in tests), url is empty here + // so we set it using $url provided in the updateEntry call. + // Not sure what are the other possible cases where this property is empty + if (empty($entry->getUrl()) && !empty($url)) { + $entry->setUrl($url); + } + $this->stockEntry($entry, $content); } + /** + * Use a Symfony validator to ensure the language is well formatted. + * + * @param Entry $entry + * @param string $value Language to validate and save + */ + public function updateLanguage(Entry $entry, $value) + { + // some lang are defined as fr-FR, es-ES. + // replacing - by _ might increase language support + $value = str_replace('-', '_', $value); + + $errors = $this->validator->validate( + $value, + (new LocaleConstraint()) + ); + + if (0 === \count($errors)) { + $entry->setLanguage($value); + + return; + } + + $this->logger->warning('Language validation failed. ' . (string) $errors); + } + + /** + * Use a Symfony validator to ensure the preview picture is a real url. + * + * @param Entry $entry + * @param string $value URL to validate and save + */ + public function updatePreviewPicture(Entry $entry, $value) + { + $errors = $this->validator->validate( + $value, + (new UrlConstraint()) + ); + + if (0 === \count($errors)) { + $entry->setPreviewPicture($value); + + return; + } + + $this->logger->warning('PreviewPicture validation failed. ' . (string) $errors); + } + + /** + * Update date. + * + * @param Entry $entry + * @param string $value Date to validate and save + */ + public function updatePublishedAt(Entry $entry, $value) + { + $date = $value; + + // is it a timestamp? + if (false !== filter_var($date, FILTER_VALIDATE_INT)) { + $date = '@' . $date; + } + + try { + // is it already a DateTime? + // (it's inside the try/catch in case of fail to be parse time string) + if (!$date instanceof \DateTime) { + $date = new \DateTime($date); + } + + $entry->setPublishedAt($date); + } catch (\Exception $e) { + $this->logger->warning('Error while defining date', ['e' => $e, 'url' => $entry->getUrl(), 'date' => $value]); + } + } + + /** + * Helper to extract and save host from entry url. + * + * @param Entry $entry + */ + public function setEntryDomainName(Entry $entry) + { + $domainName = parse_url($entry->getUrl(), PHP_URL_HOST); + if (false !== $domainName) { + $entry->setDomainName($domainName); + } + } + + /** + * Helper to set a default title using: + * - url basename, if applicable + * - hostname. + * + * @param Entry $entry + */ + public function setDefaultEntryTitle(Entry $entry) + { + $url = parse_url($entry->getUrl()); + $path = pathinfo($url['path'], PATHINFO_BASENAME); + + if (empty($path)) { + $path = $url['host']; + } + + $entry->setTitle($path); + } + + /** + * Try to sanitize the title of the fetched content from wrong character encodings and invalid UTF-8 character. + * + * @param $title + * @param $contentType + * + * @return string + */ + private function sanitizeContentTitle($title, $contentType) + { + if ('application/pdf' === $contentType) { + $title = $this->convertPdfEncodingToUTF8($title); + } + + return $this->sanitizeUTF8Text($title); + } + + /** + * If the title from the fetched content comes from a PDF, then its very possible that the character encoding is not + * UTF-8. This methods tries to identify the character encoding and translate the title to UTF-8. + * + * @param $title + * + * @return string (maybe contains invalid UTF-8 character) + */ + private function convertPdfEncodingToUTF8($title) + { + // first try UTF-8 because its easier to detect its present/absence + foreach (['UTF-8', 'UTF-16BE', 'WINDOWS-1252'] as $encoding) { + if (mb_check_encoding($title, $encoding)) { + return mb_convert_encoding($title, 'UTF-8', $encoding); + } + } + + return $title; + } + + /** + * Remove invalid UTF-8 characters from the given string. + * + * @param string $rawText + * + * @return string + */ + private function sanitizeUTF8Text($rawText) + { + if (mb_check_encoding($rawText, 'UTF-8')) { + return $rawText; + } + + return iconv('UTF-8', 'UTF-8//IGNORE', $rawText); + } + /** * Stock entry with fetched or imported content. * Will fall back to OpenGraph data if available. @@ -75,12 +246,9 @@ class ContentProxy */ private function stockEntry(Entry $entry, array $content) { - $entry->setUrl($content['url']); + $this->updateOriginUrl($entry, $content['url']); - $domainName = parse_url($entry->getUrl(), PHP_URL_HOST); - if (false !== $domainName) { - $entry->setDomainName($domainName); - } + $this->setEntryDomainName($entry); if (!empty($content['title'])) { $entry->setTitle($content['title']); @@ -105,11 +273,11 @@ class ContentProxy $entry->setHttpStatus($content['status']); } - if (!empty($content['authors']) && is_array($content['authors'])) { + if (!empty($content['authors']) && \is_array($content['authors'])) { $entry->setPublishedBy($content['authors']); } - if (!empty($content['all_headers'])) { + if (!empty($content['all_headers']) && $this->storeArticleHeaders) { $entry->setHeaders($content['all_headers']); } @@ -126,11 +294,8 @@ class ContentProxy } // if content is an image, define it as a preview too - if (!empty($content['content_type']) && in_array($this->mimeGuesser->guess($content['content_type']), ['jpeg', 'jpg', 'gif', 'png'], true)) { - $this->validateAndSetPreviewPicture( - $entry, - $content['url'] - ); + if (!empty($content['content_type']) && \in_array($this->mimeGuesser->guess($content['content_type']), ['jpeg', 'jpg', 'gif', 'png'], true)) { + $this->updatePreviewPicture($entry, $content['url']); } if (!empty($content['content_type'])) { @@ -148,84 +313,123 @@ class ContentProxy } /** - * Validate that the given content has at least a title, an html and a url. - * - * @param array $content - * - * @return bool true if valid otherwise false - */ - private function validateContent(array $content) - { - return !empty($content['title']) && !empty($content['html']) && !empty($content['url']); - } - - /** - * Use a Symfony validator to ensure the language is well formatted. + * Update the origin_url field when a redirection occurs + * This field is set if it is empty and new url does not match ignore list. * * @param Entry $entry - * @param string $value Language to validate and save + * @param string $url */ - public function updateLanguage(Entry $entry, $value) + private function updateOriginUrl(Entry $entry, $url) { - // some lang are defined as fr-FR, es-ES. - // replacing - by _ might increase language support - $value = str_replace('-', '_', $value); - - $errors = $this->validator->validate( - $value, - (new LocaleConstraint()) - ); - - if (0 === count($errors)) { - $entry->setLanguage($value); + if (empty($url) || $entry->getUrl() === $url) { + return false; + } - return; + $parsed_entry_url = parse_url($entry->getUrl()); + $parsed_content_url = parse_url($url); + + /** + * The following part computes the list of part changes between two + * parse_url arrays. + * + * As array_diff_assoc only computes changes to go from the left array + * to the right one, we make two differents arrays to have both + * directions. We merge these two arrays and sort keys before passing + * the result to the switch. + * + * The resulting array gives us all changing parts between the two + * urls: scheme, host, path, query and/or fragment. + */ + $diff_ec = array_diff_assoc($parsed_entry_url, $parsed_content_url); + $diff_ce = array_diff_assoc($parsed_content_url, $parsed_entry_url); + + $diff = array_merge($diff_ec, $diff_ce); + $diff_keys = array_keys($diff); + sort($diff_keys); + + if ($this->ignoreUrl($entry->getUrl())) { + $entry->setUrl($url); + + return false; } - $this->logger->warning('Language validation failed. ' . (string) $errors); + /** + * This switch case lets us apply different behaviors according to + * changing parts of urls. + * + * As $diff_keys is an array, we provide arrays as cases. ['path'] means + * 'only the path is different between the two urls' whereas + * ['fragment', 'query'] means 'only fragment and query string parts are + * different between the two urls'. + * + * Note that values in $diff_keys are sorted. + */ + switch ($diff_keys) { + case ['path']: + if (($parsed_entry_url['path'] . '/' === $parsed_content_url['path']) // diff is trailing slash, we only replace the url of the entry + || ($url === urldecode($entry->getUrl()))) { // we update entry url if new url is a decoded version of it, see EntryRepository#findByUrlAndUserId + $entry->setUrl($url); + } + break; + case ['scheme']: + $entry->setUrl($url); + break; + case ['fragment']: + // noop + break; + default: + if (empty($entry->getOriginUrl())) { + $entry->setOriginUrl($entry->getUrl()); + } + $entry->setUrl($url); + break; + } } /** - * Use a Symfony validator to ensure the preview picture is a real url. + * Check entry url against an ignore list to replace with content url. * - * @param Entry $entry - * @param string $value URL to validate and save + * XXX: move the ignore list in the database to let users handle it + * + * @param string $url url to test + * + * @return bool true if url matches ignore list otherwise false */ - public function updatePreviewPicture(Entry $entry, $value) + private function ignoreUrl($url) { - $errors = $this->validator->validate( - $value, - (new UrlConstraint()) - ); + $ignored_hosts = ['feedproxy.google.com', 'feeds.reuters.com']; + $ignored_patterns = ['https?://www\.lemonde\.fr/tiny.*']; - if (0 === count($errors)) { - $entry->setPreviewPicture($value); + $parsed_url = parse_url($url); - return; + $filtered = array_filter($ignored_hosts, function ($var) use ($parsed_url) { + return $var === $parsed_url['host']; + }); + + if ([] !== $filtered) { + return true; } - $this->logger->warning('PreviewPicture validation failed. ' . (string) $errors); + $filtered = array_filter($ignored_patterns, function ($var) use ($url) { + return preg_match("`$var`i", $url); + }); + + if ([] !== $filtered) { + return true; + } + + return false; } /** - * Update date. + * Validate that the given content has at least a title, an html and a url. * - * @param Entry $entry - * @param string $value Date to validate and save + * @param array $content + * + * @return bool true if valid otherwise false */ - public function updatePublishedAt(Entry $entry, $value) + private function validateContent(array $content) { - $date = $value; - - // is it a timestamp? - if (filter_var($date, FILTER_VALIDATE_INT) !== false) { - $date = '@'.$value; - } - - try { - $entry->setPublishedAt(new \DateTime($date)); - } catch (\Exception $e) { - $this->logger->warning('Error while defining date', ['e' => $e, 'url' => $entry->getUrl(), 'date' => $value]); - } + return !empty($content['title']) && !empty($content['html']) && !empty($content['url']); } }