From a5cc1c2b42e947712d3c70583868d5d60a13a0de Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 4 Oct 2024 01:01:47 +0200 Subject: [PATCH 01/33] HttpDriver.php: backport from upstream --- inc/Data/Driver/HttpDriver.php | 131 +++++++++++++++++++++++++++++++++ inc/context.php | 6 +- 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 inc/Data/Driver/HttpDriver.php diff --git a/inc/Data/Driver/HttpDriver.php b/inc/Data/Driver/HttpDriver.php new file mode 100644 index 00000000..022fcbab --- /dev/null +++ b/inc/Data/Driver/HttpDriver.php @@ -0,0 +1,131 @@ +inner); + \curl_setopt_array($this->inner, [ + \CURLOPT_URL => $url, + \CURLOPT_TIMEOUT => $timeout, + \CURLOPT_USERAGENT => 'Tinyboard', + \CURLOPT_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS, + ]); + } + + public function __construct(int $timeout, int $max_file_size) { + $this->inner = \curl_init(); + $this->timeout = $timeout; + $this->max_file_size = $max_file_size; + } + + public function __destruct() { + \curl_close($this->inner); + } + + /** + * Execute a GET request. + * + * @param string $endpoint Uri endpoint. + * @param ?array $data Optional GET parameters. + * @param int $timeout Optional request timeout in seconds. Use the default timeout if 0. + * @return string Returns the body of the response. + * @throws RuntimeException Throws on IO error. + */ + public function requestGet(string $endpoint, ?array $data, int $timeout = 0): string { + if (!empty($data)) { + $endpoint .= '?' . \http_build_query($data); + } + if ($timeout == 0) { + $timeout = $this->timeout; + } + + $this->resetTowards($endpoint, $timeout); + \curl_setopt($this->inner, \CURLOPT_RETURNTRANSFER, true); + $ret = \curl_exec($this->inner); + + if ($ret === false) { + throw new \RuntimeException(\curl_error($this->inner)); + } + return $ret; + } + + /** + * Execute a POST request. + * + * @param string $endpoint Uri endpoint. + * @param ?array $data Optional POST parameters. + * @param int $timeout Optional request timeout in seconds. Use the default timeout if 0. + * @return string Returns the body of the response. + * @throws RuntimeException Throws on IO error. + */ + public function requestPost(string $endpoint, ?array $data, int $timeout = 0): string { + if ($timeout == 0) { + $timeout = $this->timeout; + } + + $this->resetTowards($endpoint, $timeout); + \curl_setopt($this->inner, \CURLOPT_POST, true); + if (!empty($data)) { + \curl_setopt($this->inner, \CURLOPT_POSTFIELDS, \http_build_query($data)); + } + \curl_setopt($this->inner, \CURLOPT_RETURNTRANSFER, true); + $ret = \curl_exec($this->inner); + + if ($ret === false) { + throw new \RuntimeException(\curl_error($this->inner)); + } + return $ret; + } + + /** + * Download the url's target with curl. + * + * @param string $url Url to the file to download. + * @param ?array $data Optional GET parameters. + * @param resource $fd File descriptor to save the content to. + * @param int $timeout Optional request timeout in seconds. Use the default timeout if 0. + * @return bool Returns true on success, false if the file was too large. + * @throws RuntimeException Throws on IO error. + */ + public function requestGetInto(string $endpoint, ?array $data, mixed $fd, int $timeout = 0): bool { + if (!empty($data)) { + $endpoint .= '?' . \http_build_query($data); + } + if ($timeout == 0) { + $timeout = $this->timeout; + } + + $this->resetTowards($endpoint, $timeout); + // Adapted from: https://stackoverflow.com/a/17642638 + $opt = (\PHP_MAJOR_VERSION >= 8 && \PHP_MINOR_VERSION >= 2) ? \CURLOPT_XFERINFOFUNCTION : \CURLOPT_PROGRESSFUNCTION; + \curl_setopt_array($this->inner, [ + \CURLOPT_NOPROGRESS => false, + $opt => fn($res, $next_dl, $dl, $next_up, $up) => (int)($dl <= $this->max_file_size), + \CURLOPT_FAILONERROR => true, + \CURLOPT_FOLLOWLOCATION => false, + \CURLOPT_FILE => $fd, + \CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, + ]); + $ret = \curl_exec($this->inner); + + if ($ret === false) { + if (\curl_errno($this->inner) === CURLE_ABORTED_BY_CALLBACK) { + return false; + } + + throw new \RuntimeException(\curl_error($this->inner)); + } + return true; + } +} diff --git a/inc/context.php b/inc/context.php index 11a153ec..9a0a378d 100644 --- a/inc/context.php +++ b/inc/context.php @@ -2,7 +2,7 @@ namespace Vichan; use Vichan\Data\{IpNoteQueries, ReportQueries, UserPostQueries}; -use Vichan\Data\Driver\{CacheDriver, ErrorLogLogDriver, FileLogDriver, LogDriver, StderrLogDriver, SyslogLogDriver}; +use Vichan\Data\Driver\{CacheDriver, HttpDriver, ErrorLogLogDriver, FileLogDriver, LogDriver, StderrLogDriver, SyslogLogDriver}; defined('TINYBOARD') or exit; @@ -63,6 +63,10 @@ function build_context(array $config): Context { // Use the global for backwards compatibility. return \cache::getCache(); }, + HttpDriver::class => function($c) { + $config = $c->get('config'); + return new HttpDriver($config['upload_by_url_timeout'], $config['max_filesize']); + }, \PDO::class => function($c) { global $pdo; // Ensure the PDO is initialized. From bf42570d5dada46f281951f56487efba692dc44c Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sun, 16 Mar 2025 22:52:28 +0100 Subject: [PATCH 02/33] HttpDriver.php: add headers to requestGet --- inc/Data/Driver/HttpDriver.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/inc/Data/Driver/HttpDriver.php b/inc/Data/Driver/HttpDriver.php index 022fcbab..197f2681 100644 --- a/inc/Data/Driver/HttpDriver.php +++ b/inc/Data/Driver/HttpDriver.php @@ -38,11 +38,12 @@ class HttpDriver { * * @param string $endpoint Uri endpoint. * @param ?array $data Optional GET parameters. + * @param ?array $data Optional HTTP headers. * @param int $timeout Optional request timeout in seconds. Use the default timeout if 0. * @return string Returns the body of the response. * @throws RuntimeException Throws on IO error. */ - public function requestGet(string $endpoint, ?array $data, int $timeout = 0): string { + public function requestGet(string $endpoint, ?array $data, ?array $headers, int $timeout = 0): string { if (!empty($data)) { $endpoint .= '?' . \http_build_query($data); } @@ -51,6 +52,9 @@ class HttpDriver { } $this->resetTowards($endpoint, $timeout); + if (!empty($headers)) { + \curl_setopt($this->inner, \CURLOPT_HTTPHEADER, $headers); + } \curl_setopt($this->inner, \CURLOPT_RETURNTRANSFER, true); $ret = \curl_exec($this->inner); From 0e9c9de5c68b4858e39e0e93bd5af02aa50e2e5a Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sun, 16 Mar 2025 23:26:05 +0100 Subject: [PATCH 03/33] OembedResponse.php: add oembed POD --- inc/Data/OembedResponse.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 inc/Data/OembedResponse.php diff --git a/inc/Data/OembedResponse.php b/inc/Data/OembedResponse.php new file mode 100644 index 00000000..0466d956 --- /dev/null +++ b/inc/Data/OembedResponse.php @@ -0,0 +1,12 @@ + Date: Sun, 16 Mar 2025 23:26:45 +0100 Subject: [PATCH 04/33] OembedExtractor.php: add extractor --- inc/Services/Embed/OembedExtractor.php | 70 ++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 inc/Services/Embed/OembedExtractor.php diff --git a/inc/Services/Embed/OembedExtractor.php b/inc/Services/Embed/OembedExtractor.php new file mode 100644 index 00000000..e4706859 --- /dev/null +++ b/inc/Services/Embed/OembedExtractor.php @@ -0,0 +1,70 @@ +cache = $cache; + $this->http = $http; + $this->provider_timeout = $provider_timeout; + } + + /** + * Fetch the oembed data from the given provider with the given url. + * + * @param string $identifier Opaque identifier for caching, must be unique for each $url-$provider combination. + * @return OembedResponse The serialized remove response. May be cached. + */ + public function fetch(string $provider_url, string $url): OembedResponse { + $ret = $this->cache->get("oembed_embedder_$provider_url$url"); + if ($ret === null) { + $body = $this->http->requestGet( + $provider_url, + [ + 'url' => $url, + 'format' => 'json' + ], + [ + 'Content-Type: application/json' + ], + $this->provider_timeout + ); + $json = \json_decode($body, null, 512, \JSON_THROW_ON_ERROR); + + if (!isset($json['title'])) { + throw new \RuntimeException("Missing type in response from $provider_url"); + } + $ret = [ + 'type' => $json['type'], + 'title' => $json['title'], + 'cache_age' => $json['cache_age'], + 'thumbnail_url' => $json['thumbnail_url'], + 'thumbnail_width' => $json['thumbnail_width'], + 'thumbnail_height' => $json['thumbnail_heigh'] + ]; + + $cache_timeout = $ret['cache_age'] ?? self::DEFAULT_CACHE_TIMEOUT; + + $this->cache->set("oembed_embedder_$url$provider_url", $ret, $cache_timeout); + } + + $resp = new OembedResponse(); + $resp->type = $ret['title']; + $resp->title = $ret['title'] ?? null; + $resp->cache_age = $ret['cache_age'] ?? null; + $resp->thumbnail_url = $ret['thumbnail_url'] ?? null; + $resp->thumbnail_width = $ret['thumbnail_width'] ?? null; + $resp->thumbnail_height = $ret['thumbnail_height'] ?? null; + return $ret; + } +} From 698451a6d54ed20aa0d97ab18cf6ffb1062874de Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 17 Mar 2025 00:30:03 +0100 Subject: [PATCH 05/33] EmbedService.php: add WIP --- inc/Services/Embed/EmbedService.php | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 inc/Services/Embed/EmbedService.php diff --git a/inc/Services/Embed/EmbedService.php b/inc/Services/Embed/EmbedService.php new file mode 100644 index 00000000..eb3ba21b --- /dev/null +++ b/inc/Services/Embed/EmbedService.php @@ -0,0 +1,64 @@ +log = $log; + } + + /** + * Undocumented function + * + * @param Context $ctx + * @param string $rawText + * @return void + */ + public function matchEmbed(Context $ctx, string $rawText) { + if (\filter_var($rawText, \FILTER_VALIDATE_URL) === false) { + return null; + } + $rawText = \trim($rawText); + + foreach ($this->tuples as $cfg) { + if (!isset($cfg['match_regex'])) { + throw new \RuntimeException('Missing \'match_regex\' field'); + } + $match_regex = $cfg['match_regex']; + + if (\preg_match($match_regex, $rawText, $matches)) { + if (!isset($cfg['type'])) { + throw new \RuntimeException('Missing \'type\' field'); + } + $type = $cfg['type']; + + if ($type === 'oembed') { + if (!isset($cfg['provider'])) { + throw new \RuntimeException('Missing \'provider\' field'); + } + $provider = $cfg['provider']; + + $extractor = $ctx->get(OembedExtractor::class); + $oembed_resp = $extractor->fetch($provider, $rawText); + + + } elseif ($type === 'regex') { + if (!isset($cfg['thumbnail_url'])) { + throw new \RuntimeException('Missing \'thumbnail_url\' field'); + } + $thumbnail_url_regex = $cfg['thumbnail_url']; + // Plz somebody review this. + $thumbnail_url = \preg_replace($match_regex, $thumbnail_url_regex, $rawText); + } else { + $this->log->log(LogDriver::ERROR, "Unknown embed type '$type', ignoring"); + } + } + } + } +} From 4850a8ddd307dec6447c2e266ac8fb74a64442f2 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 17 Mar 2025 00:30:35 +0100 Subject: [PATCH 06/33] config.php: add WIP embedding_2 --- inc/config.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/inc/config.php b/inc/config.php index 25031bfb..a604bb84 100644 --- a/inc/config.php +++ b/inc/config.php @@ -1265,6 +1265,36 @@ $config['embed_width'] = 300; $config['embed_height'] = 246; + /** + * Replacement parameters: + * - $1-$N: matched arguments from 'match_regex'. + * - %%thumbnail_path%%: Path to the downloaded thumbnail. + */ + $config['embedding_2'] = [ + [ + 'match_regex' => '/^(?:(?:https?:)?\/\/)?((?:www|m)\.)?(?:(?:youtube(?:-nocookie)?\.com|youtu\.be))(?:\/(?:[\w\-]+\?v=|embed\/|live\/|v\/)?)([\w\-]{11})((?:\?|\&)\S+)?$/i', + 'type' => 'regex', + 'thumbnail_url' => 'https://img.youtube.com/vi/$2/0.jpg', + 'html' => '
+ + + +
' + ], + [ + 'match_regex' => '/^https?:\/\/(\w+\.)?tiktok\.com\/@[a-z0-9\-_]+\/video\/([0-9]+)\?.*$/i', + 'type' => 'oembed', + 'provider' => 'https://www.youtube.com/oembed', + 'thumb_width' => 288, + 'thumb_height' => 512, + 'html' => '
+ + + +
' + ] + ]; + /* * ==================== * Error messages From 5a8c661257539782d3bcb5531408f25794e8b814 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 17 Mar 2025 12:47:08 +0100 Subject: [PATCH 07/33] OembedExtractor.php: finalize --- inc/Services/Embed/OembedExtractor.php | 28 +++++++++++--------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/inc/Services/Embed/OembedExtractor.php b/inc/Services/Embed/OembedExtractor.php index e4706859..0de72ce3 100644 --- a/inc/Services/Embed/OembedExtractor.php +++ b/inc/Services/Embed/OembedExtractor.php @@ -7,6 +7,7 @@ use Vichan\Data\OembedResponse; class OembedExtractor { private const DEFAULT_CACHE_TIMEOUT = 3600; // 1 hour. + private const MIN_CACHE_TIMEOUT = 900; // 15 minutes. private CacheDriver $cache; private HttpDriver $http; @@ -41,30 +42,25 @@ class OembedExtractor { ); $json = \json_decode($body, null, 512, \JSON_THROW_ON_ERROR); - if (!isset($json['title'])) { - throw new \RuntimeException("Missing type in response from $provider_url"); - } $ret = [ - 'type' => $json['type'], - 'title' => $json['title'], - 'cache_age' => $json['cache_age'], - 'thumbnail_url' => $json['thumbnail_url'], - 'thumbnail_width' => $json['thumbnail_width'], - 'thumbnail_height' => $json['thumbnail_heigh'] + 'title' => $json['title'] ?? null, + 'thumbnail_url' => $json['thumbnail_url'] ?? null, ]; - $cache_timeout = $ret['cache_age'] ?? self::DEFAULT_CACHE_TIMEOUT; + $cache_timeout = self::DEFAULT_CACHE_TIMEOUT; + if (isset($json['cache_age'])) { + $cache_age = \intval($json['cache_age']); + if ($cache_age > 0) { + $cache_age = \max($cache_age, self::MIN_CACHE_TIMEOUT); + } + } $this->cache->set("oembed_embedder_$url$provider_url", $ret, $cache_timeout); } $resp = new OembedResponse(); - $resp->type = $ret['title']; - $resp->title = $ret['title'] ?? null; - $resp->cache_age = $ret['cache_age'] ?? null; - $resp->thumbnail_url = $ret['thumbnail_url'] ?? null; - $resp->thumbnail_width = $ret['thumbnail_width'] ?? null; - $resp->thumbnail_height = $ret['thumbnail_height'] ?? null; + $resp->title = $ret['title']; + $resp->thumbnail_url = $ret['thumbnail_url']; return $ret; } } From 8ac67e9e8526b211e6e0bc784f03f6c93e12cffd Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 17 Mar 2025 12:47:23 +0100 Subject: [PATCH 08/33] OembedResponse.php: trim --- inc/Data/OembedResponse.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/inc/Data/OembedResponse.php b/inc/Data/OembedResponse.php index 0466d956..0e99f5ff 100644 --- a/inc/Data/OembedResponse.php +++ b/inc/Data/OembedResponse.php @@ -2,11 +2,10 @@ namespace Vichan\Data; +/** + * Raw return values, those aren't validated beyond being not null and the type. + */ class OembedResponse { - public string $type; public ?string $title; - public ?int $cache_age; public ?string $thumbnail_url; - public ?int $thumbnail_width; - public ?int $thumbnail_height; } From 256a9682fa26dee67c80807ec7cdc13f38fb1b6e Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 17 Mar 2025 12:47:42 +0100 Subject: [PATCH 09/33] EmbedService.php: download and handle thumbnails --- inc/Services/Embed/EmbedService.php | 140 ++++++++++++++++++++-------- 1 file changed, 103 insertions(+), 37 deletions(-) diff --git a/inc/Services/Embed/EmbedService.php b/inc/Services/Embed/EmbedService.php index eb3ba21b..9db84d4a 100644 --- a/inc/Services/Embed/EmbedService.php +++ b/inc/Services/Embed/EmbedService.php @@ -1,64 +1,130 @@ log = $log; + $this->oembed_extractor = $oembed_extractor; + $this->embed_entries = $embed_entries; + $this->thumb_download_timeout = $thumb_download_timeout; + } + + private function make_tmp_file(): string { + $ret = \tempnam($this->tmp_dir, self::TMP_FILE_PREFIX); + if ($ret === false) { + throw new \RuntimeException("Could not create temporary file in {$this->tmp_dir}"); + } + \register_shutdown_function(fn() => @unlink($ret)); + return $ret; } /** - * Undocumented function + * Downloads the thumbnail into a temporary file. * - * @param Context $ctx - * @param string $rawText - * @return void + * @return ?string The path to the temporary file, null if the file was too large. */ - public function matchEmbed(Context $ctx, string $rawText) { - if (\filter_var($rawText, \FILTER_VALIDATE_URL) === false) { - return null; + private function fetchThumbnail(string $thumbnail_url): ?string { + $tmp_file = $this->make_tmp_file(); + $fd = \fopen($tmp_file, 'w+b'); + if ($fd === false) { + throw new \RuntimeException("Could not open temporary file $tmp_file for read/write"); } - $rawText = \trim($rawText); - foreach ($this->tuples as $cfg) { - if (!isset($cfg['match_regex'])) { - throw new \RuntimeException('Missing \'match_regex\' field'); - } - $match_regex = $cfg['match_regex']; + $ret = $this->http->requestGetInto($thumbnail_url, null, $fd, $this->thumb_download_timeout); + return $ret ? $tmp_file : null; + } + + /** + * Matches an alleged embed url and returns the path to the thumbnail, if any. + * + * @param string $rawText + * @return ?array Returns the url to the thumbnail and the path to the fallback if any embedding matches, null otherwise. + */ + private function matchAndExtract(string $rawText) { + foreach ($this->embed_entries as $embed_entry) { + $match_regex = $embed_entry['match_regex']; if (\preg_match($match_regex, $rawText, $matches)) { - if (!isset($cfg['type'])) { - throw new \RuntimeException('Missing \'type\' field'); - } - $type = $cfg['type']; + $type = $embed_entry['type']; if ($type === 'oembed') { - if (!isset($cfg['provider'])) { - throw new \RuntimeException('Missing \'provider\' field'); - } - $provider = $cfg['provider']; - - $extractor = $ctx->get(OembedExtractor::class); - $oembed_resp = $extractor->fetch($provider, $rawText); - + $thumbnail_url_fallback = $embed_entry['thumbnail_url_fallback'] ?? null; + $provider = $embed_entry['provider']; + $oembed_resp = $this->oembed_extractor->fetch($provider, $rawText); + return [ $oembed_resp->thumbnail_url, $thumbnail_url_fallback ]; } elseif ($type === 'regex') { - if (!isset($cfg['thumbnail_url'])) { - throw new \RuntimeException('Missing \'thumbnail_url\' field'); - } - $thumbnail_url_regex = $cfg['thumbnail_url']; + $thumbnail_url_regex = $embed_entry['thumbnail_url']; // Plz somebody review this. - $thumbnail_url = \preg_replace($match_regex, $thumbnail_url_regex, $rawText); + return [ \preg_replace($match_regex, $thumbnail_url_regex, $rawText), null ]; } else { - $this->log->log(LogDriver::ERROR, "Unknown embed type '$type', ignoring"); + $this->log->log(LogDriver::ERROR, "Unknown embed type '$type', ignoring the embed entry"); } } } + + return null; + } + + /** + * @return array|int Returns the url to the thumbnail and if it should be moved if any embedding matches, + * otherwise it returns a MATCH_EMBED_ERR_* constant. + */ + public function matchEmbed(string $rawText) { + $rawText = \trim($rawText); + if (\filter_var($rawText, \FILTER_VALIDATE_URL) === false) { + return self::MATCH_EMBED_ERR_NOT_AN_URL; + } + + $ret = $this->matchAndExtract($rawText); + if ($ret === null) { + return self::MATCH_EMBED_ERR_NO_MATCH; + } + list($thumbnail_url, $thumbnail_url_fallback) = $ret; + if (!isset($thumbnail_url, $thumbnail_url_fallback)) { + return self::MATCH_EMBED_ERR_NO_THUMBNAIL; + } + + if (\filter_var($thumbnail_url, \FILTER_VALIDATE_URL) === false) { + $this->log->log(LogDriver::ERROR, "Thumbnail URL '$thumbnail_url' is not a valid URL, trying fallback"); + } else { + $tmp_file = $this->fetchThumbnail($thumbnail_url); + if ($tmp_file !== null) { + return [ $tmp_file, true ]; + } + $this->log->log(LogDriver::NOTICE, "Thumbnail at '$thumbnail_url' was too large, trying fallback"); + } + + if ($thumbnail_url_fallback === null) { + return self::MATCH_EMBED_ERR_NO_THUMBNAIL; + } + return [ $thumbnail_url_fallback, false ]; } } From cd8d0e060f704043a939fe941754c567d201949b Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 17 Mar 2025 15:06:12 +0100 Subject: [PATCH 10/33] config.php: update embedding_2 --- inc/config.php | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/inc/config.php b/inc/config.php index a604bb84..82fce638 100644 --- a/inc/config.php +++ b/inc/config.php @@ -1282,14 +1282,12 @@ ' ], [ - 'match_regex' => '/^https?:\/\/(\w+\.)?tiktok\.com\/@[a-z0-9\-_]+\/video\/([0-9]+)\?.*$/i', + 'match_regex' => '/^https?:\/\/(\w+\.)?tiktok\.com\/@([a-z0-9\-_]+)\/video\/([0-9]+)\?.*$/i', 'type' => 'oembed', - 'provider' => 'https://www.youtube.com/oembed', - 'thumb_width' => 288, - 'thumb_height' => 512, - 'html' => '
- - + 'provider_url' => 'https://www.tiktok.com/oembed', + 'html' => '' ] From 01811cb50fc06f666b728da4f6e56df0534bbcd4 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 17 Mar 2025 14:44:57 +0100 Subject: [PATCH 11/33] EmbedService.php: refactor --- inc/Services/Embed/EmbedService.php | 111 +++++++++++++++------------- 1 file changed, 61 insertions(+), 50 deletions(-) diff --git a/inc/Services/Embed/EmbedService.php b/inc/Services/Embed/EmbedService.php index 9db84d4a..b5dfc055 100644 --- a/inc/Services/Embed/EmbedService.php +++ b/inc/Services/Embed/EmbedService.php @@ -1,26 +1,13 @@ embed_entries as $embed_entry) { - $match_regex = $embed_entry['match_regex']; + private function extractThumb(string $url, int $entry_index) { + $embed_entry = $this->embed_entries[$entry_index]; + $match_regex = $embed_entry['match_regex']; + $type = $embed_entry['type']; - if (\preg_match($match_regex, $rawText, $matches)) { - $type = $embed_entry['type']; + if ($type === 'oembed') { + $thumbnail_url_fallback = $embed_entry['thumbnail_url_fallback'] ?? null; + $provider = $embed_entry['provider_url']; + $oembed_resp = $this->oembed_extractor->fetch($provider, $url); - if ($type === 'oembed') { - $thumbnail_url_fallback = $embed_entry['thumbnail_url_fallback'] ?? null; - $provider = $embed_entry['provider']; - $oembed_resp = $this->oembed_extractor->fetch($provider, $rawText); + return [ $oembed_resp->thumbnail_url, $thumbnail_url_fallback ]; + } elseif ($type === 'regex') { + $thumbnail_url_regex = $embed_entry['thumbnail_url']; + // Plz somebody review this. + return [ \preg_replace($match_regex, $thumbnail_url_regex, $url), null ]; + } else { + $this->log->log(LogDriver::ERROR, "Unknown embed type '$type' in embed entry $entry_index, ignoring the entry"); + return [ null, null ]; + } + } - return [ $oembed_resp->thumbnail_url, $thumbnail_url_fallback ]; - } elseif ($type === 'regex') { - $thumbnail_url_regex = $embed_entry['thumbnail_url']; - // Plz somebody review this. - return [ \preg_replace($match_regex, $thumbnail_url_regex, $rawText), null ]; - } else { - $this->log->log(LogDriver::ERROR, "Unknown embed type '$type', ignoring the embed entry"); - } + /** + * Find the embed entry matching with the url, if any. + * + * @param string $url Url to embed. MUST BE ALREADY VALIDATED. + * @return int The index of the matched embed entry or null. + */ + public function matchEmbed(string $url): ?int { + for ($i = 0; $i < \count($this->embed_entries); $i++) { + $match_regex = $this->embed_entries[$i]['match_regex']; + if (\preg_match($match_regex, $url, $matches)) { + return $i; } } @@ -94,26 +94,22 @@ class EmbedService { } /** - * @return array|int Returns the url to the thumbnail and if it should be moved if any embedding matches, - * otherwise it returns a MATCH_EMBED_ERR_* constant. + * Get the embed's thumbnail if possible. May download it from the network into a temporary file, or use a static file. + * + * @param string $url Url to embed. MUST BE ALREADY VALIDATED. + * @param int The index of the matched embed entry. + * @return ?array Null if no thumbnail can be selected, otherwise an array with the local file path to the thumbnail + * and if the the file is a temporary or a static one. */ - public function matchEmbed(string $rawText) { - $rawText = \trim($rawText); - if (\filter_var($rawText, \FILTER_VALIDATE_URL) === false) { - return self::MATCH_EMBED_ERR_NOT_AN_URL; - } - - $ret = $this->matchAndExtract($rawText); - if ($ret === null) { - return self::MATCH_EMBED_ERR_NO_MATCH; - } + public function getEmbedThumb(string $url, int $entry_index): ?array { + $ret = $this->extractThumb($url, $entry_index); list($thumbnail_url, $thumbnail_url_fallback) = $ret; if (!isset($thumbnail_url, $thumbnail_url_fallback)) { - return self::MATCH_EMBED_ERR_NO_THUMBNAIL; + return null; } if (\filter_var($thumbnail_url, \FILTER_VALIDATE_URL) === false) { - $this->log->log(LogDriver::ERROR, "Thumbnail URL '$thumbnail_url' is not a valid URL, trying fallback"); + $this->log->log(LogDriver::ERROR, "Thumbnail URL '$thumbnail_url' from embed entry $entry_index is not a valid URL, trying fallback"); } else { $tmp_file = $this->fetchThumbnail($thumbnail_url); if ($tmp_file !== null) { @@ -123,8 +119,23 @@ class EmbedService { } if ($thumbnail_url_fallback === null) { - return self::MATCH_EMBED_ERR_NO_THUMBNAIL; + return null; } return [ $thumbnail_url_fallback, false ]; } + + public function renderEmbed(string $url, int $entry_index, string $thumbnail_path): string { + $embed_entry = $this->embed_entries[$entry_index]; + $match_regex = $embed_entry['match_regex']; + $html = $embed_entry['html']; + + $ret = \preg_replace($match_regex, $html, $url); + if (!\is_string($ret)) { + throw new \RuntimeException("Error while applying regex replacement for embed entry $entry_index"); + } + + \str_replace('%%embed_url%%', $url, $ret); + \str_replace('%%thumbnail_path%%', $thumbnail_path, $ret); + return $ret; + } } From b71d53c1a8d3780b2d16e0bf039b5d7e61c495d8 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 17 Mar 2025 15:10:19 +0100 Subject: [PATCH 12/33] post.php: validate embed url --- post.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/post.php b/post.php index 27a45413..8f229135 100644 --- a/post.php +++ b/post.php @@ -953,7 +953,11 @@ function handle_post(Context $ctx) // Check for an embed field if ($config['enable_embedding'] && isset($_POST['embed']) && !empty($_POST['embed'])) { // yep; validate it - $value = $_POST['embed']; + $value = \trim($_POST['embed']); + if (\filter_var($value, \FILTER_VALIDATE_URL) === false) { + error($config['error']['invalid_embed']); + } + foreach ($config['embedding'] as &$embed) { if (preg_match($embed[0], $value)) { // Valid link From ffe7a44635b05f4279544995c65f4db369afcc14 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 17 Mar 2025 15:13:29 +0100 Subject: [PATCH 13/33] config.php: add embed thumb download timeout --- inc/config.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/inc/config.php b/inc/config.php index 82fce638..8afb699f 100644 --- a/inc/config.php +++ b/inc/config.php @@ -1265,6 +1265,9 @@ $config['embed_width'] = 300; $config['embed_height'] = 246; + // Download timeout for the remove embed thumbnails in seconds. + $config['embed_thumb_timeout'] = 2; + /** * Replacement parameters: * - $1-$N: matched arguments from 'match_regex'. From 388fc2c05daff77ae76b607e713be19722f9ac8d Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 17 Mar 2025 15:18:02 +0100 Subject: [PATCH 14/33] context.php: add OembedExtractor and EmbedService --- inc/context.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/inc/context.php b/inc/context.php index 9a0a378d..8835bf2d 100644 --- a/inc/context.php +++ b/inc/context.php @@ -3,6 +3,8 @@ namespace Vichan; use Vichan\Data\{IpNoteQueries, ReportQueries, UserPostQueries}; use Vichan\Data\Driver\{CacheDriver, HttpDriver, ErrorLogLogDriver, FileLogDriver, LogDriver, StderrLogDriver, SyslogLogDriver}; +use Vichan\Services\Embed\EmbedService; +use Vichan\Services\Embed\OembedExtractor; defined('TINYBOARD') or exit; @@ -82,5 +84,19 @@ function build_context(array $config): Context { return new UserPostQueries($c->get(\PDO::class)); }, IpNoteQueries::class => fn($c) => new IpNoteQueries($c->get(\PDO::class), $c->get(CacheDriver::class)), + OembedExtractor::class => fn($c) => new OembedExtractor( + $c->get(CacheDriver::class), + $c->get(HttpDriver::class), + $c->get('config')['embed_thumb_timeout'] + ), + EmbedService::class => function($c) { + $config = $c->get('config'); + return new EmbedService( + $c->get(LogDriver::class), + $c->get(OembedExtractor::class), + $config['embedding_2'], + $config['embed_thumb_timeout'] + ); + } ]); } From 811698d9efb397e38d48dd00e6f824911e2a2a17 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 17 Mar 2025 15:37:37 +0100 Subject: [PATCH 15/33] post.php: extract normalize _FILES --- post.php | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/post.php b/post.php index 8f229135..b8d2073f 100644 --- a/post.php +++ b/post.php @@ -262,6 +262,26 @@ function send_matrix_report( } } +function normalize_files(array $file_array) { + $out_files = []; + // If more than 0 files were uploaded + if (!empty($file_array['tmp_name'][0])) { + $i = 0; + $n = count($file_array['tmp_name']); + while ($i < $n) { + $out_files[strval($i + 1)] = array( + 'name' => $file_array['name'][$i], + 'tmp_name' => $file_array['tmp_name'][$i], + 'type' => $file_array['type'][$i], + 'error' => $file_array['error'][$i], + 'size' => $file_array['size'][$i] + ); + $i++; + } + } + return $out_files; +} + /** * Deletes the (single) captcha associated with the ip and code. * @@ -924,7 +944,6 @@ function handle_post(Context $ctx) isset($post['thread']) ? $post['thread'] : ($config['try_smarter'] && isset($_POST['page']) ? 0 - (int) $_POST['page'] : null) ) ); - //$post['antispam_hash'] = checkSpam(); if ($post['antispam_hash'] === true) { error($config['error']['spam']); @@ -994,23 +1013,7 @@ function handle_post(Context $ctx) // Convert multiple upload format to array of files. This makes the following code // work the same whether we used the JS or HTML multiple file upload techniques. if (array_key_exists('file_multiple', $_FILES)) { - $file_array = $_FILES['file_multiple']; - $_FILES = []; - // If more than 0 files were uploaded - if (!empty($file_array['tmp_name'][0])) { - $i = 0; - $n = count($file_array['tmp_name']); - while ($i < $n) { - $_FILES[strval($i + 1)] = array( - 'name' => $file_array['name'][$i], - 'tmp_name' => $file_array['tmp_name'][$i], - 'type' => $file_array['type'][$i], - 'error' => $file_array['error'][$i], - 'size' => $file_array['size'][$i] - ); - $i++; - } - } + $_FILES = normalize_files($_FILES['file_multiple']); } // We must do this check now before the passowrd is hashed and overwritten. From 6ee867040144d14d5e032f4ad9bdb74a2c4a0922 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 17 Mar 2025 16:17:42 +0100 Subject: [PATCH 16/33] post-menu.js: use unicode code with variant selector for equilateral triangle --- js/post-menu.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/post-menu.js b/js/post-menu.js index 79cfd868..c2155c00 100644 --- a/js/post-menu.js +++ b/js/post-menu.js @@ -104,8 +104,10 @@ function buildMenu(e) { function addButton(post) { var $ele = $(post); + // Use unicode code with ascii variant selector + // https://stackoverflow.com/questions/37906969/how-to-prevent-ios-from-converting-ascii-into-emoji $ele.find('input.delete').after( - $('', {href: '#', class: 'post-btn', title: 'Post menu'}).text('►') + $('', {href: '#', class: 'post-btn', title: 'Post menu'}).text('\u{25B6}\u{fe0e}') ); } From 28a747b3357454f462c9ef1b1305531159325ce4 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Tue, 18 Mar 2025 09:58:45 +0100 Subject: [PATCH 17/33] EmbedService.php: move to right namespace --- inc/{Services => Service}/Embed/EmbedService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename inc/{Services => Service}/Embed/EmbedService.php (98%) diff --git a/inc/Services/Embed/EmbedService.php b/inc/Service/Embed/EmbedService.php similarity index 98% rename from inc/Services/Embed/EmbedService.php rename to inc/Service/Embed/EmbedService.php index b5dfc055..13d0b650 100644 --- a/inc/Services/Embed/EmbedService.php +++ b/inc/Service/Embed/EmbedService.php @@ -1,7 +1,7 @@ Date: Tue, 18 Mar 2025 09:59:16 +0100 Subject: [PATCH 18/33] OembedExtractor.php: move to right namespace --- inc/{Services => Service}/Embed/OembedExtractor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename inc/{Services => Service}/Embed/OembedExtractor.php (98%) diff --git a/inc/Services/Embed/OembedExtractor.php b/inc/Service/Embed/OembedExtractor.php similarity index 98% rename from inc/Services/Embed/OembedExtractor.php rename to inc/Service/Embed/OembedExtractor.php index 0de72ce3..759e9c60 100644 --- a/inc/Services/Embed/OembedExtractor.php +++ b/inc/Service/Embed/OembedExtractor.php @@ -1,5 +1,5 @@ Date: Tue, 18 Mar 2025 09:59:35 +0100 Subject: [PATCH 19/33] context.php: update --- inc/context.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inc/context.php b/inc/context.php index 8835bf2d..c5375d89 100644 --- a/inc/context.php +++ b/inc/context.php @@ -3,8 +3,8 @@ namespace Vichan; use Vichan\Data\{IpNoteQueries, ReportQueries, UserPostQueries}; use Vichan\Data\Driver\{CacheDriver, HttpDriver, ErrorLogLogDriver, FileLogDriver, LogDriver, StderrLogDriver, SyslogLogDriver}; -use Vichan\Services\Embed\EmbedService; -use Vichan\Services\Embed\OembedExtractor; +use Vichan\Service\Embed\EmbedService; +use Vichan\Service\Embed\OembedExtractor; defined('TINYBOARD') or exit; From 501e696891c68e38c9897eb70682ab820cd6946d Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sun, 23 Mar 2025 17:05:20 +0100 Subject: [PATCH 20/33] banners: remove just monika banner This reverts commit 6bcf22aa7ed5d341b8f6201977df5d55fc5fdc82. --- static/banners/just-monika-opt.webp | Bin 26620 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 static/banners/just-monika-opt.webp diff --git a/static/banners/just-monika-opt.webp b/static/banners/just-monika-opt.webp deleted file mode 100644 index 731f985a228069545967c42ed4b0a25d131e2307..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26620 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 n- Date: Wed, 26 Mar 2025 13:40:28 +0100 Subject: [PATCH 21/33] news.html: remove spurious stuff from css --- templates/themes/categories/news.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/themes/categories/news.html b/templates/themes/categories/news.html index 484c4eb0..76722e41 100644 --- a/templates/themes/categories/news.html +++ b/templates/themes/categories/news.html @@ -11,7 +11,7 @@ .home-description { margin: 20px auto 0 auto; text-align: center; - max-width: 700px;" + max-width: 700px; } {{ boardlist.top }} From 9431536112e98b708b1c8fb4ea20f2f0945496b7 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Wed, 26 Mar 2025 13:54:55 +0100 Subject: [PATCH 22/33] frames.html: trim --- templates/themes/categories/frames.html | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/templates/themes/categories/frames.html b/templates/themes/categories/frames.html index 6b2fac38..ec9cdc25 100644 --- a/templates/themes/categories/frames.html +++ b/templates/themes/categories/frames.html @@ -16,13 +16,13 @@ border-width: 2px; margin-right: 15px; } - + .introduction { grid-column: 2 / 9; grid-row: 1; width: 100%; } - + .content { grid-column: 2 / 9; grid-row: 2; @@ -35,7 +35,7 @@ gap: 20px; height: 100vh; } - + .modlog { width: 50%; text-align: left; @@ -69,7 +69,7 @@ li a.system { font-weight: bold; } - + @media (max-width:768px) { body{ display: grid; @@ -78,7 +78,7 @@ height: 100vh; width: 100%; } - + .introduction { grid-column: 1; grid-row: 1; @@ -97,16 +97,16 @@ grid-row: 3; width: 100%; } - + .modlog { width: 100%; text-align: center; } - + table { table-layout: fixed; } - + table.modlog tr th { white-space: normal; word-wrap: break-word; From 8da10af1014699d165b58abced86695242530d81 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Wed, 26 Mar 2025 13:55:04 +0100 Subject: [PATCH 23/33] frames.html: break long words to prevent page getting cut --- templates/themes/categories/frames.html | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/themes/categories/frames.html b/templates/themes/categories/frames.html index ec9cdc25..1c4673cc 100644 --- a/templates/themes/categories/frames.html +++ b/templates/themes/categories/frames.html @@ -96,6 +96,7 @@ grid-column: 1; grid-row: 3; width: 100%; + word-break: break-all; } .modlog { From fa56876c3620f5aa0895fbde70216bf25b113bb7 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Thu, 27 Mar 2025 00:17:17 +0100 Subject: [PATCH 24/33] style.css: set minimum file container width and multifile margin --- stylesheets/style.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/stylesheets/style.css b/stylesheets/style.css index 815e1853..74c455a5 100644 --- a/stylesheets/style.css +++ b/stylesheets/style.css @@ -380,6 +380,7 @@ form table tr td div.center { .file { float: left; + min-width: 100px; } .file:not(.multifile) .post-image { @@ -390,6 +391,10 @@ form table tr td div.center { float: none; } +.file.multifile { + margin: 0 20px 0 0; +} + .file.multifile > p { width: 0px; min-width: 100%; From 81c02be563b65d339c572ccbfbddebff4d719240 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Thu, 27 Mar 2025 01:23:37 +0100 Subject: [PATCH 25/33] style.css: reduce margin between multiple files --- stylesheets/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stylesheets/style.css b/stylesheets/style.css index 74c455a5..4a090f33 100644 --- a/stylesheets/style.css +++ b/stylesheets/style.css @@ -392,7 +392,7 @@ form table tr td div.center { } .file.multifile { - margin: 0 20px 0 0; + margin: 0 10px 0 0; } .file.multifile > p { From fbf0c051f0f5e4c1c69f4679180643e2c0e5a5e5 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 28 Mar 2025 11:06:24 +0100 Subject: [PATCH 26/33] OembedExtractor.php: fix --- inc/Service/Embed/OembedExtractor.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/inc/Service/Embed/OembedExtractor.php b/inc/Service/Embed/OembedExtractor.php index 759e9c60..f86b2013 100644 --- a/inc/Service/Embed/OembedExtractor.php +++ b/inc/Service/Embed/OembedExtractor.php @@ -40,7 +40,7 @@ class OembedExtractor { ], $this->provider_timeout ); - $json = \json_decode($body, null, 512, \JSON_THROW_ON_ERROR); + $json = \json_decode($body, true, 512, \JSON_THROW_ON_ERROR); $ret = [ 'title' => $json['title'] ?? null, @@ -55,12 +55,12 @@ class OembedExtractor { } } - $this->cache->set("oembed_embedder_$url$provider_url", $ret, $cache_timeout); + $this->cache->set("oembed_embedder_$provider_url$url", $ret, $cache_timeout); } $resp = new OembedResponse(); $resp->title = $ret['title']; $resp->thumbnail_url = $ret['thumbnail_url']; - return $ret; + return $resp; } } From fe5368f0965cbfd656a9c3822a2dcddc40ad8742 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 28 Mar 2025 11:09:12 +0100 Subject: [PATCH 27/33] HttpDriver.php: set requestGet header to default to null --- inc/Data/Driver/HttpDriver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/Data/Driver/HttpDriver.php b/inc/Data/Driver/HttpDriver.php index 197f2681..2e379f27 100644 --- a/inc/Data/Driver/HttpDriver.php +++ b/inc/Data/Driver/HttpDriver.php @@ -43,7 +43,7 @@ class HttpDriver { * @return string Returns the body of the response. * @throws RuntimeException Throws on IO error. */ - public function requestGet(string $endpoint, ?array $data, ?array $headers, int $timeout = 0): string { + public function requestGet(string $endpoint, ?array $data, ?array $headers = null, int $timeout = 0): string { if (!empty($data)) { $endpoint .= '?' . \http_build_query($data); } From 336c40b0f75d4c487a09035e7791a6ea035da624 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 28 Mar 2025 15:05:01 +0100 Subject: [PATCH 28/33] remove all tesseract traces --- inc/config.php | 9 --------- post.php | 34 ---------------------------------- tmp/tesseract/.gitkeep | 0 3 files changed, 43 deletions(-) delete mode 100644 tmp/tesseract/.gitkeep diff --git a/inc/config.php b/inc/config.php index 25031bfb..61325d10 100644 --- a/inc/config.php +++ b/inc/config.php @@ -985,15 +985,6 @@ // Set this to true if you're using Linux and you can execute `md5sum` binary. $config['gnu_md5'] = false; - // Use Tesseract OCR to retrieve text from images, so you can use it as a spamfilter. - $config['tesseract_ocr'] = false; - - // Tesseract parameters - $config['tesseract_params'] = ''; - - // Tesseract preprocess command - $config['tesseract_preprocess_command'] = 'convert -monochrome %s -'; - // Number of posts in a "View Last X Posts" page $config['noko50_count'] = 50; // Number of posts a thread needs before it gets a "View Last X Posts" page. diff --git a/post.php b/post.php index 27a45413..977bab05 100644 --- a/post.php +++ b/post.php @@ -1551,35 +1551,6 @@ function handle_post(Context $ctx) } } - if ($config['tesseract_ocr'] && $file['thumb'] != 'file') { - // Let's OCR it! - $fname = $file['tmp_name']; - - if ($file['height'] > 500 || $file['width'] > 500) { - $fname = $file['thumb']; - } - - if ($fname == 'spoiler') { - // We don't have that much CPU time, do we? - } else { - $tmpname = __DIR__ . "/tmp/tesseract/" . rand(0, 10000000); - - // Preprocess command is an ImageMagick b/w quantization - $error = shell_exec_error(sprintf($config['tesseract_preprocess_command'], escapeshellarg($fname)) . " | " . - 'tesseract stdin ' . escapeshellarg($tmpname) . ' ' . $config['tesseract_params']); - $tmpname .= ".txt"; - - $value = @file_get_contents($tmpname); - @unlink($tmpname); - - if ($value && trim($value)) { - // This one has an effect, that the body is appended to a post body. So you can write a correct - // spamfilter. - $post['body_nomarkup'] .= "" . htmlspecialchars($value) . ""; - } - } - } - if (!isset($dont_copy_file) || !$dont_copy_file) { if (isset($file['file_tmp'])) { if (!@rename($file['tmp_name'], $file['file'])) { @@ -1627,11 +1598,6 @@ function handle_post(Context $ctx) } } - // Do filters again if OCRing - if ($config['tesseract_ocr'] && !hasPermission($config['mod']['bypass_filters'], $board['uri']) && !$dropped_post) { - do_filters($ctx, $post); - } - if (!hasPermission($config['mod']['postunoriginal'], $board['uri']) && $config['robot_enable'] && checkRobot($post['body_nomarkup']) && !$dropped_post) { undoImage($post); if ($config['robot_mute']) { diff --git a/tmp/tesseract/.gitkeep b/tmp/tesseract/.gitkeep deleted file mode 100644 index e69de29b..00000000 From 87029580b6080d0e6ebbd1bcc692b87857077f85 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sat, 29 Mar 2025 00:30:08 +0100 Subject: [PATCH 29/33] post.php: default minimum_copy_resize to true by removing it --- post.php | 1 - 1 file changed, 1 deletion(-) diff --git a/post.php b/post.php index 977bab05..0e46e80d 100644 --- a/post.php +++ b/post.php @@ -1402,7 +1402,6 @@ function handle_post(Context $ctx) $file['thumbwidth'] = $size[0]; $file['thumbheight'] = $size[1]; } elseif ( - $config['minimum_copy_resize'] && $image->size->width <= $config['thumb_width'] && $image->size->height <= $config['thumb_height'] && $file['extension'] == ($config['thumb_ext'] ? $config['thumb_ext'] : $file['extension']) From 42e850091ad5acbee0a79b4c13f0afacd4f5c8d3 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sat, 29 Mar 2025 00:29:58 +0100 Subject: [PATCH 30/33] config.php: remove minimum_copy_resize --- inc/config.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/inc/config.php b/inc/config.php index 61325d10..71b0fbf4 100644 --- a/inc/config.php +++ b/inc/config.php @@ -943,10 +943,6 @@ // Location of thumbnail to use for deleted images. $config['image_deleted'] = 'static/deleted.png'; - // When a thumbnailed image is going to be the same (in dimension), just copy the entire file and use - // that as a thumbnail instead of resizing/redrawing. - $config['minimum_copy_resize'] = false; - // Maximum image upload size in bytes. $config['max_filesize'] = 10 * 1024 * 1024; // 10MB // Maximum image dimensions. From c1c20bdab2e663826f898f80316fa7fd16a2fa15 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sat, 29 Mar 2025 00:32:56 +0100 Subject: [PATCH 31/33] post.php: fix typo --- post.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/post.php b/post.php index 0e46e80d..02ccd53e 100644 --- a/post.php +++ b/post.php @@ -1407,7 +1407,7 @@ function handle_post(Context $ctx) $file['extension'] == ($config['thumb_ext'] ? $config['thumb_ext'] : $file['extension']) ) { // Copy, because there's nothing to resize - coopy($file['tmp_name'], $file['thumb']); + copy($file['tmp_name'], $file['thumb']); $file['thumbwidth'] = $image->size->width; $file['thumbheight'] = $image->size->height; From d224c0af23e9f9cfcf00f7505053e58b49f62c3d Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sat, 29 Mar 2025 00:34:37 +0100 Subject: [PATCH 32/33] post.php: skip resize only if already stripped --- post.php | 1 + 1 file changed, 1 insertion(+) diff --git a/post.php b/post.php index 02ccd53e..b5852edb 100644 --- a/post.php +++ b/post.php @@ -1402,6 +1402,7 @@ function handle_post(Context $ctx) $file['thumbwidth'] = $size[0]; $file['thumbheight'] = $size[1]; } elseif ( + (($config['strip_exif'] && $file['exif_stripped']) || !$config['strip_exif']) && $image->size->width <= $config['thumb_width'] && $image->size->height <= $config['thumb_height'] && $file['extension'] == ($config['thumb_ext'] ? $config['thumb_ext'] : $file['extension']) From 92fc2daa9c9c768bb625449005653f8ab030faf8 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Sat, 29 Mar 2025 08:55:15 +0100 Subject: [PATCH 33/33] post.php: fix undefined exif_stripped --- post.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/post.php b/post.php index b5852edb..5483600d 100644 --- a/post.php +++ b/post.php @@ -1402,7 +1402,7 @@ function handle_post(Context $ctx) $file['thumbwidth'] = $size[0]; $file['thumbheight'] = $size[1]; } elseif ( - (($config['strip_exif'] && $file['exif_stripped']) || !$config['strip_exif']) && + (($config['strip_exif'] && isset($file['exif_stripped']) && $file['exif_stripped']) || !$config['strip_exif']) && $image->size->width <= $config['thumb_width'] && $image->size->height <= $config['thumb_height'] && $file['extension'] == ($config['thumb_ext'] ? $config['thumb_ext'] : $file['extension'])