diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..8ae84728 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +**/.git +**/.gitignore +/local-instances +**/.gitkeep diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..8b1717bf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,69 @@ +name: Bug Report +description: File a bug report for Vichan +title: "[BUG] " +labels: ["bug"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + **Thank you for reporting a bug! Please provide as much detail as possible.** + + Before submitting, check the [Vichan Wiki](https://vichan.info) to see if there's already a solution to your problem. + + - type: textarea + id: bug_description + attributes: + label: "Describe the bug" + description: "A clear and concise description of what the bug is." + placeholder: "Posting doesn't go through and displays a collation error. The exact error message given is the text below and I've attached a screenshot..." + validations: + required: true + + - type: textarea + id: steps_to_reproduce + attributes: + label: "Steps to Reproduce" + description: "Provide step-by-step instructions to reproduce the issue. If you're unsure on how, that is alright, just try and explain as well as you can." + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + render: markdown + validations: + required: true + + - type: textarea + id: expected_behavior + attributes: + label: "Expected Behavior" + description: "What did you expect to happen?" + placeholder: "Expected behavior here..." + render: markdown + validations: + required: true + + - type: textarea + id: server_specs + attributes: + label: "Server Specifications" + description: "Provide details about your server environment. If you're unsure about any of this, you might be using shared hosting (Hostinger, HostGator, Serv00, etc). If so, put the name of your hosting provider here." + placeholder: | + - OS: (Ubuntu, CentOS, Windows Server 2025, etc.) + - PHP Version: (e.g., 7.4, 8.0, 8.4) + - Web Server: (Apache, NGINX, etc.) + - Database: (MySQL, MariaDB, etc.) + - Vichan Version: (5.2.0, 5.3.0 (dev branch), etc) + render: markdown + validations: + required: true + + - type: textarea + id: additional_context + attributes: + label: "Additional Context" + description: "Any other details we should know?" + placeholder: "Add any additional context here..." + render: markdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore index 220b0e11..5e0ab052 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,6 @@ Thumbs.db #vichan custom favicon.ico /static/spoiler.png +/local-instances /vendor/ diff --git a/README.md b/README.md index b1794df9..2c7001be 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,11 @@ WebM support ------------ Read `inc/lib/webm/README.md` for information about enabling webm. +Docker +------------ +Vichan comes with a Dockerfile and docker-compose configuration, the latter aimed primarily at development and testing. +See the `docker/doc.md` file for more information. + vichan API ---------- vichan provides by default a 4chan-compatible JSON API. For documentation on this, see: diff --git a/b.php b/b.php index 83285b81..446fb47c 100644 --- a/b.php +++ b/b.php @@ -1,20 +1,8 @@ +$name = $files[array_rand($files)]; +header("Location: /static/banners/$name", true, 307); +header('Cache-Control: no-cache'); diff --git a/compose.yml b/compose.yml new file mode 100644 index 00000000..4b87e0b6 --- /dev/null +++ b/compose.yml @@ -0,0 +1,40 @@ +services: + #nginx webserver + php 8.x + web: + build: + context: . + dockerfile: ./docker/nginx/Dockerfile + ports: + - "9090:80" + depends_on: + - db + volumes: + - ./local-instances/${INSTANCE:-0}/www:/var/www/html + - ./docker/nginx/vichan.conf:/etc/nginx/conf.d/default.conf + - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf + - ./docker/nginx/proxy.conf:/etc/nginx/conf.d/proxy.conf + links: + - php + + php: + build: + context: . + dockerfile: ./docker/php/Dockerfile + volumes: + - ./local-instances/${INSTANCE:-0}/www:/var/www + - ./docker/php/www.conf:/usr/local/etc/php-fpm.d/www.conf + - ./docker/php/jit.ini:/usr/local/etc/php/conf.d/jit.ini + + #MySQL Service + db: + image: mysql:8.0.35 + container_name: db + restart: unless-stopped + tty: true + ports: + - "3306:3306" + environment: + MYSQL_DATABASE: vichan + MYSQL_ROOT_PASSWORD: password + volumes: + - ./local-instances/${INSTANCE:-0}/mysql:/var/lib/mysql diff --git a/composer.json b/composer.json index ec4a090d..a2e906fe 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "gettext/gettext": "^5.5", "mrclay/minify": "^2.1.6", "geoip/geoip": "^1.17", - "dapphp/securimage": "^4.0" + "dapphp/securimage": "^4.0", + "erusev/parsedown": "^1.7.4" }, "autoload": { "classmap": ["inc/"], @@ -32,7 +33,14 @@ "inc/mod/auth.php", "inc/lock.php", "inc/queue.php", - "inc/functions.php" + "inc/functions.php", + "inc/functions/dice.php", + "inc/functions/format.php", + "inc/functions/net.php", + "inc/functions/num.php", + "inc/functions/theme.php", + "inc/service/captcha-queries.php", + "inc/context.php" ] }, "license": "Tinyboard + vichan", diff --git a/docker/doc.md b/docker/doc.md new file mode 100644 index 00000000..051ae56e --- /dev/null +++ b/docker/doc.md @@ -0,0 +1,20 @@ +The `php-fpm` process runs containerized. +The php application always uses `/var/www` as it's work directory and home folder, and if `/var/www` is bind mounted it +is necessary to adjust the path passed via FastCGI to `php-fpm` by changing the root directory to `/var/www`. +This can achieved in nginx by setting the `fastcgi_param SCRIPT_FILENAME` to `/var/www/$fastcgi_script_name;` + +The default docker compose settings are intended for development and testing purposes. +The folder structure expected by compose is as follows + +``` + +└── local-instances + └── 1 + ├── mysql + └── www +``` +The vichan container is by itself much less rigid. + + +Use `docker compose up --build` to start the docker compose. +Use `docker compose up --build -d php` to rebuild just the vichan container while the compose is running. Useful for development. diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile new file mode 100644 index 00000000..d9d4bcc4 --- /dev/null +++ b/docker/nginx/Dockerfile @@ -0,0 +1,8 @@ +FROM nginx:1.25.3-alpine + +COPY . /code +RUN adduser --system www-data \ + && adduser www-data www-data + +CMD [ "nginx", "-g", "daemon off;" ] +EXPOSE 80 diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 00000000..7c6b6587 --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,34 @@ +# This and proxy.conf are based on +# https://github.com/dead-guru/devichan/blob/master/nginx/nginx.conf + +user www-data; +worker_processes auto; + +error_log /dev/stdout warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Switch logging to console out to view via Docker + access_log /dev/stdout; + error_log /dev/stdout warn; + sendfile on; + keepalive_timeout 5; + + gzip on; + gzip_http_version 1.0; + gzip_vary on; + gzip_comp_level 6; + gzip_types text/xml text/plain text/css application/xhtml+xml application/xml application/rss+xml application/atom_xml application/x-javascript application/x-httpd-php; + gzip_disable "MSIE [1-6]\."; + + + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-available/*.conf; +} \ No newline at end of file diff --git a/docker/nginx/proxy.conf b/docker/nginx/proxy.conf new file mode 100644 index 00000000..6830cd5f --- /dev/null +++ b/docker/nginx/proxy.conf @@ -0,0 +1,40 @@ +proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=czone:4m max_size=50m inactive=120m; +proxy_temp_path /var/tmp/nginx; +proxy_cache_key "$scheme://$host$request_uri"; + + +map $http_forwarded_request_id $x_request_id { + "" $request_id; + default $http_forwarded_request_id; +} + +map $http_forwarded_forwarded_host $forwardedhost { + "" $host; + default $http_forwarded_forwarded_host; +} + + +map $http_x_forwarded_proto $fcgi_https { + default ""; + https on; +} + +map $http_x_forwarded_proto $real_scheme { + default $scheme; + https https; +} + +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Forwarded-Server $host; + +real_ip_header X-Forwarded-For; + +set_real_ip_from 10.0.0.0/8; +set_real_ip_from 172.16.0.0/12; +set_real_ip_from 172.18.0.0; +set_real_ip_from 192.168.0.0/24; +set_real_ip_from 127.0.0.0/8; + +real_ip_recursive on; \ No newline at end of file diff --git a/docker/nginx/vichan.conf b/docker/nginx/vichan.conf new file mode 100644 index 00000000..35f6bc08 --- /dev/null +++ b/docker/nginx/vichan.conf @@ -0,0 +1,66 @@ +upstream php-upstream { + server php:9000; +} + +server { + listen 80 default_server; + listen [::]:80 default_server ipv6only=on; + server_name vichan; + root /var/www/html; + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + + index index.html index.php; + + charset utf-8; + + location ~ ^([^.\?]*[^\/])$ { + try_files $uri @addslash; + } + + # Expire rules for static content + # Media: images, icons, video, audio, HTC + location ~* \.(?:jpg|jpeg|gif|png|webp|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ { + expires 1M; + access_log off; + log_not_found off; + add_header Cache-Control "public"; + } + # CSS and Javascript + location ~* \.(?:css|js)$ { + expires 1y; + access_log off; + log_not_found off; + add_header Cache-Control "public"; + } + + location ~* \.(html)$ { + expires -1; + } + + location @addslash { + return 301 $uri/; + } + + location / { + try_files $uri $uri/ /index.php$is_args$args; + } + + client_max_body_size 2G; + + location ~ \.php$ { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Request-Id $x_request_id; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Forwarded-Request-Id $x_request_id; + fastcgi_pass php-upstream; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME /var/www/$fastcgi_script_name; + fastcgi_read_timeout 600; + include fastcgi_params; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 00000000..1882bc9d --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,88 @@ +# Based on https://github.com/dead-guru/devichan/blob/master/php-fpm/Dockerfile + +FROM composer:lts AS composer +FROM php:8.1-fpm-alpine + +RUN apk add --no-cache \ + zlib \ + zlib-dev \ + libpng \ + libpng-dev \ + libjpeg-turbo \ + libjpeg-turbo-dev \ + libwebp \ + libwebp-dev \ + libcurl \ + curl-dev \ + imagemagick \ + graphicsmagick \ + gifsicle \ + ffmpeg \ + bind-tools \ + gettext \ + gettext-dev \ + icu-dev \ + oniguruma \ + oniguruma-dev \ + libmcrypt \ + libmcrypt-dev \ + lz4-libs \ + lz4-dev \ + imagemagick-dev \ + pcre-dev \ + $PHPIZE_DEPS \ + && docker-php-ext-configure gd \ + --with-webp=/usr/include/webp \ + --with-jpeg=/usr/include \ + && docker-php-ext-install -j$(nproc) \ + gd \ + curl \ + bcmath \ + opcache \ + pdo_mysql \ + gettext \ + intl \ + mbstring \ + && pecl update-channels \ + && pecl install -o -f igbinary \ + && pecl install redis \ + && pecl install imagick \ + $$ docker-php-ext-enable \ + igbinary \ + redis \ + imagick \ + && apk del \ + zlib-dev \ + libpng-dev \ + libjpeg-turbo-dev \ + libwebp-dev \ + curl-dev \ + gettext-dev \ + oniguruma-dev \ + libmcrypt-dev \ + lz4-dev \ + imagemagick-dev \ + pcre-dev \ + $PHPIZE_DEPS \ + && rm -rf /var/cache/* \ + && rm -rf /tmp/pear +RUN rmdir /var/www/html \ + && install -d -m 744 -o www-data -g www-data /var/www \ + && install -d -m 700 -o www-data -g www-data /var/tmp/vichan \ + && install -d -m 700 -o www-data -g www-data /var/cache/gen-cache \ + && install -d -m 700 -o www-data -g www-data /var/cache/template-cache + +# Copy the bootstrap script. +COPY ./docker/php/bootstrap.sh /usr/local/bin/bootstrap.sh + +COPY --from=composer /usr/bin/composer /usr/local/bin/composer + +# Copy the actual project (use .dockerignore to exclude stuff). +COPY . /code + +# Install the compose depedencies. +RUN cd /code && composer install + +WORKDIR "/var/www" +CMD [ "bootstrap.sh" ] +EXPOSE 9000 diff --git a/docker/php/Dockerfile.profile b/docker/php/Dockerfile.profile new file mode 100644 index 00000000..ad2019ab --- /dev/null +++ b/docker/php/Dockerfile.profile @@ -0,0 +1,16 @@ +# syntax = devthefuture/dockerfile-x +INCLUDE ./docker/php/Dockerfile + +RUN apk add --no-cache \ + linux-headers \ + $PHPIZE_DEPS \ + && pecl update-channels \ + && pecl install xdebug \ + && docker-php-ext-enable xdebug \ + && apk del \ + linux-headers \ + $PHPIZE_DEPS \ + && rm -rf /var/cache/* + +ENV XDEBUG_OUT_DIR=/var/www/xdebug_out +CMD [ "bootstrap.sh" ] \ No newline at end of file diff --git a/docker/php/bootstrap.sh b/docker/php/bootstrap.sh new file mode 100755 index 00000000..cc3f43d0 --- /dev/null +++ b/docker/php/bootstrap.sh @@ -0,0 +1,87 @@ +#!/bin/sh + +set -eu + +function set_cfg() { + if [ -L "/var/www/inc/$1" ]; then + echo "INFO: Resetting $1" + rm "/var/www/inc/$1" + cp "/code/inc/$1" "/var/www/inc/$1" + chown www-data "/var/www/inc/$1" + chgrp www-data "/var/www/inc/$1" + chmod 600 "/var/www/inc/$1" + else + echo "INFO: Using existing $1" + fi +} + +if ! mountpoint -q /var/www; then + echo "WARNING: '/var/www' is not a mountpoint. All the data will remain inside the container!" +fi + +if [ ! -w /var/www ] ; then + echo "ERROR: '/var/www' is not writable. Closing." + exit 1 +fi + +if [ -z "${XDEBUG_OUT_DIR:-''}" ] ; then + echo "INFO: Initializing xdebug out directory at $XDEBUG_OUT_DIR" + mkdir -p "$XDEBUG_OUT_DIR" + chown www-data "$XDEBUG_OUT_DIR" + chgrp www-data "$XDEBUG_OUT_DIR" + chmod 755 "$XDEBUG_OUT_DIR" +fi + +# Link the entrypoints from the exposed directory. +ln -nfs \ + /code/tools/ \ + /code/*.php \ + /code/LICENSE.* \ + /code/install.sql \ + /var/www/ +# Static files accessible from the webserver must be copied. +cp -ur /code/static /var/www/ +cp -ur /code/stylesheets /var/www/ + +# Ensure correct permissions are set, since this might be bind mount. +chown www-data /var/www +chgrp www-data /var/www + +# Initialize an empty robots.txt with the default if it doesn't exist. +touch /var/www/robots.txt + +# Link the cache and tmp files directory. +ln -nfs /var/tmp/vichan /var/www/tmp + +# Link the javascript directory. +ln -nfs /code/js /var/www/ + +# Link the html templates directory and it's cache. +ln -nfs /code/templates /var/www/ +ln -nfs -T /var/cache/template-cache /var/www/templates/cache +chown -h www-data /var/www/templates/cache +chgrp -h www-data /var/www/templates/cache + +# Link the generic cache. +ln -nfs -T /var/cache/gen-cache /var/www/tmp/cache +chown -h www-data /var/www/tmp/cache +chgrp -h www-data /var/www/tmp/cache + +# Create the included files directory and link them +install -d -m 700 -o www-data -g www-data /var/www/inc +for file in /code/inc/*; do + file="${file##*/}" + if [ ! -e /var/www/inc/$file ]; then + ln -s /code/inc/$file /var/www/inc/ + fi +done + +# Copy an empty instance configuration if the file is a link (it was linked because it did not exist before). +set_cfg 'instance-config.php' +set_cfg 'secrets.php' + +# Link the composer dependencies. +ln -nfs /code/vendor /var/www/ + +# Start the php-fpm server. +exec php-fpm diff --git a/docker/php/jit.ini b/docker/php/jit.ini new file mode 100644 index 00000000..ecfb44c5 --- /dev/null +++ b/docker/php/jit.ini @@ -0,0 +1,2 @@ +opcache.jit_buffer_size=192M +opcache.jit=tracing diff --git a/docker/php/www.conf b/docker/php/www.conf new file mode 100644 index 00000000..6e78ad26 --- /dev/null +++ b/docker/php/www.conf @@ -0,0 +1,13 @@ +[www] +access.log = /proc/self/fd/2 + +; Ensure worker stdout and stderr are sent to the main error log. +catch_workers_output = yes +decorate_workers_output = no + +user = www-data +group = www-data + +listen = 127.0.0.1:9000 +pm = static +pm.max_children = 16 diff --git a/docker/php/xdebug-prof.ini b/docker/php/xdebug-prof.ini new file mode 100644 index 00000000..c6dc008e --- /dev/null +++ b/docker/php/xdebug-prof.ini @@ -0,0 +1,7 @@ +zend_extension=xdebug + +[xdebug] +xdebug.mode = profile +xdebug.start_with_request = start +error_reporting = E_ALL +xdebug.output_dir = /var/www/xdebug_out diff --git a/inc/Data/Driver/ApcuCacheDriver.php b/inc/Data/Driver/ApcuCacheDriver.php new file mode 100644 index 00000000..a39bb656 --- /dev/null +++ b/inc/Data/Driver/ApcuCacheDriver.php @@ -0,0 +1,28 @@ +name = $name; + $this->level = $level; + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + $lv = $this->levelToString($level); + $line = "{$this->name} $lv: $message"; + \error_log($line, 0, null, null); + } + } +} diff --git a/inc/Data/Driver/FileLogDriver.php b/inc/Data/Driver/FileLogDriver.php new file mode 100644 index 00000000..2c9f14a0 --- /dev/null +++ b/inc/Data/Driver/FileLogDriver.php @@ -0,0 +1,61 @@ +fd = \fopen($file_path, 'a'); + if ($this->fd === false) { + throw new \RuntimeException("Unable to open log file at $file_path"); + } + + $this->name = $name; + $this->level = $level; + + // In some cases PHP does not run the destructor. + \register_shutdown_function([$this, 'close']); + } + + public function __destruct() { + $this->close(); + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + $lv = $this->levelToString($level); + $line = "{$this->name} $lv: $message\n"; + \flock($this->fd, LOCK_EX); + \fwrite($this->fd, $line); + \fflush($this->fd); + \flock($this->fd, LOCK_UN); + } + } + + public function close() { + \flock($this->fd, LOCK_UN); + \fclose($this->fd); + } +} diff --git a/inc/Data/Driver/FsCachedriver.php b/inc/Data/Driver/FsCachedriver.php new file mode 100644 index 00000000..b543cfa6 --- /dev/null +++ b/inc/Data/Driver/FsCachedriver.php @@ -0,0 +1,155 @@ +prefix . $key; + } + + private function sharedLockCache(): void { + \flock($this->lock_fd, LOCK_SH); + } + + private function exclusiveLockCache(): void { + \flock($this->lock_fd, LOCK_EX); + } + + private function unlockCache(): void { + \flock($this->lock_fd, LOCK_UN); + } + + private function collectImpl(): int { + /* + * A read lock is ok, since it's alright if we delete expired items from under the feet of other processes, and + * no other process add new cache items or refresh existing ones. + */ + $files = \glob($this->base_path . $this->prefix . '*', \GLOB_NOSORT); + $count = 0; + foreach ($files as $file) { + $data = \file_get_contents($file); + $wrapped = \json_decode($data, true, 512, \JSON_THROW_ON_ERROR); + if ($wrapped['expires'] !== false && $wrapped['expires'] <= \time()) { + if (@\unlink($file)) { + $count++; + } + } + } + return $count; + } + + private function maybeCollect(): void { + if ($this->collect_chance_den !== false && \mt_rand(0, $this->collect_chance_den - 1) === 0) { + $this->collect_chance_den = false; // Collect only once per instance (aka process). + $this->collectImpl(); + } + } + + public function __construct(string $prefix, string $base_path, string $lock_file, int|false $collect_chance_den) { + if ($base_path[\strlen($base_path) - 1] !== '/') { + $base_path = "$base_path/"; + } + + if (!\is_dir($base_path)) { + throw new \RuntimeException("$base_path is not a directory!"); + } + + if (!\is_writable($base_path)) { + throw new \RuntimeException("$base_path is not writable!"); + } + + $this->lock_fd = \fopen($base_path . $lock_file, 'w'); + if ($this->lock_fd === false) { + throw new \RuntimeException('Unable to open the lock file!'); + } + + $this->prefix = $prefix; + $this->base_path = $base_path; + $this->collect_chance_den = $collect_chance_den; + } + + public function __destruct() { + $this->close(); + } + + public function get(string $key): mixed { + $key = $this->prepareKey($key); + + $this->sharedLockCache(); + + // Collect expired items first so if the target key is expired we shortcut to failure in the next lines. + $this->maybeCollect(); + + $fd = \fopen($this->base_path . $key, 'r'); + if ($fd === false) { + $this->unlockCache(); + return null; + } + + $data = \stream_get_contents($fd); + \fclose($fd); + $this->unlockCache(); + $wrapped = \json_decode($data, true, 512, \JSON_THROW_ON_ERROR); + + if ($wrapped['expires'] !== false && $wrapped['expires'] <= \time()) { + // Already expired, leave it there since we already released the lock and pretend it doesn't exist. + return null; + } else { + return $wrapped['inner']; + } + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + $key = $this->prepareKey($key); + + $wrapped = [ + 'expires' => $expires ? \time() + $expires : false, + 'inner' => $value + ]; + + $data = \json_encode($wrapped); + $this->exclusiveLockCache(); + $this->maybeCollect(); + \file_put_contents($this->base_path . $key, $data); + $this->unlockCache(); + } + + public function delete(string $key): void { + $key = $this->prepareKey($key); + + $this->exclusiveLockCache(); + @\unlink($this->base_path . $key); + $this->maybeCollect(); + $this->unlockCache(); + } + + public function collect(): int { + $this->sharedLockCache(); + $count = $this->collectImpl(); + $this->unlockCache(); + return $count; + } + + public function flush(): void { + $this->exclusiveLockCache(); + $files = \glob($this->base_path . $this->prefix . '*', \GLOB_NOSORT); + foreach ($files as $file) { + @\unlink($file); + } + $this->unlockCache(); + } + + public function close(): void { + \fclose($this->lock_fd); + } +} diff --git a/inc/Data/Driver/HttpDriver.php b/inc/Data/Driver/HttpDriver.php new file mode 100644 index 00000000..989b47ae --- /dev/null +++ b/inc/Data/Driver/HttpDriver.php @@ -0,0 +1,131 @@ +inner); + \curl_setopt_array($this->inner, [ + \CURLOPT_URL => $url, + \CURLOPT_TIMEOUT => $timeout, + \CURLOPT_USERAGENT => 'Tinyboard', + \CURLOPT_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS, + ]); + } + + public function __construct(int $timeout, int $max_file_size) { + $this->inner = \curl_init(); + $this->timeout = $timeout; + $this->max_file_size = $max_file_size; + } + + public function __destruct() { + \curl_close($this->inner); + } + + /** + * Execute a GET request. + * + * @param string $endpoint Uri endpoint. + * @param ?array $data Optional GET parameters. + * @param int $timeout Optional request timeout in seconds. Use the default timeout if 0. + * @return string Returns the body of the response. + * @throws RuntimeException Throws on IO error. + */ + public function requestGet(string $endpoint, ?array $data, int $timeout = 0): string { + if (!empty($data)) { + $endpoint .= '?' . \http_build_query($data); + } + if ($timeout == 0) { + $timeout = $this->timeout; + } + + $this->resetTowards($endpoint, $timeout); + \curl_setopt($this->inner, \CURLOPT_RETURNTRANSFER, true); + $ret = \curl_exec($this->inner); + + if ($ret === false) { + throw new \RuntimeException(\curl_error($this->inner)); + } + return $ret; + } + + /** + * Execute a POST request. + * + * @param string $endpoint Uri endpoint. + * @param ?array $data Optional POST parameters. + * @param int $timeout Optional request timeout in seconds. Use the default timeout if 0. + * @return string Returns the body of the response. + * @throws RuntimeException Throws on IO error. + */ + public function requestPost(string $endpoint, ?array $data, int $timeout = 0): string { + if ($timeout == 0) { + $timeout = $this->timeout; + } + + $this->resetTowards($endpoint, $timeout); + \curl_setopt($this->inner, \CURLOPT_POST, true); + if (!empty($data)) { + \curl_setopt($this->inner, \CURLOPT_POSTFIELDS, \http_build_query($data)); + } + \curl_setopt($this->inner, \CURLOPT_RETURNTRANSFER, true); + $ret = \curl_exec($this->inner); + + if ($ret === false) { + throw new \RuntimeException(\curl_error($this->inner)); + } + return $ret; + } + + /** + * Download the url's target with curl. + * + * @param string $url Url to the file to download. + * @param ?array $data Optional GET parameters. + * @param resource $fd File descriptor to save the content to. + * @param int $timeout Optional request timeout in seconds. Use the default timeout if 0. + * @return bool Returns true on success, false if the file was too large. + * @throws RuntimeException Throws on IO error. + */ + public function requestGetInto(string $endpoint, ?array $data, $fd, int $timeout = 0): bool { + if (!empty($data)) { + $endpoint .= '?' . \http_build_query($data); + } + if ($timeout == 0) { + $timeout = $this->timeout; + } + + $this->resetTowards($endpoint, $timeout); + // Adapted from: https://stackoverflow.com/a/17642638 + $opt = (\PHP_MAJOR_VERSION >= 8 && \PHP_MINOR_VERSION >= 2) ? \CURLOPT_XFERINFOFUNCTION : \CURLOPT_PROGRESSFUNCTION; + \curl_setopt_array($this->inner, [ + \CURLOPT_NOPROGRESS => false, + $opt => fn($res, $next_dl, $dl, $next_up, $up) => (int)($dl <= $this->max_file_size), + \CURLOPT_FAILONERROR => true, + \CURLOPT_FOLLOWLOCATION => false, + \CURLOPT_FILE => $fd, + \CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, + ]); + $ret = \curl_exec($this->inner); + + if ($ret === false) { + if (\curl_errno($this->inner) === CURLE_ABORTED_BY_CALLBACK) { + return false; + } + + throw new \RuntimeException(\curl_error($this->inner)); + } + return true; + } +} diff --git a/inc/Data/Driver/LogDriver.php b/inc/Data/Driver/LogDriver.php new file mode 100644 index 00000000..fddc3f27 --- /dev/null +++ b/inc/Data/Driver/LogDriver.php @@ -0,0 +1,22 @@ +inner = new \Memcached(); + if (!$this->inner->setOption(\Memcached::OPT_BINARY_PROTOCOL, true)) { + throw new \RuntimeException('Unable to set the memcached protocol!'); + } + if (!$this->inner->setOption(\Memcached::OPT_PREFIX_KEY, $prefix)) { + throw new \RuntimeException('Unable to set the memcached prefix!'); + } + if (!$this->inner->addServers($memcached_server)) { + throw new \RuntimeException('Unable to add the memcached server!'); + } + } + + public function get(string $key): mixed { + $ret = $this->inner->get($key); + // If the returned value is false but the retrival was a success, then the value stored was a boolean false. + if ($ret === false && $this->inner->getResultCode() !== \Memcached::RES_SUCCESS) { + return null; + } + return $ret; + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + $this->inner->set($key, $value, (int)$expires); + } + + public function delete(string $key): void { + $this->inner->delete($key); + } + + public function flush(): void { + $this->inner->flush(); + } +} diff --git a/inc/Data/Driver/NoneCacheDriver.php b/inc/Data/Driver/NoneCacheDriver.php new file mode 100644 index 00000000..8b260a50 --- /dev/null +++ b/inc/Data/Driver/NoneCacheDriver.php @@ -0,0 +1,26 @@ +inner = new \Redis(); + $this->inner->connect($host, $port); + if ($password) { + $this->inner->auth($password); + } + if (!$this->inner->select($database)) { + throw new \RuntimeException('Unable to connect to Redis!'); + } + + $$this->prefix = $prefix; + } + + public function get(string $key): mixed { + $ret = $this->inner->get($this->prefix . $key); + if ($ret === false) { + return null; + } + return \json_decode($ret, true); + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + if ($expires === false) { + $this->inner->set($this->prefix . $key, \json_encode($value)); + } else { + $expires = $expires * 1000; // Seconds to milliseconds. + $this->inner->setex($this->prefix . $key, $expires, \json_encode($value)); + } + } + + public function delete(string $key): void { + $this->inner->del($this->prefix . $key); + } + + public function flush(): void { + $this->inner->flushDB(); + } +} diff --git a/inc/Data/Driver/StderrLogDriver.php b/inc/Data/Driver/StderrLogDriver.php new file mode 100644 index 00000000..4c766033 --- /dev/null +++ b/inc/Data/Driver/StderrLogDriver.php @@ -0,0 +1,27 @@ +name = $name; + $this->level = $level; + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + $lv = $this->levelToString($level); + \fwrite(\STDERR, "{$this->name} $lv: $message\n"); + } + } +} diff --git a/inc/Data/Driver/SyslogLogDriver.php b/inc/Data/Driver/SyslogLogDriver.php new file mode 100644 index 00000000..c0df5304 --- /dev/null +++ b/inc/Data/Driver/SyslogLogDriver.php @@ -0,0 +1,35 @@ +level = $level; + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + if (isset($_SERVER['REMOTE_ADDR'], $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'])) { + // CGI + \syslog($level, "$message - client: {$_SERVER['REMOTE_ADDR']}, request: \"{$_SERVER['REQUEST_METHOD']} {$_SERVER['REQUEST_URI']}\""); + } else { + \syslog($level, $message); + } + } + } +} diff --git a/inc/anti-bot.php b/inc/anti-bot.php index 48150328..6f684c55 100644 --- a/inc/anti-bot.php +++ b/inc/anti-bot.php @@ -1,191 +1,5 @@ ?=-` '; - if ($unicode_chars) { - $len = strlen($chars) / 10; - for ($n = 0; $n < $len; $n++) - $chars .= mb_convert_encoding('&#' . mt_rand(0x2600, 0x26FF) . ';', 'UTF-8', 'HTML-ENTITIES'); - } - - $chars = preg_split('//u', $chars, -1, PREG_SPLIT_NO_EMPTY); - - $ch = array(); - - // fill up $ch until we reach $length - while (count($ch) < $length) { - $n = $length - count($ch); - $keys = array_rand($chars, $n > count($chars) ? count($chars) : $n); - if ($n == 1) { - $ch[] = $chars[$keys]; - break; - } - shuffle($keys); - foreach ($keys as $key) - $ch[] = $chars[$key]; - } - - $chars = $ch; - - return implode('', $chars); - } - - public static function make_confusing($string) { - $chars = preg_split('//u', $string, -1, PREG_SPLIT_NO_EMPTY); - - foreach ($chars as &$c) { - if (mt_rand(0, 3) != 0) - $c = utf8tohtml($c); - else - $c = mb_encode_numericentity($c, array(0, 0xffff, 0, 0xffff), 'UTF-8'); - } - - return implode('', $chars); - } - - public function __construct(array $salt = array()) { - global $config; - - if (!empty($salt)) { - // create a salted hash of the "extra salt" - $this->salt = implode(':', $salt); - } else { - $this->salt = ''; - } - - shuffle($config['spam']['hidden_input_names']); - - $input_count = mt_rand($config['spam']['hidden_inputs_min'], $config['spam']['hidden_inputs_max']); - $hidden_input_names_x = 0; - - for ($x = 0; $x < $input_count ; $x++) { - if ($hidden_input_names_x === false || mt_rand(0, 2) == 0) { - // Use an obscure name - $name = $this->randomString(mt_rand(10, 40), false, false, $config['spam']['unicode']); - } else { - // Use a pre-defined confusing name - $name = $config['spam']['hidden_input_names'][$hidden_input_names_x++]; - if ($hidden_input_names_x >= count($config['spam']['hidden_input_names'])) - $hidden_input_names_x = false; - } - - if (mt_rand(0, 2) == 0) { - // Value must be null - $this->inputs[$name] = ''; - } elseif (mt_rand(0, 4) == 0) { - // Numeric value - $this->inputs[$name] = (string)mt_rand(0, 100000); - } else { - // Obscure value - $this->inputs[$name] = $this->randomString(mt_rand(5, 100), true, true, $config['spam']['unicode']); - } - } - } - - public static function space() { - if (mt_rand(0, 3) != 0) - return ' '; - return str_repeat(' ', mt_rand(1, 3)); - } - - public function html($count = false) { - global $config; - - $elements = array( - '', - '', - '', - '', - '', - '', - '', - '
', - '
', - '', - '' - ); - - $html = ''; - - if ($count === false) { - $count = mt_rand(1, abs(count($this->inputs) / 15) + 1); - } - - if ($count === true) { - // all elements - $inputs = array_slice($this->inputs, $this->index); - } else { - $inputs = array_slice($this->inputs, $this->index, $count); - } - $this->index += count($inputs); - - foreach ($inputs as $name => $value) { - $element = false; - while (!$element) { - $element = $elements[array_rand($elements)]; - $element = str_replace(' ', self::space(), $element); - if (mt_rand(0, 5) == 0) - $element = str_replace('>', self::space() . '>', $element); - if (strpos($element, 'textarea') !== false && $value == '') { - // There have been some issues with mobile web browsers and empty '; + $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..7ba255f3 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) { + 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-filename.js b/js/expand-filename.js new file mode 100644 index 00000000..2f3ac0fd --- /dev/null +++ b/js/expand-filename.js @@ -0,0 +1,53 @@ +/* + * expand-filename.js + * https://github.com/vichan-devel/vichan/blob/master/js/expand-filename.js + * + * Released under the MIT license + * Copyright (c) 2024 Perdedora + * + * Usage: + * $config['additional_javascript'][] = 'js/expand-filename.js'; + * + */ + +function doFilename(element) { + const filenames = element.querySelectorAll('[data-truncate="true"]'); + filenames.forEach(filename => { + filename.addEventListener('mouseover', event => addHover(event.target)); + filename.addEventListener('mouseout', event => removeHover(event.target)); + }); +} + +function addHover(element) { + element.dataset.truncatedFilename = element.textContent; + element.textContent = element.download; +} + +function removeHover(element) { + element.textContent = element.dataset.truncatedFilename; + delete element.dataset.truncatedFilename; +} + +document.addEventListener('DOMContentLoaded', () => { + doFilename(document); + + // Create a MutationObserver to watch for new elements + const observer = new MutationObserver(mutationsList => { + mutationsList.forEach(mutation => { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach(addedNode => { + if (addedNode.nodeType === Node.ELEMENT_NODE) { + // Apply `doFilename` to newly added elements + doFilename(addedNode); + } + }); + } + }); + }); + + // Start observing the document body for changes + observer.observe(document.body, { + childList: true, + subtree: true + }); +}); diff --git a/js/expand-too-long.js b/js/expand-too-long.js deleted file mode 100644 index bbadb5f1..00000000 --- a/js/expand-too-long.js +++ /dev/null @@ -1,39 +0,0 @@ -/* - * expand-too-long.js - * https://github.com/vichan-devel/vichan/blob/master/js/expand-too-long.js - * - * Released under the MIT license - * Copyright (c) 2013-2014 Marcin Łabanowski - * - * Usage: - * $config['additional_javascript'][] = 'js/jquery.min.js'; - * $config['additional_javascript'][] = 'js/expand-too-long.js'; - * - */ - -$(function() { - var do_expand = function() { - $(this).find('a').click(function(e) { - e.preventDefault(); - - var url = $(this).attr('href'); - var body = $(this).parents('.body'); - - $.ajax({ - url: url, - context: document.body, - success: function(data) { - var content = $(data).find('#'+url.split('#')[1]).parent().parent().find(".body").first().html(); - - body.html(content); - } - }); - }); - }; - - $('.toolong').each(do_expand); - - $(document).on("new_post", function(e, post) { - $(post).find('.toolong').each(do_expand) - }); -}); diff --git a/js/expand-video.js b/js/expand-video.js index 08b474c1..748cf7cd 100644 --- a/js/expand-video.js +++ b/js/expand-video.js @@ -2,243 +2,283 @@ /* 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; + function setVolume() { + const volume = setting("videovolume"); + video.volume = volume; + video.muted = (volume === 0); + } - 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"; + // 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.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.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"; - 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"); - } + setVolume(); + video.controls = true; + if (video.readyState == 0) { + video.addEventListener("loadedmetadata", expand2, false); + } else { + setTimeout(expand2, 0); + } + let promise = video.play(); + if (promise !== undefined) { + promise.then(_ => { + }).catch(_ => { + video.muted = true; + video.play(); + }); + } + e.preventDefault(); + } + }, false); - // Hovering over thumbnail displays video - thumb.addEventListener("mouseover", function(e) { - if (setting("videohover")) { - getVideo(); - expanded = false; - hovering = true; + 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"); + } - var docRight = document.documentElement.getBoundingClientRect().right; - var thumbRight = thumb.querySelector("img, video").getBoundingClientRect().right; - var maxWidth = docRight - thumbRight - 20; - if (maxWidth < 250) maxWidth = 250; + // Hovering over thumbnail displays video + thumb.addEventListener("mouseover", function(e) { + if (setting("videohover")) { + getVideo(); + expanded = false; + hovering = true; - 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"; + 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.display = "inline"; - videoHide.style.display = "none"; - videoContainer.style.display = "inline"; - videoContainer.style.position = "fixed"; + 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.muted = (setting("videovolume") == 0); - video.volume = setting("videovolume"); - video.controls = false; - video.play(); - } - }, false); + video.style.display = "inline"; + videoHide.style.display = "none"; + videoContainer.style.display = "inline"; + videoContainer.style.position = "fixed"; - thumb.addEventListener("mouseout", unhover, false); + setVolume(); + video.controls = false; + let promise = video.play(); + if (promise !== undefined) { + promise.then(_ => { + }).catch(_ => { + video.muted = true; + video.play(); + }); + } + } + }, 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); + thumb.addEventListener("mouseout", unhover, 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); - } + // 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); - 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]); - } + // [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]); + } } 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 fc3125db..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,49 +13,54 @@ * */ -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) { - showBackLinks.call(post); + $(document).on('new_post', function(e, post) { if ($(post).hasClass("op")) { $(post).find('div.post.reply').each(showBackLinks); + } else { + $(post).parent().find('div.post.reply').each(showBackLinks); } }); }); 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");