*/ private $article_link_cache = []; /** @var PluginHost $host */ private $host; /** @var DiskCache $cache */ private $cache; function about() { return array(null, "Resizes media on the fly (for API clients or web UI)", "fox", false, "https://dev.tt-rss.org/fox/ttrss-api-resize"); } function is_public_method($method) { return $method === "api_resize"; } function init($host) { $this->host = $host; $this->cache = DiskCache::instance("images"); $host->add_hook($host::HOOK_RENDER_ARTICLE_API, $this, 999); $host->add_hook($host::HOOK_ARTICLE_IMAGE, $this, 999); $host->add_hook($host::HOOK_PREFS_TAB, $this); $host->add_hook($host::HOOK_RENDER_ARTICLE_CDM, $this, 999); $host->add_hook($host::HOOK_RENDER_ARTICLE, $this, 999); $host->add_hook($host::HOOK_ENCLOSURE_ENTRY, $this, 999); $host->add_hook($host::HOOK_ARTICLE_FILTER, $this, 999); } private function make_thumbnail(string $input_filename, string $output_filename, int $dim_max_x = 600, int $dim_max_y = 600, string $content_type = "", bool $force_stamp = false, int $quality = self::DEFAULT_QUALITY) : void { if ($content_type == "image/webp") { $o_im = @imagecreatefromwebp($input_filename); } else { $o_im = @imagecreatefromstring(file_get_contents($input_filename)); } if ($o_im) { $imageinfo = @getimagesize($input_filename); $o_width = imagesx($o_im) ; $o_height = imagesy($o_im) ; if (max($o_width, $o_height) < max($dim_max_x, $dim_max_y)) { $t_height = $o_height; $t_width = $o_width; } else { if ($o_height > $o_width) { $t_height = $dim_max_x; $t_width = round($o_width/$o_height * $t_height); } else { $t_width = $dim_max_y; $t_height = round($o_height/$o_width * $t_width); } } // print "$o_file : $t_file : $o_height * $o_width -> $t_height * $t_width
"; $t_im = imageCreateTrueColor($t_width, $t_height); $need_stamp = false; if ($force_stamp || ($imageinfo && $imageinfo["mime"] == "image/gif" && $this->is_animated_gif($input_filename))) { $need_stamp = true; imageFill($t_im, 0, 0, 0xffffff); /*} else { -- this might make resized image significantly larger imagealphablending($t_im, false); imagesavealpha($t_im, true); */ } imageCopyResampled($t_im, $o_im, 0, 0, 0, 0, $t_width, $t_height, $o_width, $o_height); if ($need_stamp) { $stamp = imagecreatefrompng(__DIR__ . '/images/play-outline.png'); if ($stamp) { $sx = imagesx($stamp); $sy = imagesy($stamp); imagecopy($t_im, $stamp, (int)(imagesx($t_im)/2 - $sx/2), (int)(imagesy($t_im)/2 - $sy/2), 0, 0, imagesx($stamp), imagesy($stamp)); } } /*if ($need_stamp || !$need_alpha) @imageJpeg($t_im, $output_filename, 75); else @imagePng($t_im, $output_filename, 5);*/ imagewebp($t_im, $output_filename, $quality); imageDestroy($o_im); imageDestroy($t_im); } } /** * @param string $url * @param array $widths * @param bool $force_stamp * @return void * @throws PDOException */ function prepare_thumbnails(string $url, array $widths, bool $force_stamp) : void { Debug::log("[api_resize_media] checking URL $url (force_stamp=$force_stamp)...", Debug::LOG_VERBOSE); $url = UrlHelper::validate($url); if (!$url) { Debug::log("[api_resize_media] URL failed validation, skipping.", Debug::LOG_VERBOSE); return; } $origin_domain = parse_url($url, PHP_URL_HOST); $domain_blacklist = $this->host->get_array($this, "domain_blacklist", self::DEFAULT_DOMAIN_BLACKLIST); if (in_array($origin_domain, $domain_blacklist)) { Debug::log("[api_resize_media] URL is blacklisted, skipping.", Debug::LOG_VERBOSE); return; } $local_filename = sha1($url); $local_filename_flag = "$local_filename.api_resize-flag"; $quality = $this->host->get($this, "quality", self::DEFAULT_QUALITY); if ($this->cache->exists($local_filename_flag)) { Debug::log("[api_resize_media] $local_filename_flag exists, looks like we failed on this URL before; skipping.", Debug::LOG_VERBOSE); return; } else { $this->cache->put($local_filename_flag, ""); } if (!$this->cache->exists($local_filename)) { $data = UrlHelper::fetch(["url" => $url, "max_size" => Config::get(Config::MAX_CACHE_FILE_SIZE)]); if ($data) { if (!$this->cache->put($local_filename, $data)) { Debug::log("[api_resize_media] error: could not save URL to local cache.", Debug::LOG_VERBOSE); return; } } } foreach ($widths as $width) { if ($width <= 0) continue; if ($width > self::MAX_WIDTH) $width = self::MAX_WIDTH; $resized_filename = sha1($url) . "-$width"; if (!$this->cache->exists($resized_filename)) { Debug::log("[api_resize_media] preparing width: $resized_filename (q=$quality)", Debug::LOG_VERBOSE); if ($this->cache->exists($local_filename)) { $this->make_thumbnail( $this->cache->get_full_path($local_filename), $this->cache->get_full_path($resized_filename), $width, $width, $this->cache->get_mime_type($local_filename), $force_stamp, $quality); } } } } public function api_resize() : void { $url = UrlHelper::validate($_REQUEST["url"]); // TODO: render error image using GD if (!$url) return; $referrer = UrlHelper::validate($_REQUEST["referrer"] ?? ""); $width = (int) $_REQUEST["width"]; $force_stamp = sql_bool_to_bool($_REQUEST["force_stamp"]); $origin_domain = parse_url($url, PHP_URL_HOST); $domain_blacklist = $this->host->get_array($this, "domain_blacklist", self::DEFAULT_DOMAIN_BLACKLIST); if (in_array($origin_domain, $domain_blacklist)) { header("Location: $url"); return; } if ($width > self::MAX_WIDTH) $width = self::MAX_WIDTH; $local_filename = sha1($url); $flag_filename = sha1($url) . ".flag"; $resized_filename = sha1($url) . "-$width"; $quality = $this->host->profile_get($this, "quality", self::DEFAULT_QUALITY); if (!$this->cache->exists($flag_filename) || $width <= 0) { header("Location: $url"); return; } if ($this->cache->exists($local_filename)) { if ($this->cache->exists($resized_filename)) { header("Location: " . $this->cache->get_url($resized_filename)); return; } else { $this->make_thumbnail( $this->cache->get_full_path($local_filename), $this->cache->get_full_path($resized_filename), $width, $width, $this->cache->get_mime_type($local_filename), $force_stamp, $quality); if ($this->cache->exists($resized_filename)) { header("Location: " . $this->cache->get_url($resized_filename)); return; } else { header("Location: " . $this->cache->get_url($local_filename)); return; } } } else { $data = UrlHelper::fetch(["url" => $url, "http_referrer" => $referrer, "max_size" => Config::get(Config::MAX_CACHE_FILE_SIZE)]); if ($data) { if ($this->cache->put($local_filename, $data)) { if ($this->cache->exists($resized_filename)) { header("Location: " . $this->cache->get_url($resized_filename)); return; } else { $this->make_thumbnail( $this->cache->get_full_path($local_filename), $this->cache->get_full_path($resized_filename), $width, $width, $this->cache->get_mime_type($local_filename), $force_stamp, $quality); if ($this->cache->exists($resized_filename)) { header("Location: " . $this->cache->get_url($resized_filename)); return; } else { header("Location: " . $this->cache->get_url($local_filename)); return; } } } } else { if (function_exists("imagecreate") && !isset($_REQUEST["text"])) { $img = imagecreate(450, 75); /*$bg =*/ imagecolorallocate($img, 255, 255, 255); $textcolor = imagecolorallocate($img, 255, 0, 0); imagerectangle($img, 0, 0, 450-1, 75-1, $textcolor); imagestring($img, 5, 5, 5, "Proxy request failed", $textcolor); imagestring($img, 5, 5, 30, truncate_middle($url, 46, "..."), $textcolor); imagestring($img, 5, 5, 55, "HTTP Code: ".UrlHelper::$fetch_last_error_code, $textcolor); header("Content-type: image/png"); print imagepng($img); imagedestroy($img); } else { header("Content-type: text/plain"); http_response_code(400); print "Proxy request failed.\n". "Fetch error ".UrlHelper::$fetch_last_error." (".UrlHelper::$fetch_last_error_code.")\n". "Requested URL: $url"; } } } } private function rewrite_url_if_needed(string $url, int $width, ?string $referrer = "", bool $force_stamp = false) : string { $scheme = parse_url($url, PHP_URL_SCHEME) ?? ""; if ($width > 0 && !in_array($scheme, self::IGNORE_SCHEMES)) { if ($width > self::MAX_WIDTH) $width = self::MAX_WIDTH; $local_filename = sha1($url) . "-$width"; if ($this->cache->exists($local_filename)) { return $this->cache->get_url($local_filename); } else { $flag = sha1($url) . ".flag"; if (!$this->cache->exists($flag)) $this->cache->put($flag, ""); if ($this->cache->exists($flag)) { return $this->host->get_public_method_url($this, "api_resize", ["url" => $url, "referrer" => $referrer ?? '', "width" => $width, "force_stamp" => $force_stamp]); } } } return $url; } /** * @param array $row * @param int $width * @return array */ private function process_article(array $row, int $width) : array { $need_saving = false; $article = isset($row['headline']) ? $row['headline'] : $row['article']; if ($width <= 0) return $article; $doc = new DOMDocument(); if (@$doc->loadHTML('' . $article["content"])) { $xpath = new DOMXPath($doc); $imgs = $xpath->query("//img[@src]"); foreach ($imgs as $img) { $orig_src = $img->getAttribute("src"); $new_src = $this->rewrite_url_if_needed($orig_src, $width, $article['link']); if ($new_src != $orig_src) { $need_saving = true; $parent = $img->parentNode; if ($parent && $parent->tagName != "a") { $img_link = $doc->createElement("a"); $img_link->setAttribute("href", $orig_src); $img_link->setAttribute("target", "_blank"); $parent->replaceChild($img_link, $img); $img_link->appendChild($img); } $img->setAttribute("src", $new_src); $img->removeAttribute("srcset"); } } $vids = $xpath->query("//video[@poster]"); foreach ($vids as $vid) { $new_poster = $this->rewrite_url_if_needed($vid->getAttribute("poster"), $width, $article['link'], true); if ($new_poster != $vid->getAttribute("poster")) { $vid->setAttribute("poster", $new_poster); $need_saving = true; } } $psrcs = $xpath->query("//picture/source[@src]"); foreach ($psrcs as $src) { $new_src = $this->rewrite_url_if_needed($src->getAttribute("src"), $width, $article['link']); if ($new_src != $src->getAttribute("src")) { $src->setAttribute("src", $new_src); $need_saving = true; } } } if (isset($article["attachments"]) && is_array($article["attachments"])) { $tmp =& $article["attachments"]; for ($i = 0; $i < count($tmp); $i++) { if (preg_match("/image/", $tmp[$i]["content_type"])) { $tmp[$i]["content_url"] = $this->rewrite_url_if_needed($tmp[$i]["content_url"], $width, $article['link']); } } } if ($need_saving) $article["content"] = $doc->saveHTML(); return $article; } function hook_article_image($enclosures, $content, $site_url, $article) { $width = (int) clean($_REQUEST["resize_width"] ?? 0); $article = $this->process_article([ "headline" => [ "content" => $content, "link" => $article['link'] ] ], $width); return ["", "", $article["content"]]; } function hook_enclosure_entry($enc, $id, $rv) { $force_width = (int) $this->host->profile_get($this, "force_width", 0); if (!isset($this->article_link_cache[$id])) { $article = ORM::for_table('ttrss_entries') ->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue') ->where('ue.owner_uid', $_SESSION['uid']) ->find_one((int)$id); $this->article_link_cache[$id] = $article->link; } $enc["content_url"] = $this->rewrite_url_if_needed($enc["content_url"], $force_width, ($this->article_link_cache[$id] ?? "")); return $enc; } function hook_article_filter($article) { $widths = array_unique( array_map("intval", $this->host->get_array($this, "prepare_widths"))); if (count($widths) == 0) return $article; $site_url = $article["feed"]["site_url"] ?? ""; $doc = new DOMDocument(); if (@$doc->loadHTML('' . $article["content"])) { $xpath = new DOMXPath($doc); $imgs = $xpath->query("//img[@src]"); foreach ($imgs as $img) { $this->prepare_thumbnails( UrlHelper::rewrite_relative($site_url, $img->getAttribute("src")), $widths, false); } $vids = $xpath->query("//video[@poster]"); foreach ($vids as $vid) { $this->prepare_thumbnails( UrlHelper::rewrite_relative($site_url, $vid->getAttribute("poster")), $widths, false); } $posters = $xpath->query("//picture/source[@src]"); foreach ($posters as $poster) { $this->prepare_thumbnails( UrlHelper::rewrite_relative($site_url, $poster->getAttribute("src")), $widths, true); } /** @var FeedEnclosure $enc */ foreach ($article["enclosures"] as $enc) { if (preg_match("/image/", $enc->type)) { $this->prepare_thumbnails( UrlHelper::rewrite_relative($site_url, $enc->link), $widths, false); } } } return $article; } function hook_render_article_cdm($row) { $force_width = (int) $this->host->profile_get($this, "force_width", 0); return $this->process_article(["article" => $row], $force_width); } function hook_render_article($row) { $force_width = (int) $this->host->profile_get($this, "force_width", 0); return $this->process_article(["article" => $row], $force_width); } function hook_render_article_api($row) { $width = (int) clean($_REQUEST["resize_width"] ?? 0); return $this->process_article($row, $width); } // https://stackoverflow.com/questions/280658/can-i-detect-animated-gifs-using-php-and-gd function is_animated_gif(string $filename) : bool { $raw = file_get_contents($filename); if ($raw) { $offset = 0; $frames = 0; while ($frames < 2 && $offset <= 500000) { $where1 = strpos($raw, "\x00\x21\xF9\x04", $offset); if ($where1 === false) { break; } else { $offset = $where1 + 1; $where2 = strpos($raw, "\x00\x2C", $offset); if ($where2 === false) { break; } else { if ($where1 + 8 == $where2) { $frames++; } $offset = $where2 + 1; } } } return $frames > 1; } return false; } function hook_prefs_tab($args) { if ($args != "prefFeeds") return; $force_width = (int) $this->host->profile_get($this, "force_width", 0); $prepare_widths = implode(", ", $this->host->get_array($this, "prepare_widths")); $domain_blacklist = implode(", ", $this->host->get_array($this, "domain_blacklist", self::DEFAULT_DOMAIN_BLACKLIST)); $quality = (int) $this->host->profile_get($this, "quality", self::DEFAULT_QUALITY); ?>
" required='1' name='force_width' value="">   __('(pixels)') ?>
" name='prepare_widths' value="">   __('(comma-separated list, disabled if empty)') ?>
" name='domain_blacklist' value="">   __('(comma-separated list, disabled if empty)') ?>
" required='1' name='quality' value="">

__( "Save")) ?>
host->set($this, "prepare_widths", $prepare_widths); $this->host->set($this, "domain_blacklist", $domain_blacklist); $this->host->profile_set($this, "force_width", $force_width); $this->host->profile_set($this, "quality", $quality); echo $this->T_sprintf("Configuration has been saved."); } function api_version() { return 2; } }