1
0
mirror of https://github.com/vichan-devel/vichan.git synced 2024-11-27 17:00:52 +01:00

Merge branch 'dev' into hash-passwords

This commit is contained in:
Weav 2024-06-20 13:21:05 +00:00 committed by GitHub
commit fee67b6719
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 1829 additions and 622 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
**/.git
**/.gitignore
/local-instances
**/.gitkeep

1
.gitignore vendored
View File

@ -44,5 +44,6 @@ Thumbs.db
#vichan custom
favicon.ico
/static/spoiler.png
/local-instances
/vendor/

View File

@ -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:

22
b.php
View File

@ -1,20 +1,8 @@
<?php
$dir = "static/banners/";
$files = scandir($dir, SCANDIR_SORT_NONE);
$images = array_diff($files, array('.', '..'));
$name = $images[array_rand($images)];
// open the file in a binary mode
$fp = fopen($dir . $name, 'rb');
// send the right headers
header('Cache-Control: no-cache, no-store, must-revalidate'); // HTTP 1.1
header('Pragma: no-cache'); // HTTP 1.0
header('Expires: 0'); // Proxies
$fstat = fstat($fp);
header('Content-Type: ' . mime_content_type($dir . $name));
header('Content-Length: ' . $fstat['size']);
$files = scandir('static/banners/', SCANDIR_SORT_NONE);
$files = array_diff($files, ['.', '..']);
// dump the picture and stop the script
fpassthru($fp);
exit;
?>
$name = $files[array_rand($files)];
header("Location: /static/banners/$name", true, 307);
header('Cache-Control: no-cache');

View File

@ -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",

40
docker-compose.yml Normal file
View File

@ -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

20
docker/doc.md Normal file
View File

