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/compose.yml b/compose.yml
new file mode 100644
index 00000000..4b87e0b6
--- /dev/null
+++ b/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/${INSTANCE:-0}/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/${INSTANCE:-0}/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/${INSTANCE:-0}/mysql:/var/lib/mysql
diff --git a/composer.json b/composer.json
index ec4a090d..a2e906fe 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,14 @@
"inc/mod/auth.php",
"inc/lock.php",
"inc/queue.php",
- "inc/functions.php"
+ "inc/functions.php",
+ "inc/functions/dice.php",
+ "inc/functions/format.php",
+ "inc/functions/net.php",
+ "inc/functions/num.php",
+ "inc/functions/theme.php",
+ "inc/service/captcha-queries.php",
+ "inc/context.php"
]
},
"license": "Tinyboard + vichan",
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..1882bc9d
--- /dev/null
+++ b/docker/php/Dockerfile
@@ -0,0 +1,88 @@
+# Based on https://github.com/dead-guru/devichan/blob/master/php-fpm/Dockerfile
+
+FROM composer:lts 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/* \
+ && rm -rf /tmp/pear
+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/Data/Driver/ApcuCacheDriver.php b/inc/Data/Driver/ApcuCacheDriver.php
new file mode 100644
index 00000000..a39bb656
--- /dev/null
+++ b/inc/Data/Driver/ApcuCacheDriver.php
@@ -0,0 +1,28 @@
+name = $name;
+ $this->level = $level;
+ }
+
+ public function log(int $level, string $message): void {
+ if ($level <= $this->level) {
+ $lv = $this->levelToString($level);
+ $line = "{$this->name} $lv: $message";
+ \error_log($line, 0, null, null);
+ }
+ }
+}
diff --git a/inc/Data/Driver/FileLogDriver.php b/inc/Data/Driver/FileLogDriver.php
new file mode 100644
index 00000000..2c9f14a0
--- /dev/null
+++ b/inc/Data/Driver/FileLogDriver.php
@@ -0,0 +1,61 @@
+fd = \fopen($file_path, 'a');
+ if ($this->fd === false) {
+ throw new \RuntimeException("Unable to open log file at $file_path");
+ }
+
+ $this->name = $name;
+ $this->level = $level;
+
+ // In some cases PHP does not run the destructor.
+ \register_shutdown_function([$this, 'close']);
+ }
+
+ public function __destruct() {
+ $this->close();
+ }
+
+ public function log(int $level, string $message): void {
+ if ($level <= $this->level) {
+ $lv = $this->levelToString($level);
+ $line = "{$this->name} $lv: $message\n";
+ \flock($this->fd, LOCK_EX);
+ \fwrite($this->fd, $line);
+ \fflush($this->fd);
+ \flock($this->fd, LOCK_UN);
+ }
+ }
+
+ public function close() {
+ \flock($this->fd, LOCK_UN);
+ \fclose($this->fd);
+ }
+}
diff --git a/inc/Data/Driver/FsCachedriver.php b/inc/Data/Driver/FsCachedriver.php
new file mode 100644
index 00000000..b543cfa6
--- /dev/null
+++ b/inc/Data/Driver/FsCachedriver.php
@@ -0,0 +1,155 @@
+prefix . $key;
+ }
+
+ private function sharedLockCache(): void {
+ \flock($this->lock_fd, LOCK_SH);
+ }
+
+ private function exclusiveLockCache(): void {
+ \flock($this->lock_fd, LOCK_EX);
+ }
+
+ private function unlockCache(): void {
+ \flock($this->lock_fd, LOCK_UN);
+ }
+
+ private function collectImpl(): int {
+ /*
+ * A read lock is ok, since it's alright if we delete expired items from under the feet of other processes, and
+ * no other process add new cache items or refresh existing ones.
+ */
+ $files = \glob($this->base_path . $this->prefix . '*', \GLOB_NOSORT);
+ $count = 0;
+ foreach ($files as $file) {
+ $data = \file_get_contents($file);
+ $wrapped = \json_decode($data, true, 512, \JSON_THROW_ON_ERROR);
+ if ($wrapped['expires'] !== false && $wrapped['expires'] <= \time()) {
+ if (@\unlink($file)) {
+ $count++;
+ }
+ }
+ }
+ return $count;
+ }
+
+ private function maybeCollect(): void {
+ if ($this->collect_chance_den !== false && \mt_rand(0, $this->collect_chance_den - 1) === 0) {
+ $this->collect_chance_den = false; // Collect only once per instance (aka process).
+ $this->collectImpl();
+ }
+ }
+
+ public function __construct(string $prefix, string $base_path, string $lock_file, int|false $collect_chance_den) {
+ if ($base_path[\strlen($base_path) - 1] !== '/') {
+ $base_path = "$base_path/";
+ }
+
+ if (!\is_dir($base_path)) {
+ throw new \RuntimeException("$base_path is not a directory!");
+ }
+
+ if (!\is_writable($base_path)) {
+ throw new \RuntimeException("$base_path is not writable!");
+ }
+
+ $this->lock_fd = \fopen($base_path . $lock_file, 'w');
+ if ($this->lock_fd === false) {
+ throw new \RuntimeException('Unable to open the lock file!');
+ }
+
+ $this->prefix = $prefix;
+ $this->base_path = $base_path;
+ $this->collect_chance_den = $collect_chance_den;
+ }
+
+ public function __destruct() {
+ $this->close();
+ }
+
+ public function get(string $key): mixed {
+ $key = $this->prepareKey($key);
+
+ $this->sharedLockCache();
+
+ // Collect expired items first so if the target key is expired we shortcut to failure in the next lines.
+ $this->maybeCollect();
+
+ $fd = \fopen($this->base_path . $key, 'r');
+ if ($fd === false) {
+ $this->unlockCache();
+ return null;
+ }
+
+ $data = \stream_get_contents($fd);
+ \fclose($fd);
+ $this->unlockCache();
+ $wrapped = \json_decode($data, true, 512, \JSON_THROW_ON_ERROR);
+
+ if ($wrapped['expires'] !== false && $wrapped['expires'] <= \time()) {
+ // Already expired, leave it there since we already released the lock and pretend it doesn't exist.
+ return null;
+ } else {
+ return $wrapped['inner'];
+ }
+ }
+
+ public function set(string $key, mixed $value, mixed $expires = false): void {
+ $key = $this->prepareKey($key);
+
+ $wrapped = [
+ 'expires' => $expires ? \time() + $expires : false,
+ 'inner' => $value
+ ];
+
+ $data = \json_encode($wrapped);
+ $this->exclusiveLockCache();
+ $this->maybeCollect();
+ \file_put_contents($this->base_path . $key, $data);
+ $this->unlockCache();
+ }
+
+ public function delete(string $key): void {
+ $key = $this->prepareKey($key);
+
+ $this->exclusiveLockCache();
+ @\unlink($this->base_path . $key);
+ $this->maybeCollect();
+ $this->unlockCache();
+ }
+
+ public function collect(): int {
+ $this->sharedLockCache();
+ $count = $this->collectImpl();
+ $this->unlockCache();
+ return $count;
+ }
+
+ public function flush(): void {
+ $this->exclusiveLockCache();
+ $files = \glob($this->base_path . $this->prefix . '*', \GLOB_NOSORT);
+ foreach ($files as $file) {
+ @\unlink($file);
+ }
+ $this->unlockCache();
+ }
+
+ public function close(): void {
+ \fclose($this->lock_fd);
+ }
+}
diff --git a/inc/Data/Driver/HttpDriver.php b/inc/Data/Driver/HttpDriver.php
new file mode 100644
index 00000000..022fcbab
--- /dev/null
+++ b/inc/Data/Driver/HttpDriver.php
@@ -0,0 +1,131 @@
+inner);
+ \curl_setopt_array($this->inner, [
+ \CURLOPT_URL => $url,
+ \CURLOPT_TIMEOUT => $timeout,
+ \CURLOPT_USERAGENT => 'Tinyboard',
+ \CURLOPT_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS,
+ ]);
+ }
+
+ public function __construct(int $timeout, int $max_file_size) {
+ $this->inner = \curl_init();
+ $this->timeout = $timeout;
+ $this->max_file_size = $max_file_size;
+ }
+
+ public function __destruct() {
+ \curl_close($this->inner);
+ }
+
+ /**
+ * Execute a GET request.
+ *
+ * @param string $endpoint Uri endpoint.
+ * @param ?array $data Optional GET parameters.
+ * @param int $timeout Optional request timeout in seconds. Use the default timeout if 0.
+ * @return string Returns the body of the response.
+ * @throws RuntimeException Throws on IO error.
+ */
+ public function requestGet(string $endpoint, ?array $data, int $timeout = 0): string {
+ if (!empty($data)) {
+ $endpoint .= '?' . \http_build_query($data);
+ }
+ if ($timeout == 0) {
+ $timeout = $this->timeout;
+ }
+
+ $this->resetTowards($endpoint, $timeout);
+ \curl_setopt($this->inner, \CURLOPT_RETURNTRANSFER, true);
+ $ret = \curl_exec($this->inner);
+
+ if ($ret === false) {
+ throw new \RuntimeException(\curl_error($this->inner));
+ }
+ return $ret;
+ }
+
+ /**
+ * Execute a POST request.
+ *
+ * @param string $endpoint Uri endpoint.
+ * @param ?array $data Optional POST parameters.
+ * @param int $timeout Optional request timeout in seconds. Use the default timeout if 0.
+ * @return string Returns the body of the response.
+ * @throws RuntimeException Throws on IO error.
+ */
+ public function requestPost(string $endpoint, ?array $data, int $timeout = 0): string {
+ if ($timeout == 0) {
+ $timeout = $this->timeout;
+ }
+
+ $this->resetTowards($endpoint, $timeout);
+ \curl_setopt($this->inner, \CURLOPT_POST, true);
+ if (!empty($data)) {
+ \curl_setopt($this->inner, \CURLOPT_POSTFIELDS, \http_build_query($data));
+ }
+ \curl_setopt($this->inner, \CURLOPT_RETURNTRANSFER, true);
+ $ret = \curl_exec($this->inner);
+
+ if ($ret === false) {
+ throw new \RuntimeException(\curl_error($this->inner));
+ }
+ return $ret;
+ }
+
+ /**
+ * Download the url's target with curl.
+ *
+ * @param string $url Url to the file to download.
+ * @param ?array $data Optional GET parameters.
+ * @param resource $fd File descriptor to save the content to.
+ * @param int $timeout Optional request timeout in seconds. Use the default timeout if 0.
+ * @return bool Returns true on success, false if the file was too large.
+ * @throws RuntimeException Throws on IO error.
+ */
+ public function requestGetInto(string $endpoint, ?array $data, mixed $fd, int $timeout = 0): bool {
+ if (!empty($data)) {
+ $endpoint .= '?' . \http_build_query($data);
+ }
+ if ($timeout == 0) {
+ $timeout = $this->timeout;
+ }
+
+ $this->resetTowards($endpoint, $timeout);
+ // Adapted from: https://stackoverflow.com/a/17642638
+ $opt = (\PHP_MAJOR_VERSION >= 8 && \PHP_MINOR_VERSION >= 2) ? \CURLOPT_XFERINFOFUNCTION : \CURLOPT_PROGRESSFUNCTION;
+ \curl_setopt_array($this->inner, [
+ \CURLOPT_NOPROGRESS => false,
+ $opt => fn($res, $next_dl, $dl, $next_up, $up) => (int)($dl <= $this->max_file_size),
+ \CURLOPT_FAILONERROR => true,
+ \CURLOPT_FOLLOWLOCATION => false,
+ \CURLOPT_FILE => $fd,
+ \CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
+ ]);
+ $ret = \curl_exec($this->inner);
+
+ if ($ret === false) {
+ if (\curl_errno($this->inner) === CURLE_ABORTED_BY_CALLBACK) {
+ return false;
+ }
+
+ throw new \RuntimeException(\curl_error($this->inner));
+ }
+ return true;
+ }
+}
diff --git a/inc/Data/Driver/LogDriver.php b/inc/Data/Driver/LogDriver.php
new file mode 100644
index 00000000..fddc3f27
--- /dev/null
+++ b/inc/Data/Driver/LogDriver.php
@@ -0,0 +1,22 @@
+inner = new \Memcached();
+ if (!$this->inner->setOption(\Memcached::OPT_BINARY_PROTOCOL, true)) {
+ throw new \RuntimeException('Unable to set the memcached protocol!');
+ }
+ if (!$this->inner->setOption(\Memcached::OPT_PREFIX_KEY, $prefix)) {
+ throw new \RuntimeException('Unable to set the memcached prefix!');
+ }
+ if (!$this->inner->addServers($memcached_server)) {
+ throw new \RuntimeException('Unable to add the memcached server!');
+ }
+ }
+
+ public function get(string $key): mixed {
+ $ret = $this->inner->get($key);
+ // If the returned value is false but the retrival was a success, then the value stored was a boolean false.
+ if ($ret === false && $this->inner->getResultCode() !== \Memcached::RES_SUCCESS) {
+ return null;
+ }
+ return $ret;
+ }
+
+ public function set(string $key, mixed $value, mixed $expires = false): void {
+ $this->inner->set($key, $value, (int)$expires);
+ }
+
+ public function delete(string $key): void {
+ $this->inner->delete($key);
+ }
+
+ public function flush(): void {
+ $this->inner->flush();
+ }
+}
diff --git a/inc/Data/Driver/NoneCacheDriver.php b/inc/Data/Driver/NoneCacheDriver.php
new file mode 100644
index 00000000..8b260a50
--- /dev/null
+++ b/inc/Data/Driver/NoneCacheDriver.php
@@ -0,0 +1,26 @@
+inner = new \Redis();
+ $this->inner->connect($host, $port);
+ if ($password) {
+ $this->inner->auth($password);
+ }
+ if (!$this->inner->select($database)) {
+ throw new \RuntimeException('Unable to connect to Redis!');
+ }
+
+ $$this->prefix = $prefix;
+ }
+
+ public function get(string $key): mixed {
+ $ret = $this->inner->get($this->prefix . $key);
+ if ($ret === false) {
+ return null;
+ }
+ return \json_decode($ret, true);
+ }
+
+ public function set(string $key, mixed $value, mixed $expires = false): void {
+ if ($expires === false) {
+ $this->inner->set($this->prefix . $key, \json_encode($value));
+ } else {
+ $expires = $expires * 1000; // Seconds to milliseconds.
+ $this->inner->setex($this->prefix . $key, $expires, \json_encode($value));
+ }
+ }
+
+ public function delete(string $key): void {
+ $this->inner->del($this->prefix . $key);
+ }
+
+ public function flush(): void {
+ $this->inner->flushDB();
+ }
+}
diff --git a/inc/Data/Driver/StderrLogDriver.php b/inc/Data/Driver/StderrLogDriver.php
new file mode 100644
index 00000000..4c766033
--- /dev/null
+++ b/inc/Data/Driver/StderrLogDriver.php
@@ -0,0 +1,27 @@
+name = $name;
+ $this->level = $level;
+ }
+
+ public function log(int $level, string $message): void {
+ if ($level <= $this->level) {
+ $lv = $this->levelToString($level);
+ \fwrite(\STDERR, "{$this->name} $lv: $message\n");
+ }
+ }
+}
diff --git a/inc/Data/Driver/SyslogLogDriver.php b/inc/Data/Driver/SyslogLogDriver.php
new file mode 100644
index 00000000..c0df5304
--- /dev/null
+++ b/inc/Data/Driver/SyslogLogDriver.php
@@ -0,0 +1,35 @@
+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);
+ }
+ }
+ }
+}
diff --git a/inc/anti-bot.php b/inc/anti-bot.php
index 48150328..6f684c55 100644
--- a/inc/anti-bot.php
+++ b/inc/anti-bot.php
@@ -1,191 +1,5 @@
?=-` ';
- if ($unicode_chars) {
- $len = strlen($chars) / 10;
- for ($n = 0; $n < $len; $n++)
- $chars .= mb_convert_encoding('' . mt_rand(0x2600, 0x26FF) . ';', 'UTF-8', 'HTML-ENTITIES');
- }
-
- $chars = preg_split('//u', $chars, -1, PREG_SPLIT_NO_EMPTY);
-
- $ch = array();
-
- // fill up $ch until we reach $length
- while (count($ch) < $length) {
- $n = $length - count($ch);
- $keys = array_rand($chars, $n > count($chars) ? count($chars) : $n);
- if ($n == 1) {
- $ch[] = $chars[$keys];
- break;
- }
- shuffle($keys);
- foreach ($keys as $key)
- $ch[] = $chars[$key];
- }
-
- $chars = $ch;
-
- return implode('', $chars);
- }
-
- public static function make_confusing($string) {
- $chars = preg_split('//u', $string, -1, PREG_SPLIT_NO_EMPTY);
-
- foreach ($chars as &$c) {
- if (mt_rand(0, 3) != 0)
- $c = utf8tohtml($c);
- else
- $c = mb_encode_numericentity($c, array(0, 0xffff, 0, 0xffff), 'UTF-8');
- }
-
- return implode('', $chars);
- }
-
- public function __construct(array $salt = array()) {
- global $config;
-
- if (!empty($salt)) {
- // create a salted hash of the "extra salt"
- $this->salt = implode(':', $salt);
- } else {
- $this->salt = '';
- }
-
- shuffle($config['spam']['hidden_input_names']);
-
- $input_count = mt_rand($config['spam']['hidden_inputs_min'], $config['spam']['hidden_inputs_max']);
- $hidden_input_names_x = 0;
-
- for ($x = 0; $x < $input_count ; $x++) {
- if ($hidden_input_names_x === false || mt_rand(0, 2) == 0) {
- // Use an obscure name
- $name = $this->randomString(mt_rand(10, 40), false, false, $config['spam']['unicode']);
- } else {
- // Use a pre-defined confusing name
- $name = $config['spam']['hidden_input_names'][$hidden_input_names_x++];
- if ($hidden_input_names_x >= count($config['spam']['hidden_input_names']))
- $hidden_input_names_x = false;
- }
-
- if (mt_rand(0, 2) == 0) {
- // Value must be null
- $this->inputs[$name] = '';
- } elseif (mt_rand(0, 4) == 0) {
- // Numeric value
- $this->inputs[$name] = (string)mt_rand(0, 100000);
- } else {
- // Obscure value
- $this->inputs[$name] = $this->randomString(mt_rand(5, 100), true, true, $config['spam']['unicode']);
- }
- }
- }
-
- public static function space() {
- if (mt_rand(0, 3) != 0)
- return ' ';
- return str_repeat(' ', mt_rand(1, 3));
- }
-
- public function html($count = false) {
- global $config;
-
- $elements = array(
- '',
- '',
- '',
- '',
- '',
- '',
- '',
- '',
- '',
- '',
- ''
- );
-
- $html = '';
-
- if ($count === false) {
- $count = mt_rand(1, abs(count($this->inputs) / 15) + 1);
- }
-
- if ($count === true) {
- // all elements
- $inputs = array_slice($this->inputs, $this->index);
- } else {
- $inputs = array_slice($this->inputs, $this->index, $count);
- }
- $this->index += count($inputs);
-
- foreach ($inputs as $name => $value) {
- $element = false;
- while (!$element) {
- $element = $elements[array_rand($elements)];
- $element = str_replace(' ', self::space(), $element);
- if (mt_rand(0, 5) == 0)
- $element = str_replace('>', self::space() . '>', $element);
- if (strpos($element, 'textarea') !== false && $value == '') {
- // There have been some issues with mobile web browsers and empty
{% for footer in config.footer %}{{ footer }}
{% endfor %}
diff --git a/templates/header.html b/templates/header.html
index d35fabb9..51a9593c 100644
--- a/templates/header.html
+++ b/templates/header.html
@@ -1,55 +1,55 @@
-
- {% if config.url_favicon %}{% endif %}
-
-
- {% if config.meta_keywords %}{% endif %}
- {% if config.default_stylesheet.1 != '' %}{% endif %}
- {% if config.font_awesome %}{% endif %}
- {% if config.country_flags_condensed %}{% endif %}
-
- {% if not nojavascript %}
-
- {% if not config.additional_javascript_compile %}
- {% for javascript in config.additional_javascript %}{% endfor %}
- {% endif %}
- {% if mod %}
-
- {% endif %}
- {% endif %}
- {% if config.recaptcha %}
- {% endif %}
- {% if config.hcaptcha %}
-
- {% endif %}
+
+{% if config.url_favicon %}{% endif %}
+
+
+{% if config.meta_keywords %}{% endif %}
+{% if config.default_stylesheet.1 != '' %}{% endif %}
+{% if config.font_awesome %}{% endif %}
+{% if config.country_flags_condensed %}{% endif %}
+
+{% if not nojavascript %}
+
+ {% if not config.additional_javascript_compile %}
+ {% for javascript in config.additional_javascript %}{% endfor %}
+ {% endif %}
+ {% if mod %}
+
+ {% endif %}
+{% endif %}
+{% if config.captcha.provider == 'recaptcha' %}
+{% endif %}
+{% if config.captcha.provider.hcaptcha %}
+
+{% endif %}
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 @@
-
+