diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..8ae84728 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +**/.git +**/.gitignore +/local-instances +**/.gitkeep diff --git a/.gitignore b/.gitignore index 220b0e11..5e0ab052 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,6 @@ Thumbs.db #vichan custom favicon.ico /static/spoiler.png +/local-instances /vendor/ diff --git a/README.md b/README.md index b1794df9..2c7001be 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,11 @@ WebM support ------------ Read `inc/lib/webm/README.md` for information about enabling webm. +Docker +------------ +Vichan comes with a Dockerfile and docker-compose configuration, the latter aimed primarily at development and testing. +See the `docker/doc.md` file for more information. + vichan API ---------- vichan provides by default a 4chan-compatible JSON API. For documentation on this, see: diff --git a/b.php b/b.php index 83285b81..446fb47c 100644 --- a/b.php +++ b/b.php @@ -1,20 +1,8 @@ +$name = $files[array_rand($files)]; +header("Location: /static/banners/$name", true, 307); +header('Cache-Control: no-cache'); diff --git a/composer.json b/composer.json index ec4a090d..80eaf918 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "gettext/gettext": "^5.5", "mrclay/minify": "^2.1.6", "geoip/geoip": "^1.17", - "dapphp/securimage": "^4.0" + "dapphp/securimage": "^4.0", + "erusev/parsedown": "^1.7.4" }, "autoload": { "classmap": ["inc/"], @@ -32,7 +33,13 @@ "inc/mod/auth.php", "inc/lock.php", "inc/queue.php", - "inc/functions.php" + "inc/functions.php", + "inc/functions/net.php", + "inc/functions/num.php", + "inc/functions/format.php", + "inc/driver/http-driver.php", + "inc/driver/log-driver.php", + "inc/service/captcha-queries.php" ] }, "license": "Tinyboard + vichan", diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..da45b113 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + #nginx webserver + php 8.x + web: + build: + context: . + dockerfile: ./docker/nginx/Dockerfile + ports: + - "9090:80" + depends_on: + - db + volumes: + - ./local-instances/1/www:/var/www/html + - ./docker/nginx/vichan.conf:/etc/nginx/conf.d/default.conf + - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf + - ./docker/nginx/proxy.conf:/etc/nginx/conf.d/proxy.conf + links: + - php + + php: + build: + context: . + dockerfile: ./docker/php/Dockerfile + volumes: + - ./local-instances/1/www:/var/www + - ./docker/php/www.conf:/usr/local/etc/php-fpm.d/www.conf + - ./docker/php/jit.ini:/usr/local/etc/php/conf.d/jit.ini + + #MySQL Service + db: + image: mysql:8.0.35 + container_name: db + restart: unless-stopped + tty: true + ports: + - "3306:3306" + environment: + MYSQL_DATABASE: vichan + MYSQL_ROOT_PASSWORD: password + volumes: + - ./local-instances/1/mysql:/var/lib/mysql diff --git a/docker/doc.md b/docker/doc.md new file mode 100644 index 00000000..051ae56e --- /dev/null +++ b/docker/doc.md @@ -0,0 +1,20 @@ +The `php-fpm` process runs containerized. +The php application always uses `/var/www` as it's work directory and home folder, and if `/var/www` is bind mounted it +is necessary to adjust the path passed via FastCGI to `php-fpm` by changing the root directory to `/var/www`. +This can achieved in nginx by setting the `fastcgi_param SCRIPT_FILENAME` to `/var/www/$fastcgi_script_name;` + +The default docker compose settings are intended for development and testing purposes. +The folder structure expected by compose is as follows + +``` + +└── local-instances + └── 1 + ├── mysql + └── www +``` +The vichan container is by itself much less rigid. + + +Use `docker compose up --build` to start the docker compose. +Use `docker compose up --build -d php` to rebuild just the vichan container while the compose is running. Useful for development. diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile new file mode 100644 index 00000000..d9d4bcc4 --- /dev/null +++ b/docker/nginx/Dockerfile @@ -0,0 +1,8 @@ +FROM nginx:1.25.3-alpine + +COPY . /code +RUN adduser --system www-data \ + && adduser www-data www-data + +CMD [ "nginx", "-g", "daemon off;" ] +EXPOSE 80 diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 00000000..7c6b6587 --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,34 @@ +# This and proxy.conf are based on +# https://github.com/dead-guru/devichan/blob/master/nginx/nginx.conf + +user www-data; +worker_processes auto; + +error_log /dev/stdout warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Switch logging to console out to view via Docker + access_log /dev/stdout; + error_log /dev/stdout warn; + sendfile on; + keepalive_timeout 5; + + gzip on; + gzip_http_version 1.0; + gzip_vary on; + gzip_comp_level 6; + gzip_types text/xml text/plain text/css application/xhtml+xml application/xml application/rss+xml application/atom_xml application/x-javascript application/x-httpd-php; + gzip_disable "MSIE [1-6]\."; + + + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-available/*.conf; +} \ No newline at end of file diff --git a/docker/nginx/proxy.conf b/docker/nginx/proxy.conf new file mode 100644 index 00000000..6830cd5f --- /dev/null +++ b/docker/nginx/proxy.conf @@ -0,0 +1,40 @@ +proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=czone:4m max_size=50m inactive=120m; +proxy_temp_path /var/tmp/nginx; +proxy_cache_key "$scheme://$host$request_uri"; + + +map $http_forwarded_request_id $x_request_id { + "" $request_id; + default $http_forwarded_request_id; +} + +map $http_forwarded_forwarded_host $forwardedhost { + "" $host; + default $http_forwarded_forwarded_host; +} + + +map $http_x_forwarded_proto $fcgi_https { + default ""; + https on; +} + +map $http_x_forwarded_proto $real_scheme { + default $scheme; + https https; +} + +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Forwarded-Server $host; + +real_ip_header X-Forwarded-For; + +set_real_ip_from 10.0.0.0/8; +set_real_ip_from 172.16.0.0/12; +set_real_ip_from 172.18.0.0; +set_real_ip_from 192.168.0.0/24; +set_real_ip_from 127.0.0.0/8; + +real_ip_recursive on; \ No newline at end of file diff --git a/docker/nginx/vichan.conf b/docker/nginx/vichan.conf new file mode 100644 index 00000000..35f6bc08 --- /dev/null +++ b/docker/nginx/vichan.conf @@ -0,0 +1,66 @@ +upstream php-upstream { + server php:9000; +} + +server { + listen 80 default_server; + listen [::]:80 default_server ipv6only=on; + server_name vichan; + root /var/www/html; + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + + index index.html index.php; + + charset utf-8; + + location ~ ^([^.\?]*[^\/])$ { + try_files $uri @addslash; + } + + # Expire rules for static content + # Media: images, icons, video, audio, HTC + location ~* \.(?:jpg|jpeg|gif|png|webp|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ { + expires 1M; + access_log off; + log_not_found off; + add_header Cache-Control "public"; + } + # CSS and Javascript + location ~* \.(?:css|js)$ { + expires 1y; + access_log off; + log_not_found off; + add_header Cache-Control "public"; + } + + location ~* \.(html)$ { + expires -1; + } + + location @addslash { + return 301 $uri/; + } + + location / { + try_files $uri $uri/ /index.php$is_args$args; + } + + client_max_body_size 2G; + + location ~ \.php$ { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Request-Id $x_request_id; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Forwarded-Request-Id $x_request_id; + fastcgi_pass php-upstream; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME /var/www/$fastcgi_script_name; + fastcgi_read_timeout 600; + include fastcgi_params; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 00000000..0e2f741d --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,87 @@ +# Based on https://github.com/dead-guru/devichan/blob/master/php-fpm/Dockerfile + +FROM composer AS composer +FROM php:8.1-fpm-alpine + +RUN apk add --no-cache \ + zlib \ + zlib-dev \ + libpng \ + libpng-dev \ + libjpeg-turbo \ + libjpeg-turbo-dev \ + libwebp \ + libwebp-dev \ + libcurl \ + curl-dev \ + imagemagick \ + graphicsmagick \ + gifsicle \ + ffmpeg \ + bind-tools \ + gettext \ + gettext-dev \ + icu-dev \ + oniguruma \ + oniguruma-dev \ + libmcrypt \ + libmcrypt-dev \ + lz4-libs \ + lz4-dev \ + imagemagick-dev \ + pcre-dev \ + $PHPIZE_DEPS \ + && docker-php-ext-configure gd \ + --with-webp=/usr/include/webp \ + --with-jpeg=/usr/include \ + && docker-php-ext-install -j$(nproc) \ + gd \ + curl \ + bcmath \ + opcache \ + pdo_mysql \ + gettext \ + intl \ + mbstring \ + && pecl update-channels \ + && pecl install -o -f igbinary \ + && pecl install redis \ + && pecl install imagick \ + $$ docker-php-ext-enable \ + igbinary \ + redis \ + imagick \ + && apk del \ + zlib-dev \ + libpng-dev \ + libjpeg-turbo-dev \ + libwebp-dev \ + curl-dev \ + gettext-dev \ + oniguruma-dev \ + libmcrypt-dev \ + lz4-dev \ + imagemagick-dev \ + pcre-dev \ + $PHPIZE_DEPS \ + && rm -rf /var/cache/* +RUN rmdir /var/www/html \ + && install -d -m 744 -o www-data -g www-data /var/www \ + && install -d -m 700 -o www-data -g www-data /var/tmp/vichan \ + && install -d -m 700 -o www-data -g www-data /var/cache/gen-cache \ + && install -d -m 700 -o www-data -g www-data /var/cache/template-cache + +# Copy the bootstrap script. +COPY ./docker/php/bootstrap.sh /usr/local/bin/bootstrap.sh + +COPY --from=composer /usr/bin/composer /usr/local/bin/composer + +# Copy the actual project (use .dockerignore to exclude stuff). +COPY . /code + +# Install the compose depedencies. +RUN cd /code && composer install + +WORKDIR "/var/www" +CMD [ "bootstrap.sh" ] +EXPOSE 9000 diff --git a/docker/php/Dockerfile.profile b/docker/php/Dockerfile.profile new file mode 100644 index 00000000..ad2019ab --- /dev/null +++ b/docker/php/Dockerfile.profile @@ -0,0 +1,16 @@ +# syntax = devthefuture/dockerfile-x +INCLUDE ./docker/php/Dockerfile + +RUN apk add --no-cache \ + linux-headers \ + $PHPIZE_DEPS \ + && pecl update-channels \ + && pecl install xdebug \ + && docker-php-ext-enable xdebug \ + && apk del \ + linux-headers \ + $PHPIZE_DEPS \ + && rm -rf /var/cache/* + +ENV XDEBUG_OUT_DIR=/var/www/xdebug_out +CMD [ "bootstrap.sh" ] \ No newline at end of file diff --git a/docker/php/bootstrap.sh b/docker/php/bootstrap.sh new file mode 100755 index 00000000..cc3f43d0 --- /dev/null +++ b/docker/php/bootstrap.sh @@ -0,0 +1,87 @@ +#!/bin/sh + +set -eu + +function set_cfg() { + if [ -L "/var/www/inc/$1" ]; then + echo "INFO: Resetting $1" + rm "/var/www/inc/$1" + cp "/code/inc/$1" "/var/www/inc/$1" + chown www-data "/var/www/inc/$1" + chgrp www-data "/var/www/inc/$1" + chmod 600 "/var/www/inc/$1" + else + echo "INFO: Using existing $1" + fi +} + +if ! mountpoint -q /var/www; then + echo "WARNING: '/var/www' is not a mountpoint. All the data will remain inside the container!" +fi + +if [ ! -w /var/www ] ; then + echo "ERROR: '/var/www' is not writable. Closing." + exit 1 +fi + +if [ -z "${XDEBUG_OUT_DIR:-''}" ] ; then + echo "INFO: Initializing xdebug out directory at $XDEBUG_OUT_DIR" + mkdir -p "$XDEBUG_OUT_DIR" + chown www-data "$XDEBUG_OUT_DIR" + chgrp www-data "$XDEBUG_OUT_DIR" + chmod 755 "$XDEBUG_OUT_DIR" +fi + +# Link the entrypoints from the exposed directory. +ln -nfs \ + /code/tools/ \ + /code/*.php \ + /code/LICENSE.* \ + /code/install.sql \ + /var/www/ +# Static files accessible from the webserver must be copied. +cp -ur /code/static /var/www/ +cp -ur /code/stylesheets /var/www/ + +# Ensure correct permissions are set, since this might be bind mount. +chown www-data /var/www +chgrp www-data /var/www + +# Initialize an empty robots.txt with the default if it doesn't exist. +touch /var/www/robots.txt + +# Link the cache and tmp files directory. +ln -nfs /var/tmp/vichan /var/www/tmp + +# Link the javascript directory. +ln -nfs /code/js /var/www/ + +# Link the html templates directory and it's cache. +ln -nfs /code/templates /var/www/ +ln -nfs -T /var/cache/template-cache /var/www/templates/cache +chown -h www-data /var/www/templates/cache +chgrp -h www-data /var/www/templates/cache + +# Link the generic cache. +ln -nfs -T /var/cache/gen-cache /var/www/tmp/cache +chown -h www-data /var/www/tmp/cache +chgrp -h www-data /var/www/tmp/cache + +# Create the included files directory and link them +install -d -m 700 -o www-data -g www-data /var/www/inc +for file in /code/inc/*; do + file="${file##*/}" + if [ ! -e /var/www/inc/$file ]; then + ln -s /code/inc/$file /var/www/inc/ + fi +done + +# Copy an empty instance configuration if the file is a link (it was linked because it did not exist before). +set_cfg 'instance-config.php' +set_cfg 'secrets.php' + +# Link the composer dependencies. +ln -nfs /code/vendor /var/www/ + +# Start the php-fpm server. +exec php-fpm diff --git a/docker/php/jit.ini b/docker/php/jit.ini new file mode 100644 index 00000000..ecfb44c5 --- /dev/null +++ b/docker/php/jit.ini @@ -0,0 +1,2 @@ +opcache.jit_buffer_size=192M +opcache.jit=tracing diff --git a/docker/php/www.conf b/docker/php/www.conf new file mode 100644 index 00000000..6e78ad26 --- /dev/null +++ b/docker/php/www.conf @@ -0,0 +1,13 @@ +[www] +access.log = /proc/self/fd/2 + +; Ensure worker stdout and stderr are sent to the main error log. +catch_workers_output = yes +decorate_workers_output = no + +user = www-data +group = www-data + +listen = 127.0.0.1:9000 +pm = static +pm.max_children = 16 diff --git a/docker/php/xdebug-prof.ini b/docker/php/xdebug-prof.ini new file mode 100644 index 00000000..c6dc008e --- /dev/null +++ b/docker/php/xdebug-prof.ini @@ -0,0 +1,7 @@ +zend_extension=xdebug + +[xdebug] +xdebug.mode = profile +xdebug.start_with_request = start +error_reporting = E_ALL +xdebug.output_dir = /var/www/xdebug_out diff --git a/inc/anti-bot.php b/inc/anti-bot.php index 48150328..29279296 100644 --- a/inc/anti-bot.php +++ b/inc/anti-bot.php @@ -123,7 +123,7 @@ class AntiBot { $html = ''; if ($count === false) { - $count = mt_rand(1, abs(count($this->inputs) / 15) + 1); + $count = mt_rand(1, (int)abs(count($this->inputs) / 15) + 1); } if ($count === true) { diff --git a/inc/bans.php b/inc/bans.php index 8ea3b921..c6847a4a 100644 --- a/inc/bans.php +++ b/inc/bans.php @@ -1,5 +1,6 @@ bindValue(':post', null, PDO::PARAM_NULL); $query->execute() or error(db_error($query)); - if (isset($mod['id']) && $mod['id'] == $mod_id) { - modLog('Created a new ' . - ($length > 0 ? preg_replace('/^(\d+) (\w+?)s?$/', '$1-$2', until($length)) : 'permanent') . - ' ban on ' . - ($ban_board ? '/' . $ban_board . '/' : 'all boards') . - ' for ' . - (filter_var($mask, FILTER_VALIDATE_IP) !== false ? "$cloaked_mask" : $cloaked_mask) . - ' (#' . $pdo->lastInsertId() . ')' . - ' with ' . ($reason ? 'reason: ' . utf8tohtml($reason) . '' : 'no reason')); - } + + $ban_len = $length > 0 ? preg_replace('/^(\d+) (\w+?)s?$/', '$1-$2', Format\until($length)) : 'permanent'; + $ban_board = $ban_board ? "/$ban_board/" : 'all boards'; + $ban_ip = filter_var($mask, FILTER_VALIDATE_IP) !== false ? "$cloaked_mask" : $cloaked_mask; + $ban_id = $pdo->lastInsertId(); + $ban_reason = $reason ? 'reason: ' . utf8tohtml($reason) : 'no reason'; + + modLog("Created a new $ban_len ban on $ban_board for $ban_ip (# $ban_id ) with $ban_reason"); rebuildThemes('bans'); diff --git a/inc/cache.php b/inc/cache.php index 0979138b..c4053610 100644 --- a/inc/cache.php +++ b/inc/cache.php @@ -165,31 +165,3 @@ class Cache { return false; } } - -class Twig_Cache_TinyboardFilesystem extends Twig\Cache\FilesystemCache -{ - private $directory; - private $options; - - /** - * {@inheritdoc} - */ - public function __construct($directory, $options = 0) - { - parent::__construct($directory, $options); - - $this->directory = $directory; - } - - /** - * This function was removed in Twig 2.x due to developer views on the Twig library. Who says we can't keep it for ourselves though? - */ - public function clear() - { - foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->directory), RecursiveIteratorIterator::LEAVES_ONLY) as $file) { - if ($file->isFile()) { - @unlink($file->getPathname()); - } - } - } -} \ No newline at end of file diff --git a/inc/config.php b/inc/config.php index 664d1710..bf62afb7 100644 --- a/inc/config.php +++ b/inc/config.php @@ -65,9 +65,22 @@ // been generated. This keeps the script from querying the database and causing strain when not needed. $config['has_installed'] = '.installed'; - // Use syslog() for logging all error messages and unauthorized login attempts. + // Deprecated, use 'log_system'. $config['syslog'] = false; + $config['log_system'] = []; + // Log all error messages and unauthorized login attempts. + // Can be "syslog", "error_log" (default), "file", "stderr" or "none". + $config['log_system']['type'] = 'error_log'; + // The application name used by the logging system. Defaults to "tinyboard" for backwards compatibility. + $config['log_system']['name'] = 'tinyboard'; + // Only relevant if 'log_system' is set to "syslog". If true, double print the logs also in stderr. + // Defaults to false. + $config['log_system']['syslog_stderr'] = false; + // Only relevant if "log_system" is set to `file`. Sets the file that vichan will log to. + // Defaults to '/var/log/vichan.log'. + $config['log_system']['file_path'] = '/var/log/vichan.log'; + // Use `host` via shell_exec() to lookup hostnames, avoiding query timeouts. May not work on your system. // Requires safe_mode to be disabled. $config['dns_system'] = false; @@ -173,7 +186,7 @@ // How long should the cookies last (in seconds). Defines how long should moderators should remain logged // in (0 = browser session). - $config['cookies']['expire'] = 60 * 60 * 24 * 30 * 6; // ~6 months + $config['cookies']['expire'] = 60 * 60 * 24 * 7; // 1 week. // Make this something long and random for security. $config['cookies']['salt'] = 'abcdefghijklmnopqrstuvwxyz09123456789!@#$%^&*()'; @@ -181,6 +194,14 @@ // Whether or not you can access the mod cookie in JavaScript. Most users should not need to change this. $config['cookies']['httponly'] = true; + // Do not allow logins via unsecure connections. + // 0 = off. Allow logins on unencrypted HTTP connections. Should only be used in testing environments. + // 1 = on, trust HTTP headers. Allow logins on (at least reportedly partial) HTTPS connections. Use this only if you + // use a proxy, CDN or load balancer via an unencrypted connection. Be sure to filter 'HTTP_X_FORWARDED_PROTO' in + // the remote server, since an attacker could inject the header from the client. + // 2 = on, do not trust HTTP headers. Secure default, allow logins only on HTTPS connections. + $config['cookies']['secure_login_only'] = 2; + // Used to salt secure tripcodes ("##trip") and poster IDs (if enabled). $config['secure_trip_salt'] = ')(*&^%$#@!98765432190zyxwvutsrqponmlkjihgfedcba'; @@ -614,6 +635,9 @@ // Example: Custom secure tripcode. // $config['custom_tripcode']['##securetrip'] = '!!somethingelse'; + //Disable tripcodes. This will make it so all new posts will act as if no tripcode exists. + $config['disable_tripcodes'] = false; + // Allow users to mark their image as a "spoiler" when posting. The thumbnail will be replaced with a // static spoiler image instead (see $config['spoiler_image']). $config['spoiler_images'] = false; @@ -979,11 +1003,11 @@ // Timezone to use for displaying dates/times. $config['timezone'] = 'America/Los_Angeles'; - // The format string passed to strftime() for displaying dates. - // http://www.php.net/manual/en/function.strftime.php - $config['post_date'] = '%m/%d/%y (%a) %H:%M:%S'; + // The format string passed to DateTime::format() for displaying dates. ISO 8601-like by default. + // https://www.php.net/manual/en/datetime.format.php + $config['post_date'] = 'm/d/y (D) H:i:s'; // Same as above, but used for "you are banned' pages. - $config['ban_date'] = '%A %e %B, %Y'; + $config['ban_date'] = 'l j F, Y'; // The names on the post buttons. (On most imageboards, these are both just "Post"). $config['button_newtopic'] = _('New Topic'); @@ -1235,11 +1259,14 @@ $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 $config['error']['toomanyunban'] = _('You are only allowed to unban %s users at a time. You tried to unban %u users.'); $config['error']['invalid'] = _('Invalid username and/or password.'); + $config['error']['insecure'] = _('Login on insecure connections is disabled.'); $config['error']['notamod'] = _('You are not a mod…'); $config['error']['invalidafter'] = _('Invalid username and/or password. Your user may have been deleted or changed.'); $config['error']['malformed'] = _('Invalid/malformed cookies.'); @@ -1834,6 +1861,9 @@ // Boards for searching //$config['search']['boards'] = array('a', 'b', 'c', 'd', 'e'); + // Blacklist boards for searching, basically the opposite of the one above + //$config['search']['disallowed_boards'] = array('j', 'z'); + // Enable public logs? 0: NO, 1: YES, 2: YES, but drop names $config['public_logs'] = 0; diff --git a/inc/context.php b/inc/context.php new file mode 100644 index 00000000..bb261047 --- /dev/null +++ b/inc/context.php @@ -0,0 +1,66 @@ +config = $config; + } + + public function buildLogDriver(): Log { + $name = $this->config['log_system']['name']; + $level = $this->config['debug'] ? Log::DEBUG : Log::NOTICE; + $backend = $this->config['log_system']['type']; + + // Check 'syslog' for backwards compatibility. + if ((isset($this->config['syslog']) && $this->config['syslog']) || $backend === 'syslog') { + return LogDrivers::syslog($name, $level, $this->config['log_system']['syslog_stderr']); + } elseif ($backend === 'file') { + return LogDrivers::file($name, $level, $this->config['log_system']['file_path']); + } elseif ($backend === 'stderr') { + return LogDrivers::stderr($name, $level); + } elseif ($backend === 'none') { + return LogDrivers::none(); + } else { + return LogDrivers::error_log($name, $level); + } + } + + public function buildHttpDriver(): HttpDriver { + return HttpDrivers::getHttpDriver( + $this->config['upload_by_url_timeout'], + $this->config['max_filesize'] + ); + } +} + +class Context { + private DependencyFactory $factory; + private ?Log $log; + private ?HttpDriver $http; + + + public function __construct(DependencyFactory $factory) { + $this->factory = $factory; + } + + public function getLog(): Log { + return $this->log ??= $this->factory->buildLogDriver(); + } + + public function getHttpDriver(): HttpDriver { + return $this->http ??= $this->factory->buildHttpDriver(); + } +} diff --git a/inc/display.php b/inc/display.php index 1d74424f..66e4a37f 100644 --- a/inc/display.php +++ b/inc/display.php @@ -71,7 +71,7 @@ function createBoardlist($mod=false) { ); } -function error($message, $priority = true, $debug_stuff = false) { +function error($message, $priority = true, $debug_stuff = []) { global $board, $mod, $config, $db_error; if ($config['syslog'] && $priority !== false) { @@ -351,13 +351,20 @@ class Post { if (isset($this->files) && $this->files) { $this->files = is_string($this->files) ? json_decode($this->files) : $this->files; // Compatibility for posts before individual file hashing - foreach ($this->files as $i => &$file) { + foreach ($this->files as $i => &$file) { if (empty($file)) { unset($this->files[$i]); continue; } - if (!isset($file->hash)) - $file->hash = $this->filehash; + if (is_array($file)) { + if (!isset($file['hash'])) { + $file['hash'] = $this->filehash; + } + } else if (is_object($file)) { + if (!isset($file->hash)) { + $file->hash = $this->filehash; + } + } } } 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/driver/log-driver.php b/inc/driver/log-driver.php new file mode 100644 index 00000000..0026f009 --- /dev/null +++ b/inc/driver/log-driver.php @@ -0,0 +1,189 @@ +level = $level; + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + if (isset($_SERVER['REMOTE_ADDR'], $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'])) { + // CGI + syslog($level, "$message - client: {$_SERVER['REMOTE_ADDR']}, request: \"{$_SERVER['REQUEST_METHOD']} {$_SERVER['REQUEST_URI']}\""); + } else { + syslog($level, $message); + } + } + } + }; + } + + /** + * Log via the php function error_log. + */ + public static function error_log(string $name, int $level): Log { + return new class($name, $level) implements Log { + private string $name; + private int $level; + + public function __construct(string $name, int $level) { + $this->name = $name; + $this->level = $level; + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + $lv = LogDrivers::levelToString($level); + $line = "{$this->name} $lv: $message"; + error_log($line, 0, null, null); + } + } + }; + } + + /** + * Log to a file. + */ + public static function file(string $name, int $level, string $file_path): Log { + /* + * error_log is slow as hell in it's 3rd mode, so use fopen + file locking instead. + * https://grobmeier.solutions/performance-ofnonblocking-write-to-files-via-php-21082009.html + * + * Whatever file appending is atomic is contentious: + * - There are no POSIX guarantees: https://stackoverflow.com/a/7237901 + * - But linus suggested they are on linux, on some filesystems: https://web.archive.org/web/20151201111541/http://article.gmane.org/gmane.linux.kernel/43445 + * - But it doesn't seem to be always the case: https://www.notthewizard.com/2014/06/17/are-files-appends-really-atomic/ + * + * So we just use file locking to be sure. + */ + + $fd = fopen($file_path, 'a'); + if ($fd === false) { + throw new RuntimeException("Unable to open log file at $file_path"); + } + + $logger = new class($name, $level, $fd) implements Log { + private string $name; + private int $level; + private mixed $fd; + + public function __construct(string $name, int $level, mixed $fd) { + $this->name = $name; + $this->level = $level; + $this->fd = $fd; + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + $lv = LogDrivers::levelToString($level); + $line = "{$this->name} $lv: $message\n"; + flock($this->fd, LOCK_EX); + fwrite($this->fd, $line); + flock($this->fd, LOCK_UN); + } + } + + public function close() { + fclose($this->fd); + } + }; + + // Close the file on shutdown. + register_shutdown_function([$logger, 'close']); + + return $logger; + } + + /** + * Log to php's standard error file stream. + */ + public static function stderr(string $name, int $level): Log { + return new class($name, $level) implements Log { + private $name; + private $level; + + public function __construct(string $name, int $level) { + $this->name = $name; + $this->level = $level; + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + $lv = LogDrivers::levelToString($level); + fwrite(STDERR, "{$this->name} $lv: $message\n"); + } + } + }; + } + + /** + * No-op logging system. + */ + public static function none(): Log { + return new class() implements Log { + public function log($level, $message): void { + // No-op. + } + }; + } +} + +interface Log { + public const EMERG = LOG_EMERG; + public const ERROR = LOG_ERR; + public const WARNING = LOG_WARNING; + public const NOTICE = LOG_NOTICE; + public const INFO = LOG_INFO; + public const DEBUG = LOG_DEBUG; + + + /** + * Log a message if the level of relevancy is at least the minimum. + * + * @param int $level Message level. Use Log interface constants. + * @param string $message The message to log. + */ + public function log(int $level, string $message): void; +} diff --git a/inc/functions.php b/inc/functions.php index adf83e9b..46e03d2a 100755 --- a/inc/functions.php +++ b/inc/functions.php @@ -21,9 +21,7 @@ loadConfig(); function init_locale($locale, $error='error') { if (extension_loaded('gettext')) { - if (setlocale(LC_ALL, $locale) === false) { - //$error('The specified locale (' . $locale . ') does not exist on your platform!'); - } + setlocale(LC_ALL, $locale); bindtextdomain('tinyboard', './inc/locale'); bind_textdomain_codeset('tinyboard', 'UTF-8'); textdomain('tinyboard'); @@ -55,8 +53,9 @@ function loadConfig() { if (isset($config['cache_config']) && - $config['cache_config'] && - $config = Cache::get('config_' . $boardsuffix ) ) { + $config['cache_config'] && + $config = Cache::get('config_' . $boardsuffix)) + { $events = Cache::get('events_' . $boardsuffix ); define_groups(); @@ -66,11 +65,10 @@ function loadConfig() { } if ($config['locale'] != $current_locale) { - $current_locale = $config['locale']; - init_locale($config['locale'], $error); - } - } - else { + $current_locale = $config['locale']; + init_locale($config['locale'], $error); + } + } else { $config = array(); reset_events(); @@ -180,8 +178,8 @@ function loadConfig() { '(' . str_replace('%d', '\d+', preg_quote($config['file_page'], '/')) . '|' . str_replace('%d', '\d+', preg_quote($config['file_page50'], '/')) . '|' . - str_replace(array('%d', '%s'), array('\d+', '[a-z0-9-]+'), preg_quote($config['file_page_slug'], '/')) . '|' . - str_replace(array('%d', '%s'), array('\d+', '[a-z0-9-]+'), preg_quote($config['file_page50_slug'], '/')) . + str_replace(array('%d', '%s'), array('\d+', '[a-z0-9-]+'), preg_quote($config['file_page_slug'], '/')) . '|' . + str_replace(array('%d', '%s'), array('\d+', '[a-z0-9-]+'), preg_quote($config['file_page50_slug'], '/')) . ')' . '|' . preg_quote($config['file_mod'], '/') . '\?\/.+' . @@ -242,12 +240,13 @@ function loadConfig() { $__version = file_exists('.installed') ? trim(file_get_contents('.installed')) : false; $config['version'] = $__version; - if ($config['allow_roll']) + if ($config['allow_roll']) { event_handler('post', 'diceRoller'); + } - if (in_array('webm', $config['allowed_ext_files']) || - in_array('mp4', $config['allowed_ext_files'])) + if (in_array('webm', $config['allowed_ext_files']) || in_array('mp4', $config['allowed_ext_files'])) { event_handler('post', 'postHandler'); + } } // Effectful config processing below: @@ -280,8 +279,7 @@ function loadConfig() { if ($config['cache']['enabled']) require_once 'inc/cache.php'; - if (in_array('webm', $config['allowed_ext_files']) || - in_array('mp4', $config['allowed_ext_files'])) + if (in_array('webm', $config['allowed_ext_files']) || in_array('mp4', $config['allowed_ext_files'])) require_once 'inc/lib/webm/posthandler.php'; event('load-config'); @@ -428,10 +426,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 +448,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']); } } @@ -517,12 +515,11 @@ function mb_substr_replace($string, $replacement, $start, $length) { function setupBoard($array) { global $board, $config; - $board = array( + $board = [ 'uri' => $array['uri'], 'title' => $array['title'], 'subtitle' => $array['subtitle'], - #'indexed' => $array['indexed'], - ); + ]; // older versions $board['name'] = &$board['title']; @@ -718,12 +715,18 @@ function file_unlink($path) { $debug['unlink'][] = $path; } - $ret = @unlink($path); + if (file_exists($path)) { + $ret = @unlink($path); + } else { + $ret = true; + } - if ($config['gzip_static']) { - $gzpath = "$path.gz"; + if ($config['gzip_static']) { + $gzpath = "$path.gz"; - @unlink($gzpath); + if (file_exists($gzpath)) { + @unlink($gzpath); + } } if (isset($config['purge']) && $path[0] != '/' && isset($_SERVER['HTTP_HOST'])) { @@ -797,42 +800,6 @@ function listBoards($just_uri = false) { return $boards; } -function until($timestamp) { - $difference = $timestamp - time(); - switch(TRUE){ - case ($difference < 60): - return $difference . ' ' . ngettext('second', 'seconds', $difference); - case ($difference < 3600): //60*60 = 3600 - return ($num = round($difference/(60))) . ' ' . ngettext('minute', 'minutes', $num); - case ($difference < 86400): //60*60*24 = 86400 - return ($num = round($difference/(3600))) . ' ' . ngettext('hour', 'hours', $num); - case ($difference < 604800): //60*60*24*7 = 604800 - return ($num = round($difference/(86400))) . ' ' . ngettext('day', 'days', $num); - case ($difference < 31536000): //60*60*24*365 = 31536000 - return ($num = round($difference/(604800))) . ' ' . ngettext('week', 'weeks', $num); - default: - return ($num = round($difference/(31536000))) . ' ' . ngettext('year', 'years', $num); - } -} - -function ago($timestamp) { - $difference = time() - $timestamp; - switch(TRUE){ - case ($difference < 60) : - return $difference . ' ' . ngettext('second', 'seconds', $difference); - case ($difference < 3600): //60*60 = 3600 - return ($num = round($difference/(60))) . ' ' . ngettext('minute', 'minutes', $num); - case ($difference < 86400): //60*60*24 = 86400 - return ($num = round($difference/(3600))) . ' ' . ngettext('hour', 'hours', $num); - case ($difference < 604800): //60*60*24*7 = 604800 - return ($num = round($difference/(86400))) . ' ' . ngettext('day', 'days', $num); - case ($difference < 31536000): //60*60*24*365 = 31536000 - return ($num = round($difference/(604800))) . ' ' . ngettext('week', 'weeks', $num); - default: - return ($num = round($difference/(31536000))) . ' ' . ngettext('year', 'years', $num); - } -} - function displayBan($ban) { global $config, $board; @@ -1267,25 +1234,25 @@ function deletePost($id, $error_if_doesnt_exist=true, $rebuild_after=true) { $query->bindValue(':board', $board['uri']); $query->execute() or error(db_error($query)); - // No need to run on OPs - if ($config['anti_bump_flood'] && isset($thread_id)) { - $query = prepare(sprintf("SELECT `sage` FROM ``posts_%s`` WHERE `id` = :thread", $board['uri'])); - $query->bindValue(':thread', $thread_id); - $query->execute() or error(db_error($query)); - $bumplocked = (bool)$query->fetchColumn(); + // No need to run on OPs + if ($config['anti_bump_flood'] && isset($thread_id)) { + $query = prepare(sprintf("SELECT `sage` FROM ``posts_%s`` WHERE `id` = :thread", $board['uri'])); + $query->bindValue(':thread', $thread_id); + $query->execute() or error(db_error($query)); + $bumplocked = (bool)$query->fetchColumn(); - if (!$bumplocked) { - $query = prepare(sprintf("SELECT `time` FROM ``posts_%s`` WHERE (`thread` = :thread AND NOT email <=> 'sage') OR `id` = :thread ORDER BY `time` DESC LIMIT 1", $board['uri'])); - $query->bindValue(':thread', $thread_id); - $query->execute() or error(db_error($query)); - $bump = $query->fetchColumn(); + if (!$bumplocked) { + $query = prepare(sprintf("SELECT `time` FROM ``posts_%s`` WHERE (`thread` = :thread AND NOT email <=> 'sage') OR `id` = :thread ORDER BY `time` DESC LIMIT 1", $board['uri'])); + $query->bindValue(':thread', $thread_id); + $query->execute() or error(db_error($query)); + $bump = $query->fetchColumn(); - $query = prepare(sprintf("UPDATE ``posts_%s`` SET `bump` = :bump WHERE `id` = :thread", $board['uri'])); - $query->bindValue(':bump', $bump); - $query->bindValue(':thread', $thread_id); - $query->execute() or error(db_error($query)); - } - } + $query = prepare(sprintf("UPDATE ``posts_%s`` SET `bump` = :bump WHERE `id` = :thread", $board['uri'])); + $query->bindValue(':bump', $bump); + $query->bindValue(':thread', $thread_id); + $query->execute() or error(db_error($query)); + } + } if (isset($rebuild) && $rebuild_after) { buildThread($rebuild); @@ -2029,7 +1996,7 @@ function extract_modifiers($body) { } function remove_modifiers($body) { - return preg_replace('@(.+?)@usm', '', $body); + return $body ? preg_replace('@(.+?)@usm', '', $body) : null; } function markup(&$body, $track_cites = false, $op = false) { @@ -2298,6 +2265,7 @@ function escape_markup_modifiers($string) { } function defined_flags_accumulate($desired_flags) { + global $config; $output_flags = 0x0; foreach ($desired_flags as $flagname) { if (defined($flagname)) { @@ -2315,7 +2283,7 @@ function defined_flags_accumulate($desired_flags) { function utf8tohtml($utf8) { $flags = defined_flags_accumulate(['ENT_NOQUOTES', 'ENT_SUBSTITUTE', 'ENT_DISALLOWED']); - return htmlspecialchars($utf8, $flags, 'UTF-8'); + return $utf8 ? htmlspecialchars($utf8, $flags, 'UTF-8') : ''; } function ordutf8($string, &$offset) { @@ -2572,35 +2540,6 @@ function generate_tripcode($name) { return array($name, $trip); } -// Highest common factor -function hcf($a, $b){ - $gcd = 1; - if ($a>$b) { - $a = $a+$b; - $b = $a-$b; - $a = $a-$b; - } - if ($b==(round($b/$a))*$a) - $gcd=$a; - else { - for ($i=round($a/2);$i;$i--) { - if ($a == round($a/$i)*$i && $b == round($b/$i)*$i) { - $gcd = $i; - $i = false; - } - } - } - return $gcd; -} - -function fraction($numerator, $denominator, $sep) { - $gcf = hcf($numerator, $denominator); - $numerator = $numerator / $gcf; - $denominator = $denominator / $gcf; - - return "{$numerator}{$sep}{$denominator}"; -} - function getPostByHash($hash) { global $board; $query = prepare(sprintf("SELECT `id`,`thread` FROM ``posts_%s`` WHERE `filehash` = :hash", $board['uri'])); @@ -2835,10 +2774,10 @@ function link_for($post, $page50 = false, $foreignlink = false, $thread = false) if ($slug === false) { $query = prepare(sprintf("SELECT `slug` FROM ``posts_%s`` WHERE `id` = :id", $b['uri'])); - $query->bindValue(':id', $id, PDO::PARAM_INT); - $query->execute() or error(db_error($query)); + $query->bindValue(':id', $id, PDO::PARAM_INT); + $query->execute() or error(db_error($query)); - $thread = $query->fetch(PDO::FETCH_ASSOC); + $thread = $query->fetch(PDO::FETCH_ASSOC); $slug = $thread['slug']; @@ -2854,7 +2793,7 @@ function link_for($post, $page50 = false, $foreignlink = false, $thread = false) } - if ( $page50 && $slug) $tpl = $config['file_page50_slug']; + if ( $page50 && $slug) $tpl = $config['file_page50_slug']; else if (!$page50 && $slug) $tpl = $config['file_page_slug']; else if ( $page50 && !$slug) $tpl = $config['file_page50']; else if (!$page50 && !$slug) $tpl = $config['file_page']; @@ -2866,24 +2805,6 @@ function prettify_textarea($s){ return str_replace("\t", ' ', str_replace("\n", ' ', htmlentities($s))); } -/*class HTMLPurifier_URIFilter_NoExternalImages extends HTMLPurifier_URIFilter { - public $name = 'NoExternalImages'; - public function filter(&$uri, $c, $context) { - global $config; - $ct = $context->get('CurrentToken'); - - if (!$ct || $ct->name !== 'img') return true; - - if (!isset($uri->host) && !isset($uri->scheme)) return true; - - if (!in_array($uri->scheme . '://' . $uri->host . '/', $config['allowed_offsite_urls'])) { - error('No off-site links in board announcement images.'); - } - - return true; - } -}*/ - function purify_html($s) { global $config; @@ -2899,7 +2820,6 @@ function purify_html($s) { function markdown($s) { $pd = new Parsedown(); $pd->setMarkupEscaped(true); - $pd->setimagesEnabled(false); return $pd->text($s); } @@ -2918,7 +2838,20 @@ 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))); + $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'; + } + $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/functions/format.php b/inc/functions/format.php new file mode 100644 index 00000000..79a71021 --- /dev/null +++ b/inc/functions/format.php @@ -0,0 +1,28 @@ + $b) { + $a = $a+$b; + $b = $a-$b; + $a = $a-$b; + } + if ($b == (round($b / $a)) * $a) { + $gcd = $a; + } else { + for ($i = round($a / 2); $i; $i--) { + if ($a == round($a / $i) * $i && $b == round($b / $i) * $i) { + $gcd = $i; + $i = false; + } + } + } + + return $gcd; +} + +function fraction($numerator, $denominator, $sep) { + $gcf = hcf($numerator, $denominator); + $numerator = $numerator / $gcf; + $denominator = $denominator / $gcf; + + return "{$numerator}{$sep}{$denominator}"; +} diff --git a/inc/image.php b/inc/image.php index 2429f682..840c9004 100644 --- a/inc/image.php +++ b/inc/image.php @@ -291,6 +291,7 @@ class ImageConvert extends ImageBase { } else { rename($this->temp, $src); chmod($src, 0664); + $this->temp = false; } } public function width() { @@ -300,8 +301,10 @@ class ImageConvert extends ImageBase { return $this->height; } public function destroy() { - @unlink($this->temp); - $this->temp = false; + if ($this->temp !== false) { + @unlink($this->temp); + $this->temp = false; + } } public function resize() { global $config; diff --git a/inc/lock.php b/inc/lock.php index 4fb2f5df..5a83562a 100644 --- a/inc/lock.php +++ b/inc/lock.php @@ -1,39 +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); - // 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; - } + $fd = fopen("tmp/locks/$key", "w"); + if ($fd === false) { + return false; + } - // 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; - } + return new class($fd) implements Lock { + // Resources have no type in php. + private mixed $f; - // Free a lock - function free() { global $config; - if ($config['lock']['enabled'] == 'fs') { - flock($this->f, LOCK_UN); - } - return $this; - } + + 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; + } + }; + } + + /** + * 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; + } + }; + } + + public static function get_lock(array $config, string $key): Lock|false { + if ($config['lock']['enabled'] == 'fs') { + return self::filesystem($key); + } else { + return self::none(); + } + } +} + +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/mod/auth.php b/inc/mod/auth.php index 493149c8..ec9d6057 100644 --- a/inc/mod/auth.php +++ b/inc/mod/auth.php @@ -4,19 +4,21 @@ * Copyright (c) 2010-2013 Tinyboard Development Group */ +use Vichan\Functions\Net; + defined('TINYBOARD') or exit; // create a hash/salt pair for validate logins -function mkhash($username, $password, $salt = false) { +function mkhash(string $username, string $password, mixed $salt = false): array|string { global $config; - + if (!$salt) { // create some sort of salt for the hash $salt = substr(base64_encode(sha1(rand() . time(), true) . $config['cookies']['salt']), 0, 15); - + $generated_salt = true; } - + // generate hash (method is not important as long as it's strong) $hash = substr( base64_encode( @@ -30,62 +32,59 @@ function mkhash($username, $password, $salt = false) { ) ), 0, 20 ); - - if (isset($generated_salt)) - return array($hash, $salt); - else + + if (isset($generated_salt)) { + return [ $hash, $salt ]; + } else { return $hash; + } } -function crypt_password_old($password) { - $salt = generate_salt(); - $password = hash('sha256', $salt . sha1($password)); - return array($salt, $password); -} - -function crypt_password($password) { +function crypt_password(string $password): array { global $config; // `salt` database field is reused as a version value. We don't want it to be 0. $version = $config['password_crypt_version'] ? $config['password_crypt_version'] : 1; $new_salt = generate_salt(); $password = crypt($password, $config['password_crypt'] . $new_salt . "$"); - return array($version, $password); + return [ $version, $password ]; } -function test_password($password, $salt, $test) { - global $config; - +function test_password(string $password, string $salt, string $test): array { // Version = 0 denotes an old password hashing schema. In the same column, the // password hash was kept previously - $version = (strlen($salt) <= 8) ? (int) $salt : 0; + $version = strlen($salt) <= 8 ? (int)$salt : 0; if ($version == 0) { $comp = hash('sha256', $salt . sha1($test)); - } - else { + } else { $comp = crypt($test, $password); } - return array($version, hash_equals($password, $comp)); + return [ $version, hash_equals($password, $comp) ]; } -function generate_salt() { - // mcrypt_create_iv() was deprecated in PHP 7.1.0, only use it if we're below that version number. - if (PHP_VERSION_ID < 70100) { - // 128 bits of entropy - return strtr(base64_encode(mcrypt_create_iv(16, MCRYPT_DEV_URANDOM)), '+', '.'); - } - - // Otherwise, use random_bytes() +function generate_salt(): string { return strtr(base64_encode(random_bytes(16)), '+', '.'); } -function login($username, $password) { +function calc_cookie_name(bool $is_https, bool $is_path_jailed, string $base_name): string { + if ($is_https) { + if ($is_path_jailed) { + return "__Host-$base_name"; + } else { + return "__Secure-$base_name"; + } + } else { + return $base_name; + } +} + +function login(string $username, string $password): array|false { global $mod, $config; - + $query = prepare("SELECT `id`, `type`, `boards`, `password`, `version` FROM ``mods`` WHERE BINARY `username` = :username"); $query->bindValue(':username', $username); $query->execute() or error(db_error($query)); - + if ($user = $query->fetch(PDO::FETCH_ASSOC)) { list($version, $ok) = test_password($user['password'], $user['version'], $password); @@ -100,40 +99,83 @@ function login($username, $password) { $query->execute() or error(db_error($query)); } - return $mod = array( + return $mod = [ 'id' => $user['id'], 'type' => $user['type'], 'username' => $username, 'hash' => mkhash($username, $user['password']), 'boards' => explode(',', $user['boards']) - ); + ]; } } - + return false; } -function setCookies() { +function setCookies(): void { global $mod, $config; - if (!$mod) + if (!$mod) { error('setCookies() was called for a non-moderator!'); - - setcookie($config['cookies']['mod'], - $mod['username'] . // username - ':' . - $mod['hash'][0] . // password - ':' . - $mod['hash'][1], // salt - time() + $config['cookies']['expire'], $config['cookies']['jail'] ? $config['cookies']['path'] : '/', null, !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off', $config['cookies']['httponly']); + } + + $is_https = Net\is_connection_secure($config['cookies']['secure_login_only'] === 1); + $is_path_jailed = $config['cookies']['jail']; + $name = calc_cookie_name($is_https, $is_path_jailed, $config['cookies']['mod']); + + // :: + $value = "{$mod['username']}:{$mod['hash'][0]}:{$mod['hash'][1]}"; + + $options = [ + 'expires' => time() + $config['cookies']['expire'], + 'path' => $is_path_jailed ? $config['cookies']['path'] : '/', + 'secure' => $is_https, + 'httponly' => $config['cookies']['httponly'], + 'samesite' => 'Strict' + ]; + + setcookie($name, $value, $options); } -function destroyCookies() { +function destroyCookies(): void { global $config; - // Delete the cookies - setcookie($config['cookies']['mod'], 'deleted', time() - $config['cookies']['expire'], $config['cookies']['jail']?$config['cookies']['path'] : '/', null, !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off', true); + $base_name = $config['cookies']['mod']; + $del_time = time() - 60 * 60 * 24 * 365; // 1 year. + $jailed_path = $config['cookies']['jail'] ? $config['cookies']['path'] : '/'; + $http_only = $config['cookies']['httponly']; + + $options_multi = [ + $base_name => [ + 'expires' => $del_time, + 'path' => $jailed_path , + 'secure' => false, + 'httponly' => $http_only, + 'samesite' => 'Strict' + ], + "__Host-$base_name" => [ + 'expires' => $del_time, + 'path' => $jailed_path, + 'secure' => true, + 'httponly' => $http_only, + 'samesite' => 'Strict' + ], + "__Secure-$base_name" => [ + 'expires' => $del_time, + 'path' => '/', + 'secure' => true, + 'httponly' => $http_only, + 'samesite' => 'Strict' + ] + ]; + + foreach ($options_multi as $name => $options) { + if (isset($_COOKIE[$name])) { + setcookie($name, 'deleted', $options); + unset($_COOKIE[$name]); + } + } } -function modLog($action, $_board=null) { +function modLog(string $action, ?string $_board = null): void { global $mod, $board, $config; $query = prepare("INSERT INTO ``modlogs`` VALUES (:id, :ip, :board, :time, :text)"); $query->bindValue(':id', (isset($mod['id']) ? $mod['id'] : -1), PDO::PARAM_INT); @@ -147,62 +189,72 @@ function modLog($action, $_board=null) { else $query->bindValue(':board', null, PDO::PARAM_NULL); $query->execute() or error(db_error($query)); - - if ($config['syslog']) + + if ($config['syslog']) { _syslog(LOG_INFO, '[mod/' . $mod['username'] . ']: ' . $action); + } } -function create_pm_header() { +function create_pm_header(): mixed { global $mod, $config; - + if ($config['cache']['enabled'] && ($header = cache::get('pm_unread_' . $mod['id'])) != false) { - if ($header === true) + if ($header === true) { return false; - + } + return $header; } - + $query = prepare("SELECT `id` FROM ``pms`` WHERE `to` = :id AND `unread` = 1"); $query->bindValue(':id', $mod['id'], PDO::PARAM_INT); $query->execute() or error(db_error($query)); - - if ($pm = $query->fetch(PDO::FETCH_ASSOC)) - $header = array('id' => $pm['id'], 'waiting' => $query->rowCount() - 1); - else + + if ($pm = $query->fetch(PDO::FETCH_ASSOC)) { + $header = [ 'id' => $pm['id'], 'waiting' => $query->rowCount() - 1 ]; + } else { $header = true; - - if ($config['cache']['enabled']) + } + + if ($config['cache']['enabled']) { cache::set('pm_unread_' . $mod['id'], $header); - - if ($header === true) + } + + if ($header === true) { return false; - + } + return $header; } -function make_secure_link_token($uri) { +function make_secure_link_token(string $uri): string { global $mod, $config; return substr(sha1($config['cookies']['salt'] . '-' . $uri . '-' . $mod['id']), 0, 8); } -function check_login($prompt = false) { +function check_login(bool $prompt = false): void { global $config, $mod; + + $is_https = Net\is_connection_secure($config['cookies']['secure_login_only'] === 1); + $is_path_jailed = $config['cookies']['jail']; + $expected_cookie_name = calc_cookie_name($is_https, $is_path_jailed, $config['cookies']['mod']); + // Validate session - if (isset($_COOKIE[$config['cookies']['mod']])) { + if (isset($_COOKIE[$expected_cookie_name])) { // Should be username:hash:salt - $cookie = explode(':', $_COOKIE[$config['cookies']['mod']]); + $cookie = explode(':', $_COOKIE[$expected_cookie_name]); if (count($cookie) != 3) { // Malformed cookies destroyCookies(); if ($prompt) mod_login(); exit; } - + $query = prepare("SELECT `id`, `type`, `boards`, `password` FROM ``mods`` WHERE `username` = :username"); $query->bindValue(':username', $cookie[0]); $query->execute() or error(db_error($query)); $user = $query->fetch(PDO::FETCH_ASSOC); - + // validate password hash if ($cookie[1] !== mkhash($cookie[0], $user['password'], $cookie[2])) { // Malformed cookies @@ -210,7 +262,7 @@ function check_login($prompt = false) { if ($prompt) mod_login(); exit; } - + $mod = array( 'id' => (int)$user['id'], 'type' => (int)$user['type'], diff --git a/inc/mod/pages.php b/inc/mod/pages.php index 8a651f51..77fba803 100644 --- a/inc/mod/pages.php +++ b/inc/mod/pages.php @@ -3,9 +3,13 @@ /* * Copyright (c) 2010-2013 Tinyboard Development Group */ +use Vichan\Functions\Format; + +use Vichan\Functions\Net; defined('TINYBOARD') or exit; + function mod_page($title, $template, $args, $subtitle = false) { global $config, $mod; @@ -29,9 +33,12 @@ function mod_page($title, $template, $args, $subtitle = false) { function mod_login($redirect = false) { global $config; - $args = array(); + $args = []; - if (isset($_POST['login'])) { + $secure_login_mode = $config['cookies']['secure_login_only']; + if ($secure_login_mode !== 0 && !Net\is_connection_secure($secure_login_mode === 1)) { + $args['error'] = $config['error']['insecure']; + } elseif (isset($_POST['login'])) { // Check if inputs are set and not empty if (!isset($_POST['username'], $_POST['password']) || $_POST['username'] == '' || $_POST['password'] == '') { $args['error'] = $config['error']['invalid']; @@ -1335,8 +1342,8 @@ function mod_move($originBoard, $postID) { if ($targetBoard === $originBoard) error(_('Target and source board are the same.')); - // copy() if leaving a shadow thread behind; else, rename(). - $clone = $shadow ? 'copy' : 'rename'; + // link() if leaving a shadow thread behind; else, rename(). + $clone = $shadow ? 'link' : 'rename'; // indicate that the post is a thread $post['op'] = true; @@ -1553,7 +1560,7 @@ function mod_ban_post($board, $delete, $post, $token = false) { if (isset($_POST['public_message'], $_POST['message'])) { // public ban message - $length_english = Bans::parse_time($_POST['length']) ? 'for ' . until(Bans::parse_time($_POST['length'])) : 'permanently'; + $length_english = Bans::parse_time($_POST['length']) ? 'for ' . Format\until(Bans::parse_time($_POST['length'])) : 'permanently'; $_POST['message'] = preg_replace('/[\r\n]/', '', $_POST['message']); $_POST['message'] = str_replace('%length%', $length_english, $_POST['message']); $_POST['message'] = str_replace('%LENGTH%', strtoupper($length_english), $_POST['message']); diff --git a/inc/queue.php b/inc/queue.php index 66305b3b..a5905c84 100644 --- a/inc/queue.php +++ b/inc/queue.php @@ -1,49 +1,98 @@ lock = new Lock($key); - $key = str_replace('/', '::', $key); - $key = str_replace("\0", '', $key); - $this->key = "tmp/queue/$key/"; - } - } +class Queues { + private static $queues = array(); - 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; - } - } + /** + * 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): bool { + $this->lock->get_ex(); + $ret = file_put_contents($this->key . microtime(true), $str); + $this->lock->free(); + return $ret !== false; + } + + 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; + } + }; + } + + /** + * No-op. Can be used for mocking. + */ + public static function none(): Queue { + return new class() implements Queue { + public function push(string $str): bool { + return true; + } + + public function pop(int $n = 1): array { + return array(); + } + }; + } + + 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(); + } + } + 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): bool; -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; } 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/inc/template.php b/inc/template.php index 0362111c..4f30e00c 100644 --- a/inc/template.php +++ b/inc/template.php @@ -11,12 +11,14 @@ $twig = false; function load_twig() { global $twig, $config; + $cache_dir = "{$config['dir']['template']}/cache/"; + $loader = new Twig\Loader\FilesystemLoader($config['dir']['template']); $loader->setPaths($config['dir']['template']); $twig = new Twig\Environment($loader, array( 'autoescape' => false, - 'cache' => is_writable('templates') || (is_dir('templates/cache') && is_writable('templates/cache')) ? - new Twig_Cache_TinyboardFilesystem("{$config['dir']['template']}/cache") : false, + 'cache' => is_writable('templates/') || (is_dir($cache_dir) && is_writable($cache_dir)) ? + new TinyboardTwigCache($cache_dir) : false, 'debug' => $config['debug'], 'auto_reload' => $config['twig_auto_reload'] )); @@ -28,17 +30,17 @@ function load_twig() { function Element($templateFile, array $options) { global $config, $debug, $twig, $build_pages; - + if (!$twig) load_twig(); - + if (function_exists('create_pm_header') && ((isset($options['mod']) && $options['mod']) || isset($options['__mod'])) && !preg_match('!^mod/!', $templateFile)) { $options['pm'] = create_pm_header(); } - + if (isset($options['body']) && $config['debug']) { $_debug = $debug; - + if (isset($debug['start'])) { $_debug['time']['total'] = '~' . round((microtime(true) - $_debug['start']) * 1000, 2) . 'ms'; $_debug['time']['init'] = '~' . round(($_debug['start_debug'] - $_debug['start']) * 1000, 2) . 'ms'; @@ -56,18 +58,44 @@ function Element($templateFile, array $options) { str_replace("\n", '
', utf8tohtml(print_r($_debug, true))) . ''; } - + // 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)) { $body = trim(preg_replace("/[\t\r\n]/", '', $body)); } - + 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']}'!"); + } +} + +class TinyboardTwigCache extends Twig\Cache\FilesystemCache { + private string $directory; + + public function __construct(string $directory) { + parent::__construct($directory); + $this->directory = $directory; + } + + /** + * This function was removed in Twig 2.x due to developer views on the Twig library. + * Who says we can't keep it for ourselves though? + */ + public function clear() { + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->directory), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iter as $file) { + if ($file->isFile()) { + @unlink($file->getPathname()); + } + } } } @@ -93,8 +121,8 @@ class Tinyboard extends Twig\Extension\AbstractExtension new Twig\TwigFilter('date', 'twig_date_filter'), new Twig\TwigFilter('poster_id', 'poster_id'), new Twig\TwigFilter('count', 'count'), - new Twig\TwigFilter('ago', 'ago'), - new Twig\TwigFilter('until', 'until'), + new Twig\TwigFilter('ago', 'Vichan\Functions\Format\ago'), + new Twig\TwigFilter('until', 'Vichan\Functions\Format\until'), new Twig\TwigFilter('push', 'twig_push_filter'), new Twig\TwigFilter('bidi_cleanup', 'bidi_cleanup'), new Twig\TwigFilter('addslashes', 'addslashes'), @@ -102,7 +130,7 @@ class Tinyboard extends Twig\Extension\AbstractExtension new Twig\TwigFilter('cloak_mask', 'cloak_mask'), ); } - + /** * Returns a list of functions to add to the existing list. * @@ -113,7 +141,6 @@ class Tinyboard extends Twig\Extension\AbstractExtension return array( new Twig\TwigFunction('time', 'time'), new Twig\TwigFunction('floor', 'floor'), - new Twig\TwigFunction('timezone', 'twig_timezone_function'), new Twig\TwigFunction('hiddenInputs', 'hiddenInputs'), new Twig\TwigFunction('hiddenInputsHash', 'hiddenInputsHash'), new Twig\TwigFunction('ratio', 'twig_ratio_function'), @@ -122,7 +149,7 @@ class Tinyboard extends Twig\Extension\AbstractExtension new Twig\TwigFunction('link_for', 'link_for') ); } - + /** * Returns the name of the extension. * @@ -134,17 +161,18 @@ class Tinyboard extends Twig\Extension\AbstractExtension } } -function twig_timezone_function() { - return 'Z'; -} - function twig_push_filter($array, $value) { array_push($array, $value); return $array; } function twig_date_filter($date, $format) { - return gmstrftime($format, $date); + if (is_numeric($date)) { + $date = new DateTime("@$date", new DateTimeZone('UTC')); + } else { + $date = new DateTime($date, new DateTimeZone('UTC')); + } + return $date->format($format); } function twig_hasPermission_filter($mod, $permission, $board = null) { @@ -154,7 +182,7 @@ function twig_hasPermission_filter($mod, $permission, $board = null) { function twig_extension_filter($value, $case_insensitive = true) { $ext = mb_substr($value, mb_strrpos($value, '.') + 1); if($case_insensitive) - $ext = mb_strtolower($ext); + $ext = mb_strtolower($ext); return $ext; } @@ -179,7 +207,7 @@ function twig_filename_truncate_filter($value, $length = 30, $separator = '…') $value = strrev($value); $array = array_reverse(explode(".", $value, 2)); $array = array_map("strrev", $array); - + $filename = &$array[0]; $extension = isset($array[1]) ? $array[1] : false; diff --git a/install.php b/install.php index 0b25ae02..90d8af57 100644 --- a/install.php +++ b/install.php @@ -1,7 +1,7 @@ true, 'message' => 'vichan requires PHP 7.4 or better.', ), - array( - 'category' => 'PHP', - 'name' => 'PHP ≥ 5.6', - 'result' => PHP_VERSION_ID >= 50600, - 'required' => false, - 'message' => 'vichan works best on PHP 5.6 or better.', - ), array( 'category' => 'PHP', 'name' => 'mbstring extension installed', @@ -856,14 +851,14 @@ if ($step == 0) { array( 'category' => 'File permissions', 'name' => getcwd() . '/templates/cache', - 'result' => is_writable('templates') || (is_dir('templates/cache') && is_writable('templates/cache')), + 'result' => is_dir('templates/cache/') && is_writable('templates/cache/'), 'required' => true, 'message' => 'You must give vichan permission to create (and write to) the templates/cache directory or performance will be drastically reduced.' ), array( 'category' => 'File permissions', 'name' => getcwd() . '/tmp/cache', - 'result' => is_dir('tmp/cache') && is_writable('tmp/cache'), + 'result' => is_dir('tmp/cache/') && is_writable('tmp/cache/'), 'required' => true, 'message' => 'You must give vichan permission to write to the tmp/cache directory.' ), @@ -874,6 +869,13 @@ if ($step == 0) { 'required' => false, 'message' => 'vichan does not have permission to make changes to inc/secrets.php. To complete the installation, you will be asked to manually copy and paste code into the file instead.' ), + array( + 'category' => 'Misc', + 'name' => 'HTTPS not being used', + 'result' => $httpsvalue, + 'required' => false, + 'message' => 'You are not currently using https for vichan, or at least for your backend server. If this intentional, add "$config[\'cookies\'][\'secure_login_only\'] = 0;" (or 1 if using a proxy) on a new line under "Additional configuration" on the next page.' + ), array( 'category' => 'Misc', 'name' => 'Caching available (APCu, Memcached or Redis)', @@ -989,12 +991,16 @@ if ($step == 0) { $queries[] = Element('posts.sql', array('board' => 'b')); $sql_errors = ''; + $sql_err_count = 0; foreach ($queries as $query) { if ($mysql_version < 50503) $query = preg_replace('/(CHARSET=|CHARACTER SET )utf8mb4/', '$1utf8', $query); $query = preg_replace('/^([\w\s]*)`([0-9a-zA-Z$_\x{0080}-\x{FFFF}]+)`/u', '$1``$2``', $query); - if (!query($query)) - $sql_errors .= '
  • ' . db_error() . '
  • '; + if (!query($query)) { + $sql_err_count++; + $error = db_error(); + $sql_errors .= "
  • $sql_err_count
    • $query
    • $error
  • "; + } } $page['title'] = 'Installation complete'; @@ -1033,4 +1039,3 @@ if ($step == 0) { echo Element('page.html', $page); } - diff --git a/js/catalog-link.js b/js/catalog-link.js index 2a3a8853..2811f025 100644 --- a/js/catalog-link.js +++ b/js/catalog-link.js @@ -30,17 +30,7 @@ function catalog() { var link = document.createElement('a'); link.href = catalog_url; - if (pages) { - link.textContent = _('Catalog'); - link.style.color = '#F10000'; - link.style.padding = '4px'; - link.style.paddingLeft = '9px'; - link.style.borderLeft = '1px solid'; - link.style.borderLeftColor = '#A8A8A8'; - link.style.textDecoration = "underline"; - - pages.appendChild(link); - } else { + if (!pages) { link.textContent = '['+_('Catalog')+']'; link.style.paddingLeft = '10px'; link.style.textDecoration = "underline"; diff --git a/js/local-time.js b/js/local-time.js index 1a05002b..73cee494 100644 --- a/js/local-time.js +++ b/js/local-time.js @@ -17,29 +17,40 @@ $(document).ready(function(){ 'use strict'; var iso8601 = function(s) { - s = s.replace(/\.\d\d\d+/,""); // remove milliseconds - s = s.replace(/-/,"/").replace(/-/,"/"); - s = s.replace(/T/," ").replace(/Z/," UTC"); - s = s.replace(/([\+\-]\d\d)\:?(\d\d)/," $1$2"); // -04:00 -> -0400 + var parts = s.split('T'); + if (parts.length === 2) { + var timeParts = parts[1].split(':'); + if (timeParts.length === 3) { + var secondsParts = timeParts[2].split('.'); + if (secondsParts[0] > 59) { + secondsParts[0] = '59'; + } + timeParts[2] = secondsParts.join('.'); + } + parts[1] = timeParts.join(':'); + } + s = parts.join('T'); + if (!s.endsWith('Z')) { + s += 'Z'; + } return new Date(s); }; + var zeropad = function(num, count) { return [Math.pow(10, count - num.toString().length), num].join('').substr(1); }; var dateformat = (typeof strftime === 'undefined') ? function(t) { return zeropad(t.getMonth() + 1, 2) + "/" + zeropad(t.getDate(), 2) + "/" + t.getFullYear().toString().substring(2) + - " (" + [_("Sun"), _("Mon"), _("Tue"), _("Wed"), _("Thu"), _("Fri"), _("Sat"), _("Sun")][t.getDay()] + ") " + + " (" + ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][t.getDay()] + ") " + // time zeropad(t.getHours(), 2) + ":" + zeropad(t.getMinutes(), 2) + ":" + zeropad(t.getSeconds(), 2); - } : function(t) { // post_date is defined in templates/main.js return strftime(window.post_date, t, datelocale); }; function timeDifference(current, previous) { - var msPerMinute = 60 * 1000; var msPerHour = msPerMinute * 60; var msPerDay = msPerHour * 24; @@ -51,36 +62,35 @@ $(document).ready(function(){ if (elapsed < msPerMinute) { return 'Just now'; } else if (elapsed < msPerHour) { - return Math.round(elapsed/msPerMinute) + (Math.round(elapsed/msPerMinute)<=1 ? ' minute ago':' minutes ago'); - } else if (elapsed < msPerDay ) { - return Math.round(elapsed/msPerHour ) + (Math.round(elapsed/msPerHour)<=1 ? ' hour ago':' hours ago'); + return Math.round(elapsed / msPerMinute) + (Math.round(elapsed / msPerMinute) <= 1 ? ' minute ago' : ' minutes ago'); + } else if (elapsed < msPerDay) { + return Math.round(elapsed / msPerHour) + (Math.round(elapsed / msPerHour) <= 1 ? ' hour ago' : ' hours ago'); } else if (elapsed < msPerMonth) { - return Math.round(elapsed/msPerDay) + (Math.round(elapsed/msPerDay)<=1 ? ' day ago':' days ago'); + return Math.round(elapsed / msPerDay) + (Math.round(elapsed / msPerDay) <= 1 ? ' day ago' : ' days ago'); } else if (elapsed < msPerYear) { - return Math.round(elapsed/msPerMonth) + (Math.round(elapsed/msPerMonth)<=1 ? ' month ago':' months ago'); + return Math.round(elapsed / msPerMonth) + (Math.round(elapsed / msPerMonth) <= 1 ? ' month ago' : ' months ago'); } else { - return Math.round(elapsed/msPerYear ) + (Math.round(elapsed/msPerYear)<=1 ? ' year ago':' years ago'); + return Math.round(elapsed / msPerYear) + (Math.round(elapsed / msPerYear) <= 1 ? ' year ago' : ' years ago'); } } - var do_localtime = function(elem) { + var do_localtime = function(elem) { var times = elem.getElementsByTagName('time'); var currentTime = Date.now(); - for(var i = 0; i < times.length; i++) { + for (var i = 0; i < times.length; i++) { var t = times[i].getAttribute('datetime'); - var postTime = new Date(t); + var postTime = iso8601(t); times[i].setAttribute('data-local', 'true'); if (localStorage.show_relative_time === 'false') { - times[i].innerHTML = dateformat(iso8601(t)); + times[i].innerHTML = dateformat(postTime); times[i].setAttribute('title', timeDifference(currentTime, postTime.getTime())); } else { times[i].innerHTML = timeDifference(currentTime, postTime.getTime()); - times[i].setAttribute('title', dateformat(iso8601(t))); + times[i].setAttribute('title', dateformat(postTime)); } - } }; @@ -101,7 +111,7 @@ $(document).ready(function(){ }); if (localStorage.show_relative_time !== 'false') { - $('#show-relative-time>input').attr('checked','checked'); + $('#show-relative-time>input').attr('checked', 'checked'); interval_id = setInterval(do_localtime, 30000, document); } diff --git a/js/post-filter.js b/js/post-filter.js index 3bf55a51..c9e903d8 100644 --- a/js/post-filter.js +++ b/js/post-filter.js @@ -375,7 +375,7 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata var list = getList(); var postId = $post.find('.post_no').not('[id]').text(); - var name, trip, uid, subject, comment; + var name, trip, uid, subject, comment, flag; var i, length, array, rule, pattern; // temp variables var boardId = $post.data('board'); @@ -388,6 +388,7 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata var hasTrip = ($post.find('.trip').length > 0); var hasSub = ($post.find('.subject').length > 0); + var hasFlag = ($post.find('.flag').length > 0); $post.data('hidden', false); $post.data('hiddenByUid', false); @@ -396,6 +397,7 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata $post.data('hiddenByTrip', false); $post.data('hiddenBySubject', false); $post.data('hiddenByComment', false); + $post.data('hiddenByFlag', false); // add post with matched UID to localList if (hasUID && @@ -436,6 +438,8 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata }); comment = array.join(' '); + if (hasFlag) + flag = $post.find('.flag').attr('title') for (i = 0, length = list.generalFilter.length; i < length; i++) { rule = list.generalFilter[i]; @@ -467,6 +471,12 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata hide(post); } break; + case 'flag': + if (hasFlag && pattern.test(flag)) { + $post.data('hiddenByFlag', true); + hide(post); + } + break; } } else { switch (rule.type) { @@ -496,6 +506,13 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata hide(post); } break; + case 'flag': + pattern = new RegExp('\\b'+ rule.value+ '\\b'); + if (hasFlag && pattern.test(flag)) { + $post.data('hiddenByFlag', true); + hide(post); + } + break; } } } @@ -621,7 +638,8 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata name: 'name', trip: 'tripcode', sub: 'subject', - com: 'comment' + com: 'comment', + flag: 'flag' }; $ele.empty(); @@ -660,6 +678,7 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata '' + '' + '' + + '' + '' + '' + '' + diff --git a/post.php b/post.php index b7fd5f71..ba339395 100644 --- a/post.php +++ b/post.php @@ -5,6 +5,11 @@ require_once 'inc/bootstrap.php'; +use Vichan\{Context, WebDependencyFactory}; +use Vichan\Driver\{HttpDriver, Log}; +use Vichan\Service\{RemoteCaptchaQuery, NativeCaptchaQuery}; +use Vichan\Functions\Format; + /** * Utility functions */ @@ -61,54 +66,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 +106,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 +146,7 @@ function ocr_image(array $config, string $img_path): string { return trim($ret); } + /** * Trim an image's EXIF metadata * @@ -173,7 +155,7 @@ function ocr_image(array $config, string $img_path): string { * @throws RuntimeException Throws on IO errors. */ function strip_image_metadata(string $img_path): int { - $err = shell_exec_error('exiftool -overwrite_original -ignoreMinorErrors -q -q -all= ' . escapeshellarg($img_path)); + $err = shell_exec_error('exiftool -overwrite_original -ignoreMinorErrors -q -q -all= -Orientation ' . escapeshellarg($img_path)); if ($err === false) { throw new RuntimeException('Could not strip EXIF metadata!'); } @@ -190,6 +172,7 @@ function strip_image_metadata(string $img_path): int { */ $dropped_post = false; +$context = new Context(new WebDependencyFactory($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']) { @@ -268,7 +251,7 @@ if (isset($_GET['Newsgroups']) && $config['nntpchan']['enabled']) { $content = file_get_contents("php://input"); } elseif ($ct == 'multipart/mixed' || $ct == 'multipart/form-data') { - _syslog(LOG_INFO, "MM: Files: ".print_r($GLOBALS, true)); // Debug + $context->getLog()->log(Log::DEBUG, 'MM: Files: ' . print_r($GLOBALS, true)); $content = ''; @@ -335,7 +318,7 @@ if (isset($_GET['Newsgroups']) && $config['nntpchan']['enabled']) { $ret[] = ">>".$v['id']; } } - return implode($ret, ", "); + return implode(", ", $ret); } }, $content); @@ -413,15 +396,16 @@ if (isset($_POST['delete'])) { } if ($post['time'] < time() - $config['max_delete_time'] && $config['max_delete_time'] != false) { - error(sprintf($config['error']['delete_too_late'], until($post['time'] + $config['max_delete_time']))); + error(sprintf($config['error']['delete_too_late'], Format\until($post['time'] + $config['max_delete_time']))); } if (!hash_equals($post['password'], $password) && (!$thread || !hash_equals($thread['password'], $password))) { error($config['error']['invalidpassword']); } + if ($post['time'] > time() - $config['delete_time'] && (!$thread || !hash_equals($thread['password'], $password))) { - error(sprintf($config['error']['delete_too_soon'], until($post['time'] + $config['delete_time']))); + error(sprintf($config['error']['delete_too_soon'], Format\until($post['time'] + $config['delete_time']))); } $ip = $_SERVER['REMOTE_ADDR']; @@ -435,8 +419,9 @@ if (isset($_POST['delete'])) { modLog("User at $ip deleted their own post #$id"); } - _syslog(LOG_INFO, 'Deleted post: ' . - '/' . $board['dir'] . $config['dir']['res'] . link_for($post) . ($post['thread'] ? '#' . $id : '') + $context->getLog()->log( + Log::INFO, + 'Deleted post: /' . $board['dir'] . $config['dir']['res'] . link_for($post) . ($post['thread'] ? '#' . $id : '') ); } } @@ -489,22 +474,30 @@ 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) { + $context->getLog()->log(Log::ERROR, "Native captcha IO exception: {$e->getMessage()}"); + error($config['error']['local_io_error']); } } @@ -522,9 +515,7 @@ if (isset($_POST['delete'])) { $post = $query->fetch(PDO::FETCH_ASSOC); if ($post === false) { - if ($config['syslog']) { - _syslog(LOG_INFO, "Failed to report non-existing post #{$id} in {$board['dir']}"); - } + $context->getLog()->log(Log::INFO, "Failed to report non-existing post #{$id} in {$board['dir']}"); error($config['error']['nopost']); } @@ -533,11 +524,12 @@ if (isset($_POST['delete'])) { error($error); } - if ($config['syslog']) - _syslog(LOG_INFO, 'Reported post: ' . - '/' . $board['dir'] . $config['dir']['res'] . link_for($post) . ($post['thread'] ? '#' . $id : '') . - ' for "' . $reason . '"' - ); + $context->getLog()->log( + Log::INFO, + 'Reported post: /' + . $board['dir'] . $config['dir']['res'] . link_for($post) . ($post['thread'] ? '#' . $id : '') + . " for \"$reason\"" + ); $query = prepare("INSERT INTO ``reports`` VALUES (NULL, :time, :ip, :board, :post, :reason)"); $query->bindValue(':time', time(), PDO::PARAM_INT); $query->bindValue(':ip', $_SERVER['REMOTE_ADDR'], PDO::PARAM_STR); @@ -600,62 +592,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']); - } - - $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']); + // 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['h-captcha-response']; + $query = RemoteCaptchaQuery::withHCaptcha($context->getHttpDriver(), $config['hcaptcha_private']); + } + + if (isset($query, $response)) { + $success = $query->verify($response, $_SERVER['REMOTE_ADDR']); + if (!$success) { + error($config['error']['captcha']); + } + } } + } catch (RuntimeException $e) { + $context->getLog()->log(Log::ERROR, "Captcha IO exception: {$e->getMessage()}"); + error($config['error']['remote_io_error']); + } catch (JsonException $e) { + $context->getLog()->log(Log::ERROR, "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']))) @@ -759,7 +749,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()); } @@ -849,7 +853,12 @@ if (isset($_POST['delete'])) { $trip = generate_tripcode($post['name']); $post['name'] = $trip[0]; - $post['trip'] = isset($trip[1]) ? $trip[1] : ''; // XX: Dropped posts and tripcodes + if ($config['disable_tripcodes'] = true && !$mod) { + $post['trip'] = ''; + } + else { + $post['trip'] = isset($trip[1]) ? $trip[1] : ''; // XX: Dropped posts and tripcodes + } $noko = false; if (strtolower($post['email']) == 'noko') { @@ -1028,7 +1037,7 @@ if (isset($_POST['delete'])) { if ($file['is_an_image']) { if ($config['ie_mime_type_detection'] !== false) { // Check IE MIME type detection XSS exploit - $buffer = file_get_contents($upload, null, null, null, 255); + $buffer = file_get_contents($upload, false, null, 0, 255); if (preg_match($config['ie_mime_type_detection'], $buffer)) { undoImage($post); error($config['error']['mime_exploit']); @@ -1048,19 +1057,24 @@ if (isset($_POST['delete'])) { error($config['error']['maxsize']); } - // The following code corrects the image orientation. - if ($config['convert_auto_orient'] && ($size[2] == IMAGETYPE_JPEG)) { - // 'redraw_image' should already fix image orientation by itself - if (!($config['redraw_image'])) { + $file['exif_stripped'] = false; + + if ($file_image_has_operable_metadata && $config['convert_auto_orient']) { + // The following code corrects the image orientation. + // Currently only works with the 'convert' option selected but it could easily be expanded to work with the rest if you can be bothered. + if (!($config['redraw_image'] || (($config['strip_exif'] && !$config['use_exiftool'])))) { if (in_array($config['thumb_method'], array('convert', 'convert+gifsicle', 'gm', 'gm+gifsicle'))) { $exif = @exif_read_data($file['tmp_name']); $gm = in_array($config['thumb_method'], array('gm', 'gm+gifsicle')); if (isset($exif['Orientation']) && $exif['Orientation'] != 1) { $error = shell_exec_error(($gm ? 'gm ' : '') . 'convert ' . - escapeshellarg($file['tmp_name']) . ' -auto-orient ' . escapeshellarg($file['tmp_name'])); + escapeshellarg($file['tmp_name']) . ' -auto-orient ' . escapeshellarg($upload)); + if ($error) error(_('Could not auto-orient image!'), null, $error); $size = @getimagesize($file['tmp_name']); + if ($config['strip_exif']) + $file['exif_stripped'] = true; } } } @@ -1109,16 +1123,14 @@ if (isset($_POST['delete'])) { $dont_copy_file = false; - if ($config['redraw_image'] || (!@$file['exif_stripped'] && $config['strip_exif'] && ($file['extension'] == 'jpg' || $file['extension'] == 'jpeg'))) { + if ($config['redraw_image'] || ($file_image_has_operable_metadata && !$file['exif_stripped'] && $config['strip_exif'])) { if (!$config['redraw_image'] && $config['use_exiftool']) { try { $file['size'] = strip_image_metadata($file['tmp_name']); } catch (RuntimeException $e) { - if ($config['syslog']) { - _syslog(LOG_ERR, "Could not strip image metadata: {$e->getMessage()}"); - // Since EXIF metadata can countain sensible info, fail the request. - error(_('Could not strip EXIF metadata!'), null, $error); - } + $context->getLog()->log(Log::ERROR, "Could not strip image metadata: {$e->getMessage()}"); + // Since EXIF metadata can countain sensible info, fail the request. + error(_('Could not strip EXIF metadata!'), null, $error); } } else { $image->to($file['file']); @@ -1154,9 +1166,7 @@ if (isset($_POST['delete'])) { $post['body_nomarkup'] .= "" . htmlspecialchars($value) . ""; } } catch (RuntimeException $e) { - if ($config['syslog']) { - _syslog(LOG_ERR, "Could not OCR image: {$e->getMessage()}"); - } + $context->getLog()->log(Log::ERROR, "Could not OCR image: {$e->getMessage()}"); } } } @@ -1309,14 +1319,22 @@ if (isset($_POST['delete'])) { if (isset($_SERVER['HTTP_REFERER'])) { // Tell Javascript that we posted successfully - if (isset($_COOKIE[$config['cookies']['js']])) + if (isset($_COOKIE[$config['cookies']['js']])) { $js = json_decode($_COOKIE[$config['cookies']['js']]); - else - $js = (object) array(); + } else { + $js = (object)array(); + } // Tell it to delete the cached post for referer $js->{$_SERVER['HTTP_REFERER']} = true; - // Encode and set cookie - setcookie($config['cookies']['js'], json_encode($js), 0, $config['cookies']['jail'] ? $config['cookies']['path'] : '/', null, false, false); + + // Encode and set cookie. + $options = [ + 'expires' => 0, + 'path' => $config['cookies']['jail'] ? $config['cookies']['path'] : '/', + 'httponly' => false, + 'samesite' => 'Strict' + ]; + setcookie($config['cookies']['js'], json_encode($js), $options); } $root = $post['mod'] ? $config['root'] . $config['file_mod'] . '?/' : $config['root']; @@ -1346,9 +1364,10 @@ if (isset($_POST['delete'])) { buildThread($post['op'] ? $id : $post['thread']); - if ($config['syslog']) - _syslog(LOG_INFO, 'New post: /' . $board['dir'] . $config['dir']['res'] . - link_for($post) . (!$post['op'] ? '#' . $id : '')); + $context->getLog()->log( + Log::INFO, + 'New post: /' . $board['dir'] . $config['dir']['res'] . link_for($post) . (!$post['op'] ? '#' . $id : '') + ); if (!$post['mod']) header('X-Associated-Content: "' . $redirect . '"'); diff --git a/search.php b/search.php index fe5f2850..f114c37f 100644 --- a/search.php +++ b/search.php @@ -9,15 +9,22 @@ $queries_per_minutes_all = $config['search']['queries_per_minutes_all']; $search_limit = $config['search']['search_limit']; + //Is there a whitelist? Let's list those boards and if not, let's list everything. if (isset($config['search']['boards'])) { $boards = $config['search']['boards']; } else { $boards = listBoards(TRUE); } + + //Let's remove any disallowed boards from the above list (the blacklist) + if (isset($config['search']['disallowed_boards'])) { + $boards = array_values(array_diff($boards, $config['search']['disallowed_boards'])); + } $body = Element('search_form.html', Array('boards' => $boards, 'board' => isset($_GET['board']) ? $_GET['board'] : false, 'search' => isset($_GET['search']) ? str_replace('"', '"', utf8tohtml($_GET['search'])) : false)); if(isset($_GET['search']) && !empty($_GET['search']) && isset($_GET['board']) && in_array($_GET['board'], $boards)) { + $phrase = $_GET['search']; $_body = ''; diff --git a/static/banners/defaultbanner.png b/static/banners/defaultbanner.png index 8773c82b..44fee962 100644 Binary files a/static/banners/defaultbanner.png and b/static/banners/defaultbanner.png differ diff --git a/stylesheets/style.css b/stylesheets/style.css index 6d1d1829..6991c84c 100644 --- a/stylesheets/style.css +++ b/stylesheets/style.css @@ -906,7 +906,8 @@ pre { display: block; width: 100%; height: 100%; - margin-top: 0px; + max-width: 620px; + margin: auto; } .mentioned { diff --git a/templates/cache/.gitkeep b/templates/cache/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/templates/cache/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/templates/index.html b/templates/index.html index 118ddbcc..685dc0a9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -14,14 +14,26 @@ {% 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 }} {{ boardlist.top }} - + {% if pm %}
    You have an unread PM{% if pm.waiting > 0 %}, plus {{ pm.waiting }} more waiting{% endif %}.

    {% endif %} {% if config.url_banner %}{% endif %} - +

    {{ board.url }} - {{ board.title|e }}

    @@ -54,7 +66,7 @@ {{ btn.next }}
    {% endif %} - + {% if config.global_message %}
    {{ config.global_message }}
    {% endif %}
    {% if config.board_search %} @@ -74,7 +86,7 @@ {{ body }} {% include 'report_delete.html' %} - +
    {{ btn.prev }} {% for page in pages %} [{{ page.num }}]{% if loop.last %} {% endif %} @@ -83,7 +95,7 @@ | {% trans %}Catalog{% endtrans %} {% endif %}
    - + {{ boardlist.bottom }} {{ config.ad.bottom }} @@ -92,6 +104,6 @@ - + diff --git a/templates/mod/dashboard.html b/templates/mod/dashboard.html index 945b19fe..a5e11f82 100644 --- a/templates/mod/dashboard.html +++ b/templates/mod/dashboard.html @@ -166,7 +166,7 @@
  • A newer version of vichan (v{{ newer_release.massive }}.{{ newer_release.major }}.{{ newer_release.minor }}) is available! - See https://engine.vichan.net/ for upgrade instructions. + See https://vichan.info/ for upgrade instructions.
  • diff --git a/templates/mod/edit_page.html b/templates/mod/edit_page.html index 3d132767..48b0dedf 100644 --- a/templates/mod/edit_page.html +++ b/templates/mod/edit_page.html @@ -5,7 +5,7 @@ {% trans %}Markup method{% endtrans %} {% set allowed_html = config.allowed_html %} - {% trans %}

    "markdown" is provided by parsedown. Note: images disabled.

    + {% trans %}

    "markdown" is provided by parsedown

    "html" allows the following tags:
    {{ allowed_html }}

    "infinity" is the same as what is used in posts.

    This page will not convert between formats,
    choose it once or do the conversion yourself!

    {% endtrans %} diff --git a/templates/post/time.html b/templates/post/time.html index e6273f94..518a34b6 100644 --- a/templates/post/time.html +++ b/templates/post/time.html @@ -1 +1 @@ - + diff --git a/templates/themes/basic/index.html b/templates/themes/basic/index.html index 231d73d3..02bc4387 100644 --- a/templates/themes/basic/index.html +++ b/templates/themes/basic/index.html @@ -17,7 +17,7 @@

    {{ settings.title }}

    {{ settings.subtitle }}
    - +
    {% if news|length == 0 %}

    (No news to show.)

    @@ -29,13 +29,13 @@ {% else %} no subject {% endif %} - — by {{ entry.name }} at {{ entry.time|date(config.post_date, config.timezone) }} + — by {{ entry.name }} at {{ entry.time|date(config.post_date) }}

    {{ entry.body }}

    {% endfor %} {% endif %}
    - +
    {% include 'footer.html' %} diff --git a/templates/themes/catalog/catalog.html b/templates/themes/catalog/catalog.html index 34202907..fefaec54 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('M d H:i')}}"> + +
    + 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' %}