@ -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
```
<vichan-project>
└── 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.

8
docker/nginx/Dockerfile Normal file
View File

@ -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

34
docker/nginx/nginx.conf Normal file
View File

@ -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;
}

40
docker/nginx/proxy.conf Normal file
View File

@ -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;

66
docker/nginx/vichan.conf Normal file
View File

@ -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; }
}

87
docker/php/Dockerfile Normal file
View File

@ -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

View File

@ -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" ]

87
docker/php/bootstrap.sh Executable file
View File

@ -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

2
docker/php/jit.ini Normal file
View File

@ -0,0 +1,2 @@
opcache.jit_buffer_size=192M
opcache.jit=tracing

13
docker/php/www.conf Normal file
View File

@ -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

View File

@ -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

View File

@ -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) {

View File

@ -1,5 +1,6 @@
<?php
use Vichan\Functions\Format;
use Lifo\IP\CIDR;
class Bans {
@ -369,16 +370,14 @@ class Bans {
$query->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 ? "<a href=\"?/IP/$cloaked_mask\">$cloaked_mask</a>" : $cloaked_mask) .
' (<small>#' . $pdo->lastInsertId() . '</small>)' .
' 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 ? "<a href=\"?/IP/$cloaked_mask\">$cloaked_mask</a>" : $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 (<small># $ban_id </small>) with $ban_reason");
rebuildThemes('bans');

View File

@ -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());
}
}
}
}

View File

@ -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;

66
inc/context.php Normal file
View File

@ -0,0 +1,66 @@
<?php
namespace Vichan;
use Vichan\Driver\{HttpDriver, HttpDrivers, Log, LogDrivers};
defined('TINYBOARD') or exit;
interface DependencyFactory {
public function buildLogDriver(): Log;
public function buildHttpDriver(): HttpDriver;
}
class WebDependencyFactory implements DependencyFactory {
private array $config;
public function __construct(array $config) {
$this->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();
}
}

View File

@ -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;
}
}
}
}

151
inc/driver/http-driver.php Normal file
View File

@ -0,0 +1,151 @@
<?php // Honestly this is just a wrapper for cURL. Still useful to mock it and have an OOP API on PHP 7.
namespace Vichan\Driver;
use RuntimeException;
defined('TINYBOARD') or exit;
class HttpDrivers {
private const DEFAULT_USER_AGENT = 'Tinyboard';
public static function getHttpDriver(int $timeout, int $max_file_size): HttpDriver {
return new HttpDriver($timeout, self::DEFAULT_USER_AGENT, $max_file_size);
}
}
class HttpDriver {
private mixed $inner;
private int $timeout;
private string $user_agent;
private int $max_file_size;
private function resetTowards(string $url, int $timeout): void {
curl_reset($this->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;
}
}

189
inc/driver/log-driver.php Normal file
View File

@ -0,0 +1,189 @@
<?php // Logging
namespace Vichan\Driver;
use InvalidArgumentException;
use RuntimeException;
defined('TINYBOARD') or exit;
class LogDrivers {
public static function levelToString(int $level): string {
switch ($level) {
case Log::EMERG:
return 'EMERG';
case Log::ERROR:
return 'ERROR';
case Log::WARNING:
return 'WARNING';
case Log::NOTICE:
return 'NOTICE';
case Log::INFO:
return 'INFO';
case Log::DEBUG:
return 'DEBUG';
default:
throw new InvalidArgumentException('Not a logging level');
}
}
/**
* Log to syslog.
*/
public static function syslog(string $name, int $level, bool $print_stderr): Log {
$flags = LOG_ODELAY;
if ($print_stderr) {
$flags |= LOG_PERROR;
}
if (!openlog($name, $flags, LOG_USER)) {
throw new RuntimeException('Unable to open syslog');
}
return new class($level) implements Log {
private $level;
public function __construct(int $level) {
$this->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;
}

View File

@ -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('@<tinyboard ([\w\s]+)>(.+?)</tinyboard>@usm', '', $body);
return $body ? preg_replace('@<tinyboard ([\w\s]+)>(.+?)</tinyboard>@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", '&#09;', str_replace("\n", '&#13;&#10;', 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';

28
inc/functions/format.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace Vichan\Functions\Format;
function format_timestamp(int $delta): string {
switch (true) {
case $delta < 60:
return $delta . ' ' . ngettext('second', 'seconds', $delta);
case $delta < 3600: //60*60 = 3600
return ($num = round($delta/ 60)) . ' ' . ngettext('minute', 'minutes', $num);
case $delta < 86400: //60*60*24 = 86400
return ($num = round($delta / 3600)) . ' ' . ngettext('hour', 'hours', $num);
case $delta < 604800: //60*60*24*7 = 604800
return ($num = round($delta / 86400)) . ' ' . ngettext('day', 'days', $num);
case $delta < 31536000: //60*60*24*365 = 31536000
return ($num = round($delta / 604800)) . ' ' . ngettext('week', 'weeks', $num);
default:
return ($num = round($delta / 31536000)) . ' ' . ngettext('year', 'years', $num);
}
}
function until(int $timestamp): string {
return format_timestamp($timestamp - time());
}
function ago(int $timestamp): string {
return format_timestamp(time() - $timestamp);
}

16
inc/functions/net.php Normal file
View File

@ -0,0 +1,16 @@
<?php
namespace Vichan\Functions\Net;
/**
* @param bool $trust_headers. If true, trust the `HTTP_X_FORWARDED_PROTO` header to check if the connection is HTTPS.
* @return bool Returns if the client-server connection is an encrypted one (HTTPS).
*/
function is_connection_secure(bool $trust_headers): bool {
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
return true;
} elseif ($trust_headers && isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
return true;
}
return false;
}

33
inc/functions/num.php Normal file
View File

@ -0,0 +1,33 @@
<?php
namespace Vichan\Functions\Num;
// 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}";
}

View File

@ -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;

View File

@ -1,39 +1,84 @@
<?php
class Lock {
function __construct($key) { global $config;
if ($config['lock']['enabled'] == 'fs') {
$key = str_replace('/', '::', $key);
$key = str_replace("\0", '', $key);
$this->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;
}

View File

@ -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']);
// <username>:<password>:<salt>
$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'],

View File

@ -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']);

View File

@ -1,49 +1,98 @@
<?php
class Queue {
function __construct($key) { global $config;
if ($config['queue']['enabled'] == 'fs') {
$this->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;
}

View File

@ -0,0 +1,102 @@
<?php // Verify captchas server side.
namespace Vichan\Service;
use Vichan\Driver\HttpDriver;
defined('TINYBOARD') or exit;
class RemoteCaptchaQuery {
private HttpDriver $http;
private string $secret;
private string $endpoint;
/**
* Creates a new CaptchaRemoteQueries instance using the google recaptcha service.
*
* @param HttpDriver $http The http client.
* @param string $secret Server side secret.
* @return CaptchaRemoteQueries A new captcha query instance.
*/
public static function withRecaptcha(HttpDriver $http, string $secret): RemoteCaptchaQuery {
return new self($http, $secret, 'https://www.google.com/recaptcha/api/siteverify');
}
/**
* Creates a new CaptchaRemoteQueries instance using the hcaptcha service.
*
* @param HttpDriver $http The http client.
* @param string $secret Server side secret.
* @return CaptchaRemoteQueries A new captcha query instance.
*/
public static function withHCaptcha(HttpDriver $http, string $secret): RemoteCaptchaQuery {
return new self($http, $secret, 'https://hcaptcha.com/siteverify');
}
private function __construct(HttpDriver $http, string $secret, string $endpoint) {
$this->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';
}
}

View File

@ -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", '<br/>', utf8tohtml(print_r($_debug, true))) .
'</pre>';
}
// 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;

View File

@ -1,7 +1,7 @@
<?php
// Installation/upgrade file
define('VERSION', '5.1.4');
define('VERSION', '5.2.0');
require 'inc/bootstrap.php';
loadConfig();
@ -689,6 +689,8 @@ if ($step == 0) {
echo Element('page.html', $page);
} elseif ($step == 1) {
// The HTTPS check doesn't work properly when in those arrays, so let's run it here and pass along the result during the actual check.
$httpsvalue = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
$page['title'] = 'Pre-installation test';
$can_exec = true;
@ -734,13 +736,6 @@ if ($step == 0) {
'required' => true,
'message' => 'vichan requires PHP 7.4 or better.',
),
array(
'category' => 'PHP',
'name' => 'PHP &ge; 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 <code>templates/cache</code> 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 <code>tmp/cache</code> directory.'
),
@ -874,6 +869,13 @@ if ($step == 0) {
'required' => false,
'message' => 'vichan does not have permission to make changes to <code>inc/secrets.php</code>. 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 .= '<li>' . db_error() . '</li>';
if (!query($query)) {
$sql_err_count++;
$error = db_error();
$sql_errors .= "<li>$sql_err_count<ul><li>$query</li><li>$error</li></ul></li>";
}
}
$page['title'] = 'Installation complete';
@ -1033,4 +1039,3 @@ if ($step == 0) {
echo Element('page.html', $page);
}

View File

@ -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";

View File

@ -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);
}

View File

@ -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
'<option value="trip">'+_('Tripcode')+'</option>' +
'<option value="sub">'+_('Subject')+'</option>' +
'<option value="com">'+_('Comment')+'</option>' +
'<option value="flag">'+_('Flag')+'</option>' +
'</select>' +
'<input type="text">' +
'<input type="checkbox">' +

303
post.php
View File

@ -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'] . '<br/>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']}
<script>
if (actually_load_captcha !== undefined)
actually_load_captcha(
\"{$config['captcha']['provider_get']}\",
\"{$config['captcha']['extra']}\"
);
</script>"
);
}
}
}
// 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'] .
'<script>if (actually_load_captcha !== undefined) actually_load_captcha("'.$config['captcha']['provider_get'].'", "'.$config['captcha']['extra'].'");</script>');
}
}
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'] .= "<tinyboard ocr image $key>" . htmlspecialchars($value) . "</tinyboard>";
}
} 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 . '"');

View File

@ -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('"', '&quot;', utf8tohtml($_GET['search'])) : false));
if(isset($_GET['search']) && !empty($_GET['search']) && isset($_GET['board']) && in_array($_GET['board'], $boards)) {
$phrase = $_GET['search'];
$_body = '';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -906,7 +906,8 @@ pre {
display: block;
width: 100%;
height: 100%;
margin-top: 0px;
max-width: 620px;
margin: auto;
}
.mentioned {

View File

@ -1 +0,0 @@

View File

@ -14,14 +14,26 @@
</script>
{% 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 %}
<meta name="description" content="{{ meta_subject }}" />
<meta name="twitter:card" value="summary">
<meta name="twitter:title" content="{{ board.url }} - {{ board.title|e }}" />
<meta name="twitter:description" content="{{ meta_subject }}" />
<meta name="twitter:image" content="{{ config.domain }}/{{ config.logo }}" />
<meta property="og:title" content="{{ board.url }} - {{ board.title|e }}" />
<meta property="og:type" content="article" />
<meta property="og:image" content="{{ config.domain }}/{{ config.logo }}" />
<meta property="og:description" content="{{ meta_subject }}" />
<title>{{ board.url }} - {{ board.title|e }}</title>
</head>
<body class="8chan vichan {% if mod %}is-moderator{% else %}is-not-moderator{% endif %} active-{% if not no_post_form %}index{% else %}ukko{% endif %}" data-stylesheet="{% if config.default_stylesheet.1 != '' %}{{ config.default_stylesheet.1 }}{% else %}default{% endif %}">
{{ boardlist.top }}
{% if pm %}<div class="top_notice">You have <a href="?/PM/{{ pm.id }}">an unread PM</a>{% if pm.waiting > 0 %}, plus {{ pm.waiting }} more waiting{% endif %}.</div><hr />{% endif %}
{% if config.url_banner %}<img class="board_image" src="{{ config.url_banner }}" {% if config.banner_width or config.banner_height %}style="{% if config.banner_width %}width:{{ config.banner_width }}px{% endif %};{% if config.banner_width %}height:{{ config.banner_height }}px{% endif %}" {% endif %}alt="" />{% endif %}
<header>
<h1>{{ board.url }} - {{ board.title|e }}</h1>
<div class="subtitle">
@ -54,7 +66,7 @@
{{ btn.next }}
</div>
{% endif %}
{% if config.global_message %}<hr /><div class="blotter">{{ config.global_message }}</div>{% endif %}
<hr />
{% if config.board_search %}
@ -74,7 +86,7 @@
{{ body }}
{% include 'report_delete.html' %}
</form>
<div class="pages">
{{ btn.prev }} {% for page in pages %}
[<a {% if page.selected %}class="selected"{% endif %}{% if not page.selected %}href="{{ page.link }}"{% endif %}>{{ page.num }}</a>]{% if loop.last %} {% endif %}
@ -83,7 +95,7 @@
| <a href="{{ config.root }}{% if mod %}{{ config.file_mod }}?/{% endif %}{{ board.dir }}{{ config.catalog_link }}">{% trans %}Catalog{% endtrans %}</a>
{% endif %}
</div>
{{ boardlist.bottom }}
{{ config.ad.bottom }}
@ -92,6 +104,6 @@
<script type="text/javascript">{% verbatim %}
ready();
{% endverbatim %}</script>
</body>
</html>

View File

@ -166,7 +166,7 @@
<li>
A newer version of vichan
(<strong>v{{ newer_release.massive }}.{{ newer_release.major }}.{{ newer_release.minor }}</strong>) is available!
See <a href="https://engine.vichan.net">https://engine.vichan.net/</a> for upgrade instructions.
See <a href="https://vichan.info">https://vichan.info/</a> for upgrade instructions.
</li>
</ul>
</fieldset>

View File

@ -5,7 +5,7 @@
<tr>
<th>{% trans %}Markup method{% endtrans %}
{% set allowed_html = config.allowed_html %}
{% trans %}<p class="unimportant">"markdown" is provided by <a href="http://parsedown.org/">parsedown</a>. Note: images disabled.</p>
{% trans %}<p class="unimportant">"markdown" is provided by <a href="http://parsedown.org/">parsedown</a></p>
<p class="unimportant">"html" allows the following tags:<br/>{{ allowed_html }}</p>
<p class="unimportant">"infinity" is the same as what is used in posts.</p>
<p class="unimportant">This page will not convert between formats,<br/>choose it once or do the conversion yourself!</p>{% endtrans %}

View File

@ -1 +1 @@
<time datetime="{{ post.time|date('%Y-%m-%dT%H:%M:%S') }}{{ timezone() }}">{{ post.time|date(config.post_date) }}</time>
<time datetime="{{ post.time|date('Y-m-d\\TH:i:s\Z') }}">{{ post.time|date(config.post_date) }}</time>

View File

@ -17,7 +17,7 @@
<h1>{{ settings.title }}</h1>
<div class="subtitle">{{ settings.subtitle }}</div>
</header>
<div class="ban">
{% if news|length == 0 %}
<p style="text-align:center" class="unimportant">(No news to show.)</p>
@ -29,13 +29,13 @@
{% else %}
<em>no subject</em>
{% endif %}
<span class="unimportant"> &mdash; by {{ entry.name }} at {{ entry.time|date(config.post_date, config.timezone) }}</span>
<span class="unimportant"> &mdash; by {{ entry.name }} at {{ entry.time|date(config.post_date) }}</span>
</h2>
<p>{{ entry.body }}</p>
{% endfor %}
{% endif %}
</div>
<hr/>
{% include 'footer.html' %}
</body>

View File

@ -19,24 +19,24 @@
</div>
</header>
<span>{% trans 'Sort by' %}: </span>
<select id="sort_by" style="display: inline-block">
<option selected value="bump:desc">{% trans 'Bump order' %}</option>
<option value="time:desc">{% trans 'Creation date' %}</option>
<option value="reply:desc">{% trans 'Reply count' %}</option>
<option value="random:desc">{% trans 'Random' %}</option>
</select>
<span>{% trans 'Image size' %}: </span>
<select id="image_size" style="display: inline-block">
<option value="vsmall">{% trans 'Very small' %}</option>
<option selected value="small">{% trans 'Small' %}</option>
<option value="large">{% trans 'Large' %}</option>
</select>
<div class="threads">
<div id="Grid">
{% for post in recent_posts %}
<div class="mix"
<span>{% trans 'Sort by' %}: </span>
<select id="sort_by" style="display: inline-block">
<option selected value="bump:desc">{% trans 'Bump order' %}</option>
<option value="time:desc">{% trans 'Creation date' %}</option>
<option value="reply:desc">{% trans 'Reply count' %}</option>
<option value="random:desc">{% trans 'Random' %}</option>
</select>
<span>{% trans 'Image size' %}: </span>
<select id="image_size" style="display: inline-block">
<option value="vsmall">{% trans 'Very small' %}</option>
<option selected value="small">{% trans 'Small' %}</option>
<option value="large">{% trans 'Large' %}</option>
</select>
<div class="threads">
<div id="Grid">
{% for post in recent_posts %}
<div class="mix"
data-reply="{{ post.reply_count }}"
data-bump="{{ post.bump }}"
data-time="{{ post.time }}"
@ -44,18 +44,18 @@
data-sticky="{% if post.sticky %}true{% else %}false{% endif %}"
data-locked="{% if post.locked %}true{% else %}false{% endif %}"
>
<div class="thread grid-li grid-size-small">
<a href="{{post.link}}">
<div class="thread grid-li grid-size-small">
<a href="{{post.link}}">
{% if post.youtube %}
<img src="//img.youtube.com/vi/{{ post.youtube }}/0.jpg"
<img src="//img.youtube.com/vi/{{ post.youtube }}/0.jpg"
{% else %}
<img src="{{post.file}}"
<img src="{{post.file}}"
{% endif %}
id="img-{{ post.id }}" data-subject="{% if post.subject %}{{ post.subject|e }}{% endif %}" data-name="{{ post.name|e }}" data-muhdifference="{{ post.muhdifference }}" class="{{post.board}} thread-image" title="{{post.bump|date('%b %d %H:%M')}}">
</a>
<div class="replies">
<strong>R: {{ post.reply_count }} / I: {{ post.image_count }}{% if post.sticky %} (sticky){% endif %}</strong>
{% 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')}}">
</a>
<div class="replies">
<strong>R: {{ post.reply_count }} / I: {{ post.image_count }}{% if post.sticky %} (sticky){% endif %}</strong>
{% if post.subject %}
<p class="intro">
<span class="subject">
{{ post.subject|e }}
@ -66,13 +66,13 @@
{% endif %}
{{ post.body }}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<hr/>
{% include 'footer.html' %}
<script type="text/javascript">{% verbatim %}

View File

@ -1,7 +1,14 @@
<!doctype html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="twitter:card" value="summary">
<meta name="twitter:title" content="{{ settings.title }}" />
<meta name="twitter:image" content="{{ config.domain }}/{{ config.logo }}" />
<meta property="og:title" content="{{ settings.title }}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{ config.domain }}" />
<meta property="og:image" content="{{ config.domain }}/{{ config.logo }}" />
<link rel="stylesheet" media="screen" href="{{ config.url_stylesheet }}">
<style type="text/css">
iframe{border:none;margin:0;padding:0;height:99%;position:absolute}

View File

@ -10,7 +10,7 @@
{% for thread in thread_list %}
<url>
<loc>{{ settings.url ~ (config.board_path | format(board)) ~ config.dir.res ~ link_for(thread) }}</loc>
<lastmod>{{ thread.lastmod | date('%Y-%m-%dT%H:%M:%S') }}{{ timezone() }}</lastmod>
<lastmod>{{ thread.lastmod | date('Y-m-d\\TH:i:s\Z') }}</lastmod>
<changefreq>{{ settings.changefreq }}</changefreq>
</url>
{% endfor %}

View File

@ -15,6 +15,9 @@
<meta name="description" content="{{ board.url }} - {{ board.title|e }} - {{ meta_subject }}" />
<meta name="twitter:card" value="summary">
<meta name="twitter:title" content="{{ meta_subject }}" />
<meta name="twitter:description" content="{{ thread.body_nomarkup|e }}" />
{% if thread.files.0.thumb %}<meta name="twitter:image" content="{{ config.domain }}/{{ board.uri }}/{{ config.dir.thumb }}{{ thread.files.0.thumb }}" />{% endif %}
<meta property="og:title" content="{{ meta_subject }}" />
<meta property="og:type" content="article" />
<meta property="og:url" content="{{ config.domain }}/{{ board.uri }}/{{ config.dir.res }}{{ thread.id }}.html" />