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/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 00000000..8b1717bf
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,69 @@
+name: Bug Report
+description: File a bug report for Vichan
+title: "[BUG] "
+labels: ["bug"]
+assignees: []
+
+body:
+ - type: markdown
+ attributes:
+ value: |
+ **Thank you for reporting a bug! Please provide as much detail as possible.**
+
+ Before submitting, check the [Vichan Wiki](https://vichan.info) to see if there's already a solution to your problem.
+
+ - type: textarea
+ id: bug_description
+ attributes:
+ label: "Describe the bug"
+ description: "A clear and concise description of what the bug is."
+ placeholder: "Posting doesn't go through and displays a collation error. The exact error message given is the text below and I've attached a screenshot..."
+ validations:
+ required: true
+
+ - type: textarea
+ id: steps_to_reproduce
+ attributes:
+ label: "Steps to Reproduce"
+ description: "Provide step-by-step instructions to reproduce the issue. If you're unsure on how, that is alright, just try and explain as well as you can."
+ placeholder: |
+ 1. Go to '...'
+ 2. Click on '....'
+ 3. Scroll down to '....'
+ 4. See error
+ render: markdown
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected_behavior
+ attributes:
+ label: "Expected Behavior"
+ description: "What did you expect to happen?"
+ placeholder: "Expected behavior here..."
+ render: markdown
+ validations:
+ required: true
+
+ - type: textarea
+ id: server_specs
+ attributes:
+ label: "Server Specifications"
+ description: "Provide details about your server environment. If you're unsure about any of this, you might be using shared hosting (Hostinger, HostGator, Serv00, etc). If so, put the name of your hosting provider here."
+ placeholder: |
+ - OS: (Ubuntu, CentOS, Windows Server 2025, etc.)
+ - PHP Version: (e.g., 7.4, 8.0, 8.4)
+ - Web Server: (Apache, NGINX, etc.)
+ - Database: (MySQL, MariaDB, etc.)
+ - Vichan Version: (5.2.0, 5.3.0 (dev branch), etc)
+ render: markdown
+ validations:
+ required: true
+
+ - type: textarea
+ id: additional_context
+ attributes:
+ label: "Additional Context"
+ description: "Any other details we should know?"
+ placeholder: "Add any additional context here..."
+ render: markdown
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000..bbcbbe7d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
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..989b47ae
--- /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, $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 @@
-
+