diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..8ae84728 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +**/.git +**/.gitignore +/local-instances +**/.gitkeep diff --git a/.gitignore b/.gitignore index 220b0e11..5e0ab052 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,6 @@ Thumbs.db #vichan custom favicon.ico /static/spoiler.png +/local-instances /vendor/ diff --git a/README.md b/README.md index b1794df9..2c7001be 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,11 @@ WebM support ------------ Read `inc/lib/webm/README.md` for information about enabling webm. +Docker +------------ +Vichan comes with a Dockerfile and docker-compose configuration, the latter aimed primarily at development and testing. +See the `docker/doc.md` file for more information. + vichan API ---------- vichan provides by default a 4chan-compatible JSON API. For documentation on this, see: diff --git a/b.php b/b.php index 83285b81..446fb47c 100644 --- a/b.php +++ b/b.php @@ -1,20 +1,8 @@ +$name = $files[array_rand($files)]; +header("Location: /static/banners/$name", true, 307); +header('Cache-Control: no-cache'); diff --git a/compose.yml b/compose.yml new file mode 100644 index 00000000..4b87e0b6 --- /dev/null +++ b/compose.yml @@ -0,0 +1,40 @@ +services: + #nginx webserver + php 8.x + web: + build: + context: . + dockerfile: ./docker/nginx/Dockerfile + ports: + - "9090:80" + depends_on: + - db + volumes: + - ./local-instances/${INSTANCE:-0}/www:/var/www/html + - ./docker/nginx/vichan.conf:/etc/nginx/conf.d/default.conf + - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf + - ./docker/nginx/proxy.conf:/etc/nginx/conf.d/proxy.conf + links: + - php + + php: + build: + context: . + dockerfile: ./docker/php/Dockerfile + volumes: + - ./local-instances/${INSTANCE:-0}/www:/var/www + - ./docker/php/www.conf:/usr/local/etc/php-fpm.d/www.conf + - ./docker/php/jit.ini:/usr/local/etc/php/conf.d/jit.ini + + #MySQL Service + db: + image: mysql:8.0.35 + container_name: db + restart: unless-stopped + tty: true + ports: + - "3306:3306" + environment: + MYSQL_DATABASE: vichan + MYSQL_ROOT_PASSWORD: password + volumes: + - ./local-instances/${INSTANCE:-0}/mysql:/var/lib/mysql diff --git a/composer.json b/composer.json index ec4a090d..a2e906fe 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "gettext/gettext": "^5.5", "mrclay/minify": "^2.1.6", "geoip/geoip": "^1.17", - "dapphp/securimage": "^4.0" + "dapphp/securimage": "^4.0", + "erusev/parsedown": "^1.7.4" }, "autoload": { "classmap": ["inc/"], @@ -32,7 +33,14 @@ "inc/mod/auth.php", "inc/lock.php", "inc/queue.php", - "inc/functions.php" + "inc/functions.php", + "inc/functions/dice.php", + "inc/functions/format.php", + "inc/functions/net.php", + "inc/functions/num.php", + "inc/functions/theme.php", + "inc/service/captcha-queries.php", + "inc/context.php" ] }, "license": "Tinyboard + vichan", diff --git a/docker/doc.md b/docker/doc.md new file mode 100644 index 00000000..051ae56e --- /dev/null +++ b/docker/doc.md @@ -0,0 +1,20 @@ +The `php-fpm` process runs containerized. +The php application always uses `/var/www` as it's work directory and home folder, and if `/var/www` is bind mounted it +is necessary to adjust the path passed via FastCGI to `php-fpm` by changing the root directory to `/var/www`. +This can achieved in nginx by setting the `fastcgi_param SCRIPT_FILENAME` to `/var/www/$fastcgi_script_name;` + +The default docker compose settings are intended for development and testing purposes. +The folder structure expected by compose is as follows + +``` + +└── local-instances + └── 1 + ├── mysql + └── www +``` +The vichan container is by itself much less rigid. + + +Use `docker compose up --build` to start the docker compose. +Use `docker compose up --build -d php` to rebuild just the vichan container while the compose is running. Useful for development. diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile new file mode 100644 index 00000000..d9d4bcc4 --- /dev/null +++ b/docker/nginx/Dockerfile @@ -0,0 +1,8 @@ +FROM nginx:1.25.3-alpine + +COPY . /code +RUN adduser --system www-data \ + && adduser www-data www-data + +CMD [ "nginx", "-g", "daemon off;" ] +EXPOSE 80 diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 00000000..7c6b6587 --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,34 @@ +# This and proxy.conf are based on +# https://github.com/dead-guru/devichan/blob/master/nginx/nginx.conf + +user www-data; +worker_processes auto; + +error_log /dev/stdout warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Switch logging to console out to view via Docker + access_log /dev/stdout; + error_log /dev/stdout warn; + sendfile on; + keepalive_timeout 5; + + gzip on; + gzip_http_version 1.0; + gzip_vary on; + gzip_comp_level 6; + gzip_types text/xml text/plain text/css application/xhtml+xml application/xml application/rss+xml application/atom_xml application/x-javascript application/x-httpd-php; + gzip_disable "MSIE [1-6]\."; + + + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-available/*.conf; +} \ No newline at end of file diff --git a/docker/nginx/proxy.conf b/docker/nginx/proxy.conf new file mode 100644 index 00000000..6830cd5f --- /dev/null +++ b/docker/nginx/proxy.conf @@ -0,0 +1,40 @@ +proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=czone:4m max_size=50m inactive=120m; +proxy_temp_path /var/tmp/nginx; +proxy_cache_key "$scheme://$host$request_uri"; + + +map $http_forwarded_request_id $x_request_id { + "" $request_id; + default $http_forwarded_request_id; +} + +map $http_forwarded_forwarded_host $forwardedhost { + "" $host; + default $http_forwarded_forwarded_host; +} + + +map $http_x_forwarded_proto $fcgi_https { + default ""; + https on; +} + +map $http_x_forwarded_proto $real_scheme { + default $scheme; + https https; +} + +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Forwarded-Server $host; + +real_ip_header X-Forwarded-For; + +set_real_ip_from 10.0.0.0/8; +set_real_ip_from 172.16.0.0/12; +set_real_ip_from 172.18.0.0; +set_real_ip_from 192.168.0.0/24; +set_real_ip_from 127.0.0.0/8; + +real_ip_recursive on; \ No newline at end of file diff --git a/docker/nginx/vichan.conf b/docker/nginx/vichan.conf new file mode 100644 index 00000000..35f6bc08 --- /dev/null +++ b/docker/nginx/vichan.conf @@ -0,0 +1,66 @@ +upstream php-upstream { + server php:9000; +} + +server { + listen 80 default_server; + listen [::]:80 default_server ipv6only=on; + server_name vichan; + root /var/www/html; + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + + index index.html index.php; + + charset utf-8; + + location ~ ^([^.\?]*[^\/])$ { + try_files $uri @addslash; + } + + # Expire rules for static content + # Media: images, icons, video, audio, HTC + location ~* \.(?:jpg|jpeg|gif|png|webp|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ { + expires 1M; + access_log off; + log_not_found off; + add_header Cache-Control "public"; + } + # CSS and Javascript + location ~* \.(?:css|js)$ { + expires 1y; + access_log off; + log_not_found off; + add_header Cache-Control "public"; + } + + location ~* \.(html)$ { + expires -1; + } + + location @addslash { + return 301 $uri/; + } + + location / { + try_files $uri $uri/ /index.php$is_args$args; + } + + client_max_body_size 2G; + + location ~ \.php$ { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Request-Id $x_request_id; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Forwarded-Request-Id $x_request_id; + fastcgi_pass php-upstream; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME /var/www/$fastcgi_script_name; + fastcgi_read_timeout 600; + include fastcgi_params; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 00000000..1882bc9d --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,88 @@ +# Based on https://github.com/dead-guru/devichan/blob/master/php-fpm/Dockerfile + +FROM composer:lts AS composer +FROM php:8.1-fpm-alpine + +RUN apk add --no-cache \ + zlib \ + zlib-dev \ + libpng \ + libpng-dev \ + libjpeg-turbo \ + libjpeg-turbo-dev \ + libwebp \ + libwebp-dev \ + libcurl \ + curl-dev \ + imagemagick \ + graphicsmagick \ + gifsicle \ + ffmpeg \ + bind-tools \ + gettext \ + gettext-dev \ + icu-dev \ + oniguruma \ + oniguruma-dev \ + libmcrypt \ + libmcrypt-dev \ + lz4-libs \ + lz4-dev \ + imagemagick-dev \ + pcre-dev \ + $PHPIZE_DEPS \ + && docker-php-ext-configure gd \ + --with-webp=/usr/include/webp \ + --with-jpeg=/usr/include \ + && docker-php-ext-install -j$(nproc) \ + gd \ + curl \ + bcmath \ + opcache \ + pdo_mysql \ + gettext \ + intl \ + mbstring \ + && pecl update-channels \ + && pecl install -o -f igbinary \ + && pecl install redis \ + && pecl install imagick \ + $$ docker-php-ext-enable \ + igbinary \ + redis \ + imagick \ + && apk del \ + zlib-dev \ + libpng-dev \ + libjpeg-turbo-dev \ + libwebp-dev \ + curl-dev \ + gettext-dev \ + oniguruma-dev \ + libmcrypt-dev \ + lz4-dev \ + imagemagick-dev \ + pcre-dev \ + $PHPIZE_DEPS \ + && rm -rf /var/cache/* \ + && rm -rf /tmp/pear +RUN rmdir /var/www/html \ + && install -d -m 744 -o www-data -g www-data /var/www \ + && install -d -m 700 -o www-data -g www-data /var/tmp/vichan \ + && install -d -m 700 -o www-data -g www-data /var/cache/gen-cache \ + && install -d -m 700 -o www-data -g www-data /var/cache/template-cache + +# Copy the bootstrap script. +COPY ./docker/php/bootstrap.sh /usr/local/bin/bootstrap.sh + +COPY --from=composer /usr/bin/composer /usr/local/bin/composer + +# Copy the actual project (use .dockerignore to exclude stuff). +COPY . /code + +# Install the compose depedencies. +RUN cd /code && composer install + +WORKDIR "/var/www" +CMD [ "bootstrap.sh" ] +EXPOSE 9000 diff --git a/docker/php/Dockerfile.profile b/docker/php/Dockerfile.profile new file mode 100644 index 00000000..ad2019ab --- /dev/null +++ b/docker/php/Dockerfile.profile @@ -0,0 +1,16 @@ +# syntax = devthefuture/dockerfile-x +INCLUDE ./docker/php/Dockerfile + +RUN apk add --no-cache \ + linux-headers \ + $PHPIZE_DEPS \ + && pecl update-channels \ + && pecl install xdebug \ + && docker-php-ext-enable xdebug \ + && apk del \ + linux-headers \ + $PHPIZE_DEPS \ + && rm -rf /var/cache/* + +ENV XDEBUG_OUT_DIR=/var/www/xdebug_out +CMD [ "bootstrap.sh" ] \ No newline at end of file diff --git a/docker/php/bootstrap.sh b/docker/php/bootstrap.sh new file mode 100755 index 00000000..cc3f43d0 --- /dev/null +++ b/docker/php/bootstrap.sh @@ -0,0 +1,87 @@ +#!/bin/sh + +set -eu + +function set_cfg() { + if [ -L "/var/www/inc/$1" ]; then + echo "INFO: Resetting $1" + rm "/var/www/inc/$1" + cp "/code/inc/$1" "/var/www/inc/$1" + chown www-data "/var/www/inc/$1" + chgrp www-data "/var/www/inc/$1" + chmod 600 "/var/www/inc/$1" + else + echo "INFO: Using existing $1" + fi +} + +if ! mountpoint -q /var/www; then + echo "WARNING: '/var/www' is not a mountpoint. All the data will remain inside the container!" +fi + +if [ ! -w /var/www ] ; then + echo "ERROR: '/var/www' is not writable. Closing." + exit 1 +fi + +if [ -z "${XDEBUG_OUT_DIR:-''}" ] ; then + echo "INFO: Initializing xdebug out directory at $XDEBUG_OUT_DIR" + mkdir -p "$XDEBUG_OUT_DIR" + chown www-data "$XDEBUG_OUT_DIR" + chgrp www-data "$XDEBUG_OUT_DIR" + chmod 755 "$XDEBUG_OUT_DIR" +fi + +# Link the entrypoints from the exposed directory. +ln -nfs \ + /code/tools/ \ + /code/*.php \ + /code/LICENSE.* \ + /code/install.sql \ + /var/www/ +# Static files accessible from the webserver must be copied. +cp -ur /code/static /var/www/ +cp -ur /code/stylesheets /var/www/ + +# Ensure correct permissions are set, since this might be bind mount. +chown www-data /var/www +chgrp www-data /var/www + +# Initialize an empty robots.txt with the default if it doesn't exist. +touch /var/www/robots.txt + +# Link the cache and tmp files directory. +ln -nfs /var/tmp/vichan /var/www/tmp + +# Link the javascript directory. +ln -nfs /code/js /var/www/ + +# Link the html templates directory and it's cache. +ln -nfs /code/templates /var/www/ +ln -nfs -T /var/cache/template-cache /var/www/templates/cache +chown -h www-data /var/www/templates/cache +chgrp -h www-data /var/www/templates/cache + +# Link the generic cache. +ln -nfs -T /var/cache/gen-cache /var/www/tmp/cache +chown -h www-data /var/www/tmp/cache +chgrp -h www-data /var/www/tmp/cache + +# Create the included files directory and link them +install -d -m 700 -o www-data -g www-data /var/www/inc +for file in /code/inc/*; do + file="${file##*/}" + if [ ! -e /var/www/inc/$file ]; then + ln -s /code/inc/$file /var/www/inc/ + fi +done + +# Copy an empty instance configuration if the file is a link (it was linked because it did not exist before). +set_cfg 'instance-config.php' +set_cfg 'secrets.php' + +# Link the composer dependencies. +ln -nfs /code/vendor /var/www/ + +# Start the php-fpm server. +exec php-fpm diff --git a/docker/php/jit.ini b/docker/php/jit.ini new file mode 100644 index 00000000..ecfb44c5 --- /dev/null +++ b/docker/php/jit.ini @@ -0,0 +1,2 @@ +opcache.jit_buffer_size=192M +opcache.jit=tracing diff --git a/docker/php/www.conf b/docker/php/www.conf new file mode 100644 index 00000000..6e78ad26 --- /dev/null +++ b/docker/php/www.conf @@ -0,0 +1,13 @@ +[www] +access.log = /proc/self/fd/2 + +; Ensure worker stdout and stderr are sent to the main error log. +catch_workers_output = yes +decorate_workers_output = no + +user = www-data +group = www-data + +listen = 127.0.0.1:9000 +pm = static +pm.max_children = 16 diff --git a/docker/php/xdebug-prof.ini b/docker/php/xdebug-prof.ini new file mode 100644 index 00000000..c6dc008e --- /dev/null +++ b/docker/php/xdebug-prof.ini @@ -0,0 +1,7 @@ +zend_extension=xdebug + +[xdebug] +xdebug.mode = profile +xdebug.start_with_request = start +error_reporting = E_ALL +xdebug.output_dir = /var/www/xdebug_out diff --git a/inc/Data/Driver/ApcuCacheDriver.php b/inc/Data/Driver/ApcuCacheDriver.php new file mode 100644 index 00000000..a39bb656 --- /dev/null +++ b/inc/Data/Driver/ApcuCacheDriver.php @@ -0,0 +1,28 @@ +name = $name; + $this->level = $level; + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + $lv = $this->levelToString($level); + $line = "{$this->name} $lv: $message"; + \error_log($line, 0, null, null); + } + } +} diff --git a/inc/Data/Driver/FileLogDriver.php b/inc/Data/Driver/FileLogDriver.php new file mode 100644 index 00000000..2c9f14a0 --- /dev/null +++ b/inc/Data/Driver/FileLogDriver.php @@ -0,0 +1,61 @@ +fd = \fopen($file_path, 'a'); + if ($this->fd === false) { + throw new \RuntimeException("Unable to open log file at $file_path"); + } + + $this->name = $name; + $this->level = $level; + + // In some cases PHP does not run the destructor. + \register_shutdown_function([$this, 'close']); + } + + public function __destruct() { + $this->close(); + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + $lv = $this->levelToString($level); + $line = "{$this->name} $lv: $message\n"; + \flock($this->fd, LOCK_EX); + \fwrite($this->fd, $line); + \fflush($this->fd); + \flock($this->fd, LOCK_UN); + } + } + + public function close() { + \flock($this->fd, LOCK_UN); + \fclose($this->fd); + } +} diff --git a/inc/Data/Driver/FsCachedriver.php b/inc/Data/Driver/FsCachedriver.php new file mode 100644 index 00000000..b543cfa6 --- /dev/null +++ b/inc/Data/Driver/FsCachedriver.php @@ -0,0 +1,155 @@ +prefix . $key; + } + + private function sharedLockCache(): void { + \flock($this->lock_fd, LOCK_SH); + } + + private function exclusiveLockCache(): void { + \flock($this->lock_fd, LOCK_EX); + } + + private function unlockCache(): void { + \flock($this->lock_fd, LOCK_UN); + } + + private function collectImpl(): int { + /* + * A read lock is ok, since it's alright if we delete expired items from under the feet of other processes, and + * no other process add new cache items or refresh existing ones. + */ + $files = \glob($this->base_path . $this->prefix . '*', \GLOB_NOSORT); + $count = 0; + foreach ($files as $file) { + $data = \file_get_contents($file); + $wrapped = \json_decode($data, true, 512, \JSON_THROW_ON_ERROR); + if ($wrapped['expires'] !== false && $wrapped['expires'] <= \time()) { + if (@\unlink($file)) { + $count++; + } + } + } + return $count; + } + + private function maybeCollect(): void { + if ($this->collect_chance_den !== false && \mt_rand(0, $this->collect_chance_den - 1) === 0) { + $this->collect_chance_den = false; // Collect only once per instance (aka process). + $this->collectImpl(); + } + } + + public function __construct(string $prefix, string $base_path, string $lock_file, int|false $collect_chance_den) { + if ($base_path[\strlen($base_path) - 1] !== '/') { + $base_path = "$base_path/"; + } + + if (!\is_dir($base_path)) { + throw new \RuntimeException("$base_path is not a directory!"); + } + + if (!\is_writable($base_path)) { + throw new \RuntimeException("$base_path is not writable!"); + } + + $this->lock_fd = \fopen($base_path . $lock_file, 'w'); + if ($this->lock_fd === false) { + throw new \RuntimeException('Unable to open the lock file!'); + } + + $this->prefix = $prefix; + $this->base_path = $base_path; + $this->collect_chance_den = $collect_chance_den; + } + + public function __destruct() { + $this->close(); + } + + public function get(string $key): mixed { + $key = $this->prepareKey($key); + + $this->sharedLockCache(); + + // Collect expired items first so if the target key is expired we shortcut to failure in the next lines. + $this->maybeCollect(); + + $fd = \fopen($this->base_path . $key, 'r'); + if ($fd === false) { + $this->unlockCache(); + return null; + } + + $data = \stream_get_contents($fd); + \fclose($fd); + $this->unlockCache(); + $wrapped = \json_decode($data, true, 512, \JSON_THROW_ON_ERROR); + + if ($wrapped['expires'] !== false && $wrapped['expires'] <= \time()) { + // Already expired, leave it there since we already released the lock and pretend it doesn't exist. + return null; + } else { + return $wrapped['inner']; + } + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + $key = $this->prepareKey($key); + + $wrapped = [ + 'expires' => $expires ? \time() + $expires : false, + 'inner' => $value + ]; + + $data = \json_encode($wrapped); + $this->exclusiveLockCache(); + $this->maybeCollect(); + \file_put_contents($this->base_path . $key, $data); + $this->unlockCache(); + } + + public function delete(string $key): void { + $key = $this->prepareKey($key); + + $this->exclusiveLockCache(); + @\unlink($this->base_path . $key); + $this->maybeCollect(); + $this->unlockCache(); + } + + public function collect(): int { + $this->sharedLockCache(); + $count = $this->collectImpl(); + $this->unlockCache(); + return $count; + } + + public function flush(): void { + $this->exclusiveLockCache(); + $files = \glob($this->base_path . $this->prefix . '*', \GLOB_NOSORT); + foreach ($files as $file) { + @\unlink($file); + } + $this->unlockCache(); + } + + public function close(): void { + \fclose($this->lock_fd); + } +} diff --git a/inc/Data/Driver/HttpDriver.php b/inc/Data/Driver/HttpDriver.php new file mode 100644 index 00000000..022fcbab --- /dev/null +++ b/inc/Data/Driver/HttpDriver.php @@ -0,0 +1,131 @@ +inner); + \curl_setopt_array($this->inner, [ + \CURLOPT_URL => $url, + \CURLOPT_TIMEOUT => $timeout, + \CURLOPT_USERAGENT => 'Tinyboard', + \CURLOPT_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS, + ]); + } + + public function __construct(int $timeout, int $max_file_size) { + $this->inner = \curl_init(); + $this->timeout = $timeout; + $this->max_file_size = $max_file_size; + } + + public function __destruct() { + \curl_close($this->inner); + } + + /** + * Execute a GET request. + * + * @param string $endpoint Uri endpoint. + * @param ?array $data Optional GET parameters. + * @param int $timeout Optional request timeout in seconds. Use the default timeout if 0. + * @return string Returns the body of the response. + * @throws RuntimeException Throws on IO error. + */ + public function requestGet(string $endpoint, ?array $data, int $timeout = 0): string { + if (!empty($data)) { + $endpoint .= '?' . \http_build_query($data); + } + if ($timeout == 0) { + $timeout = $this->timeout; + } + + $this->resetTowards($endpoint, $timeout); + \curl_setopt($this->inner, \CURLOPT_RETURNTRANSFER, true); + $ret = \curl_exec($this->inner); + + if ($ret === false) { + throw new \RuntimeException(\curl_error($this->inner)); + } + return $ret; + } + + /** + * Execute a POST request. + * + * @param string $endpoint Uri endpoint. + * @param ?array $data Optional POST parameters. + * @param int $timeout Optional request timeout in seconds. Use the default timeout if 0. + * @return string Returns the body of the response. + * @throws RuntimeException Throws on IO error. + */ + public function requestPost(string $endpoint, ?array $data, int $timeout = 0): string { + if ($timeout == 0) { + $timeout = $this->timeout; + } + + $this->resetTowards($endpoint, $timeout); + \curl_setopt($this->inner, \CURLOPT_POST, true); + if (!empty($data)) { + \curl_setopt($this->inner, \CURLOPT_POSTFIELDS, \http_build_query($data)); + } + \curl_setopt($this->inner, \CURLOPT_RETURNTRANSFER, true); + $ret = \curl_exec($this->inner); + + if ($ret === false) { + throw new \RuntimeException(\curl_error($this->inner)); + } + return $ret; + } + + /** + * Download the url's target with curl. + * + * @param string $url Url to the file to download. + * @param ?array $data Optional GET parameters. + * @param resource $fd File descriptor to save the content to. + * @param int $timeout Optional request timeout in seconds. Use the default timeout if 0. + * @return bool Returns true on success, false if the file was too large. + * @throws RuntimeException Throws on IO error. + */ + public function requestGetInto(string $endpoint, ?array $data, mixed $fd, int $timeout = 0): bool { + if (!empty($data)) { + $endpoint .= '?' . \http_build_query($data); + } + if ($timeout == 0) { + $timeout = $this->timeout; + } + + $this->resetTowards($endpoint, $timeout); + // Adapted from: https://stackoverflow.com/a/17642638 + $opt = (\PHP_MAJOR_VERSION >= 8 && \PHP_MINOR_VERSION >= 2) ? \CURLOPT_XFERINFOFUNCTION : \CURLOPT_PROGRESSFUNCTION; + \curl_setopt_array($this->inner, [ + \CURLOPT_NOPROGRESS => false, + $opt => fn($res, $next_dl, $dl, $next_up, $up) => (int)($dl <= $this->max_file_size), + \CURLOPT_FAILONERROR => true, + \CURLOPT_FOLLOWLOCATION => false, + \CURLOPT_FILE => $fd, + \CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, + ]); + $ret = \curl_exec($this->inner); + + if ($ret === false) { + if (\curl_errno($this->inner) === CURLE_ABORTED_BY_CALLBACK) { + return false; + } + + throw new \RuntimeException(\curl_error($this->inner)); + } + return true; + } +} diff --git a/inc/Data/Driver/LogDriver.php b/inc/Data/Driver/LogDriver.php new file mode 100644 index 00000000..fddc3f27 --- /dev/null +++ b/inc/Data/Driver/LogDriver.php @@ -0,0 +1,22 @@ +inner = new \Memcached(); + if (!$this->inner->setOption(\Memcached::OPT_BINARY_PROTOCOL, true)) { + throw new \RuntimeException('Unable to set the memcached protocol!'); + } + if (!$this->inner->setOption(\Memcached::OPT_PREFIX_KEY, $prefix)) { + throw new \RuntimeException('Unable to set the memcached prefix!'); + } + if (!$this->inner->addServers($memcached_server)) { + throw new \RuntimeException('Unable to add the memcached server!'); + } + } + + public function get(string $key): mixed { + $ret = $this->inner->get($key); + // If the returned value is false but the retrival was a success, then the value stored was a boolean false. + if ($ret === false && $this->inner->getResultCode() !== \Memcached::RES_SUCCESS) { + return null; + } + return $ret; + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + $this->inner->set($key, $value, (int)$expires); + } + + public function delete(string $key): void { + $this->inner->delete($key); + } + + public function flush(): void { + $this->inner->flush(); + } +} diff --git a/inc/Data/Driver/NoneCacheDriver.php b/inc/Data/Driver/NoneCacheDriver.php new file mode 100644 index 00000000..8b260a50 --- /dev/null +++ b/inc/Data/Driver/NoneCacheDriver.php @@ -0,0 +1,26 @@ +inner = new \Redis(); + $this->inner->connect($host, $port); + if ($password) { + $this->inner->auth($password); + } + if (!$this->inner->select($database)) { + throw new \RuntimeException('Unable to connect to Redis!'); + } + + $$this->prefix = $prefix; + } + + public function get(string $key): mixed { + $ret = $this->inner->get($this->prefix . $key); + if ($ret === false) { + return null; + } + return \json_decode($ret, true); + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + if ($expires === false) { + $this->inner->set($this->prefix . $key, \json_encode($value)); + } else { + $expires = $expires * 1000; // Seconds to milliseconds. + $this->inner->setex($this->prefix . $key, $expires, \json_encode($value)); + } + } + + public function delete(string $key): void { + $this->inner->del($this->prefix . $key); + } + + public function flush(): void { + $this->inner->flushDB(); + } +} diff --git a/inc/Data/Driver/StderrLogDriver.php b/inc/Data/Driver/StderrLogDriver.php new file mode 100644 index 00000000..4c766033 --- /dev/null +++ b/inc/Data/Driver/StderrLogDriver.php @@ -0,0 +1,27 @@ +name = $name; + $this->level = $level; + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + $lv = $this->levelToString($level); + \fwrite(\STDERR, "{$this->name} $lv: $message\n"); + } + } +} diff --git a/inc/Data/Driver/SyslogLogDriver.php b/inc/Data/Driver/SyslogLogDriver.php new file mode 100644 index 00000000..c0df5304 --- /dev/null +++ b/inc/Data/Driver/SyslogLogDriver.php @@ -0,0 +1,35 @@ +level = $level; + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + if (isset($_SERVER['REMOTE_ADDR'], $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'])) { + // CGI + \syslog($level, "$message - client: {$_SERVER['REMOTE_ADDR']}, request: \"{$_SERVER['REQUEST_METHOD']} {$_SERVER['REQUEST_URI']}\""); + } else { + \syslog($level, $message); + } + } + } +} diff --git a/inc/anti-bot.php b/inc/anti-bot.php index 48150328..6f684c55 100644 --- a/inc/anti-bot.php +++ b/inc/anti-bot.php @@ -1,191 +1,5 @@ ?=-` '; - if ($unicode_chars) { - $len = strlen($chars) / 10; - for ($n = 0; $n < $len; $n++) - $chars .= mb_convert_encoding('&#' . mt_rand(0x2600, 0x26FF) . ';', 'UTF-8', 'HTML-ENTITIES'); - } - - $chars = preg_split('//u', $chars, -1, PREG_SPLIT_NO_EMPTY); - - $ch = array(); - - // fill up $ch until we reach $length - while (count($ch) < $length) { - $n = $length - count($ch); - $keys = array_rand($chars, $n > count($chars) ? count($chars) : $n); - if ($n == 1) { - $ch[] = $chars[$keys]; - break; - } - shuffle($keys); - foreach ($keys as $key) - $ch[] = $chars[$key]; - } - - $chars = $ch; - - return implode('', $chars); - } - - public static function make_confusing($string) { - $chars = preg_split('//u', $string, -1, PREG_SPLIT_NO_EMPTY); - - foreach ($chars as &$c) { - if (mt_rand(0, 3) != 0) - $c = utf8tohtml($c); - else - $c = mb_encode_numericentity($c, array(0, 0xffff, 0, 0xffff), 'UTF-8'); - } - - return implode('', $chars); - } - - public function __construct(array $salt = array()) { - global $config; - - if (!empty($salt)) { - // create a salted hash of the "extra salt" - $this->salt = implode(':', $salt); - } else { - $this->salt = ''; - } - - shuffle($config['spam']['hidden_input_names']); - - $input_count = mt_rand($config['spam']['hidden_inputs_min'], $config['spam']['hidden_inputs_max']); - $hidden_input_names_x = 0; - - for ($x = 0; $x < $input_count ; $x++) { - if ($hidden_input_names_x === false || mt_rand(0, 2) == 0) { - // Use an obscure name - $name = $this->randomString(mt_rand(10, 40), false, false, $config['spam']['unicode']); - } else { - // Use a pre-defined confusing name - $name = $config['spam']['hidden_input_names'][$hidden_input_names_x++]; - if ($hidden_input_names_x >= count($config['spam']['hidden_input_names'])) - $hidden_input_names_x = false; - } - - if (mt_rand(0, 2) == 0) { - // Value must be null - $this->inputs[$name] = ''; - } elseif (mt_rand(0, 4) == 0) { - // Numeric value - $this->inputs[$name] = (string)mt_rand(0, 100000); - } else { - // Obscure value - $this->inputs[$name] = $this->randomString(mt_rand(5, 100), true, true, $config['spam']['unicode']); - } - } - } - - public static function space() { - if (mt_rand(0, 3) != 0) - return ' '; - return str_repeat(' ', mt_rand(1, 3)); - } - - public function html($count = false) { - global $config; - - $elements = array( - '', - '', - '', - '', - '', - '', - '', - '
', - '
', - '', - '' - ); - - $html = ''; - - if ($count === false) { - $count = mt_rand(1, abs(count($this->inputs) / 15) + 1); - } - - if ($count === true) { - // all elements - $inputs = array_slice($this->inputs, $this->index); - } else { - $inputs = array_slice($this->inputs, $this->index, $count); - } - $this->index += count($inputs); - - foreach ($inputs as $name => $value) { - $element = false; - while (!$element) { - $element = $elements[array_rand($elements)]; - $element = str_replace(' ', self::space(), $element); - if (mt_rand(0, 5) == 0) - $element = str_replace('>', self::space() . '>', $element); - if (strpos($element, 'textarea') !== false && $value == '') { - // There have been some issues with mobile web browsers and empty '; + $page['pm'] = create_pm_header(); echo Element($config['file_page_template'], $page); exit; } @@ -2591,18 +2773,23 @@ function mod_config($board_config = false) { exit; } - mod_page(_('Config editor') . ($board_config ? ': ' . sprintf($config['board_abbreviation'], $board_config) : ''), - $config['file_mod_config_editor'], array( + mod_page( + _('Config editor') . ($board_config ? ': ' . sprintf($config['board_abbreviation'], $board_config) : ''), + $config['file_mod_config_editor'], + [ 'boards' => listBoards(), 'board' => $board_config, 'conf' => $conf, 'file' => $config_file, 'token' => make_secure_link_token('config' . ($board_config ? '/' . $board_config : '')) - )); + ], + $mod + ); } -function mod_themes_list() { - global $config; +function mod_themes_list(Context $ctx) { + global $mod; + $config = $ctx->get('config'); if (!hasPermission($config['mod']['themes'])) error($config['error']['noaccess']); @@ -2616,10 +2803,10 @@ function mod_themes_list() { $themes_in_use = $query->fetchAll(PDO::FETCH_COLUMN); // Scan directory for themes - $themes = array(); + $themes = []; while ($file = readdir($dir)) { if ($file[0] != '.' && is_dir($config['dir']['themes'] . '/' . $file)) { - $themes[$file] = loadThemeConfig($file); + $themes[$file] = Vichan\Functions\Theme\load_theme_config($file); } } closedir($dir); @@ -2629,22 +2816,30 @@ function mod_themes_list() { $theme['uninstall_token'] = make_secure_link_token('themes/' . $theme_name . '/uninstall'); } - mod_page(_('Manage themes'), $config['file_mod_themes'], array( - 'themes' => $themes, - 'themes_in_use' => $themes_in_use, - )); + mod_page( + _('Manage themes'), + $config['file_mod_themes'], + [ + 'themes' => $themes, + 'themes_in_use' => $themes_in_use, + ], + $mod + ); } -function mod_theme_configure($theme_name) { - global $config; +function mod_theme_configure(Context $ctx, $theme_name) { + global $mod; + $config = $ctx->get('config'); if (!hasPermission($config['mod']['themes'])) error($config['error']['noaccess']); - if (!$theme = loadThemeConfig($theme_name)) { + if (!$theme = Vichan\Functions\Theme\load_theme_config($theme_name)) { error($config['error']['invalidtheme']); } + $cache = $ctx->get(CacheDriver::class); + if (isset($_POST['install'])) { // Check if everything is submitted foreach ($theme['config'] as &$conf) { @@ -2673,13 +2868,13 @@ function mod_theme_configure($theme_name) { $query->execute() or error(db_error($query)); // Clean cache - Cache::delete("themes"); - Cache::delete("theme_settings_".$theme_name); + $cache->delete("themes"); + $cache->delete("theme_settings_$theme_name"); $result = true; $message = false; if (isset($theme['install_callback'])) { - $ret = $theme['install_callback'](themeSettings($theme_name)); + $ret = $theme['install_callback'](Vichan\Functions\Theme\theme_settings($theme_name)); if ($ret && !empty($ret)) { if (is_array($ret) && count($ret) == 2) { $result = $ret[0]; @@ -2696,59 +2891,77 @@ function mod_theme_configure($theme_name) { } // Build themes - rebuildThemes('all'); + Vichan\Functions\Theme\rebuild_themes('all'); - mod_page(sprintf(_($result ? 'Installed theme: %s' : 'Installation failed: %s'), $theme['name']), $config['file_mod_theme_installed'], array( - 'theme_name' => $theme_name, - 'theme' => $theme, - 'result' => $result, - 'message' => $message - )); + mod_page( + sprintf(_($result ? 'Installed theme: %s' : 'Installation failed: %s'), $theme['name']), + $config['file_mod_theme_installed'], + [ + 'theme_name' => $theme_name, + 'theme' => $theme, + 'result' => $result, + 'message' => $message + ], + $mod + ); return; } - $settings = themeSettings($theme_name); + $settings = Vichan\Functions\Theme\theme_settings($theme_name); - mod_page(sprintf(_('Configuring theme: %s'), $theme['name']), $config['file_mod_theme_config'], array( - 'theme_name' => $theme_name, - 'theme' => $theme, - 'settings' => $settings, - 'token' => make_secure_link_token('themes/' . $theme_name) - )); + mod_page( + sprintf(_('Configuring theme: %s'), $theme['name']), + $config['file_mod_theme_config'], + [ + 'theme_name' => $theme_name, + 'theme' => $theme, + 'settings' => $settings, + 'token' => make_secure_link_token('themes/' . $theme_name) + ], + $mod + ); } -function mod_theme_uninstall($theme_name) { - global $config; +function mod_theme_uninstall(Context $ctx, $theme_name) { + $config = $ctx->get('config'); if (!hasPermission($config['mod']['themes'])) error($config['error']['noaccess']); + $cache = $ctx->get(CacheDriver::class); + $query = prepare("DELETE FROM ``theme_settings`` WHERE `theme` = :theme"); $query->bindValue(':theme', $theme_name); $query->execute() or error(db_error($query)); // Clean cache - Cache::delete("themes"); - Cache::delete("theme_settings_".$theme_name); + $cache->delete("themes"); + $cache->delete("theme_settings_$theme_name"); header('Location: ?/themes', true, $config['redirect_http']); } -function mod_theme_rebuild($theme_name) { - global $config; +function mod_theme_rebuild(Context $ctx, $theme_name) { + global $mod; + $config = $ctx->get('config'); if (!hasPermission($config['mod']['themes'])) error($config['error']['noaccess']); - rebuildTheme($theme_name, 'all'); + Vichan\Functions\Theme\rebuild_theme($theme_name, 'all'); - mod_page(sprintf(_('Rebuilt theme: %s'), $theme_name), $config['file_mod_theme_rebuilt'], array( - 'theme_name' => $theme_name, - )); + mod_page( + sprintf(_('Rebuilt theme: %s'), $theme_name), + $config['file_mod_theme_rebuilt'], + [ + 'theme_name' => $theme_name, + ], + $mod + ); } // This needs to be done for `secure` CSRF prevention compatibility, otherwise the $board will be read in as the token if editing global pages. -function delete_page_base($page = '', $board = false) { +function delete_page_base(Context $ctx, $page = '', $board = false) { global $config, $mod; if (empty($board)) @@ -2775,16 +2988,17 @@ function delete_page_base($page = '', $board = false) { header('Location: ?/edit_pages' . ($board ? ('/' . $board) : ''), true, $config['redirect_http']); } -function mod_delete_page($page = '') { - delete_page_base($page); +function mod_delete_page(Context $ctx, $page = '') { + delete_page_base($ctx, $page); } -function mod_delete_page_board($page = '', $board = false) { - delete_page_base($page, $board); +function mod_delete_page_board(Context $ctx, $page = '', $board = false) { + delete_page_base($ctx, $page, $board); } -function mod_edit_page($id) { - global $config, $mod, $board; +function mod_edit_page(Context $ctx, $id) { + global $mod, $board; + $config = $ctx->get('config'); $query = prepare('SELECT * FROM ``pages`` WHERE `id` = :id'); $query->bindValue(':id', $id); @@ -2840,7 +3054,13 @@ function mod_edit_page($id) { $fn = (isset($board['uri']) ? ($board['uri'] . '/') : '') . $page['name'] . '.html'; $body = "
$write
"; - $html = Element($config['file_page_template'], array('config' => $config, 'boardlist' => createBoardlist(), 'body' => $body, 'title' => utf8tohtml($page['title']))); + $html = Element($config['file_page_template'], [ + 'config' => $config, + 'boardlist' => createBoardlist(), + 'body' => $body, + 'title' => utf8tohtml($page['title']), + 'pm' => create_pm_header() + ]); file_write($fn, $html); } @@ -2851,11 +3071,22 @@ function mod_edit_page($id) { $content = $query->fetchColumn(); } - mod_page(sprintf(_('Editing static page: %s'), $page['name']), $config['file_mod_edit_page'], array('page' => $page, 'token' => make_secure_link_token("edit_page/$id"), 'content' => prettify_textarea($content), 'board' => $board)); + mod_page( + sprintf(_('Editing static page: %s'), $page['name']), + $config['file_mod_edit_page'], + [ + 'page' => $page, + 'token' => make_secure_link_token("edit_page/$id"), + 'content' => prettify_textarea($content), + 'board' => $board + ], + $mod + ); } -function mod_pages($board = false) { - global $config, $mod, $pdo; +function mod_pages(Context $ctx, $board = false) { + global $mod, $pdo; + $config = $ctx->get('config'); if (empty($board)) $board = false; @@ -2905,13 +3136,22 @@ function mod_pages($board = false) { $p['delete_token'] = make_secure_link_token('edit_pages/delete/' . $p['name'] . ($board ? ('/' . $board) : '')); } - mod_page(_('Pages'), $config['file_mod_pages'], array('pages' => $pages, 'token' => make_secure_link_token('edit_pages' . ($board ? ('/' . $board) : '')), 'board' => $board)); + mod_page( + _('Pages'), + $config['file_mod_pages'], + [ + 'pages' => $pages, + 'token' => make_secure_link_token('edit_pages' . ($board ? ('/' . $board) : '')), + 'board' => $board + ], + $mod + ); } -function mod_debug_antispam() { - global $pdo, $config; +function mod_debug_antispam(Context $ctx) { + global $pdo, $config, $mod; - $args = array(); + $args = []; if (isset($_POST['board'], $_POST['thread'])) { $where = '`board` = ' . $pdo->quote($_POST['board']); @@ -2942,11 +3182,11 @@ function mod_debug_antispam() { $query = query('SELECT * FROM ``antispam`` ' . ($where ? "WHERE $where" : '') . ' ORDER BY `created` DESC LIMIT 20') or error(db_error()); $args['recent'] = $query->fetchAll(PDO::FETCH_ASSOC); - mod_page(_('Debug: Anti-spam'), $config['file_mod_debug_antispam'], $args); + mod_page(_('Debug: Anti-spam'), $config['file_mod_debug_antispam'], $args, $mod); } -function mod_debug_recent_posts() { - global $pdo, $config; +function mod_debug_recent_posts(Context $ctx) { + global $pdo, $config, $mod; $limit = 500; @@ -2976,11 +3216,12 @@ function mod_debug_recent_posts() { } } - mod_page(_('Debug: Recent posts'), $config['file_mod_debug_recent_posts'], array('posts' => $posts, 'flood_posts' => $flood_posts)); + mod_page(_('Debug: Recent posts'), $config['file_mod_debug_recent_posts'], [ 'posts' => $posts, 'flood_posts' => $flood_posts ], $mod); } -function mod_debug_sql() { - global $config; +function mod_debug_sql(Context $ctx) { + global $mod; + $config = $ctx->get('config'); if (!hasPermission($config['mod']['debug_sql'])) error($config['error']['noaccess']); @@ -3000,5 +3241,5 @@ function mod_debug_sql() { } } - mod_page(_('Debug: SQL'), $config['file_mod_debug_sql'], $args); + mod_page(_('Debug: SQL'), $config['file_mod_debug_sql'], $args, $mod); } diff --git a/inc/queue.php b/inc/queue.php index 66305b3b..a5905c84 100644 --- a/inc/queue.php +++ b/inc/queue.php @@ -1,49 +1,98 @@ lock = new Lock($key); - $key = str_replace('/', '::', $key); - $key = str_replace("\0", '', $key); - $this->key = "tmp/queue/$key/"; - } - } +class Queues { + private static $queues = array(); - function push($str) { global $config; - if ($config['queue']['enabled'] == 'fs') { - $this->lock->get_ex(); - file_put_contents($this->key.microtime(true), $str); - $this->lock->free(); - } - return $this; - } - function pop($n = 1) { global $config; - if ($config['queue']['enabled'] == 'fs') { - $this->lock->get_ex(); - $dir = opendir($this->key); - $paths = array(); - while ($n > 0) { - $path = readdir($dir); - if ($path === FALSE) break; - elseif ($path == '.' || $path == '..') continue; - else { $paths[] = $path; $n--; } - } - $out = array(); - foreach ($paths as $v) { - $out []= file_get_contents($this->key.$v); - unlink($this->key.$v); - } - $this->lock->free(); - return $out; - } - } + /** + * This queue implementation isn't actually ordered, so it works more as a "bag". + */ + private static function filesystem(string $key, Lock $lock): Queue { + $key = str_replace('/', '::', $key); + $key = str_replace("\0", '', $key); + $key = "tmp/queue/$key/"; + + return new class($key, $lock) implements Queue { + private Lock $lock; + private string $key; + + + function __construct(string $key, Lock $lock) { + $this->lock = $lock; + $this->key = $key; + } + + public function push(string $str): bool { + $this->lock->get_ex(); + $ret = file_put_contents($this->key . microtime(true), $str); + $this->lock->free(); + return $ret !== false; + } + + public function pop(int $n = 1): array { + $this->lock->get_ex(); + $dir = opendir($this->key); + $paths = array(); + + while ($n > 0) { + $path = readdir($dir); + if ($path === false) { + break; + } elseif ($path == '.' || $path == '..') { + continue; + } else { + $paths[] = $path; + $n--; + } + } + + $out = array(); + foreach ($paths as $v) { + $out[] = file_get_contents($this->key . $v); + unlink($this->key . $v); + } + + $this->lock->free(); + return $out; + } + }; + } + + /** + * No-op. Can be used for mocking. + */ + public static function none(): Queue { + return new class() implements Queue { + public function push(string $str): bool { + return true; + } + + public function pop(int $n = 1): array { + return array(); + } + }; + } + + public static function get_queue(array $config, string $name): Queue|false { + if (!isset(self::$queues[$name])) { + if ($config['queue']['enabled'] == 'fs') { + $lock = Locks::get_lock($config, $name); + if ($lock === false) { + return false; + } + self::$queues[$name] = self::filesystem($name, $lock); + } else { + self::$queues[$name] = self::none(); + } + } + return self::$queues[$name]; + } } -// Don't use the constructor. Use the get_queue function. -$queues = array(); +interface Queue { + // Push a string in the queue. + public function push(string $str): bool; -function get_queue($name) { global $queues; - return $queues[$name] = isset ($queues[$name]) ? $queues[$name] : new Queue($name); + // Get a string from the queue. + public function pop(int $n = 1): array; } diff --git a/inc/service/captcha-queries.php b/inc/service/captcha-queries.php new file mode 100644 index 00000000..76d7acd8 --- /dev/null +++ b/inc/service/captcha-queries.php @@ -0,0 +1,143 @@ +http = $http; + $this->secret = $secret; + } + + public function responseField(): string { + return 'g-recaptcha-response'; + } + + public function verify(string $response, ?string $remote_ip): bool { + $data = [ + 'secret' => $this->secret, + 'response' => $response + ]; + + if ($remote_ip !== null) { + $data['remoteip'] = $remote_ip; + } + + $ret = $this->http->requestGet('https://www.google.com/recaptcha/api/siteverify', $data); + $resp = json_decode($ret, true, 16, JSON_THROW_ON_ERROR); + + return isset($resp['success']) && $resp['success']; + } +} + +class HCaptchaQuery implements RemoteCaptchaQuery { + private HttpDriver $http; + private string $secret; + private string $sitekey; + + /** + * Creates a new HCaptchaQuery using the hCaptcha service. + * + * @param HttpDriver $http The http client. + * @param string $secret Server side secret. + * @return HCaptchaQuery A new hCaptcha query instance. + */ + public function __construct(HttpDriver $http, string $secret, string $sitekey) { + $this->http = $http; + $this->secret = $secret; + $this->sitekey = $sitekey; + } + + public function responseField(): string { + return 'h-captcha-response'; + } + + public function verify(string $response, ?string $remote_ip): bool { + $data = [ + 'secret' => $this->secret, + 'response' => $response, + 'sitekey' => $this->sitekey + ]; + + if ($remote_ip !== null) { + $data['remoteip'] = $remote_ip; + } + + $ret = $this->http->requestGet('https://hcaptcha.com/siteverify', $data); + $resp = json_decode($ret, true, 16, JSON_THROW_ON_ERROR); + + return isset($resp['success']) && $resp['success']; + } +} + +interface RemoteCaptchaQuery { + /** + * Name of the response field in the form data expected by the implementation. + * + * @return string The name of the field. + */ + public function responseField(): string; + + /** + * Checks if the user at the remote ip passed the captcha. + * + * @param string $response User provided response. + * @param ?string $remote_ip User ip. Leave to null to only check the response value. + * @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; +} + +class NativeCaptchaQuery { + private HttpDriver $http; + private string $domain; + private string $provider_check; + private string $extra; + + /** + * @param HttpDriver $http The http client. + * @param string $domain The server's domain. + * @param string $provider_check Path to the endpoint. + * @param string $extra Extra http parameters. + */ + function __construct(HttpDriver $http, string $domain, string $provider_check, string $extra) { + $this->http = $http; + $this->domain = $domain; + $this->provider_check = $provider_check; + $this->extra = $extra; + } + + /** + * Checks if the user at the remote ip passed the native vichan captcha. + * + * @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 $user_text, string $user_cookie): bool { + $data = [ + 'mode' => 'check', + 'text' => $user_text, + 'extra' => $this->extra, + 'cookie' => $user_cookie + ]; + + $ret = $this->http->requestGet($this->domain . '/' . $this->provider_check, $data); + return $ret === '1'; + } +} diff --git a/inc/template.php b/inc/template.php index 0362111c..17df316b 100644 --- a/inc/template.php +++ b/inc/template.php @@ -11,12 +11,14 @@ $twig = false; function load_twig() { global $twig, $config; + $cache_dir = "{$config['dir']['template']}/cache/"; + $loader = new Twig\Loader\FilesystemLoader($config['dir']['template']); $loader->setPaths($config['dir']['template']); $twig = new Twig\Environment($loader, array( 'autoescape' => false, - 'cache' => is_writable('templates') || (is_dir('templates/cache') && is_writable('templates/cache')) ? - new Twig_Cache_TinyboardFilesystem("{$config['dir']['template']}/cache") : false, + 'cache' => is_writable('templates/') || (is_dir($cache_dir) && is_writable($cache_dir)) ? + new TinyboardTwigCache($cache_dir) : false, 'debug' => $config['debug'], 'auto_reload' => $config['twig_auto_reload'] )); @@ -28,17 +30,13 @@ 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 +54,44 @@ function Element($templateFile, array $options) { str_replace("\n", '
', utf8tohtml(print_r($_debug, true))) . ''; } - + // Read the template file - if (@file_get_contents("{$config['dir']['template']}/${templateFile}")) { + if (@file_get_contents("{$config['dir']['template']}/{$templateFile}")) { $body = $twig->render($templateFile, $options); - + if ($config['minify_html'] && preg_match('/\.html$/', $templateFile)) { $body = trim(preg_replace("/[\t\r\n]/", '', $body)); } - + return $body; } else { - throw new Exception("Template file '${templateFile}' does not exist or is empty in '{$config['dir']['template']}'!"); + throw new Exception("Template file '{$templateFile}' does not exist or is empty in '{$config['dir']['template']}'!"); + } +} + +class TinyboardTwigCache extends Twig\Cache\FilesystemCache { + private string $directory; + + public function __construct(string $directory) { + parent::__construct($directory); + $this->directory = $directory; + } + + /** + * This function was removed in Twig 2.x due to developer views on the Twig library. + * Who says we can't keep it for ourselves though? + */ + public function clear() { + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->directory), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iter as $file) { + if ($file->isFile()) { + @unlink($file->getPathname()); + } + } } } @@ -93,8 +117,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 +126,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 +137,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 +145,7 @@ class Tinyboard extends Twig\Extension\AbstractExtension new Twig\TwigFunction('link_for', 'link_for') ); } - + /** * Returns the name of the extension. * @@ -134,17 +157,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 +178,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 +203,7 @@ function twig_filename_truncate_filter($value, $length = 30, $separator = '…') $value = strrev($value); $array = array_reverse(explode(".", $value, 2)); $array = array_map("strrev", $array); - + $filename = &$array[0]; $extension = isset($array[1]) ? $array[1] : false; diff --git a/install.php b/install.php index c174771b..36151c87 100644 --- a/install.php +++ b/install.php @@ -1,7 +1,7 @@ true, 'message' => 'vichan requires PHP 7.4 or better.', ), - array( - 'category' => 'PHP', - 'name' => 'PHP ≥ 5.6', - 'result' => PHP_VERSION_ID >= 50600, - 'required' => false, - 'message' => 'vichan works best on PHP 5.6 or better.', - ), array( 'category' => 'PHP', 'name' => 'mbstring extension installed', @@ -856,14 +851,14 @@ if ($step == 0) { array( 'category' => 'File permissions', 'name' => getcwd() . '/templates/cache', - 'result' => is_writable('templates') || (is_dir('templates/cache') && is_writable('templates/cache')), + 'result' => is_dir('templates/cache/') && is_writable('templates/cache/'), 'required' => true, 'message' => 'You must give vichan permission to create (and write to) the templates/cache directory or performance will be drastically reduced.' ), array( 'category' => 'File permissions', 'name' => getcwd() . '/tmp/cache', - 'result' => is_dir('tmp/cache') && is_writable('tmp/cache'), + 'result' => is_dir('tmp/cache/') && is_writable('tmp/cache/'), 'required' => true, 'message' => 'You must give vichan permission to write to the tmp/cache directory.' ), @@ -874,6 +869,13 @@ if ($step == 0) { 'required' => false, 'message' => 'vichan does not have permission to make changes to inc/secrets.php. To complete the installation, you will be asked to manually copy and paste code into the file instead.' ), + array( + 'category' => 'Misc', + 'name' => 'HTTPS 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)', @@ -919,6 +921,7 @@ if ($step == 0) { $sg = new SaltGen(); $config['cookies']['salt'] = $sg->generate(); $config['secure_trip_salt'] = $sg->generate(); + $config['secure_password_salt'] = $sg->generate(); echo Element('page.html', array( 'body' => Element('installer/config.html', array( @@ -988,12 +991,16 @@ if ($step == 0) { $queries[] = Element('posts.sql', array('board' => 'b')); $sql_errors = ''; + $sql_err_count = 0; foreach ($queries as $query) { if ($mysql_version < 50503) $query = preg_replace('/(CHARSET=|CHARACTER SET )utf8mb4/', '$1utf8', $query); $query = preg_replace('/^([\w\s]*)`([0-9a-zA-Z$_\x{0080}-\x{FFFF}]+)`/u', '$1``$2``', $query); - if (!query($query)) - $sql_errors .= '
  • ' . db_error() . '
  • '; + if (!query($query)) { + $sql_err_count++; + $error = db_error(); + $sql_errors .= "
  • $sql_err_count
  • "; + } } $page['title'] = 'Installation complete'; @@ -1032,4 +1039,3 @@ if ($step == 0) { echo Element('page.html', $page); } - diff --git a/install.sql b/install.sql index ee005a99..fa6d223a 100644 --- a/install.sql +++ b/install.sql @@ -294,7 +294,8 @@ CREATE TABLE IF NOT EXISTS `ban_appeals` ( `message` text NOT NULL, `denied` tinyint(1) NOT NULL, PRIMARY KEY (`id`), - KEY `ban_id` (`ban_id`) + KEY `ban_id` (`ban_id`), + CONSTRAINT `fk_ban_id` FOREIGN KEY (`ban_id`) REFERENCES `bans`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 AUTO_INCREMENT=1 ; -- -------------------------------------------------------- diff --git a/js/catalog-link.js b/js/catalog-link.js index 2a3a8853..2811f025 100644 --- a/js/catalog-link.js +++ b/js/catalog-link.js @@ -30,17 +30,7 @@ function catalog() { var link = document.createElement('a'); link.href = catalog_url; - if (pages) { - link.textContent = _('Catalog'); - link.style.color = '#F10000'; - link.style.padding = '4px'; - link.style.paddingLeft = '9px'; - link.style.borderLeft = '1px solid'; - link.style.borderLeftColor = '#A8A8A8'; - link.style.textDecoration = "underline"; - - pages.appendChild(link); - } else { + if (!pages) { link.textContent = '['+_('Catalog')+']'; link.style.paddingLeft = '10px'; link.style.textDecoration = "underline"; diff --git a/js/catalog-search.js b/js/catalog-search.js index ff9af785..a5405bc3 100644 --- a/js/catalog-search.js +++ b/js/catalog-search.js @@ -2,27 +2,27 @@ * catalog-search.js * - Search and filters threads when on catalog view * - Optional shortcuts 's' and 'esc' to open and close the search. - * + * * Usage: * $config['additional_javascript'][] = 'js/jquery.min.js'; * $config['additional_javascript'][] = 'js/comment-toolbar.js'; */ if (active_page == 'catalog') { - onready(function () { + onReady(function() { 'use strict'; - // 'true' = enable shortcuts - var useKeybinds = true; + // 'true' = enable shortcuts + let useKeybinds = true; - // trigger the search 400ms after last keystroke - var delay = 400; - var timeoutHandle; + // trigger the search 400ms after last keystroke + let delay = 400; + let timeoutHandle; - //search and hide none matching threads + // search and hide none matching threads function filter(search_term) { $('.replies').each(function () { - var subject = $(this).children('.intro').text().toLowerCase(); - var comment = $(this).clone().children().remove(':lt(2)').end().text().trim().toLowerCase(); + let subject = $(this).children('.intro').text().toLowerCase(); + let comment = $(this).clone().children().remove(':lt(2)').end().text().trim().toLowerCase(); search_term = search_term.toLowerCase(); if (subject.indexOf(search_term) == -1 && comment.indexOf(search_term) == -1) { @@ -34,7 +34,7 @@ if (active_page == 'catalog') { } function searchToggle() { - var button = $('#catalog_search_button'); + let button = $('#catalog_search_button'); if (!button.data('expanded')) { button.data('expanded', '1'); @@ -59,18 +59,18 @@ if (active_page == 'catalog') { }); if (useKeybinds) { - // 's' + // 's' $('body').on('keydown', function (e) { if (e.which === 83 && e.target.tagName === 'BODY' && !(e.ctrlKey || e.altKey || e.shiftKey)) { e.preventDefault(); - if ($('#search_field').length !== 0) { + if ($('#search_field').length !== 0) { $('#search_field').focus(); } else { searchToggle(); } } }); - // 'esc' + // 'esc' $('.catalog_search').on('keydown', 'input#search_field', function (e) { if (e.which === 27 && !(e.ctrlKey || e.altKey || e.shiftKey)) { window.clearTimeout(timeoutHandle); diff --git a/js/download-original.js b/js/download-original.js index cf9635ac..7c0050a0 100644 --- a/js/download-original.js +++ b/js/download-original.js @@ -15,16 +15,16 @@ * */ -onready(function(){ - var do_original_filename = function() { - var filename, truncated; +onReady(function() { + let doOriginalFilename = function() { + let filename, truncated; if ($(this).attr('title')) { filename = $(this).attr('title'); truncated = true; } else { filename = $(this).text(); } - + $(this).replaceWith( $('') .attr('download', filename) @@ -34,9 +34,9 @@ onready(function(){ ); }; - $('.postfilename').each(do_original_filename); + $('.postfilename').each(doOriginalFilename); - $(document).on('new_post', function(e, post) { - $(post).find('.postfilename').each(do_original_filename); + $(document).on('new_post', function(e, post) { + $(post).find('.postfilename').each(doOriginalFilename); }); }); diff --git a/js/expand-all-images.js b/js/expand-all-images.js index c110f51c..e313f9bd 100644 --- a/js/expand-all-images.js +++ b/js/expand-all-images.js @@ -16,37 +16,42 @@ * */ -if (active_page == 'ukko' || active_page == 'thread' || active_page == 'index') -onready(function(){ - $('hr:first').before('
    '); - $('div#expand-all-images a') - .text(_('Expand all images')) - .click(function() { - $('a img.post-image').each(function() { - // Don't expand YouTube embeds - if ($(this).parent().parent().hasClass('video-container')) - return; +if (active_page == 'ukko' || active_page == 'thread' || active_page == 'index') { + onReady(function() { + $('hr:first').before('
    '); + $('div#expand-all-images a') + .text(_('Expand all images')) + .click(function() { + $('a img.post-image').each(function() { + // Don't expand YouTube embeds + if ($(this).parent().parent().hasClass('video-container')) { + return; + } - // or WEBM - if (/^\/player\.php\?/.test($(this).parent().attr('href'))) - return; + // or WEBM + if (/^\/player\.php\?/.test($(this).parent().attr('href'))) { + return; + } - if (!$(this).parent().data('expanded')) - $(this).parent().click(); - }); - - if (!$('#shrink-all-images').length) { - $('hr:first').before('
    '); - } - - $('div#shrink-all-images a') - .text(_('Shrink all images')) - .click(function(){ - $('a img.full-image').each(function() { - if ($(this).parent().data('expanded')) - $(this).parent().click(); - }); - $(this).parent().remove(); + if (!$(this).parent().data('expanded')) { + $(this).parent().click(); + } }); - }); -}); + + if (!$('#shrink-all-images').length) { + $('hr:first').before('
    '); + } + + $('div#shrink-all-images a') + .text(_('Shrink all images')) + .click(function() { + $('a img.full-image').each(function() { + if ($(this).parent().data('expanded')) { + $(this).parent().click(); + } + }); + $(this).parent().remove(); + }); + }); + }); +} \ No newline at end of file diff --git a/js/expand-video.js b/js/expand-video.js index 08b474c1..3aa88a2c 100644 --- a/js/expand-video.js +++ b/js/expand-video.js @@ -2,243 +2,265 @@ /* Note: This code expects the global variable configRoot to be set. */ if (typeof _ == 'undefined') { - var _ = function(a) { return a; }; + var _ = function(a) { + return a; + }; } function setupVideo(thumb, url) { - if (thumb.videoAlreadySetUp) return; - thumb.videoAlreadySetUp = true; + if (thumb.videoAlreadySetUp) { + return; + } + thumb.videoAlreadySetUp = true; - var video = null; - var videoContainer, videoHide; - var expanded = false; - var hovering = false; - var loop = true; - var loopControls = [document.createElement("span"), document.createElement("span")]; - var fileInfo = thumb.parentNode.querySelector(".fileinfo"); - var mouseDown = false; + let video = null; + let videoContainer, videoHide; + let expanded = false; + let hovering = false; + let loop = true; + let loopControls = [document.createElement("span"), document.createElement("span")]; + let fileInfo = thumb.parentNode.querySelector(".fileinfo"); + let mouseDown = false; - function unexpand() { - if (expanded) { - expanded = false; - if (video.pause) video.pause(); - videoContainer.style.display = "none"; - thumb.style.display = "inline"; - video.style.maxWidth = "inherit"; - video.style.maxHeight = "inherit"; - } - } + function unexpand() { + if (expanded) { + expanded = false; + if (video.pause) { + video.pause(); + } + videoContainer.style.display = "none"; + thumb.style.display = "inline"; + video.style.maxWidth = "inherit"; + video.style.maxHeight = "inherit"; + } + } - function unhover() { - if (hovering) { - hovering = false; - if (video.pause) video.pause(); - videoContainer.style.display = "none"; - video.style.maxWidth = "inherit"; - video.style.maxHeight = "inherit"; - } - } + function unhover() { + if (hovering) { + hovering = false; + if (video.pause) { + video.pause(); + } + videoContainer.style.display = "none"; + video.style.maxWidth = "inherit"; + video.style.maxHeight = "inherit"; + } + } - // Create video element if does not exist yet - function getVideo() { - if (video == null) { - video = document.createElement("video"); - video.src = url; - video.loop = loop; - video.innerText = _("Your browser does not support HTML5 video."); + // Create video element if does not exist yet + function getVideo() { + if (video == null) { + video = document.createElement("video"); + video.src = url; + video.loop = loop; + video.innerText = _("Your browser does not support HTML5 video."); - videoHide = document.createElement("img"); - videoHide.src = configRoot + "static/collapse.gif"; - videoHide.alt = "[ - ]"; - videoHide.title = "Collapse video"; - videoHide.style.marginLeft = "-15px"; - videoHide.style.cssFloat = "left"; - videoHide.addEventListener("click", unexpand, false); + videoHide = document.createElement("img"); + videoHide.src = configRoot + "static/collapse.gif"; + videoHide.alt = "[ - ]"; + videoHide.title = "Collapse video"; + videoHide.style.marginLeft = "-15px"; + videoHide.style.cssFloat = "left"; + videoHide.addEventListener("click", unexpand, false); - videoContainer = document.createElement("div"); - videoContainer.style.paddingLeft = "15px"; - videoContainer.style.display = "none"; - videoContainer.appendChild(videoHide); - videoContainer.appendChild(video); - thumb.parentNode.insertBefore(videoContainer, thumb.nextSibling); + videoContainer = document.createElement("div"); + videoContainer.style.paddingLeft = "15px"; + videoContainer.style.display = "none"; + videoContainer.appendChild(videoHide); + videoContainer.appendChild(video); + thumb.parentNode.insertBefore(videoContainer, thumb.nextSibling); - // Dragging to the left collapses the video - video.addEventListener("mousedown", function(e) { - if (e.button == 0) mouseDown = true; - }, false); - video.addEventListener("mouseup", function(e) { - if (e.button == 0) mouseDown = false; - }, false); - video.addEventListener("mouseenter", function(e) { - mouseDown = false; - }, false); - video.addEventListener("mouseout", function(e) { - if (mouseDown && e.clientX - video.getBoundingClientRect().left <= 0) { - unexpand(); - } - mouseDown = false; - }, false); - } - } + // Dragging to the left collapses the video + video.addEventListener("mousedown", function(e) { + if (e.button == 0) mouseDown = true; + }, false); + video.addEventListener("mouseup", function(e) { + if (e.button == 0) mouseDown = false; + }, false); + video.addEventListener("mouseenter", function(e) { + mouseDown = false; + }, false); + video.addEventListener("mouseout", function(e) { + if (mouseDown && e.clientX - video.getBoundingClientRect().left <= 0) { + unexpand(); + } + mouseDown = false; + }, false); + } + } - // Clicking on thumbnail expands video - thumb.addEventListener("click", function(e) { - if (setting("videoexpand") && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) { - getVideo(); - expanded = true; - hovering = false; + // Clicking on thumbnail expands video + thumb.addEventListener("click", function(e) { + if (setting("videoexpand") && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) { + getVideo(); + expanded = true; + hovering = false; - video.style.position = "static"; - video.style.pointerEvents = "inherit"; - video.style.display = "inline"; - videoHide.style.display = "inline"; - videoContainer.style.display = "block"; - videoContainer.style.position = "static"; - video.parentNode.parentNode.removeAttribute('style'); - thumb.style.display = "none"; + video.style.position = "static"; + video.style.pointerEvents = "inherit"; + video.style.display = "inline"; + videoHide.style.display = "inline"; + videoContainer.style.display = "block"; + videoContainer.style.position = "static"; + video.parentNode.parentNode.removeAttribute('style'); + thumb.style.display = "none"; - video.muted = (setting("videovolume") == 0); - video.volume = setting("videovolume"); - video.controls = true; - if (video.readyState == 0) { - video.addEventListener("loadedmetadata", expand2, false); - } else { - setTimeout(expand2, 0); - } - video.play(); - e.preventDefault(); - } - }, false); + video.muted = (setting("videovolume") == 0); + video.volume = setting("videovolume"); + video.controls = true; + if (video.readyState == 0) { + video.addEventListener("loadedmetadata", expand2, false); + } else { + setTimeout(expand2, 0); + } + video.play(); + e.preventDefault(); + } + }, false); - function expand2() { - video.style.maxWidth = "100%"; - video.style.maxHeight = window.innerHeight + "px"; - var bottom = video.getBoundingClientRect().bottom; - if (bottom > window.innerHeight) { - window.scrollBy(0, bottom - window.innerHeight); - } - // work around Firefox volume control bug - video.volume = Math.max(setting("videovolume") - 0.001, 0); - video.volume = setting("videovolume"); - } + function expand2() { + video.style.maxWidth = "100%"; + video.style.maxHeight = window.innerHeight + "px"; + var bottom = video.getBoundingClientRect().bottom; + if (bottom > window.innerHeight) { + window.scrollBy(0, bottom - window.innerHeight); + } + // work around Firefox volume control bug + video.volume = Math.max(setting("videovolume") - 0.001, 0); + video.volume = setting("videovolume"); + } - // Hovering over thumbnail displays video - thumb.addEventListener("mouseover", function(e) { - if (setting("videohover")) { - getVideo(); - expanded = false; - hovering = true; + // Hovering over thumbnail displays video + thumb.addEventListener("mouseover", function(e) { + if (setting("videohover")) { + getVideo(); + expanded = false; + hovering = true; - var docRight = document.documentElement.getBoundingClientRect().right; - var thumbRight = thumb.querySelector("img, video").getBoundingClientRect().right; - var maxWidth = docRight - thumbRight - 20; - if (maxWidth < 250) maxWidth = 250; + let docRight = document.documentElement.getBoundingClientRect().right; + let thumbRight = thumb.querySelector("img, video").getBoundingClientRect().right; + let maxWidth = docRight - thumbRight - 20; + if (maxWidth < 250) { + maxWidth = 250; + } - video.style.position = "fixed"; - video.style.right = "0px"; - video.style.top = "0px"; - var docRight = document.documentElement.getBoundingClientRect().right; - var thumbRight = thumb.querySelector("img, video").getBoundingClientRect().right; - video.style.maxWidth = maxWidth + "px"; - video.style.maxHeight = "100%"; - video.style.pointerEvents = "none"; + video.style.position = "fixed"; + video.style.right = "0px"; + video.style.top = "0px"; + docRight = document.documentElement.getBoundingClientRect().right; + thumbRight = thumb.querySelector("img, video").getBoundingClientRect().right; + video.style.maxWidth = maxWidth + "px"; + video.style.maxHeight = "100%"; + video.style.pointerEvents = "none"; - video.style.display = "inline"; - videoHide.style.display = "none"; - videoContainer.style.display = "inline"; - videoContainer.style.position = "fixed"; + video.style.display = "inline"; + videoHide.style.display = "none"; + videoContainer.style.display = "inline"; + videoContainer.style.position = "fixed"; - video.muted = (setting("videovolume") == 0); - video.volume = setting("videovolume"); - video.controls = false; - video.play(); - } - }, false); + video.muted = (setting("videovolume") == 0); + video.volume = setting("videovolume"); + video.controls = false; + video.play(); + } + }, false); - thumb.addEventListener("mouseout", unhover, false); + thumb.addEventListener("mouseout", unhover, false); - // Scroll wheel on thumbnail adjusts default volume - thumb.addEventListener("wheel", function(e) { - if (setting("videohover")) { - var volume = setting("videovolume"); - if (e.deltaY > 0) volume -= 0.1; - if (e.deltaY < 0) volume += 0.1; - if (volume < 0) volume = 0; - if (volume > 1) volume = 1; - if (video != null) { - video.muted = (volume == 0); - video.volume = volume; - } - changeSetting("videovolume", volume); - e.preventDefault(); - } - }, false); + // Scroll wheel on thumbnail adjusts default volume + thumb.addEventListener("wheel", function(e) { + if (setting("videohover")) { + var volume = setting("videovolume"); + if (e.deltaY > 0) { + volume -= 0.1; + } + if (e.deltaY < 0) { + volume += 0.1; + } + if (volume < 0) { + volume = 0; + } + if (volume > 1) { + volume = 1; + } + if (video != null) { + video.muted = (volume == 0); + video.volume = volume; + } + changeSetting("videovolume", volume); + e.preventDefault(); + } + }, false); - // [play once] vs [loop] controls - function setupLoopControl(i) { - loopControls[i].addEventListener("click", function(e) { - loop = (i != 0); - thumb.href = thumb.href.replace(/([\?&])loop=\d+/, "$1loop=" + i); - if (video != null) { - video.loop = loop; - if (loop && video.currentTime >= video.duration) { - video.currentTime = 0; - } - } - loopControls[i].style.fontWeight = "bold"; - loopControls[1-i].style.fontWeight = "inherit"; - }, false); - } + // [play once] vs [loop] controls + function setupLoopControl(i) { + loopControls[i].addEventListener("click", function(e) { + loop = (i != 0); + thumb.href = thumb.href.replace(/([\?&])loop=\d+/, "$1loop=" + i); + if (video != null) { + video.loop = loop; + if (loop && video.currentTime >= video.duration) { + video.currentTime = 0; + } + } + loopControls[i].style.fontWeight = "bold"; + loopControls[1-i].style.fontWeight = "inherit"; + }, false); + } - loopControls[0].textContent = _("[play once]"); - loopControls[1].textContent = _("[loop]"); - loopControls[1].style.fontWeight = "bold"; - for (var i = 0; i < 2; i++) { - setupLoopControl(i); - loopControls[i].style.whiteSpace = "nowrap"; - fileInfo.appendChild(document.createTextNode(" ")); - fileInfo.appendChild(loopControls[i]); - } + loopControls[0].textContent = _("[play once]"); + loopControls[1].textContent = _("[loop]"); + loopControls[1].style.fontWeight = "bold"; + for (var i = 0; i < 2; i++) { + setupLoopControl(i); + loopControls[i].style.whiteSpace = "nowrap"; + fileInfo.appendChild(document.createTextNode(" ")); + fileInfo.appendChild(loopControls[i]); + } } function setupVideosIn(element) { - var thumbs = element.querySelectorAll("a.file"); - for (var i = 0; i < thumbs.length; i++) { - if (/\.webm$|\.mp4$/.test(thumbs[i].pathname)) { - setupVideo(thumbs[i], thumbs[i].href); - } else { - var m = thumbs[i].search.match(/\bv=([^&]*)/); - if (m != null) { - var url = decodeURIComponent(m[1]); - if (/\.webm$|\.mp4$/.test(url)) setupVideo(thumbs[i], url); - } - } - } + let thumbs = element.querySelectorAll("a.file"); + for (let i = 0; i < thumbs.length; i++) { + if (/\.webm$|\.mp4$/.test(thumbs[i].pathname)) { + setupVideo(thumbs[i], thumbs[i].href); + } else { + let m = thumbs[i].search.match(/\bv=([^&]*)/); + if (m != null) { + let url = decodeURIComponent(m[1]); + if (/\.webm$|\.mp4$/.test(url)) { + setupVideo(thumbs[i], url); + } + } + } + } } -onready(function(){ - // Insert menu from settings.js - if (typeof settingsMenu != "undefined" && typeof Options == "undefined") - document.body.insertBefore(settingsMenu, document.getElementsByTagName("hr")[0]); +onReady(function(){ + // Insert menu from settings.js + if (typeof settingsMenu != "undefined" && typeof Options == "undefined") { + document.body.insertBefore(settingsMenu, document.getElementsByTagName("hr")[0]); + } - // Setup Javascript events for videos in document now - setupVideosIn(document); + // Setup Javascript events for videos in document now + setupVideosIn(document); - // Setup Javascript events for videos added by updater - if (window.MutationObserver) { - var observer = new MutationObserver(function(mutations) { - for (var i = 0; i < mutations.length; i++) { - var additions = mutations[i].addedNodes; - if (additions == null) continue; - for (var j = 0; j < additions.length; j++) { - var node = additions[j]; - if (node.nodeType == 1) { - setupVideosIn(node); - } - } - } - }); - observer.observe(document.body, {childList: true, subtree: true}); - } + // Setup Javascript events for videos added by updater + if (window.MutationObserver) { + let observer = new MutationObserver(function(mutations) { + for (let i = 0; i < mutations.length; i++) { + let additions = mutations[i].addedNodes; + if (additions == null) { + continue; + } + for (let j = 0; j < additions.length; j++) { + let node = additions[j]; + if (node.nodeType == 1) { + setupVideosIn(node); + } + } + } + }); + observer.observe(document.body, {childList: true, subtree: true}); + } }); - diff --git a/js/inline-expanding-filename.js b/js/inline-expanding-filename.js index ac79fcf0..c5d325e6 100644 --- a/js/inline-expanding-filename.js +++ b/js/inline-expanding-filename.js @@ -13,21 +13,21 @@ * */ -onready(function(){ - var inline_expanding_filename = function() { - $(this).find(".fileinfo > a").click(function(){ - var imagelink = $(this).parent().parent().find('a[target="_blank"]:first'); - if(imagelink.length > 0) { +onReady(function() { + let inlineExpandingFilename = function() { + $(this).find(".fileinfo > a").click(function() { + let imagelink = $(this).parent().parent().find('a[target="_blank"]:first'); + if (imagelink.length > 0) { imagelink.click(); return false; } }); }; - $('div[id^="thread_"]').each(inline_expanding_filename); - - // allow to work with auto-reload.js, etc. - $(document).on('new_post', function(e, post) { - inline_expanding_filename.call(post); - }); + $('div[id^="thread_"]').each(inlineExpandingFilename); + + // allow to work with auto-reload.js, etc. + $(document).on('new_post', function(e, post) { + inlineExpandingFilename.call(post); + }); }); diff --git a/js/local-time.js b/js/local-time.js index 1a05002b..9c39ed78 100644 --- a/js/local-time.js +++ b/js/local-time.js @@ -17,29 +17,45 @@ $(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 seconds = timeParts[2]; + if (seconds.length > 2) { + seconds = seconds.substr(0, 2) + '.' + seconds.substr(2); + } + // Ensure seconds part is valid + if (parseFloat(seconds) > 59) { + seconds = '59'; + } + timeParts[2] = seconds; + } + 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 +67,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 +116,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); } @@ -113,3 +128,4 @@ $(document).ready(function(){ do_localtime(document); }); + diff --git a/js/mod/ban-list.js b/js/mod/ban-list.js index d50fb5d2..415934b1 100644 --- a/js/mod/ban-list.js +++ b/js/mod/ban-list.js @@ -129,14 +129,16 @@ var banlist_init = function(token, my_boards, inMod) { $(".banform").on("submit", function() { return false; }); $("#unban").on("click", function() { - $(".banform .hiddens").remove(); - $("").appendTo(".banform"); - - $.each(selected, function(e) { - $("").appendTo(".banform"); - }); - - $(".banform").off("submit").submit(); + if (confirm('Are you sure you want to unban the selected IPs?')) { + $(".banform .hiddens").remove(); + $("").appendTo(".banform"); + + $.each(selected, function(e) { + $("").appendTo(".banform"); + }); + + $(".banform").off("submit").submit(); + } }); if (device_type == 'desktop') { diff --git a/js/post-filter.js b/js/post-filter.js index 3bf55a51..c9e903d8 100644 --- a/js/post-filter.js +++ b/js/post-filter.js @@ -375,7 +375,7 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata var list = getList(); var postId = $post.find('.post_no').not('[id]').text(); - var name, trip, uid, subject, comment; + var name, trip, uid, subject, comment, flag; var i, length, array, rule, pattern; // temp variables var boardId = $post.data('board'); @@ -388,6 +388,7 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata var hasTrip = ($post.find('.trip').length > 0); var hasSub = ($post.find('.subject').length > 0); + var hasFlag = ($post.find('.flag').length > 0); $post.data('hidden', false); $post.data('hiddenByUid', false); @@ -396,6 +397,7 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata $post.data('hiddenByTrip', false); $post.data('hiddenBySubject', false); $post.data('hiddenByComment', false); + $post.data('hiddenByFlag', false); // add post with matched UID to localList if (hasUID && @@ -436,6 +438,8 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata }); comment = array.join(' '); + if (hasFlag) + flag = $post.find('.flag').attr('title') for (i = 0, length = list.generalFilter.length; i < length; i++) { rule = list.generalFilter[i]; @@ -467,6 +471,12 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata hide(post); } break; + case 'flag': + if (hasFlag && pattern.test(flag)) { + $post.data('hiddenByFlag', true); + hide(post); + } + break; } } else { switch (rule.type) { @@ -496,6 +506,13 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata hide(post); } break; + case 'flag': + pattern = new RegExp('\\b'+ rule.value+ '\\b'); + if (hasFlag && pattern.test(flag)) { + $post.data('hiddenByFlag', true); + hide(post); + } + break; } } } @@ -621,7 +638,8 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata name: 'name', trip: 'tripcode', sub: 'subject', - com: 'comment' + com: 'comment', + flag: 'flag' }; $ele.empty(); @@ -660,6 +678,7 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata '' + '' + '' + + '' + '' + '' + '' + diff --git a/js/post-hover.js b/js/post-hover.js index 780f8cff..d7970871 100644 --- a/js/post-hover.js +++ b/js/post-hover.js @@ -13,59 +13,62 @@ * */ -onready(function(){ - var dont_fetch_again = []; - init_hover = function() { - var $link = $(this); - - var id; - var matches; +onReady(function() { + let dontFetchAgain = []; + initHover = function() { + let link = $(this); + let id; + let matches; - if ($link.is('[data-thread]')) { - id = $link.attr('data-thread'); - } - else if(matches = $link.text().match(/^>>(?:>\/([^\/]+)\/)?(\d+)$/)) { + if (link.is('[data-thread]')) { + id = link.attr('data-thread'); + } else if (matches = link.text().match(/^>>(?:>\/([^\/]+)\/)?(\d+)$/)) { id = matches[2]; - } - else { + } else { return; } - - var board = $(this); + + let board = $(this); while (board.data('board') === undefined) { board = board.parent(); } - var threadid; - if ($link.is('[data-thread]')) threadid = 0; - else threadid = board.attr('id').replace("thread_", ""); + let threadid; + if (link.is('[data-thread]')) { + threadid = 0; + } else { + threadid = board.attr('id').replace("thread_", ""); + } board = board.data('board'); - var parentboard = board; - - if ($link.is('[data-thread]')) parentboard = $('form[name="post"] input[name="board"]').val(); - else if (matches[1] !== undefined) board = matches[1]; + let parentboard = board; - var $post = false; - var hovering = false; - var hovered_at; - $link.hover(function(e) { + if (link.is('[data-thread]')) { + parentboard = $('form[name="post"] input[name="board"]').val(); + } else if (matches[1] !== undefined) { + board = matches[1]; + } + + let post = false; + let hovering = false; + let hoveredAt; + link.hover(function(e) { hovering = true; - hovered_at = {'x': e.pageX, 'y': e.pageY}; - - var start_hover = function($link) { - if ($post.is(':visible') && - $post.offset().top >= $(window).scrollTop() && - $post.offset().top + $post.height() <= $(window).scrollTop() + $(window).height()) { - // post is in view - $post.addClass('highlighted'); - } else { - var $newPost = $post.clone(); - $newPost.find('>.reply, >br').remove(); - $newPost.find('span.mentioned').remove(); - $newPost.find('a.post_anchor').remove(); + hoveredAt = {'x': e.pageX, 'y': e.pageY}; - $newPost + let startHover = function(link) { + if (post.is(':visible') && + post.offset().top >= $(window).scrollTop() && + post.offset().top + post.height() <= $(window).scrollTop() + $(window).height()) { + // post is in view + post.addClass('highlighted'); + } else { + let newPost = post.clone(); + newPost.find('>.reply, >br').remove(); + newPost.find('span.mentioned').remove(); + newPost.find('a.post_anchor').remove(); + + newPost .attr('id', 'post-hover-' + id) .attr('data-board', board) .addClass('post-hover') @@ -76,95 +79,99 @@ onready(function(){ .css('font-style', 'normal') .css('z-index', '100') .addClass('reply').addClass('post') - .insertAfter($link.parent()) + .insertAfter(link.parent()) - $link.trigger('mousemove'); + link.trigger('mousemove'); } }; - - $post = $('[data-board="' + board + '"] div.post#reply_' + id + ', [data-board="' + board + '"]div#thread_' + id); - if($post.length > 0) { - start_hover($(this)); + + post = $('[data-board="' + board + '"] div.post#reply_' + id + ', [data-board="' + board + '"]div#thread_' + id); + if (post.length > 0) { + startHover($(this)); } else { - var url = $link.attr('href').replace(/#.*$/, ''); - - if($.inArray(url, dont_fetch_again) != -1) { + let url = link.attr('href').replace(/#.*$/, ''); + + if ($.inArray(url, dontFetchAgain) != -1) { return; } - dont_fetch_again.push(url); - + dontFetchAgain.push(url); + $.ajax({ url: url, context: document.body, success: function(data) { - var mythreadid = $(data).find('div[id^="thread_"]').attr('id').replace("thread_", ""); + let mythreadid = $(data).find('div[id^="thread_"]').attr('id').replace("thread_", ""); if (mythreadid == threadid && parentboard == board) { $(data).find('div.post.reply').each(function() { - if($('[data-board="' + board + '"] #' + $(this).attr('id')).length == 0) { + if ($('[data-board="' + board + '"] #' + $(this).attr('id')).length == 0) { $('[data-board="' + board + '"]#thread_' + threadid + " .post.reply:first").before($(this).hide().addClass('hidden')); } }); - } - else if ($('[data-board="' + board + '"]#thread_'+mythreadid).length > 0) { + } else if ($('[data-board="' + board + '"]#thread_' + mythreadid).length > 0) { $(data).find('div.post.reply').each(function() { - if($('[data-board="' + board + '"] #' + $(this).attr('id')).length == 0) { + if ($('[data-board="' + board + '"] #' + $(this).attr('id')).length == 0) { $('[data-board="' + board + '"]#thread_' + mythreadid + " .post.reply:first").before($(this).hide().addClass('hidden')); } }); - } - else { + } else { $(data).find('div[id^="thread_"]').hide().attr('data-cached', 'yes').prependTo('form[name="postcontrols"]'); } - $post = $('[data-board="' + board + '"] div.post#reply_' + id + ', [data-board="' + board + '"]div#thread_' + id); + post = $('[data-board="' + board + '"] div.post#reply_' + id + ', [data-board="' + board + '"]div#thread_' + id); - if(hovering && $post.length > 0) { - start_hover($link); + if (hovering && post.length > 0) { + startHover(link); } } }); } }, function() { hovering = false; - if(!$post) + if (!post) { return; - - $post.removeClass('highlighted'); - if($post.hasClass('hidden') || $post.data('cached') == 'yes') - $post.css('display', 'none'); + } + + post.removeClass('highlighted'); + if (post.hasClass('hidden') || post.data('cached') == 'yes') { + post.css('display', 'none'); + } $('.post-hover').remove(); }).mousemove(function(e) { - if(!$post) + if (!post) { return; - - var $hover = $('#post-hover-' + id + '[data-board="' + board + '"]'); - if($hover.length == 0) - return; - - var scrollTop = $(window).scrollTop(); - if ($link.is("[data-thread]")) scrollTop = 0; - var epy = e.pageY; - if ($link.is("[data-thread]")) epy -= $(window).scrollTop(); - - var top = (epy ? epy : hovered_at['y']) - 10; - - if(epy < scrollTop + 15) { - top = scrollTop; - } else if(epy > scrollTop + $(window).height() - $hover.height() - 15) { - top = scrollTop + $(window).height() - $hover.height() - 15; } - - - $hover.css('left', (e.pageX ? e.pageX : hovered_at['x'])).css('top', top); + + let hover = $('#post-hover-' + id + '[data-board="' + board + '"]'); + if (hover.length == 0) { + return; + } + + let scrollTop = $(window).scrollTop(); + if (link.is("[data-thread]")) { + scrollTop = 0; + } + let epy = e.pageY; + if (link.is("[data-thread]")) { + epy -= $(window).scrollTop(); + } + + let top = (epy ? epy : hoveredAt['y']) - 10; + + if (epy < scrollTop + 15) { + top = scrollTop; + } else if (epy > scrollTop + $(window).height() - hover.height() - 15) { + top = scrollTop + $(window).height() - hover.height() - 15; + } + + hover.css('left', (e.pageX ? e.pageX : hoveredAt['x'])).css('top', top); }); }; - - $('div.body a:not([rel="nofollow"])').each(init_hover); - + + $('div.body a:not([rel="nofollow"])').each(initHover); + // allow to work with auto-reload.js, etc. $(document).on('new_post', function(e, post) { - $(post).find('div.body a:not([rel="nofollow"])').each(init_hover); + $(post).find('div.body a:not([rel="nofollow"])').each(initHover); }); }); - diff --git a/js/quote-selection.js b/js/quote-selection.js index 0722c0b1..ba67fa7c 100644 --- a/js/quote-selection.js +++ b/js/quote-selection.js @@ -10,20 +10,20 @@ * Usage: * $config['additional_javascript'][] = 'js/jquery.min.js'; * $config['additional_javascript'][] = 'js/quote-selection.js'; - * */ -$(document).ready(function(){ - if (!window.getSelection) +$(document).ready(function() { + if (!window.getSelection) { return; - + } + $.fn.selectRange = function(start, end) { return this.each(function() { if (this.setSelectionRange) { this.focus(); this.setSelectionRange(start, end); } else if (this.createTextRange) { - var range = this.createTextRange(); + let range = this.createTextRange(); range.collapse(true); range.moveEnd('character', end); range.moveStart('character', start); @@ -31,88 +31,81 @@ $(document).ready(function(){ } }); }; - - var altKey = false; - var ctrlKey = false; - var metaKey = false; - + + let altKey = false; + let ctrlKey = false; + let metaKey = false; + $(document).keyup(function(e) { - if (e.keyCode == 18) + if (e.keyCode == 18) { altKey = false; - else if (e.keyCode == 17) + } else if (e.keyCode == 17) { ctrlKey = false; - else if (e.keyCode == 91) + } else if (e.keyCode == 91) { metaKey = false; + } }); - + $(document).keydown(function(e) { - if (e.altKey) + if (e.altKey) { altKey = true; - else if (e.ctrlKey) + } else if (e.ctrlKey) { ctrlKey = true; - else if (e.metaKey) + } else if (e.metaKey) { metaKey = true; - + } + if (altKey || ctrlKey || metaKey) { - // console.log('CTRL/ALT/Something used. Ignoring'); return; } - - if (e.keyCode < 48 || e.keyCode > 90) - return; - - var selection = window.getSelection(); - var $post = $(selection.anchorNode).parents('.post'); - if ($post.length == 0) { - // console.log('Start of selection was not post div', $(selection.anchorNode).parent()); + + if (e.keyCode < 48 || e.keyCode > 90) { return; } - - var postID = $post.find('.post_no:eq(1)').text(); - + + let selection = window.getSelection(); + let post = $(selection.anchorNode).parents('.post'); + if (post.length == 0) { + return; + } + + let postID = post.find('.post_no:eq(1)').text(); + if (postID != $(selection.focusNode).parents('.post').find('.post_no:eq(1)').text()) { - // console.log('Selection left post div', $(selection.focusNode).parent()); return; } - - ; - var selectedText = selection.toString(); - // console.log('Selected text: ' + selectedText.replace(/\n/g, '\\n').replace(/\r/g, '\\r')); - - if ($('body').hasClass('debug')) + + let selectedText = selection.toString(); + + if ($('body').hasClass('debug')) { alert(selectedText); - - if (selectedText.length == 0) + } + + if (selectedText.length == 0) { return; - - var body = $('textarea#body')[0]; - - var last_quote = body.value.match(/[\S.]*(^|[\S\s]*)>>(\d+)/); - if (last_quote) + } + + let body = $('textarea#body')[0]; + + let last_quote = body.value.match(/[\S.]*(^|[\S\s]*)>>(\d+)/); + if (last_quote) { last_quote = last_quote[2]; - + } + /* to solve some bugs on weird browsers, we need to replace \r\n with \n and then undo that after */ - var quote = (last_quote != postID ? '>>' + postID + '\r\n' : '') + $.trim(selectedText).replace(/\r\n/g, '\n').replace(/^/mg, '>').replace(/\n/g, '\r\n') + '\r\n'; - - // console.log('Deselecting text'); + let quote = (last_quote != postID ? '>>' + postID + '\r\n' : '') + $.trim(selectedText).replace(/\r\n/g, '\n').replace(/^/mg, '>').replace(/\n/g, '\r\n') + '\r\n'; + selection.removeAllRanges(); - - if (document.selection) { - // IE - body.focus(); - var sel = document.selection.createRange(); - sel.text = quote; - body.focus(); - } else if (body.selectionStart || body.selectionStart == '0') { - // Mozilla - var start = body.selectionStart; - var end = body.selectionEnd; - + + if (body.selectionStart || body.selectionStart == '0') { + let start = body.selectionStart; + let end = body.selectionEnd; + if (!body.value.substring(0, start).match(/(^|\n)$/)) { quote = '\r\n\r\n' + quote; } - - body.value = body.value.substring(0, start) + quote + body.value.substring(end, body.value.length); + + body.value = body.value.substring(0, start) + quote + body.value.substring(end, body.value.length); $(body).selectRange(start + quote.length, start + quote.length); } else { // ??? @@ -121,4 +114,3 @@ $(document).ready(function(){ } }); }); - diff --git a/js/show-backlinks.js b/js/show-backlinks.js index 4c12e572..d80eebaa 100644 --- a/js/show-backlinks.js +++ b/js/show-backlinks.js @@ -4,7 +4,7 @@ * * Released under the MIT license * Copyright (c) 2012 Michael Save - * Copyright (c) 2013-2014 Marcin Łabanowski + * Copyright (c) 2013-2014 Marcin Łabanowski * * Usage: * $config['additional_javascript'][] = 'js/jquery.min.js'; @@ -13,46 +13,50 @@ * */ -onready(function(){ - var showBackLinks = function() { - var reply_id = $(this).attr('id').replace(/(^reply_)|(^op_)/, ''); - +onReady(function() { + let showBackLinks = function() { + let reply_id = $(this).attr('id').replace(/(^reply_)|(^op_)/, ''); + $(this).find('div.body a:not([rel="nofollow"])').each(function() { - var id, post, $mentioned; - - if(id = $(this).text().match(/^>>(\d+)$/)) + let id, post, $mentioned; + + if (id = $(this).text().match(/^>>(\d+)$/)) { id = id[1]; - else + } else { return; - - $post = $('#reply_' + id); - if($post.length == 0){ - $post = $('#op_' + id); - if($post.length == 0) - return; } - + + $post = $('#reply_' + id); + if ($post.length == 0){ + $post = $('#op_' + id); + if ($post.length == 0) { + return; + } + } + $mentioned = $post.find('p.intro span.mentioned'); - if($mentioned.length == 0) + if($mentioned.length == 0) { $mentioned = $('').appendTo($post.find('p.intro')); - - if ($mentioned.find('a.mentioned-' + reply_id).length != 0) + } + + if ($mentioned.find('a.mentioned-' + reply_id).length != 0) { return; - - var $link = $('>>' + + } + + let link = $('>>' + reply_id + ''); - $link.appendTo($mentioned) - + link.appendTo($mentioned) + if (window.init_hover) { - $link.each(init_hover); + link.each(init_hover); } }); }; - + $('div.post.reply').each(showBackLinks); $('div.post.op').each(showBackLinks); - $(document).on('new_post', function(e, post) { + $(document).on('new_post', function(e, post) { if ($(post).hasClass("op")) { $(post).find('div.post.reply').each(showBackLinks); } else { diff --git a/js/smartphone-spoiler.js b/js/smartphone-spoiler.js index 05273c19..21ba2284 100644 --- a/js/smartphone-spoiler.js +++ b/js/smartphone-spoiler.js @@ -4,7 +4,7 @@ * * Released under the MIT license * Copyright (c) 2012 Michael Save - * Copyright (c) 2013-2014 Marcin Łabanowski + * Copyright (c) 2013-2014 Marcin Łabanowski * * Usage: * $config['additional_javascript'][] = 'js/mobile-style.js'; @@ -12,11 +12,11 @@ * */ -onready(function(){ - if(device_type == 'mobile') { - var fix_spoilers = function(where) { - var spoilers = where.getElementsByClassName('spoiler'); - for(var i = 0; i < spoilers.length; i++) { +onReady(function() { + if (device_type == 'mobile') { + let fix_spoilers = function(where) { + let spoilers = where.getElementsByClassName('spoiler'); + for (let i = 0; i < spoilers.length; i++) { spoilers[i].onmousedown = function() { this.style.color = 'white'; }; @@ -24,11 +24,10 @@ onready(function(){ }; fix_spoilers(document); - // allow to work with auto-reload.js, etc. - $(document).on('new_post', function(e, post) { - fix_spoilers(post); - }); - + // allow to work with auto-reload.js, etc. + $(document).on('new_post', function(e, post) { + fix_spoilers(post); + }); + } }); - diff --git a/js/style-select.js b/js/style-select.js index f0fe3c16..0b6821b8 100644 --- a/js/style-select.js +++ b/js/style-select.js @@ -6,7 +6,7 @@ * * Released under the MIT license * Copyright (c) 2013 Michael Save - * Copyright (c) 2013-2014 Marcin Łabanowski + * Copyright (c) 2013-2014 Marcin Łabanowski * * Usage: * $config['additional_javascript'][] = 'js/jquery.min.js'; @@ -14,32 +14,32 @@ * */ -onready(function(){ - var stylesDiv = $('div.styles'); - var stylesSelect = $(''); - - var i = 1; +onReady(function() { + let stylesDiv = $('div.styles'); + let stylesSelect = $(''); + + let i = 1; stylesDiv.children().each(function() { - var opt = $('') + let opt = $('') .html(this.innerHTML.replace(/(^\[|\]$)/g, '')) .val(i); - if ($(this).hasClass('selected')) + if ($(this).hasClass('selected')) { opt.attr('selected', true); + } stylesSelect.append(opt); $(this).attr('id', 'style-select-' + i); i++; }); - + stylesSelect.change(function() { $('#style-select-' + $(this).val()).click(); }); - + stylesDiv.hide(); - + stylesDiv.after( $('
    ') .text(_('Style: ')) .append(stylesSelect) ); }); - diff --git a/js/youtube.js b/js/youtube.js index 9fe81b60..5537b931 100644 --- a/js/youtube.js +++ b/js/youtube.js @@ -10,7 +10,7 @@ * * Released under the MIT license * Copyright (c) 2013 Michael Save -* Copyright (c) 2013-2014 Marcin Łabanowski +* Copyright (c) 2013-2014 Marcin Łabanowski * * Usage: * $config['embedding'] = array(); @@ -22,13 +22,12 @@ * */ - -onready(function(){ - var do_embed_yt = function(tag) { +onReady(function() { + let do_embed_yt = function(tag) { $('div.video-container a', tag).click(function() { - var videoID = $(this.parentNode).data('video'); - - $(this.parentNode).html(' -
    - + {% if settings.description or settings.imageofnow or settings.quoteofnow or settings.videoofnow %} +
    +
    + {% if settings.description %} +
    {{ settings.description }}
    +
    + {% endif %} + {% if settings.imageofnow %} + +
    + {% endif %} + {% if settings.quoteofnow %} +
    {{ settings.quoteofnow }}
    +
    + {% endif %} + {% if settings.videoofnow %} + +
    + {% endif %} +
    + {% endif %} +
    {% if news|length == 0 %}

    (No news to show.)

    diff --git a/templates/themes/index/info.php b/templates/themes/index/info.php index 4b15023f..2d01cba0 100644 --- a/templates/themes/index/info.php +++ b/templates/themes/index/info.php @@ -74,12 +74,19 @@ ); $theme['config'][] = Array( - 'title' => 'Excluded boards', + 'title' => 'Excluded boards (recent posts)', 'name' => 'exclude', 'type' => 'text', 'comment' => '(space seperated)' ); + $theme['config'][] = Array( + 'title' => 'Excluded boards (boardlist)', + 'name' => 'excludeboardlist', + 'type' => 'text', + 'comment' => '(space seperated)' + ); + $theme['config'][] = Array( 'title' => '# of recent images', 'name' => 'limit_images', diff --git a/templates/themes/index/theme.php b/templates/themes/index/theme.php index f9262b82..fc75b77b 100644 --- a/templates/themes/index/theme.php +++ b/templates/themes/index/theme.php @@ -158,6 +158,13 @@ $settings['no_recent'] = (int) $settings['no_recent']; $query = query("SELECT * FROM ``news`` ORDER BY `time` DESC" . ($settings['no_recent'] ? ' LIMIT ' . $settings['no_recent'] : '')) or error(db_error()); $news = $query->fetchAll(PDO::FETCH_ASSOC); + + // Excluded boards for the boardlist + $excluded_boards = isset($settings['excludeboardlist']) ? explode(' ', $settings['excludeboardlist']) : []; + $boardlist = array_filter($boards, function($board) use ($excluded_boards) { + return !in_array($board['uri'], $excluded_boards); + }); + return Element('themes/index/index.html', Array( 'settings' => $settings, @@ -167,7 +174,7 @@ 'recent_posts' => $recent_posts, 'stats' => $stats, 'news' => $news, - 'boards' => listBoards() + 'boards' => $boardlist )); } }; diff --git a/templates/themes/recent/recent.html b/templates/themes/recent/recent.html index 2a4510f8..9fce7ab8 100644 --- a/templates/themes/recent/recent.html +++ b/templates/themes/recent/recent.html @@ -4,11 +4,11 @@ {{ settings.title }} - - + + {% if config.url_favicon %}{% endif %} - {% if config.default_stylesheet.1 != '' %}{% endif %} - {% if config.font_awesome %}{% endif %} + {% if config.default_stylesheet.1 != '' %}{% endif %} + {% if config.font_awesome %}{% endif %} {% include 'header.html' %} @@ -17,7 +17,7 @@

    {{ settings.title }}

    {{ settings.subtitle }}
    - +

    Recent Images

    @@ -36,7 +36,7 @@
      {% for post in recent_posts %}
    • - {{ post.board_name }}: + {{ post.board_name }}: {{ post.snippet }} @@ -53,11 +53,11 @@
    - +
    {% include 'footer.html' %} - + {% endapply %} diff --git a/templates/themes/sitemap/sitemap.xml b/templates/themes/sitemap/sitemap.xml index 30033239..8c4a9fa2 100644 --- a/templates/themes/sitemap/sitemap.xml +++ b/templates/themes/sitemap/sitemap.xml @@ -10,7 +10,7 @@ {% for thread in thread_list %} {{ settings.url ~ (config.board_path | format(board)) ~ config.dir.res ~ link_for(thread) }} - {{ thread.lastmod | date('%Y-%m-%dT%H:%M:%S') }}{{ timezone() }} + {{ thread.lastmod | date('Y-m-d\\TH:i:s\Z') }} {{ settings.changefreq }} {% endfor %} diff --git a/templates/thread.html b/templates/thread.html index 89eda7b0..4e8fbcec 100644 --- a/templates/thread.html +++ b/templates/thread.html @@ -15,6 +15,9 @@ + + + {% if thread.files.0.thumb %}{% endif %} diff --git a/tools/hash-passwords.php b/tools/hash-passwords.php new file mode 100644 index 00000000..3c6463ee --- /dev/null +++ b/tools/hash-passwords.php @@ -0,0 +1,17 @@ +execute() or error(db_error($query)); + + while($entry = $query->fetch(PDO::FETCH_ASSOC)) { + $update_query = prepare(sprintf("UPDATE ``posts_%s`` SET `password` = :password WHERE `password` = :password_org", $_board['uri'])); + $update_query->bindValue(':password', hashPassword($entry['password'])); + $update_query->bindValue(':password_org', $entry['password']); + $update_query->execute() or error(db_error()); + } + } diff --git a/tools/maintenance.php b/tools/maintenance.php new file mode 100644 index 00000000..a869e2fa --- /dev/null +++ b/tools/maintenance.php @@ -0,0 +1,40 @@ +collect(); + $delta = microtime(true) - $start; + echo "Deleted $deleted_count expired filesystem cache items in $delta seconds!\n"; + $time_tot = $delta; + $deleted_tot = $deleted_count; +} + +$time_tot = number_format((float)$time_tot, 4, '.', ''); +modLog("Deleted $deleted_tot expired entries in {$time_tot}s with maintenance tool");