From 3016d694289e308f2b289dcee196952e0f336f90 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 1 Apr 2024 19:36:03 +0200 Subject: [PATCH 001/137] Share REST call code and separate components via dependency injection --- composer.json | 4 +- inc/config.php | 2 + inc/context.php | 28 +++++ inc/driver/http-driver.php | 151 +++++++++++++++++++++++ inc/service/captcha-queries.php | 102 ++++++++++++++++ post.php | 208 ++++++++++++++++---------------- 6 files changed, 392 insertions(+), 103 deletions(-) create mode 100644 inc/context.php create mode 100644 inc/driver/http-driver.php create mode 100644 inc/service/captcha-queries.php diff --git a/composer.json b/composer.json index ec4a090d..e1951679 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,9 @@ "inc/mod/auth.php", "inc/lock.php", "inc/queue.php", - "inc/functions.php" + "inc/functions.php", + "inc/driver/http-driver.php", + "inc/service/captcha-queries.php" ] }, "license": "Tinyboard + vichan", diff --git a/inc/config.php b/inc/config.php index 0cece593..ffd1b544 100644 --- a/inc/config.php +++ b/inc/config.php @@ -1232,6 +1232,8 @@ $config['error']['captcha'] = _('You seem to have mistyped the verification.'); $config['error']['flag_undefined'] = _('The flag %s is undefined, your PHP version is too old!'); $config['error']['flag_wrongtype'] = _('defined_flags_accumulate(): The flag %s is of the wrong type!'); + $config['error']['remote_io_error'] = _('IO error while interacting with a remote service.'); + $config['error']['local_io_error'] = _('IO error while interacting with a local resource or service.'); // Moderator errors diff --git a/inc/context.php b/inc/context.php new file mode 100644 index 00000000..5e540bab --- /dev/null +++ b/inc/context.php @@ -0,0 +1,28 @@ +config = $config; + } + + public function getHttpDriver(): HttpDriver { + if (is_null($this->http)) { + $this->http = HttpDrivers::getHttpDriver($this->config['upload_by_url_timeout'], $this->config['max_filesize']); + } + return $this->http; + } +} diff --git a/inc/driver/http-driver.php b/inc/driver/http-driver.php new file mode 100644 index 00000000..cfbedfad --- /dev/null +++ b/inc/driver/http-driver.php @@ -0,0 +1,151 @@ +inner); + curl_setopt_array($this->inner, array( + CURLOPT_URL => $url, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_USERAGENT => $this->user_agent, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + )); + } + + private function setSizeLimit(): void { + // Adapted from: https://stackoverflow.com/a/17642638 + curl_setopt($this->inner, CURLOPT_NOPROGRESS, false); + + if (PHP_MAJOR_VERSION >= 8 && PHP_MINOR_VERSION >= 2) { + curl_setopt($this->inner, CURLOPT_XFERINFOFUNCTION, function($res, $next_dl, $dl, $next_up, $up) { + return (int)($dl <= $this->max_file_size); + }); + } else { + curl_setopt($this->inner, CURLOPT_PROGRESSFUNCTION, function($res, $next_dl, $dl, $next_up, $up) { + return (int)($dl <= $this->max_file_size); + }); + } + } + + function __construct($timeout, $user_agent, $max_file_size) { + $this->inner = curl_init(); + $this->timeout = $timeout; + $this->user_agent = $user_agent; + $this->max_file_size = $max_file_size; + } + + 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); + curl_setopt($this->inner, CURLOPT_FAILONERROR, true); + curl_setopt($this->inner, CURLOPT_FOLLOWLOCATION, false); + curl_setopt($this->inner, CURLOPT_FILE, $fd); + curl_setopt($this->inner, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); + $this->setSizeLimit(); + $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/service/captcha-queries.php b/inc/service/captcha-queries.php new file mode 100644 index 00000000..d7966501 --- /dev/null +++ b/inc/service/captcha-queries.php @@ -0,0 +1,102 @@ +http = $http; + $this->secret = $secret; + $this->endpoint = $endpoint; + } + + /** + * Checks if the user at the remote ip passed the captcha. + * + * @param string $response User provided response. + * @param string $remote_ip User ip. + * @return bool Returns true if the user passed the captcha. + * @throws RuntimeException|JsonException Throws on IO errors or if it fails to decode the answer. + */ + public function verify(string $response, string $remote_ip): bool { + $data = array( + 'secret' => $this->secret, + 'response' => $response, + 'remoteip' => $remote_ip + ); + + $ret = $this->http->requestGet($this->endpoint, $data); + $resp = json_decode($ret, true, 16, JSON_THROW_ON_ERROR); + + return isset($resp['success']) && $resp['success']; + } +} + +class NativeCaptchaQuery { + private HttpDriver $http; + private string $domain; + private string $provider_check; + + + /** + * @param HttpDriver $http The http client. + * @param string $domain The server's domain. + * @param string $provider_check Path to the endpoint. + */ + function __construct(HttpDriver $http, string $domain, string $provider_check) { + $this->http = $http; + $this->domain = $domain; + $this->provider_check = $provider_check; + } + + /** + * Checks if the user at the remote ip passed the native vichan captcha. + * + * @param string $extra Extra http parameters. + * @param string $user_text Remote user's text input. + * @param string $user_cookie Remote user cookie. + * @return bool Returns true if the user passed the check. + * @throws RuntimeException Throws on IO errors. + */ + public function verify(string $extra, string $user_text, string $user_cookie): bool { + $data = array( + 'mode' => 'check', + 'text' => $user_text, + 'extra' => $extra, + 'cookie' => $user_cookie + ); + + $ret = $this->http->requestGet($this->domain . '/' . $this->provider_check, $data); + return $ret === '1'; + } +} diff --git a/post.php b/post.php index 2cc99071..d43f37a5 100644 --- a/post.php +++ b/post.php @@ -5,6 +5,10 @@ require_once 'inc/bootstrap.php'; +use Vichan\AppContext; +use Vichan\Driver\HttpDriver; +use Vichan\Service\{RemoteCaptchaQuery, NativeCaptchaQuery}; + /** * Utility functions */ @@ -61,54 +65,27 @@ function strip_symbols($input) { } } -/** - * Download the url's target with curl. - * - * @param string $url Url to the file to download. - * @param int $timeout Request timeout in seconds. - * @param File $fd File descriptor to save the content to. - * @return null|string Returns a string on error. - */ -function download_file_into($url, $timeout, $fd) { - $err = null; - $curl = curl_init(); - curl_setopt($curl, CURLOPT_URL, $url); - curl_setopt($curl, CURLOPT_FAILONERROR, true); - curl_setopt($curl, CURLOPT_FOLLOWLOCATION, false); - curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5); - curl_setopt($curl, CURLOPT_TIMEOUT, $timeout); - curl_setopt($curl, CURLOPT_USERAGENT, 'Tinyboard'); - curl_setopt($curl, CURLOPT_FILE, $fd); - curl_setopt($curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); - curl_setopt($curl, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); - - if (curl_exec($curl) === false) { - $err = curl_error($curl); - } - - curl_close($curl); - return $err; -} - /** * Download a remote file from the given url. * The file is deleted at shutdown. * + * @param HttpDriver $http The http client. * @param string $file_url The url to download the file from. * @param int $request_timeout Timeout to retrieve the file. * @param array $extra_extensions Allowed file extensions. * @param string $tmp_dir Temporary directory to save the file into. * @param array $error_array An array with error codes, used to create exceptions on failure. - * @return array Returns an array describing the file on success. - * @throws Exception on error. + * @return array|false Returns an array describing the file on success, or false if the file was too large + * @throws InvalidArgumentException|RuntimeException Throws on invalid arguments and IO errors. */ -function download_file_from_url($file_url, $request_timeout, $allowed_extensions, $tmp_dir, &$error_array) { +function download_file_from_url(HttpDriver $http, $file_url, $request_timeout, $allowed_extensions, $tmp_dir, &$error_array) { if (!preg_match('@^https?://@', $file_url)) { throw new InvalidArgumentException($error_array['invalidimg']); } - if (mb_strpos($file_url, '?') !== false) { - $url_without_params = mb_substr($file_url, 0, mb_strpos($file_url, '?')); + $param_idx = mb_strpos($file_url, '?'); + if ($param_idx !== false) { + $url_without_params = mb_substr($file_url, 0, $param_idx); } else { $url_without_params = $file_url; } @@ -128,10 +105,13 @@ function download_file_from_url($file_url, $request_timeout, $allowed_extensions $fd = fopen($tmp_file, 'w'); - $dl_err = download_file_into($fd, $request_timeout, $fd); - fclose($fd); - if ($dl_err !== null) { - throw new Exception($error_array['nomove'] . '
Curl says: ' . $dl_err); + try { + $success = $http->requestGetInto($url_without_params, null, $fd, $request_timeout); + if (!$success) { + return false; + } + } finally { + fclose($fd); } return array( @@ -165,6 +145,7 @@ function ocr_image(array $config, string $img_path): string { return trim($ret); } + /** * Trim an image's EXIF metadata * @@ -190,6 +171,7 @@ function strip_image_metadata(string $img_path): int { */ $dropped_post = false; +$context = new AppContext($config); // Is it a post coming from NNTP? Let's extract it and pretend it's a normal post. if (isset($_GET['Newsgroups']) && $config['nntpchan']['enabled']) { @@ -487,22 +469,32 @@ if (isset($_POST['delete'])) { if (count($report) > $config['report_limit']) error($config['error']['toomanyreports']); - if ($config['report_captcha'] && !isset($_POST['captcha_text'], $_POST['captcha_cookie'])) { - error($config['error']['bot']); - } if ($config['report_captcha']) { - $ch = curl_init($config['domain'].'/'.$config['captcha']['provider_check'] . "?" . http_build_query([ - 'mode' => 'check', - 'text' => $_POST['captcha_text'], - 'extra' => $config['captcha']['extra'], - 'cookie' => $_POST['captcha_cookie'] - ])); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - $resp = curl_exec($ch); + if (!isset($_POST['captcha_text'], $_POST['captcha_cookie'])) { + error($config['error']['bot']); + } - if ($resp !== '1') { - error($config['error']['captcha']); + try { + $query = new NativeCaptchaQuery( + $context->getHttpDriver(), + $config['domain'], + $config['captcha']['provider_check'] + ); + $success = $query->verify( + $config['captcha']['extra'], + $_POST['captcha_text'], + $_POST['captcha_cookie'] + ); + + if (!$success) { + error($config['error']['captcha']); + } + } catch (RuntimeException $e) { + if ($config['syslog']) { + _syslog(LOG_ERR, "Native captcha IO exception: {$e->getMessage()}"); + } + error($config['error']['local_io_error']); } } @@ -598,62 +590,60 @@ if (isset($_POST['delete'])) { // Check if banned checkBan($board['uri']); - // Check for CAPTCHA right after opening the board so the "return" link is in there - if ($config['recaptcha']) { - if (!isset($_POST['g-recaptcha-response'])) - error($config['error']['bot']); + // Check for CAPTCHA right after opening the board so the "return" link is in there. + try { + // With our custom captcha provider + if ($config['captcha']['enabled'] || ($post['op'] && $config['new_thread_capt'])) { + $query = new NativeCaptchaQuery($context->getHttpDriver(), $config['domain'], $config['captcha']['provider_check']); + $success = $query->verify($config['captcha']['extra'], $_POST['captcha_text'], $_POST['captcha_cookie']); - // Check what reCAPTCHA has to say... - $resp = json_decode(file_get_contents(sprintf('https://www.recaptcha.net/recaptcha/api/siteverify?secret=%s&response=%s&remoteip=%s', - $config['recaptcha_private'], - urlencode($_POST['g-recaptcha-response']), - $_SERVER['REMOTE_ADDR'])), true); - - if (!$resp['success']) { - error($config['error']['captcha']); + if (!$success) { + error( + $config['error']['captcha'] + . '' + ); + } } - } - // hCaptcha - if ($config['hcaptcha']) { - if (!isset($_POST['h-captcha-response'])) { - error($config['error']['bot']); + // Remote 3rd party captchas. + else { + // recaptcha + if ($config['recaptcha']) { + if (!isset($_POST['g-recaptcha-response'])) { + error($config['error']['bot']); + } + $response = $_POST['g-recaptcha-response']; + $query = RemoteCaptchaQuery::withRecaptcha($context->getHttpDriver(), $config['recaptcha_private']); + } + // hCaptcha + elseif ($config['hcaptcha']) { + if (!isset($_POST['h-captcha-response'])) { + error($config['error']['bot']); + } + $response = $_POST['g-recaptcha-response']; + $query = RemoteCaptchaQuery::withHCaptcha($context->getHttpDriver(), $config['hcaptcha_private']); + } + + $success = $query->verify($response, $_SERVER['REMOTE_ADDR']); + if (!$success) { + error($config['error']['captcha']); + } } - - $data = array( - 'secret' => $config['hcaptcha_private'], - 'response' => $_POST['h-captcha-response'], - 'remoteip' => $_SERVER['REMOTE_ADDR'] - ); - - $hcaptchaverify = curl_init(); - curl_setopt($hcaptchaverify, CURLOPT_URL, "https://hcaptcha.com/siteverify"); - curl_setopt($hcaptchaverify, CURLOPT_POST, true); - curl_setopt($hcaptchaverify, CURLOPT_POSTFIELDS, http_build_query($data)); - curl_setopt($hcaptchaverify, CURLOPT_RETURNTRANSFER, true); - $hcaptcharesponse = curl_exec($hcaptchaverify); - - $resp = json_decode($hcaptcharesponse, true); // Decoding $hcaptcharesponse instead of $response - - if (!$resp['success']) { - error($config['error']['captcha']); + } catch (RuntimeException $e) { + if ($config['syslog']) { + _syslog(LOG_ERR, "Captcha IO exception: {$e->getMessage()}"); } + error($config['error']['remote_io_error']); + } catch (JsonException $e) { + if ($config['syslog']) { + _syslog(LOG_ERR, "Bad JSON reply to captcha: {$e->getMessage()}"); + } + error($config['error']['remote_io_error']); } - // Same, but now with our custom captcha provider - if (($config['captcha']['enabled']) || (($post['op']) && ($config['new_thread_capt'])) ) { - $ch = curl_init($config['domain'].'/'.$config['captcha']['provider_check'] . "?" . http_build_query([ - 'mode' => 'check', - 'text' => $_POST['captcha_text'], - 'extra' => $config['captcha']['extra'], - 'cookie' => $_POST['captcha_cookie'] - ])); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - $resp = curl_exec($ch); - if ($resp !== '1') { - error($config['error']['captcha'] . - ''); - } - } if (!(($post['op'] && $_POST['post'] == $config['button_newtopic']) || (!$post['op'] && $_POST['post'] == $config['button_reply']))) @@ -757,7 +747,21 @@ if (isset($_POST['delete'])) { } try { - $_FILES['file'] = download_file_from_url($_POST['file_url'], $config['upload_by_url_timeout'], $allowed_extensions, $config['tmp'], $config['error']); + $ret = download_file_from_url( + $context->getHttpDriver(), + $_POST['file_url'], + $config['upload_by_url_timeout'], + $allowed_extensions, + $config['tmp'], + $config['error'] + ); + if ($ret === false) { + error(sprintf3($config['error']['filesize'], array( + 'filesz' => 'more than that', + 'maxsz' => number_format($config['max_filesize']) + ))); + } + $_FILES['file'] = $ret; } catch (Exception $e) { error($e->getMessage()); } From 650ef8bcc28f7d364fc55f30ea19c6985d54fe30 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 1 Apr 2024 19:36:37 +0200 Subject: [PATCH 002/137] Fix crash if no captcha is enabled --- post.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/post.php b/post.php index d43f37a5..954b2ed5 100644 --- a/post.php +++ b/post.php @@ -627,9 +627,11 @@ if (isset($_POST['delete'])) { $query = RemoteCaptchaQuery::withHCaptcha($context->getHttpDriver(), $config['hcaptcha_private']); } - $success = $query->verify($response, $_SERVER['REMOTE_ADDR']); - if (!$success) { - error($config['error']['captcha']); + if (isset($query, $response)) { + $success = $query->verify($response, $_SERVER['REMOTE_ADDR']); + if (!$success) { + error($config['error']['captcha']); + } } } } catch (RuntimeException $e) { From 8120c42440ad6e74017b0c5b0cdf07355228c3b3 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 1 Apr 2024 19:46:04 +0200 Subject: [PATCH 003/137] Format injected javascript on failed native captcha --- post.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/post.php b/post.php index 954b2ed5..6fdbfc0b 100644 --- a/post.php +++ b/post.php @@ -599,12 +599,14 @@ if (isset($_POST['delete'])) { if (!$success) { error( - $config['error']['captcha'] - . '' + "{$config['error']['captcha']} + " ); } } From 00b05099f33257bd33ebb1548bec3664b653e235 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 16 Feb 2024 14:18:55 +0100 Subject: [PATCH 004/137] Format lock.php --- inc/lock.php | 68 +++++++++++++++++++++++++++------------------------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/inc/lock.php b/inc/lock.php index 4fb2f5df..23c0bce4 100644 --- a/inc/lock.php +++ b/inc/lock.php @@ -1,39 +1,43 @@ f = fopen("tmp/locks/$key", "w"); - } - } + $this->f = fopen("tmp/locks/$key", "w"); + } + } - // Get a shared lock - function get($nonblock = false) { global $config; - if ($config['lock']['enabled'] == 'fs') { - $wouldblock = false; - flock($this->f, LOCK_SH | ($nonblock ? LOCK_NB : 0), $wouldblock); - if ($nonblock && $wouldblock) return false; - } - return $this; - } + // Get a shared lock + function get($nonblock = false) { + global $config; + if ($config['lock']['enabled'] == 'fs') { + $wouldblock = false; + flock($this->f, LOCK_SH | ($nonblock ? LOCK_NB : 0), $wouldblock); + if ($nonblock && $wouldblock) return false; + } + return $this; + } - // Get an exclusive lock - function get_ex($nonblock = false) { global $config; - if ($config['lock']['enabled'] == 'fs') { - $wouldblock = false; - flock($this->f, LOCK_EX | ($nonblock ? LOCK_NB : 0), $wouldblock); - if ($nonblock && $wouldblock) return false; - } - return $this; - } + // Get an exclusive lock + function get_ex($nonblock = false) { + global $config; + if ($config['lock']['enabled'] == 'fs') { + $wouldblock = false; + flock($this->f, LOCK_EX | ($nonblock ? LOCK_NB : 0), $wouldblock); + if ($nonblock && $wouldblock) return false; + } + return $this; + } - // Free a lock - function free() { global $config; - if ($config['lock']['enabled'] == 'fs') { - flock($this->f, LOCK_UN); - } - return $this; - } + // Free a lock + function free() { + global $config; + if ($config['lock']['enabled'] == 'fs') { + flock($this->f, LOCK_UN); + } + return $this; + } } From 760431606d1309180929eda8f203d65a42039bf6 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 16 Feb 2024 14:47:20 +0100 Subject: [PATCH 005/137] Refactor lock.php --- inc/lock.php | 105 +++++++++++++++++++++++++++++++++++--------------- inc/queue.php | 2 +- 2 files changed, 74 insertions(+), 33 deletions(-) diff --git a/inc/lock.php b/inc/lock.php index 23c0bce4..5a83562a 100644 --- a/inc/lock.php +++ b/inc/lock.php @@ -1,43 +1,84 @@ f = fopen("tmp/locks/$key", "w"); +class Locks { + private static function filesystem(string $key): Lock|false { + $key = str_replace('/', '::', $key); + $key = str_replace("\0", '', $key); + + $fd = fopen("tmp/locks/$key", "w"); + if ($fd === false) { + return false; } + + return new class($fd) implements Lock { + // Resources have no type in php. + private mixed $f; + + + function __construct($fd) { + $this->f = $fd; + } + + public function get(bool $nonblock = false): Lock|false { + $wouldblock = false; + flock($this->f, LOCK_SH | ($nonblock ? LOCK_NB : 0), $wouldblock); + if ($nonblock && $wouldblock) { + return false; + } + return $this; + } + + public function get_ex(bool $nonblock = false): Lock|false { + $wouldblock = false; + flock($this->f, LOCK_EX | ($nonblock ? LOCK_NB : 0), $wouldblock); + if ($nonblock && $wouldblock) { + return false; + } + return $this; + } + + public function free(): Lock { + flock($this->f, LOCK_UN); + return $this; + } + }; } - // Get a shared lock - function get($nonblock = false) { - global $config; - if ($config['lock']['enabled'] == 'fs') { - $wouldblock = false; - flock($this->f, LOCK_SH | ($nonblock ? LOCK_NB : 0), $wouldblock); - if ($nonblock && $wouldblock) return false; - } - return $this; + /** + * No-op. Can be used for mocking. + */ + public static function none(): Lock|false { + return new class() implements Lock { + public function get(bool $nonblock = false): Lock|false { + return $this; + } + + public function get_ex(bool $nonblock = false): Lock|false { + return $this; + } + + public function free(): Lock { + return $this; + } + }; } - // Get an exclusive lock - function get_ex($nonblock = false) { - global $config; + public static function get_lock(array $config, string $key): Lock|false { if ($config['lock']['enabled'] == 'fs') { - $wouldblock = false; - flock($this->f, LOCK_EX | ($nonblock ? LOCK_NB : 0), $wouldblock); - if ($nonblock && $wouldblock) return false; + return self::filesystem($key); + } else { + return self::none(); } - return $this; - } - - // Free a lock - function free() { - global $config; - if ($config['lock']['enabled'] == 'fs') { - flock($this->f, LOCK_UN); - } - return $this; } } + +interface Lock { + // Get a shared lock + public function get(bool $nonblock = false): Lock|false; + + // Get an exclusive lock + public function get_ex(bool $nonblock = false): Lock|false; + + // Free a lock + public function free(): Lock; +} diff --git a/inc/queue.php b/inc/queue.php index 66305b3b..a3873491 100644 --- a/inc/queue.php +++ b/inc/queue.php @@ -3,7 +3,7 @@ class Queue { function __construct($key) { global $config; if ($config['queue']['enabled'] == 'fs') { - $this->lock = new Lock($key); + $this->lock = Locks::get_lock($config, $key); $key = str_replace('/', '::', $key); $key = str_replace("\0", '', $key); $this->key = "tmp/queue/$key/"; From 55034762b07cdcb143c4daa42ba26c52260094a9 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 16 Feb 2024 14:48:42 +0100 Subject: [PATCH 006/137] Format queue.php --- inc/queue.php | 74 +++++++++++++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/inc/queue.php b/inc/queue.php index a3873491..2801170e 100644 --- a/inc/queue.php +++ b/inc/queue.php @@ -1,49 +1,49 @@ lock = Locks::get_lock($config, $key); - $key = str_replace('/', '::', $key); - $key = str_replace("\0", '', $key); - $this->key = "tmp/queue/$key/"; - } - } + function __construct($key) { global $config; + if ($config['queue']['enabled'] == 'fs') { + $this->lock = Locks::get_lock($config, $key); + $key = str_replace('/', '::', $key); + $key = str_replace("\0", '', $key); + $this->key = "tmp/queue/$key/"; + } + } - function push($str) { global $config; - if ($config['queue']['enabled'] == 'fs') { - $this->lock->get_ex(); - file_put_contents($this->key.microtime(true), $str); - $this->lock->free(); - } - return $this; - } + function push($str) { global $config; + if ($config['queue']['enabled'] == 'fs') { + $this->lock->get_ex(); + file_put_contents($this->key.microtime(true), $str); + $this->lock->free(); + } + return $this; + } - function pop($n = 1) { global $config; - if ($config['queue']['enabled'] == 'fs') { - $this->lock->get_ex(); - $dir = opendir($this->key); - $paths = array(); - while ($n > 0) { - $path = readdir($dir); - if ($path === FALSE) break; - elseif ($path == '.' || $path == '..') continue; - else { $paths[] = $path; $n--; } - } - $out = array(); - foreach ($paths as $v) { - $out []= file_get_contents($this->key.$v); - unlink($this->key.$v); - } - $this->lock->free(); - return $out; - } - } + function pop($n = 1) { global $config; + if ($config['queue']['enabled'] == 'fs') { + $this->lock->get_ex(); + $dir = opendir($this->key); + $paths = array(); + while ($n > 0) { + $path = readdir($dir); + if ($path === FALSE) break; + elseif ($path == '.' || $path == '..') continue; + else { $paths[] = $path; $n--; } + } + $out = array(); + foreach ($paths as $v) { + $out []= file_get_contents($this->key.$v); + unlink($this->key.$v); + } + $this->lock->free(); + return $out; + } + } } // Don't use the constructor. Use the get_queue function. $queues = array(); function get_queue($name) { global $queues; - return $queues[$name] = isset ($queues[$name]) ? $queues[$name] : new Queue($name); + return $queues[$name] = isset ($queues[$name]) ? $queues[$name] : new Queue($name); } From e61ed35aa06c633db93903d64244a3f82221586f Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 16 Feb 2024 15:18:17 +0100 Subject: [PATCH 007/137] Refactor queue.php --- inc/functions.php | 12 ++++- inc/queue.php | 121 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 95 insertions(+), 38 deletions(-) diff --git a/inc/functions.php b/inc/functions.php index 50e0ca38..5890da8a 100755 --- a/inc/functions.php +++ b/inc/functions.php @@ -2918,8 +2918,16 @@ function generation_strategy($fun, $array=array()) { global $config; return 'rebuild'; case 'defer': // Ok, it gets interesting here :) - get_queue('generate')->push(serialize(array('build', $fun, $array, $action))); - return 'ignore'; + $queue = Queues::get_queue($config, 'generate'); + if ($queue === false) { + if ($config['syslog']) { + _syslog(LOG_ERR, "Could not initialize generate queue, falling back to immediate rebuild strategy"); + } + return 'rebuild'; + } else { + $queue->push(serialize(array('build', $fun, $array, $action))); + return 'ignore'; + } case 'build_on_load': return 'delete'; } diff --git a/inc/queue.php b/inc/queue.php index 2801170e..0ecd1e3c 100644 --- a/inc/queue.php +++ b/inc/queue.php @@ -1,49 +1,98 @@ lock = Locks::get_lock($config, $key); - $key = str_replace('/', '::', $key); - $key = str_replace("\0", '', $key); - $this->key = "tmp/queue/$key/"; - } +class Queues { + private static $queues = array(); + + + /** + * This queue implementation isn't actually ordered, so it works more as a "bag". + */ + private static function filesystem(string $key, Lock $lock): Queue { + $key = str_replace('/', '::', $key); + $key = str_replace("\0", '', $key); + $key = "tmp/queue/$key/"; + + return new class($key, $lock) implements Queue { + private Lock $lock; + private string $key; + + + function __construct(string $key, Lock $lock) { + $this->lock = $lock; + $this->key = $key; + } + + public function push(string $str): Queue { + $this->lock->get_ex(); + file_put_contents($this->key . microtime(true), $str); + $this->lock->free(); + return $this; + } + + public function pop(int $n = 1): array { + $this->lock->get_ex(); + $dir = opendir($this->key); + $paths = array(); + + while ($n > 0) { + $path = readdir($dir); + if ($path === false) { + break; + } elseif ($path == '.' || $path == '..') { + continue; + } else { + $paths[] = $path; + $n--; + } + } + + $out = array(); + foreach ($paths as $v) { + $out[] = file_get_contents($this->key . $v); + unlink($this->key . $v); + } + + $this->lock->free(); + return $out; + } + }; } - function push($str) { global $config; - if ($config['queue']['enabled'] == 'fs') { - $this->lock->get_ex(); - file_put_contents($this->key.microtime(true), $str); - $this->lock->free(); - } - return $this; + /** + * No-op. Can be used for mocking. + */ + public static function none(): Queue { + return new class() implements Queue { + public function push(string $str): Queue { + return $this; + } + + public function pop(int $n = 1): array { + return array(); + } + }; } - function pop($n = 1) { global $config; - if ($config['queue']['enabled'] == 'fs') { - $this->lock->get_ex(); - $dir = opendir($this->key); - $paths = array(); - while ($n > 0) { - $path = readdir($dir); - if ($path === FALSE) break; - elseif ($path == '.' || $path == '..') continue; - else { $paths[] = $path; $n--; } + public static function get_queue(array $config, string $name): Queue|false { + if (!isset(self::$queues[$name])) { + if ($config['queue']['enabled'] == 'fs') { + $lock = Locks::get_lock($config, $name); + if ($lock === false) { + return false; + } + self::$queues[$name] = self::filesystem($name, $lock); + } else { + self::$queues[$name] = self::none(); } - $out = array(); - foreach ($paths as $v) { - $out []= file_get_contents($this->key.$v); - unlink($this->key.$v); - } - $this->lock->free(); - return $out; } + return self::$queues[$name]; } } -// Don't use the constructor. Use the get_queue function. -$queues = array(); +interface Queue { + // Push a string in the queue. + public function push(string $str): Queue; -function get_queue($name) { global $queues; - return $queues[$name] = isset ($queues[$name]) ? $queues[$name] : new Queue($name); + // Get a string from the queue. + public function pop(int $n = 1): array; } From 09dc44ec406cdfc12d4d9a0be7f37caa1e5a7ba8 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 11 Mar 2024 10:31:19 +0100 Subject: [PATCH 008/137] functions.php: trim --- inc/functions.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/inc/functions.php b/inc/functions.php index 5890da8a..ad69e55a 100755 --- a/inc/functions.php +++ b/inc/functions.php @@ -428,10 +428,10 @@ function rebuildThemes($action, $boardname = false) { $board = $_board; // Reload the locale - if ($config['locale'] != $current_locale) { - $current_locale = $config['locale']; - init_locale($config['locale']); - } + if ($config['locale'] != $current_locale) { + $current_locale = $config['locale']; + init_locale($config['locale']); + } if (PHP_SAPI === 'cli') { echo "Rebuilding theme ".$theme['theme']."... "; @@ -450,8 +450,8 @@ function rebuildThemes($action, $boardname = false) { // Reload the locale if ($config['locale'] != $current_locale) { - $current_locale = $config['locale']; - init_locale($config['locale']); + $current_locale = $config['locale']; + init_locale($config['locale']); } } From f47332cdffb00cfe33cb5371de6e0a3d300e25f7 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Fri, 16 Feb 2024 15:48:18 +0100 Subject: [PATCH 009/137] Allow queue push to fail gracefully --- inc/functions.php | 11 ++++++++--- inc/queue.php | 12 ++++++------ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/inc/functions.php b/inc/functions.php index ad69e55a..71231203 100755 --- a/inc/functions.php +++ b/inc/functions.php @@ -2924,10 +2924,15 @@ function generation_strategy($fun, $array=array()) { global $config; _syslog(LOG_ERR, "Could not initialize generate queue, falling back to immediate rebuild strategy"); } return 'rebuild'; - } else { - $queue->push(serialize(array('build', $fun, $array, $action))); - return 'ignore'; } + $ret = $queue->push(serialize(array('build', $fun, $array, $action))); + if ($ret === false) { + if ($config['syslog']) { + _syslog(LOG_ERR, "Could not push item in the queue, falling back to immediate rebuild strategy"); + } + return 'rebuild'; + } + return 'ignore'; case 'build_on_load': return 'delete'; } diff --git a/inc/queue.php b/inc/queue.php index 0ecd1e3c..a5905c84 100644 --- a/inc/queue.php +++ b/inc/queue.php @@ -22,11 +22,11 @@ class Queues { $this->key = $key; } - public function push(string $str): Queue { + public function push(string $str): bool { $this->lock->get_ex(); - file_put_contents($this->key . microtime(true), $str); + $ret = file_put_contents($this->key . microtime(true), $str); $this->lock->free(); - return $this; + return $ret !== false; } public function pop(int $n = 1): array { @@ -63,8 +63,8 @@ class Queues { */ public static function none(): Queue { return new class() implements Queue { - public function push(string $str): Queue { - return $this; + public function push(string $str): bool { + return true; } public function pop(int $n = 1): array { @@ -91,7 +91,7 @@ class Queues { interface Queue { // Push a string in the queue. - public function push(string $str): Queue; + public function push(string $str): bool; // Get a string from the queue. public function pop(int $n = 1): array; From 542c9f3342ed712c2758765ebb9677afa6bc8edf Mon Sep 17 00:00:00 2001 From: Zankaria Date: Mon, 12 Feb 2024 12:45:47 +0100 Subject: [PATCH 010/137] Remove check-updates functionality. It relies on the defunct vichan.net website, no point keeping it around. --- inc/config.php | 5 ----- inc/mod/pages.php | 54 ----------------------------------------------- 2 files changed, 59 deletions(-) diff --git a/inc/config.php b/inc/config.php index 0cece593..c42328fa 100644 --- a/inc/config.php +++ b/inc/config.php @@ -36,11 +36,6 @@ // $config['global_message'] = 'This is an important announcement!'; $config['blotter'] = &$config['global_message']; - // Automatically check if a newer version of vichan is available when an administrator logs in. - $config['check_updates'] = false; - // How often to check for updates - $config['check_updates_time'] = 43200; // 12 hours - // Shows some extra information at the bottom of pages. Good for development/debugging. $config['debug'] = false; // For development purposes. Displays (and "dies" on) all errors and warnings. Turn on with the above. diff --git a/inc/mod/pages.php b/inc/mod/pages.php index 1e803424..16072b9d 100644 --- a/inc/mod/pages.php +++ b/inc/mod/pages.php @@ -107,60 +107,6 @@ function mod_dashboard() { $query = query('SELECT COUNT(*) FROM ``ban_appeals``') or error(db_error($query)); $args['appeals'] = $query->fetchColumn(); - if ($mod['type'] >= ADMIN && $config['check_updates']) { - if (!$config['version']) - error(_('Could not find current version! (Check .installed)')); - - if (isset($_COOKIE['update'])) { - $latest = unserialize($_COOKIE['update']); - } else { - $ctx = stream_context_create(array('http' => array('timeout' => 5))); - if ($code = @file_get_contents('http://engine.vichan.info/version.txt', 0, $ctx)) { - $ver = strtok($code, "\n"); - - if (preg_match('@^// v(\d+)\.(\d+)\.(\d+)\s*?$@', $ver, $matches)) { - $latest = array( - 'massive' => $matches[1], - 'major' => $matches[2], - 'minor' => $matches[3] - ); - if (preg_match('/(\d+)\.(\d)\.(\d+)(-dev.+)?$/', $config['version'], $matches)) { - $current = array( - 'massive' => (int) $matches[1], - 'major' => (int) $matches[2], - 'minor' => (int) $matches[3] - ); - if (isset($m[4])) { - // Development versions are always ahead in the versioning numbers - $current['minor'] --; - } - // Check if it's newer - if (!( $latest['massive'] > $current['massive'] || - $latest['major'] > $current['major'] || - ($latest['massive'] == $current['massive'] && - $latest['major'] == $current['major'] && - $latest['minor'] > $current['minor'] - ))) - $latest = false; - } else { - $latest = false; - } - } else { - // Couldn't get latest version - $latest = false; - } - } else { - // Couldn't get latest version - $latest = false; - } - - setcookie('update', serialize($latest), time() + $config['check_updates_time'], $config['cookies']['jail'] ? $config['cookies']['path'] : '/', null, !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off', true); - } - - if ($latest) - $args['newer_release'] = $latest; - } - $args['logout_token'] = make_secure_link_token('logout'); mod_page(_('Dashboard'), $config['file_mod_dashboard'], $args); From 9a014e855741405ad2f33047f6a27d83736dd7da Mon Sep 17 00:00:00 2001 From: Zankaria Date: Wed, 3 Apr 2024 16:11:54 +0200 Subject: [PATCH 011/137] template.php: fix deprecated string interning syntax --- inc/template.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inc/template.php b/inc/template.php index 0362111c..fb75aaf6 100644 --- a/inc/template.php +++ b/inc/template.php @@ -58,7 +58,7 @@ function Element($templateFile, array $options) { } // Read the template file - if (@file_get_contents("{$config['dir']['template']}/${templateFile}")) { + if (@file_get_contents("{$config['dir']['template']}/{$templateFile}")) { $body = $twig->render($templateFile, $options); if ($config['minify_html'] && preg_match('/\.html$/', $templateFile)) { @@ -67,7 +67,7 @@ function Element($templateFile, array $options) { return $body; } else { - throw new Exception("Template file '${templateFile}' does not exist or is empty in '{$config['dir']['template']}'!"); + throw new Exception("Template file '{$templateFile}' does not exist or is empty in '{$config['dir']['template']}'!"); } } From 7479360aad2764eaf34d2dd4399535b70bebb718 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Wed, 3 Apr 2024 17:18:52 +0200 Subject: [PATCH 012/137] catalog.html: format template --- templates/themes/catalog/catalog.html | 68 +++++++++++++-------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/templates/themes/catalog/catalog.html b/templates/themes/catalog/catalog.html index 34202907..de7d1461 100644 --- a/templates/themes/catalog/catalog.html +++ b/templates/themes/catalog/catalog.html @@ -19,24 +19,24 @@ - {% trans 'Sort by' %}: - - - {% trans 'Image size' %}: - -
-
- {% for post in recent_posts %} -
{% trans 'Sort by' %}: + + + {% trans 'Image size' %}: + +
+
+ {% for post in recent_posts %} +
-
- +
+ {% if post.youtube %} - - -
- R: {{ post.reply_count }} / I: {{ post.image_count }}{% if post.sticky %} (sticky){% endif %} - {% if post.subject %} + id="img-{{ post.id }}" data-subject="{% if post.subject %}{{ post.subject|e }}{% endif %}" data-name="{{ post.name|e }}" data-muhdifference="{{ post.muhdifference }}" class="{{post.board}} thread-image" title="{{post.bump|date('%b %d %H:%M')}}"> + +
+ R: {{ post.reply_count }} / I: {{ post.image_count }}{% if post.sticky %} (sticky){% endif %} + {% if post.subject %}

{{ post.subject|e }} @@ -66,13 +66,13 @@ {% endif %} {{ post.body }} -

-
-
- {% endfor %} -
-
- +
+
+
+ {% endfor %} +
+
+
{% include 'footer.html' %} - + From 735180cf542a755c9c6e39297aba36751b23f289 Mon Sep 17 00:00:00 2001 From: discomrade Date: Fri, 12 Nov 2021 04:50:47 -0100 Subject: [PATCH 080/137] Improve social media cards --- templates/index.html | 12 ++++++++++++ templates/themes/categories/frames.html | 9 ++++++++- templates/thread.html | 3 +++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/templates/index.html b/templates/index.html index e0fad639..685dc0a9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -14,6 +14,18 @@ {% include 'header.html' %} + + {% set meta_subject %}{% if config.thread_subject_in_title and thread.subject %}{{ thread.subject|e }}{% else %}{{ thread.body_nomarkup|remove_modifiers[:256]|e }}{% endif %}{% endset %} + + + + + + + + + + {{ board.url }} - {{ board.title|e }} diff --git a/templates/themes/categories/frames.html b/templates/themes/categories/frames.html index 48066bcd..291ca59f 100644 --- a/templates/themes/categories/frames.html +++ b/templates/themes/categories/frames.html @@ -1,7 +1,14 @@ - + + + + + + + +