Merge branch 'master' into fix-poster-id

This commit is contained in:
Weav
2025-03-15 17:18:22 +00:00
committed by GitHub
111 changed files with 5178 additions and 2980 deletions

View File

@@ -0,0 +1,28 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
class ApcuCacheDriver implements CacheDriver {
public function get(string $key): mixed {
$success = false;
$ret = \apcu_fetch($key, $success);
if ($success === false) {
return null;
}
return $ret;
}
public function set(string $key, mixed $value, mixed $expires = false): void {
\apcu_store($key, $value, (int)$expires);
}
public function delete(string $key): void {
\apcu_delete($key);
}
public function flush(): void {
\apcu_clear_cache();
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
/**
* A simple process-wide PHP array.
*/
class ArrayCacheDriver implements CacheDriver {
private static $inner = [];
public function get(string $key) {
return isset(self::$inner[$key]) ? self::$inner[$key] : null;
}
public function set(string $key, $value, $expires = false): void {
self::$inner[$key] = $value;
}
public function delete(string $key): void {
unset(self::$inner[$key]);
}
public function flush(): void {
self::$inner = [];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
interface CacheDriver {
/**
* Get the value of associated with the key.
*
* @param string $key The key of the value.
* @return mixed|null The value associated with the key, or null if there is none.
*/
public function get(string $key);
/**
* Set a key-value pair.
*
* @param string $key The key.
* @param mixed $value The value.
* @param int|false $expires After how many seconds the pair will expire. Use false or ignore this parameter to keep
* the value until it gets evicted to make space for more items. Some drivers will always
* ignore this parameter and store the pair until it's removed.
*/
public function set(string $key, $value, $expires = false);
/**
* Delete a key-value pair.
*
* @param string $key The key.
*/
public function delete(string $key);
/**
* Delete all the key-value pairs.
*/
public function flush();
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
/**
* Log via the php function error_log.
*/
class ErrorLogLogDriver implements LogDriver {
use LogTrait;
private string $name;
private int $level;
public function __construct(string $name, int $level) {
$this->name = $name;
$this->level = $level;
}
public function log(int $level, string $message): void {
if ($level <= $this->level) {
$lv = $this->levelToString($level);
$line = "{$this->name} $lv: $message";
\error_log($line, 0, null, null);
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
/**
* Log to a file.
*/
class FileLogDriver implements LogDriver {
use LogTrait;
private string $name;
private int $level;
private mixed $fd;
public function __construct(string $name, int $level, string $file_path) {
/*
* error_log is slow as hell in it's 3rd mode, so use fopen + file locking instead.
* https://grobmeier.solutions/performance-ofnonblocking-write-to-files-via-php-21082009.html
*
* Whatever file appending is atomic is contentious:
* - There are no POSIX guarantees: https://stackoverflow.com/a/7237901
* - But linus suggested they are on linux, on some filesystems: https://web.archive.org/web/20151201111541/http://article.gmane.org/gmane.linux.kernel/43445
* - But it doesn't seem to be always the case: https://www.notthewizard.com/2014/06/17/are-files-appends-really-atomic/
*
* So we just use file locking to be sure.
*/
$this->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);
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
class FsCacheDriver implements CacheDriver {
private string $prefix;
private string $base_path;
private mixed $lock_fd;
private int|false $collect_chance_den;
private function prepareKey(string $key): string {
$key = \str_replace('/', '::', $key);
$key = \str_replace("\0", '', $key);
return $this->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);
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
/**
* Honestly this is just a wrapper for cURL. Still useful to mock it and have an OOP API on PHP 7.
*/
class HttpDriver {
private $inner;
private int $timeout;
private int $max_file_size;
private function resetTowards(string $url, int $timeout): void {
\curl_reset($this->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;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
interface LogDriver {
public const EMERG = \LOG_EMERG;
public const ERROR = \LOG_ERR;
public const WARNING = \LOG_WARNING;
public const NOTICE = \LOG_NOTICE;
public const INFO = \LOG_INFO;
public const DEBUG = \LOG_DEBUG;
/**
* Log a message if the level of relevancy is at least the minimum.
*
* @param int $level Message level. Use Log interface constants.
* @param string $message The message to log.
*/
public function log(int $level, string $message): void;
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
trait LogTrait {
public static function levelToString(int $level): string {
switch ($level) {
case LogDriver::EMERG:
return 'EMERG';
case LogDriver::ERROR:
return 'ERROR';
case LogDriver::WARNING:
return 'WARNING';
case LogDriver::NOTICE:
return 'NOTICE';
case LogDriver::INFO:
return 'INFO';
case LogDriver::DEBUG:
return 'DEBUG';
default:
throw new \InvalidArgumentException('Not a logging level');
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
class MemcachedCacheDriver implements CacheDriver {
private \Memcached $inner;
public function __construct(string $prefix, string $memcached_server) {
$this->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();
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
/**
* No-op cache. Useful for testing.
*/
class NoneCacheDriver implements CacheDriver {
public function get(string $key): mixed {
return null;
}
public function set(string $key, mixed $value, mixed $expires = false): void {
// No-op.
}
public function delete(string $key): void {
// No-op.
}
public function flush(): void {
// No-op.
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
class RedisCacheDriver implements CacheDriver {
private string $prefix;
private \Redis $inner;
public function __construct(string $prefix, string $host, int $port, ?string $password, string $database) {
$this->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();
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
/**
* Log to php's standard error file stream.
*/
class StderrLogDriver implements LogDriver {
use LogTrait;
private string $name;
private int $level;
public function __construct(string $name, int $level) {
$this->name = $name;
$this->level = $level;
}
public function log(int $level, string $message): void {
if ($level <= $this->level) {
$lv = $this->levelToString($level);
\fwrite(\STDERR, "{$this->name} $lv: $message\n");
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Vichan\Data\Driver;
defined('TINYBOARD') or exit;
/**
* Log to syslog.
*/
class SyslogLogDriver implements LogDriver {
private int $level;
public function __construct(string $name, int $level, bool $print_stderr) {
$flags = \LOG_ODELAY;
if ($print_stderr) {
$flags |= \LOG_PERROR;
}
if (!\openlog($name, $flags, \LOG_USER)) {
throw new \RuntimeException('Unable to open syslog');
}
$this->level = $level;
}
public function log(int $level, string $message): void {
if ($level <= $this->level) {
if (isset($_SERVER['REMOTE_ADDR'], $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'])) {
// CGI
\syslog($level, "$message - client: {$_SERVER['REMOTE_ADDR']}, request: \"{$_SERVER['REQUEST_METHOD']} {$_SERVER['REQUEST_URI']}\"");
} else {
\syslog($level, $message);
}
}
}
}

View File

@@ -1,191 +1,5 @@
<?php
/*
* Copyright (c) 2010-2013 Tinyboard Development Group
* Anti-bot.php has been deprecated and removed due to its functions not being necessary and being easily bypassable, by both customized and uncustomized spambots.
*/
defined('TINYBOARD') or exit;
$hidden_inputs_twig = array();
class AntiBot {
public $salt, $inputs = array(), $index = 0;
public static function randomString($length, $uppercase = false, $special_chars = false, $unicode_chars = false) {
$chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
if ($uppercase)
$chars .= 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if ($special_chars)
$chars .= ' ~!@#$%^&*()_+,./;\'[]\\{}|:<>?=-` ';
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(
'<input type="hidden" name="%name%" value="%value%">',
'<input type="hidden" value="%value%" name="%name%">',
'<input name="%name%" value="%value%" type="hidden">',
'<input value="%value%" name="%name%" type="hidden">',
'<input style="display:none" type="text" name="%name%" value="%value%">',
'<input style="display:none" type="text" value="%value%" name="%name%">',
'<span style="display:none"><input type="text" name="%name%" value="%value%"></span>',
'<div style="display:none"><input type="text" name="%name%" value="%value%"></div>',
'<div style="display:none"><input type="text" name="%name%" value="%value%"></div>',
'<textarea style="display:none" name="%name%">%value%</textarea>',
'<textarea name="%name%" style="display:none">%value%</textarea>'
);
$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 <textarea>'s.
$element = false;
}
}
$element = str_replace('%name%', utf8tohtml($name), $element);
if (mt_rand(0, 2) == 0)
$value = $this->make_confusing($value);
else
$value = utf8tohtml($value);
if (strpos($element, 'textarea') === false)
$value = str_replace('"', '&quot;', $value);
$element = str_replace('%value%', $value, $element);
$html .= $element;
}
return $html;
}
public function reset() {
$this->index = 0;
}
public function hash() {
global $config;
// This is the tricky part: create a hash to validate it after
// First, sort the keys in alphabetical order (A-Z)
$inputs = $this->inputs;
ksort($inputs);
$hash = '';
// Iterate through each input
foreach ($inputs as $name => $value) {
$hash .= $name . '=' . $value;
}
// Add a salt to the hash
$hash .= $config['cookies']['salt'];
// Use SHA1 for the hash
return sha1($hash . $this->salt);
}
}

View File

@@ -9,14 +9,49 @@ defined('TINYBOARD') or exit;
* Class for generating json API compatible with 4chan API
*/
class Api {
function __construct(){
global $config;
/**
* Translation from local fields to fields in 4chan-style API
*/
$this->config = $config;
private bool $show_filename;
private bool $hide_email;
private bool $country_flags;
private array $postFields;
$this->postFields = array(
private const INTS = [
'no' => 1,
'resto' => 1,
'time' => 1,
'tn_w' => 1,
'tn_h' => 1,
'w' => 1,
'h' => 1,
'fsize' => 1,
'omitted_posts' => 1,
'omitted_images' => 1,
'replies' => 1,
'images' => 1,
'sticky' => 1,
'locked' => 1,
'last_modified' => 1
];
private const THREADS_PAGE_FIELDS = [
'id' => 'no',
'bump' => 'last_modified'
];
private const FILE_FIELDS = [
'thumbheight' => 'tn_h',
'thumbwidth' => 'tn_w',
'height' => 'h',
'width' => 'w',
'size' => 'fsize'
];
public function __construct(bool $show_filename, bool $hide_email, bool $country_flags) {
// Translation from local fields to fields in 4chan-style API
$this->show_filename = $show_filename;
$this->hide_email = $hide_email;
$this->country_flags = $country_flags;
$this->postFields = [
'id' => 'no',
'thread' => 'resto',
'subject' => 'sub',
@@ -35,91 +70,65 @@ class Api {
'cycle' => 'cyclical',
'bump' => 'last_modified',
'embed' => 'embed',
);
$this->threadsPageFields = array(
'id' => 'no',
'bump' => 'last_modified'
);
$this->fileFields = array(
'thumbheight' => 'tn_h',
'thumbwidth' => 'tn_w',
'height' => 'h',
'width' => 'w',
'size' => 'fsize',
);
];
if (isset($config['api']['extra_fields']) && gettype($config['api']['extra_fields']) == 'array'){
$this->postFields = array_merge($this->postFields, $config['api']['extra_fields']);
}
}
private static $ints = array(
'no' => 1,
'resto' => 1,
'time' => 1,
'tn_w' => 1,
'tn_h' => 1,
'w' => 1,
'h' => 1,
'fsize' => 1,
'omitted_posts' => 1,
'omitted_images' => 1,
'replies' => 1,
'images' => 1,
'sticky' => 1,
'locked' => 1,
'last_modified' => 1
);
private function translateFields($fields, $object, &$apiPost) {
foreach ($fields as $local => $translated) {
if (!isset($object->$local))
if (!isset($object->$local)) {
continue;
}
$toInt = isset(self::$ints[$translated]);
$toInt = isset(self::INTS[$translated]);
$val = $object->$local;
if (isset($this->config['hide_email']) && $this->config['hide_email'] && $local === 'email') {
if ($this->hide_email && $local === 'email') {
$val = '';
}
if ($val !== null && $val !== '') {
$apiPost[$translated] = $toInt ? (int) $val : $val;
}
}
}
private function translateFile($file, $post, &$apiPost) {
$this->translateFields($this->fileFields, $file, $apiPost);
$this->translateFields(self::FILE_FIELDS, $file, $apiPost);
$dotPos = strrpos($file->file, '.');
$apiPost['ext'] = substr($file->file, $dotPos);
$apiPost['tim'] = substr($file->file, 0, $dotPos);
if (isset($this->config['show_filename']) && $this->config['show_filename']) {
if ($this->show_filename) {
$apiPost['filename'] = @substr($file->name, 0, strrpos($file->name, '.'));
}
else {
} else {
$apiPost['filename'] = substr($file->file, 0, $dotPos);
}
if (isset ($file->hash) && $file->hash) {
$apiPost['md5'] = base64_encode(hex2bin($file->hash));
}
else if (isset ($post->filehash) && $post->filehash) {
} elseif (isset ($post->filehash) && $post->filehash) {
$apiPost['md5'] = base64_encode(hex2bin($post->filehash));
}
}
private function translatePost($post, $threadsPage = false) {
private function translatePost($post, bool $threadsPage = false) {
global $config, $board;
$apiPost = array();
$fields = $threadsPage ? $this->threadsPageFields : $this->postFields;
$apiPost = [];
$fields = $threadsPage ? self::THREADS_PAGE_FIELDS : $this->postFields;
$this->translateFields($fields, $post, $apiPost);
if (isset($config['poster_ids']) && $config['poster_ids']) $apiPost['id'] = poster_id($post->ip, $post->thread ?? $post->id);
if ($threadsPage) return $apiPost;
if (isset($config['poster_ids']) && $config['poster_ids']) {
$apiPost['id'] = poster_id($post->ip, $post->thread ?? $post->id);
}
if ($threadsPage) {
return $apiPost;
}
// Handle country field
if (isset($post->body_nomarkup) && $this->config['country_flags']) {
if (isset($post->body_nomarkup) && $this->country_flags) {
$modifiers = extract_modifiers($post->body_nomarkup);
if (isset($modifiers['flag']) && isset($modifiers['flag alt']) && preg_match('/^[a-z]{2}$/', $modifiers['flag'])) {
$country = strtoupper($modifiers['flag']);
@@ -139,12 +148,15 @@ class Api {
if (isset($post->files) && $post->files && !$threadsPage) {
$file = $post->files[0];
$this->translateFile($file, $post, $apiPost);
if (sizeof($post->files) > 1) {
$extra_files = array();
$extra_files = [];
foreach ($post->files as $i => $f) {
if ($i == 0) continue;
$extra_file = array();
if ($i == 0) {
continue;
}
$extra_file = [];
$this->translateFile($f, $post, $extra_file);
$extra_files[] = $extra_file;
@@ -156,8 +168,8 @@ class Api {
return $apiPost;
}
function translateThread(Thread $thread, $threadsPage = false) {
$apiPosts = array();
public function translateThread(Thread $thread, bool $threadsPage = false) {
$apiPosts = [];
$op = $this->translatePost($thread, $threadsPage);
if (!$threadsPage) $op['resto'] = 0;
$apiPosts['posts'][] = $op;
@@ -169,16 +181,16 @@ class Api {
return $apiPosts;
}
function translatePage(array $threads) {
$apiPage = array();
public function translatePage(array $threads) {
$apiPage = [];
foreach ($threads as $thread) {
$apiPage['threads'][] = $this->translateThread($thread);
}
return $apiPage;
}
function translateCatalogPage(array $threads, $threadsPage = false) {
$apiPage = array();
public function translateCatalogPage(array $threads, bool $threadsPage = false) {
$apiPage = [];
foreach ($threads as $thread) {
$ts = $this->translateThread($thread, $threadsPage);
$apiPage['threads'][] = current($ts['posts']);
@@ -186,8 +198,8 @@ class Api {
return $apiPage;
}
function translateCatalog($catalog, $threadsPage = false) {
$apiCatalog = array();
public function translateCatalog($catalog, bool $threadsPage = false) {
$apiCatalog = [];
foreach ($catalog as $page => $threads) {
$apiPage = $this->translateCatalogPage($threads, $threadsPage);
$apiPage['page'] = $page;

View File

@@ -1,8 +1,13 @@
<?php
use Vichan\Functions\Format;
use Lifo\IP\CIDR;
class Bans {
static private function shouldDelete(array $ban, bool $require_ban_view) {
return $ban['expires'] && ($ban['seen'] || !$require_ban_view) && $ban['expires'] < time();
}
static private function deleteBans(array $ban_ids) {
$len = count($ban_ids);
if ($len === 1) {
@@ -10,7 +15,7 @@ class Bans {
$query->bindValue(':id', $ban_ids[0], PDO::PARAM_INT);
$query->execute() or error(db_error());
rebuildThemes('bans');
Vichan\Functions\Theme\rebuild_themes('bans');
} elseif ($len >= 1) {
// Build the query.
$query = 'DELETE FROM ``bans`` WHERE `id` IN (';
@@ -28,10 +33,131 @@ class Bans {
$query->execute() or error(db_error());
rebuildThemes('bans');
Vichan\Functions\Theme\rebuild_themes('bans');
}
}
static private function findSingleAutoGc(string $ip, int $ban_id, bool $require_ban_view) {
// Use OR in the query to also garbage collect bans.
$query = prepare(
'SELECT ``bans``.* FROM ``bans``
WHERE ((`ipstart` = :ip OR (:ip >= `ipstart` AND :ip <= `ipend`)) OR (``bans``.id = :id))
ORDER BY `expires` IS NULL, `expires` DESC'
);
$query->bindValue(':id', $ban_id);
$query->bindValue(':ip', inet_pton($ip));
$query->execute() or error(db_error($query));
$found_ban = null;
$to_delete_list = [];
while ($ban = $query->fetch(PDO::FETCH_ASSOC)) {
if (self::shouldDelete($ban, $require_ban_view)) {
$to_delete_list[] = $ban['id'];
} elseif ($ban['id'] === $ban_id) {
if ($ban['post']) {
$ban['post'] = json_decode($ban['post'], true);
}
$ban['mask'] = self::range_to_string([$ban['ipstart'], $ban['ipend']]);
$found_ban = $ban;
}
}
self::deleteBans($to_delete_list);
return $found_ban;
}
static private function findSingleNoGc(int $ban_id) {
$query = prepare(
'SELECT ``bans``.* FROM ``bans``
WHERE ``bans``.id = :id
ORDER BY `expires` IS NULL, `expires` DESC
LIMIT 1'
);
$query->bindValue(':id', $ban_id);
$query->execute() or error(db_error($query));
$ret = $query->fetch(PDO::FETCH_ASSOC);
if ($query->rowCount() == 0) {
return null;
} else {
if ($ret['post']) {
$ret['post'] = json_decode($ret['post'], true);
}
$ret['mask'] = self::range_to_string([$ret['ipstart'], $ret['ipend']]);
return $ret;
}
}
static private function findAutoGc(?string $ip, $board, bool $get_mod_info, bool $require_ban_view, ?int $ban_id): array {
$query = prepare('SELECT ``bans``.*' . ($get_mod_info ? ', `username`' : '') . ' FROM ``bans``
' . ($get_mod_info ? 'LEFT JOIN ``mods`` ON ``mods``.`id` = `creator`' : '') . '
WHERE
(' . ($board !== false ? '(`board` IS NULL OR `board` = :board) AND' : '') . '
(`ipstart` = :ip OR (:ip >= `ipstart` AND :ip <= `ipend`)) OR (``bans``.id = :id))
ORDER BY `expires` IS NULL, `expires` DESC');
if ($board !== false) {
$query->bindValue(':board', $board, PDO::PARAM_STR);
}
$query->bindValue(':id', $ban_id);
$query->bindValue(':ip', inet_pton($ip));
$query->execute() or error(db_error($query));
$ban_list = [];
$to_delete_list = [];
while ($ban = $query->fetch(PDO::FETCH_ASSOC)) {
if (self::shouldDelete($ban, $require_ban_view)) {
$to_delete_list[] = $ban['id'];
} else {
if ($ban['post']) {
$ban['post'] = json_decode($ban['post'], true);
}
$ban['mask'] = self::range_to_string([$ban['ipstart'], $ban['ipend']]);
$ban_list[] = $ban;
}
}
self::deleteBans($to_delete_list);
return $ban_list;
}
static private function findNoGc(?string $ip, string $board, bool $get_mod_info, ?int $ban_id): array {
$query = prepare('SELECT ``bans``.*' . ($get_mod_info ? ', `username`' : '') . ' FROM ``bans``
' . ($get_mod_info ? 'LEFT JOIN ``mods`` ON ``mods``.`id` = `creator`' : '') . '
WHERE
(' . ($board !== false ? '(`board` IS NULL OR `board` = :board) AND' : '') . '
(`ipstart` = :ip OR (:ip >= `ipstart` AND :ip <= `ipend`)) OR (``bans``.id = :id))
AND (`expires` IS NULL OR `expires` >= :curr_time)
ORDER BY `expires` IS NULL, `expires` DESC');
if ($board !== false) {
$query->bindValue(':board', $board, PDO::PARAM_STR);
}
$query->bindValue(':id', $ban_id);
$query->bindValue(':ip', inet_pton($ip));
$query->bindValue(':curr_time', time());
$query->execute() or error(db_error($query));
$ban_list = $query->fetchAll(PDO::FETCH_ASSOC);
array_walk($ban_list, function (&$ban, $_index) {
if ($ban['post']) {
$ban['post'] = json_decode($ban['post'], true);
}
$ban['mask'] = self::range_to_string([$ban['ipstart'], $ban['ipend']]);
});
return $ban_list;
}
static public function range_to_string($mask) {
list($ipstart, $ipend) = $mask;
@@ -55,7 +181,7 @@ class Bans {
$cidr = new CIDR($mask);
$range = $cidr->getRange();
return array(inet_pton($range[0]), inet_pton($range[1]));
return [ inet_pton($range[0]), inet_pton($range[1]) ];
}
public static function parse_time($str) {
@@ -139,82 +265,25 @@ class Bans {
return false;
}
return array($ipstart, $ipend);
return [$ipstart, $ipend];
}
static public function findSingle(string $ip, int $ban_id, bool $require_ban_view): array|null {
/**
* Use OR in the query to also garbage collect bans. Ideally we should move the whole GC procedure to a separate
* script, but it will require a more important restructuring.
*/
$query = prepare(
'SELECT ``bans``.* FROM ``bans``
WHERE ((`ipstart` = :ip OR (:ip >= `ipstart` AND :ip <= `ipend`)) OR (``bans``.id = :id))
ORDER BY `expires` IS NULL, `expires` DESC'
);
$query->bindValue(':id', $ban_id);
$query->bindValue(':ip', inet_pton($ip));
$query->execute() or error(db_error($query));
$found_ban = null;
$to_delete_list = [];
while ($ban = $query->fetch(PDO::FETCH_ASSOC)) {
if ($ban['expires'] && ($ban['seen'] || !$require_ban_view) && $ban['expires'] < time()) {
$to_delete_list[] = $ban['id'];
} elseif ($ban['id'] === $ban_id) {
if ($ban['post']) {
$ban['post'] = json_decode($ban['post'], true);
}
$ban['mask'] = self::range_to_string(array($ban['ipstart'], $ban['ipend']));
$ban['cmask'] = cloak_mask($ban['mask']);
$found_ban = $ban;
}
static public function findSingle(string $ip, int $ban_id, bool $require_ban_view, bool $auto_gc) {
if ($auto_gc) {
return self::findSingleAutoGc($ip, $ban_id, $require_ban_view);
} else {
return self::findSingleNoGc($ban_id);
}
self::deleteBans($to_delete_list);
return $found_ban;
}
static public function find($ip, $board = false, $get_mod_info = false, $banid = null) {
static public function find(?string $ip, $board = false, bool $get_mod_info = false, ?int $ban_id = null, bool $auto_gc = true) {
global $config;
$query = prepare('SELECT ``bans``.*' . ($get_mod_info ? ', `username`' : '') . ' FROM ``bans``
' . ($get_mod_info ? 'LEFT JOIN ``mods`` ON ``mods``.`id` = `creator`' : '') . '
WHERE
(' . ($board !== false ? '(`board` IS NULL OR `board` = :board) AND' : '') . '
(`ipstart` = :ip OR (:ip >= `ipstart` AND :ip <= `ipend`)) OR (``bans``.id = :id))
ORDER BY `expires` IS NULL, `expires` DESC');
if ($board !== false)
$query->bindValue(':board', $board, PDO::PARAM_STR);
$query->bindValue(':id', $banid);
$query->bindValue(':ip', inet_pton($ip));
$query->execute() or error(db_error($query));
$ban_list = array();
$to_delete_list = [];
while ($ban = $query->fetch(PDO::FETCH_ASSOC)) {
if ($ban['expires'] && ($ban['seen'] || !$config['require_ban_view']) && $ban['expires'] < time()) {
$to_delete_list[] = $ban['id'];
} else {
if ($ban['post'])
$ban['post'] = json_decode($ban['post'], true);
$ban['mask'] = self::range_to_string(array($ban['ipstart'], $ban['ipend']));
$ban['cmask'] = cloak_mask($ban['mask']);
$ban_list[] = $ban;
}
if ($auto_gc) {
return self::findAutoGc($ip, $board, $get_mod_info, $config['require_ban_view'], $ban_id);
} else {
return self::findNoGc($ip, $board, $get_mod_info, $ban_id);
}
self::deleteBans($to_delete_list);
return $ban_list;
}
static public function stream_json($out = false, $filter_ips = false, $filter_staff = false, $board_access = false) {
@@ -230,8 +299,7 @@ class Bans {
$end = end($bans);
foreach ($bans as &$ban) {
$uncloaked_mask = self::range_to_string(array($ban['ipstart'], $ban['ipend']));
$ban['mask'] = cloak_mask($uncloaked_mask);
$ban['mask'] = self::range_to_string([$ban['ipstart'], $ban['ipend']]);
if ($ban['post']) {
$post = json_decode($ban['post']);
@@ -275,12 +343,24 @@ class Bans {
static public function seen($ban_id) {
$query = query("UPDATE ``bans`` SET `seen` = 1 WHERE `id` = " . (int)$ban_id) or error(db_error());
rebuildThemes('bans');
Vichan\Functions\Theme\rebuild_themes('bans');
}
static public function purge() {
$query = query("DELETE FROM ``bans`` WHERE `expires` IS NOT NULL AND `expires` < " . time() . " AND `seen` = 1") or error(db_error());
rebuildThemes('bans');
static public function purge($require_seen, $moratorium) {
if ($require_seen) {
$query = prepare("DELETE FROM ``bans`` WHERE `expires` IS NOT NULL AND `expires` + :moratorium < :curr_time AND `seen` = 1");
} else {
$query = prepare("DELETE FROM ``bans`` WHERE `expires` IS NOT NULL AND `expires` + :moratorium < :curr_time");
}
$query->bindValue(':moratorium', $moratorium);
$query->bindValue(':curr_time', time());
$query->execute() or error(db_error($query));
$affected = $query->rowCount();
if ($affected > 0) {
Vichan\Functions\Theme\rebuild_themes('bans');
}
return $affected;
}
static public function delete($ban_id, $modlog = false, $boards = false, $dont_rebuild = false) {
@@ -298,8 +378,7 @@ class Bans {
if ($boards !== false && !in_array($ban['board'], $boards))
error($config['error']['noaccess']);
$mask = self::range_to_string(array($ban['ipstart'], $ban['ipend']));
$cloaked_mask = cloak_mask($mask);
$mask = self::range_to_string([$ban['ipstart'], $ban['ipend']]);
modLog("Removed ban #{$ban_id} for " .
(filter_var($mask, FILTER_VALIDATE_IP) !== false ? "<a href=\"?/IP/$cloaked_mask\">$cloaked_mask</a>" : $cloaked_mask));
@@ -307,7 +386,7 @@ class Bans {
query("DELETE FROM ``bans`` WHERE `id` = " . (int)$ban_id) or error(db_error());
if (!$dont_rebuild) rebuildThemes('bans');
if (!$dont_rebuild) Vichan\Functions\Theme\rebuild_themes('bans');
return true;
}
@@ -364,23 +443,29 @@ class Bans {
openBoard($post['board']);
$post['board'] = $board['uri'];
/*
* The body can be so long to make the json longer than 64KBs, causing the query to fail.
* Truncate it to a safe length (32KBs). It could probably be longer, but if the deleted body is THAT big
* already, the likelihood of it being just assorted spam/garbage is about 101%.
*/
// We're on UTF-8 only, right...?
$post['body'] = mb_strcut($post['body'], 0, 32768);
$query->bindValue(':post', json_encode($post));
} else
$query->bindValue(':post', null, PDO::PARAM_NULL);
$query->execute() or error(db_error($query));
if (isset($mod['id']) && $mod['id'] == $mod_id) {
modLog('Created a new ' .
($length > 0 ? preg_replace('/^(\d+) (\w+?)s?$/', '$1-$2', until($length)) : 'permanent') .
' ban on ' .
($ban_board ? '/' . $ban_board . '/' : 'all boards') .
' for ' .
(filter_var($mask, FILTER_VALIDATE_IP) !== false ? "<a href=\"?/IP/$cloaked_mask\">$cloaked_mask</a>" : $cloaked_mask) .
' (<small>#' . $pdo->lastInsertId() . '</small>)' .
' with ' . ($reason ? 'reason: ' . utf8tohtml($reason) . '' : 'no reason'));
}
rebuildThemes('bans');
$ban_len = $length > 0 ? preg_replace('/^(\d+) (\w+?)s?$/', '$1-$2', Format\until($length)) : 'permanent';
$ban_board = $ban_board ? "/$ban_board/" : 'all boards';
$ban_ip = filter_var($mask, FILTER_VALIDATE_IP) !== false ? "<a href=\"?/IP/$cloaked_mask\">$cloaked_mask</a>" : $cloaked_mask;
$ban_id = $pdo->lastInsertId();
$ban_reason = $reason ? 'reason: ' . utf8tohtml($reason) : 'no reason';
modLog("Created a new $ban_len ban on $ban_board for $ban_ip (<small># $ban_id </small>) with $ban_reason");
Vichan\Functions\Theme\rebuild_themes('bans');
return $pdo->lastInsertId();
}

View File

@@ -4,192 +4,89 @@
* Copyright (c) 2010-2013 Tinyboard Development Group
*/
use Vichan\Data\Driver\{CacheDriver, ApcuCacheDriver, ArrayCacheDriver, FsCacheDriver, MemcachedCacheDriver, NoneCacheDriver, RedisCacheDriver};
defined('TINYBOARD') or exit;
class Cache {
private static $cache;
public static function init() {
private static function buildCache(): CacheDriver {
global $config;
switch ($config['cache']['enabled']) {
case 'memcached':
self::$cache = new Memcached();
self::$cache->addServers($config['cache']['memcached']);
break;
return new MemcachedCacheDriver(
$config['cache']['prefix'],
$config['cache']['memcached']
);
case 'redis':
self::$cache = new Redis();
self::$cache->connect($config['cache']['redis'][0], $config['cache']['redis'][1]);
if ($config['cache']['redis'][2]) {
self::$cache->auth($config['cache']['redis'][2]);
}
self::$cache->select($config['cache']['redis'][3]) or die('cache select failure');
break;
return new RedisCacheDriver(
$config['cache']['prefix'],
$config['cache']['redis'][0],
$config['cache']['redis'][1],
$config['cache']['redis'][2],
$config['cache']['redis'][3]
);
case 'apcu':
return new ApcuCacheDriver;
case 'fs':
return new FsCacheDriver(
$config['cache']['prefix'],
"tmp/cache/{$config['cache']['prefix']}",
'.lock',
$config['auto_maintenance'] ? 1000 : false
);
case 'none':
return new NoneCacheDriver();
case 'php':
self::$cache = array();
break;
default:
return new ArrayCacheDriver();
}
}
public static function getCache(): CacheDriver {
static $cache;
return $cache ??= self::buildCache();
}
public static function get($key) {
global $config, $debug;
$key = $config['cache']['prefix'] . $key;
$data = false;
switch ($config['cache']['enabled']) {
case 'memcached':
if (!self::$cache)
self::init();
$data = self::$cache->get($key);
break;
case 'apcu':
$data = apcu_fetch($key);
break;
case 'php':
$data = isset(self::$cache[$key]) ? self::$cache[$key] : false;
break;
case 'fs':
$key = str_replace('/', '::', $key);
$key = str_replace("\0", '', $key);
if (!file_exists('tmp/cache/'.$key)) {
$data = false;
}
else {
$data = file_get_contents('tmp/cache/'.$key);
$data = json_decode($data, true);
}
break;
case 'redis':
if (!self::$cache)
self::init();
$data = json_decode(self::$cache->get($key), true);
break;
$ret = self::getCache()->get($key);
if ($ret === null) {
$ret = false;
}
if ($config['debug'])
$debug['cached'][] = $key . ($data === false ? ' (miss)' : ' (hit)');
if ($config['debug']) {
$debug['cached'][] = $config['cache']['prefix'] . $key . ($ret === false ? ' (miss)' : ' (hit)');
}
return $data;
return $ret;
}
public static function set($key, $value, $expires = false) {
global $config, $debug;
$key = $config['cache']['prefix'] . $key;
if (!$expires)
if (!$expires) {
$expires = $config['cache']['timeout'];
switch ($config['cache']['enabled']) {
case 'memcached':
if (!self::$cache)
self::init();
self::$cache->set($key, $value, $expires);
break;
case 'redis':
if (!self::$cache)
self::init();
self::$cache->setex($key, $expires, json_encode($value));
break;
case 'apcu':
apcu_store($key, $value, $expires);
break;
case 'fs':
$key = str_replace('/', '::', $key);
$key = str_replace("\0", '', $key);
file_put_contents('tmp/cache/'.$key, json_encode($value));
break;
case 'php':
self::$cache[$key] = $value;
break;
}
if ($config['debug'])
$debug['cached'][] = $key . ' (set)';
self::getCache()->set($key, $value, $expires);
if ($config['debug']) {
$debug['cached'][] = $config['cache']['prefix'] . $key . ' (set)';
}
}
public static function delete($key) {
global $config, $debug;
$key = $config['cache']['prefix'] . $key;
self::getCache()->delete($key);
switch ($config['cache']['enabled']) {
case 'memcached':
if (!self::$cache)
self::init();
self::$cache->delete($key);
break;
case 'redis':
if (!self::$cache)
self::init();
self::$cache->del($key);
break;
case 'apcu':
apcu_delete($key);
break;
case 'fs':
$key = str_replace('/', '::', $key);
$key = str_replace("\0", '', $key);
@unlink('tmp/cache/'.$key);
break;
case 'php':
unset(self::$cache[$key]);
break;
if ($config['debug']) {
$debug['cached'][] = $config['cache']['prefix'] . $key . ' (deleted)';
}
if ($config['debug'])
$debug['cached'][] = $key . ' (deleted)';
}
public static function flush() {
global $config;
switch ($config['cache']['enabled']) {
case 'memcached':
if (!self::$cache)
self::init();
return self::$cache->flush();
case 'apcu':
return apcu_clear_cache('user');
case 'php':
self::$cache = array();
break;
case 'fs':
$files = glob('tmp/cache/*');
foreach ($files as $file) {
unlink($file);
}
break;
case 'redis':
if (!self::$cache)
self::init();
return self::$cache->flushDB();
}
self::getCache()->flush();
return false;
}
}
class Twig_Cache_TinyboardFilesystem extends Twig\Cache\FilesystemCache
{
private $directory;
private $options;
/**
* {@inheritdoc}
*/
public function __construct($directory, $options = 0)
{
parent::__construct($directory, $options);
$this->directory = $directory;
}
/**
* This function was removed in Twig 2.x due to developer views on the Twig library. Who says we can't keep it for ourselves though?
*/
public function clear()
{
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->directory), RecursiveIteratorIterator::LEAVES_ONLY) as $file) {
if ($file->isFile()) {
@unlink($file->getPathname());
}
}
}
}

View File

@@ -65,9 +65,29 @@
// been generated. This keeps the script from querying the database and causing strain when not needed.
$config['has_installed'] = '.installed';
// Use syslog() for logging all error messages and unauthorized login attempts.
// Deprecated, use 'log_system'.
$config['syslog'] = false;
$config['log_system'] = [
/*
* Log all error messages and unauthorized login attempts.
* Can be "syslog", "error_log" (default), "file", or "stderr".
*/
'type' => 'error_log',
// The application name used by the logging system. Defaults to "tinyboard" for backwards compatibility.
'name' => 'tinyboard',
/*
* Only relevant if 'log_system' is set to "syslog". If true, double print the logs also in stderr. Defaults to
* false.
*/
'syslog_stderr' => false,
/*
* Only relevant if "log_system" is set to `file`. Sets the file that vichan will log to. Defaults to
* '/var/log/vichan.log'.
*/
'file_path' => '/var/log/vichan.log',
];
// Use `host` via shell_exec() to lookup hostnames, avoiding query timeouts. May not work on your system.
// Requires safe_mode to be disabled.
$config['dns_system'] = false;
@@ -79,6 +99,11 @@
// to the environment path (seperated by :).
$config['shell_path'] = '/usr/local/bin';
// Automatically execute some maintenance tasks when some pages are opened, which may result in higher
// latencies.
// If set to false, ensure to periodically invoke the tools/maintenance.php script.
$config['auto_maintenance'] = true;
/*
* ====================
* Database settings
@@ -114,17 +139,26 @@
/*
* On top of the static file caching system, you can enable the additional caching system which is
* designed to minimize SQL queries and can significantly increase speed when posting or using the
* moderator interface. APC is the recommended method of caching.
* designed to minimize request processing can significantly increase speed when posting or using
* the moderator interface.
*
* https://github.com/vichan-devel/vichan/wiki/cache
*/
// Uses a PHP array. MUST NOT be used in multiprocess environments.
$config['cache']['enabled'] = 'php';
// The recommended in-memory method of caching. Requires the extension. Due to how APCu works, this should be
// disabled when you run tools from the cli.
// $config['cache']['enabled'] = 'apcu';
// The Memcache server. Requires the memcached extension, with a final D.
// $config['cache']['enabled'] = 'memcached';
// The Redis server. Requires the extension.
// $config['cache']['enabled'] = 'redis';
// Use the local cache folder. Slower than native but available out of the box and compatible with multiprocess
// environments. You can mount a ram-based filesystem in the cache directory to improve performance.
// $config['cache']['enabled'] = 'fs';
// Technically available, offers a no-op fake cache. Don't use this outside of testing or debugging.
// $config['cache']['enabled'] = 'none';
// Timeout for cached objects such as posts and HTML.
$config['cache']['timeout'] = 60 * 60 * 48; // 48 hours
@@ -173,7 +207,7 @@
// How long should the cookies last (in seconds). Defines how long should moderators should remain logged
// in (0 = browser session).
$config['cookies']['expire'] = 60 * 60 * 24 * 30 * 6; // ~6 months
$config['cookies']['expire'] = 60 * 60 * 24 * 7; // 1 week.
// Make this something long and random for security.
$config['cookies']['salt'] = 'abcdefghijklmnopqrstuvwxyz09123456789!@#$%^&*()';
@@ -181,9 +215,20 @@
// Whether or not you can access the mod cookie in JavaScript. Most users should not need to change this.
$config['cookies']['httponly'] = true;
// Do not allow logins via unsecure connections.
// 0 = off. Allow logins on unencrypted HTTP connections. Should only be used in testing environments.
// 1 = on, trust HTTP headers. Allow logins on (at least reportedly partial) HTTPS connections. Use this only if you
// use a proxy, CDN or load balancer via an unencrypted connection. Be sure to filter 'HTTP_X_FORWARDED_PROTO' in
// the remote server, since an attacker could inject the header from the client.
// 2 = on, do not trust HTTP headers. Secure default, allow logins only on HTTPS connections.
$config['cookies']['secure_login_only'] = 2;
// Used to salt secure tripcodes ("##trip") and poster IDs (if enabled).
$config['secure_trip_salt'] = ')(*&^%$#@!98765432190zyxwvutsrqponmlkjihgfedcba';
// Used to salt poster passwords.
$config['secure_password_salt'] = 'wKJSb7M5SyzMcFWD2gPO3j2RYUSO9B789!@#$%^&*()';
/*
* ====================
* Flood/spam settings
@@ -230,83 +275,6 @@
// To prevent bump attacks; returns the thread to last position after the last post is deleted.
$config['anti_bump_flood'] = false;
/*
* Introduction to vichan's spam filter:
*
* In simple terms, whenever a posting form on a page is generated (which happens whenever a
* post is made), vichan will add a random amount of hidden, obscure fields to it to
* confuse bots and upset hackers. These fields and their respective obscure values are
* validated upon posting with a 160-bit "hash". That hash can only be used as many times
* as you specify; otherwise, flooding bots could just keep reusing the same hash.
* Once a new set of inputs (and the hash) are generated, old hashes for the same thread
* and board are set to expire. Because you have to reload the page to get the new set
* of inputs and hash, if they expire too quickly and more than one person is viewing the
* page at a given time, vichan would return false positives (depending on how long the
* user sits on the page before posting). If your imageboard is quite fast/popular, set
* $config['spam']['hidden_inputs_max_pass'] and $config['spam']['hidden_inputs_expire'] to
* something higher to avoid false positives.
*
* See also: https://github.com/vichan-devel/vichan/wiki/your_request_looks_automated
*
*/
// Number of hidden fields to generate.
$config['spam']['hidden_inputs_min'] = 4;
$config['spam']['hidden_inputs_max'] = 12;
// How many times can a "hash" be used to post?
$config['spam']['hidden_inputs_max_pass'] = 12;
// How soon after regeneration do hashes expire (in seconds)?
$config['spam']['hidden_inputs_expire'] = 60 * 60 * 3; // three hours
// Whether to use Unicode characters in hidden input names and values.
$config['spam']['unicode'] = true;
// These are fields used to confuse the bots. Make sure they aren't actually used by vichan, or it won't work.
$config['spam']['hidden_input_names'] = array(
'user',
'username',
'login',
'search',
'q',
'url',
'firstname',
'lastname',
'text',
'message'
);
// Always update this when adding new valid fields to the post form, or EVERYTHING WILL BE DETECTED AS SPAM!
$config['spam']['valid_inputs'] = array(
'hash',
'board',
'thread',
'mod',
'name',
'email',
'subject',
'post',
'body',
'password',
'sticky',
'lock',
'raw',
'embed',
'g-recaptcha-response',
'h-captcha-response',
'captcha_cookie',
'captcha_text',
'spoiler',
'page',
'file_url',
'json_response',
'user_flag',
'no_country',
'tag',
'simple_spam'
);
// Enable simple anti-spam measure. Requires the end-user to answer a question before making a post.
// Works very well against uncustomized spam. Answers are case-insensitive.
// $config['simple_spam'] = array (
@@ -315,39 +283,39 @@
//);
$config['simple_spam'] = false;
// Enable reCaptcha to make spam even harder. Rarely necessary.
$config['recaptcha'] = false;
// Public and private key pair from https://www.google.com/recaptcha/admin/create
$config['recaptcha_public'] = '6LcXTcUSAAAAAKBxyFWIt2SO8jwx4W7wcSMRoN3f';
$config['recaptcha_private'] = '6LcXTcUSAAAAAOGVbVdhmEM1_SyRF4xTKe8jbzf_';
// Enable hCaptcha as an alternative to reCAPTCHA.
$config['hcaptcha'] = false;
// Public and private key pair for using hCaptcha.
$config['hcaptcha_public'] = '7a4b21e0-dc53-46f2-a9f8-91d2e74b63a0';
$config['hcaptcha_private'] = '0x4e9A01bE637b51dC41a7Ea9865C3fDe4aB72Cf17';
// Enable Custom Captcha you need to change a couple of settings
//Read more at: /inc/captcha/readme.md
$config['captcha'] = array();
// Enable custom captcha provider
$config['captcha']['enabled'] = false;
//New thread captcha
//Require solving a captcha to post a thread.
//Default off.
$config['new_thread_capt'] = false;
// Custom captcha get provider path (if not working get the absolute path aka your url.)
$config['captcha']['provider_get'] = '../inc/captcha/entrypoint.php';
// Custom captcha check provider path
$config['captcha']['provider_check'] = '../inc/captcha/entrypoint.php';
// Custom captcha extra field (eg. charset)
$config['captcha']['extra'] = 'abcdefghijklmnopqrstuvwxyz';
$config['captcha'] = [
// Can be false, 'recaptcha', 'hcaptcha' or 'native'.
'provider' => false,
/*
* If not false, the captcha is dynamically injected on the client if the web server set the `captcha-required`
* cookie to 1. The configuration value should be set the IP for which the captcha should be verified.
*
* Example:
*
* // Verify the captcha for users sending posts from the loopback address.
* $config['captcha']['dynamic'] = '127.0.0.1';
*/
'dynamic' => false,
'recaptcha' => [
'sitekey' => '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI',
'secret' => '6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe',
],
'hcaptcha' => [
'sitekey' => '10000000-ffff-ffff-ffff-000000000001',
'secret' => '0x0000000000000000000000000000000000000000',
],
// To enable the native captcha you need to change a couple of settings. Read more at: /inc/captcha/readme.md
'native' => [
// Custom captcha get provider path (if not working get the absolute path aka your url).
'provider_get' => '/inc/captcha/entrypoint.php',
// Custom captcha check provider path
'provider_check' => '/inc/captcha/entrypoint.php',
// Custom captcha extra field (eg. charset)
'extra' => 'abcdefghijklmnopqrstuvwxyz',
// New thread captcha. Require solving a captcha to post a thread.
'new_thread_capt' => false
]
];
// Ability to lock a board for normal users and still allow mods to post. Could also be useful for making an archive board
$config['board_locked'] = false;
@@ -487,6 +455,17 @@
// 'action' => 'reject'
// );
// Example: Expand shortened links in a post, looking for and blocking URLs that lead to an unwanted
// endpoint. Many botspam posts include a variety of shortened URLs which all point to the same few
// webhosts. You can use this filter to block the endpoint webhost instead of just the apparent URL.
// $config['filters'][] = array(
// 'condition' => array(
// 'unshorten' => '/endpoint.net/i',
// ),
// 'action' => 'reject',
// 'message' => 'None of that, please.'
// );
// Filter flood prevention conditions ("flood-match") depend on a table which contains a cache of recent
// posts across all boards. This table is automatically purged of older posts, determining the maximum
// "age" by looking at each filter. However, when determining the maximum age, vichan does not look
@@ -611,6 +590,9 @@
// Example: Custom secure tripcode.
// $config['custom_tripcode']['##securetrip'] = '!!somethingelse';
//Disable tripcodes. This will make it so all new posts will act as if no tripcode exists.
$config['disable_tripcodes'] = false;
// Allow users to mark their image as a "spoiler" when posting. The thumbnail will be replaced with a
// static spoiler image instead (see $config['spoiler_image']).
$config['spoiler_images'] = false;
@@ -662,6 +644,9 @@
);
*/
// Maximum number inline of dice rolls per markup.
$config['max_roll_count'] = 50;
// Allow dice rolling: an email field of the form "dice XdY+/-Z" will result in X Y-sided dice rolled and summed,
// with the modifier Z added, with the result displayed at the top of the post body.
$config['allow_roll'] = false;
@@ -709,6 +694,9 @@
//);
$config['premade_ban_reasons'] = false;
// How often (minimum) to purge the ban list of expired bans (which have been seen).
$config['purge_bans'] = 60 * 60 * 12; // 12 hours
// Allow users to appeal bans through vichan.
$config['ban_appeals'] = false;
@@ -730,11 +718,15 @@
* ====================
*/
// "Wiki" markup syntax ($config['wiki_markup'] in pervious versions):
$config['markup'][] = array("/'''(.+?)'''/", "<strong>\$1</strong>");
$config['markup'][] = array("/''(.+?)''/", "<em>\$1</em>");
$config['markup'][] = array("/\*\*(.+?)\*\*/", "<span class=\"spoiler\">\$1</span>");
$config['markup'][] = array("/^[ |\t]*==(.+?)==[ |\t]*$/m", "<span class=\"heading\">\$1</span>");
$config['markup'] = [
// Inline dice roll markup.
[ "/!([-+]?\d+)?([d])([-+]?\d+)([-+]\d+)?/iu", fn($m) => inline_dice_roll_markup($m, 'static/d10.svg') ],
// "Wiki" markup syntax ($config['wiki_markup'] in pervious versions):
[ "/'''(.+?)'''/", "<strong>\$1</strong>" ],
[ "/''(.+?)''/", "<em>\$1</em>" ],
[ "/\*\*(.+?)\*\*/", "<span class=\"spoiler\">\$1</span>" ],
[ "/^[ |\t]*==(.+?)==[ |\t]*$/m", "<span class=\"heading\">\$1</span>" ],
];
// Code markup. This should be set to a regular expression, using tags you want to use. Examples:
// "/\[code\](.*?)\[\/code\]/is"
@@ -839,12 +831,14 @@
$config['ie_mime_type_detection'] = '/<(?:body|head|html|img|plaintext|pre|script|table|title|a href|channel|scriptlet)/i';
// Allowed image file extensions.
$config['allowed_ext'][] = 'jpg';
$config['allowed_ext'][] = 'jpeg';
$config['allowed_ext'][] = 'bmp';
$config['allowed_ext'][] = 'gif';
$config['allowed_ext'][] = 'png';
$config['allowed_ext'][] = 'webp';
$config['allowed_ext'] = [
'jpg',
'jpeg',
'bmp',
'gif',
'png',
'webp'
];
// $config['allowed_ext'][] = 'svg';
// Allowed extensions for OP. Inherits from the above setting if set to false. Otherwise, it overrides both allowed_ext and
@@ -862,10 +856,12 @@
// };
// Thumbnail to use for the non-image file uploads.
$config['file_icons']['default'] = 'file.png';
$config['file_icons']['zip'] = 'zip.png';
$config['file_icons']['webm'] = 'video.png';
$config['file_icons']['mp4'] = 'video.png';
$config['file_icons'] = [
'default' => 'file.png',
'zip' => 'zip.png',
'webm' => 'video.png',
'mp4' => 'video.png'
];
// Example: Custom thumbnail for certain file extension.
// $config['file_icons']['extension'] = 'some_file.png';
@@ -897,11 +893,13 @@
$config['show_filename'] = true;
// WebM Settings
$config['webm']['use_ffmpeg'] = false;
$config['webm']['allow_audio'] = false;
$config['webm']['max_length'] = 120;
$config['webm']['ffmpeg_path'] = 'ffmpeg';
$config['webm']['ffprobe_path'] = 'ffprobe';
$config['webm'] = [
'use_ffmpeg' => false,
'allow_audio' => false,
'max_length' => 120,
'ffmpeg_path' => 'ffmpeg',
'ffprobe_path' => 'ffprobe'
];
// Display image identification links for ImgOps, regex.info/exif, Google Images and iqdb.
$config['image_identification'] = false;
@@ -976,11 +974,11 @@
// Timezone to use for displaying dates/times.
$config['timezone'] = 'America/Los_Angeles';
// The format string passed to strftime() for displaying dates.
// http://www.php.net/manual/en/function.strftime.php
$config['post_date'] = '%m/%d/%y (%a) %H:%M:%S';
// The format string passed to DateTime::format() for displaying dates. ISO 8601-like by default.
// https://www.php.net/manual/en/datetime.format.php
$config['post_date'] = 'm/d/y (D) H:i:s';
// Same as above, but used for "you are banned' pages.
$config['ban_date'] = '%A %e %B, %Y';
$config['ban_date'] = 'l j F, Y';
// The names on the post buttons. (On most imageboards, these are both just "Post").
$config['button_newtopic'] = _('New Topic');
@@ -1010,8 +1008,11 @@
// Custom stylesheets available for the user to choose. See the "stylesheets/" folder for a list of
// available stylesheets (or create your own).
$config['stylesheets']['Yotsuba B'] = ''; // Default; there is no additional/custom stylesheet for this.
$config['stylesheets']['Yotsuba'] = 'yotsuba.css';
$config['stylesheets'] = [
// Default; there is no additional/custom stylesheet for this.
'Yotsuba B' => '',
'Yotsuba' => 'yotsuba.css'
];
// $config['stylesheets']['Futaba'] = 'futaba.css';
// $config['stylesheets']['Dark'] = 'dark.css';
@@ -1093,6 +1094,10 @@
// <tinyboard flag style>.
$config['flag_style'] = 'width:16px;height:11px;';
// Lazy loading
// https://developer.mozilla.org/en-US/docs/Web/Performance/Lazy_loading
$config['content_lazy_loading'] = false;
/*
* ====================
* Javascript
@@ -1125,6 +1130,10 @@
// Minify Javascript using http://code.google.com/p/minify/.
$config['minify_js'] = false;
// Version number for main.js (or $config['url_javascript']).
// You can use this to bypass the user's browsers and CDN caches.
$config['resource_version'] = 0;
// Dispatch thumbnail loading and image configuration with JavaScript. It will need a certain javascript
// code to work.
$config['javascript_image_dispatch'] = false;
@@ -1141,11 +1150,11 @@
// Custom embedding (YouTube, vimeo, etc.)
// It's very important that you match the entire input (with ^ and $) or things will not work correctly.
// Be careful when creating a new embed, because depending on the URL you end up exposing yourself to an XSS.
$config['embedding'] = array(
$config['embedding'] = array(
array(
'/^https?:\/\/(\w+\.)?youtube\.com\/watch\?v=([a-zA-Z0-9\-_]{10,11})?$/i',
'<iframe style="float: left; margin: 10px 20px;" width="%%tb_width%%" height="%%tb_height%%" frameborder="0" id="ytplayer" src="https://www.youtube.com/embed/$2"></iframe>'
),
'/^https?:\/\/(\w+\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9\-_]{10,11})?$/i',
'<iframe style="float: left; margin: 10px 20px;" width="%%tb_width%%" height="%%tb_height%%" frameborder="0" id="ytplayer" src="https://www.youtube.com/embed/$3"></iframe>'
),
array(
'/^https?:\/\/(\w+\.)?vimeo\.com\/(\d{2,10})(\?.+)?$/i',
'<iframe style="float: left; margin: 10px 20px;" width="%%tb_width%%" height="%%tb_height%%" frameborder="0" src="https://player.vimeo.com/video/$2"></iframe>'
@@ -1159,9 +1168,10 @@
'<iframe style="float: left; margin: 10px 20px;" width="%%tb_width%%" height="%%tb_height%%" frameborder="0" src="https://www.metacafe.com/embed/$2/$3/" allowfullscreen></iframe>'
),
array(
'/^https?:\/\/(\w+\.)?vocaroo\.com\/([a-zA-Z0-9]{2,12})$/i',
'<iframe style="float: left; margin: 10px 20px;" width="300" height="60" frameborder="0" src="https://vocaroo.com/embed/$2"></iframe>'
)
'/^https?:\/\/(\w+\.)?(vocaroo\.com\/|voca\.ro\/)([a-zA-Z0-9]{2,12})$/i',
'<iframe style="float: left; margin: 10px 20px;" width="300" height="60" frameborder="0" src="https://vocaroo.com/embed/$3"></iframe>'
),
);
// Embedding width and height.
@@ -1174,83 +1184,86 @@
* ====================
*/
// Error messages
$config['error']['bot'] = _('You look like a bot.');
$config['error']['referer'] = _('Your browser sent an invalid or no HTTP referer.');
$config['error']['toolong'] = _('The %s field was too long.');
$config['error']['toolong_body'] = _('The body was too long.');
$config['error']['tooshort_body'] = _('The body was too short or empty.');
$config['error']['toomanylines'] = _('Your post contains too many lines!');
$config['error']['noimage'] = _('You must upload an image.');
$config['error']['toomanyimages'] = _('You have attempted to upload too many images!');
$config['error']['nomove'] = _('The server failed to handle your upload.');
$config['error']['fileext'] = _('Unsupported image format.');
$config['error']['noboard'] = _('Invalid board!');
$config['error']['nonexistant'] = _('Thread specified does not exist.');
$config['error']['nopost'] = _('Post specified does not exist.');
$config['error']['locked'] = _('Thread locked. You may not reply at this time.');
$config['error']['reply_hard_limit'] = _('Thread has reached its maximum reply limit.');
$config['error']['image_hard_limit'] = _('Thread has reached its maximum image limit.');
$config['error']['nopost'] = _('You didn\'t make a post.');
$config['error']['flood'] = _('Flood detected; Post discarded.');
$config['error']['too_many_threads'] = _('The hourly thread limit has been reached. Please post in an existing thread.');
$config['error']['spam'] = _('Your request looks automated; Post discarded.');
$config['error']['simple_spam'] = _('You must answer the question to make a new thread. See the last field.');
$config['error']['unoriginal'] = _('Unoriginal content!');
$config['error']['muted'] = _('Unoriginal content! You have been muted for %d seconds.');
$config['error']['youaremuted'] = _('You are muted! Expires in %d seconds.');
$config['error']['dnsbl'] = _('Your IP address is listed in %s.');
$config['error']['toomanylinks'] = _('Too many links; flood detected.');
$config['error']['toomanycites'] = _('Too many cites; post discarded.');
$config['error']['toomanycross'] = _('Too many cross-board links; post discarded.');
$config['error']['nodelete'] = _('You didn\'t select anything to delete.');
$config['error']['noreport'] = _('You didn\'t select anything to report.');
$config['error']['toolongreport'] = _('The reason was too long.');
$config['error']['toomanyreports'] = _('You can\'t report that many posts at once.');
$config['error']['noban'] = _('That ban doesn\'t exist or is not for you.');
$config['error']['tooshortban'] = _('You cannot appeal a ban of this length.');
$config['error']['toolongappeal'] = _('The appeal was too long.');
$config['error']['toomanyappeals'] = _('You cannot appeal this ban again.');
$config['error']['pendingappeal'] = _('There is already a pending appeal for this ban.');
$config['error']['invalidpassword'] = _('Wrong password…');
$config['error']['invalidimg'] = _('Invalid image.');
$config['error']['phpfileserror'] = _('Upload failure (file #%index%): Error code %code%. Refer to <a href="http://php.net/manual/en/features.file-upload.errors.php">http://php.net/manual/en/features.file-upload.errors.php</a>; post discarded.');
$config['error']['unknownext'] = _('Unknown file extension.');
$config['error']['filesize'] = _('Maximum file size: %maxsz% bytes<br>Your file\'s size: %filesz% bytes');
$config['error']['maxsize'] = _('The file was too big.');
$config['error']['genwebmerror'] = _('There was a problem processing your webm.');
$config['error']['webmerror'] = _('There was a problem processing your webm.');//Is this error used anywhere ?
$config['error']['invalidwebm'] = _('Invalid webm uploaded.');
$config['error']['webmhasaudio'] = _('The uploaded webm contains an audio or another type of additional stream.');
$config['error']['webmtoolong'] =_('The uploaded webm is longer than %d seconds.');
$config['error']['fileexists'] = _('That file <a href="%s">already exists</a>!');
$config['error']['fileexistsinthread'] = _('That file <a href="%s">already exists</a> in this thread!');
$config['error']['delete_too_soon'] = _('You\'ll have to wait another %s before deleting that.');
$config['error']['delete_too_late'] = _('You cannot delete a post this old.');
$config['error']['mime_exploit'] = _('MIME type detection XSS exploit (IE) detected; post discarded.');
$config['error']['invalid_embed'] = _('Couldn\'t make sense of the URL of the video you tried to embed.');
$config['error']['captcha'] = _('You seem to have mistyped the verification.');
$config['error']['flag_undefined'] = _('The flag %s is undefined, your PHP version is too old!');
$config['error']['flag_wrongtype'] = _('defined_flags_accumulate(): The flag %s is of the wrong type!');
$config['error'] = [
// General error messages
'bot' => _('You look like a bot.'),
'referer' => _('Your browser sent an invalid or no HTTP referer.'),
'toolong' => _('The %s field was too long.'),
'toolong_body' => _('The body was too long.'),
'tooshort_body' => _('The body was too short or empty.'),
'toomanylines' => _('Your post contains too many lines!'),
'noimage' => _('You must upload an image.'),
'toomanyimages' => _('You have attempted to upload too many images!'),
'nomove' => _('The server failed to handle your upload.'),
'fileext' => _('Unsupported image format.'),
'noboard' => _('Invalid board!'),
'nonexistant' => _('Thread specified does not exist.'),
'nopost' => _('Post specified does not exist.'),
'locked' => _('Thread locked. You may not reply at this time.'),
'reply_hard_limit' => _('Thread has reached its maximum reply limit.'),
'image_hard_limit' => _('Thread has reached its maximum image limit.'),
'nopost' => _('You didn\'t make a post.'),
'flood' => _('Flood detected; Post discarded.'),
'too_many_threads' => _('The hourly thread limit has been reached. Please post in an existing thread.'),
'spam' => _('Your request looks automated; Post discarded.'),
'simple_spam' => _('You must answer the question to make a new thread. See the last field.'),
'unoriginal' => _('Unoriginal content!'),
'muted' => _('Unoriginal content! You have been muted for %d seconds.'),
'youaremuted' => _('You are muted! Expires in %d seconds.'),
'dnsbl' => _('Your IP address is listed in %s.'),
'toomanylinks' => _('Too many links; flood detected.'),
'toomanycites' => _('Too many cites; post discarded.'),
'toomanycross' => _('Too many cross-board links; post discarded.'),
'nodelete' => _('You didn\'t select anything to delete.'),
'noreport' => _('You didn\'t select anything to report.'),
'toolongreport' => _('The reason was too long.'),
'toomanyreports' => _('You can\'t report that many posts at once.'),
'noban' => _('That ban doesn\'t exist or is not for you.'),
'tooshortban' => _('You cannot appeal a ban of this length.'),
'toolongappeal' => _('The appeal was too long.'),
'toomanyappeals' => _('You cannot appeal this ban again.'),
'pendingappeal' => _('There is already a pending appeal for this ban.'),
'invalidpassword' => _('Wrong password…'),
'invalidimg' => _('Invalid image.'),
'phpfileserror' => _('Upload failure (file #%index%): Error code %code%. Refer to <a href=>"http://php.net/manual/en/features.file-upload.errors.php">http://php.net/manual/en/features.file-upload.errors.php</a>; post discarded.'),
'unknownext' => _('Unknown file extension.'),
'filesize' => _('Maximum file size: %maxsz% bytes<br>Your file\'s size: %filesz% bytes'),
'maxsize' => _('The file was too big.'),
'genwebmerror' => _('There was a problem processing your webm.'),
'invalidwebm' => _('Invalid webm uploaded.'),
'webmhasaudio' => _('The uploaded webm contains an audio or another type of additional stream.'),
'webmtoolong' =>_('The uploaded webm is longer than %d seconds.'),
'fileexists' => _('That file <a href=>"%s">already exists</a>!'),
'fileexistsinthread' => _('That file <a href=>"%s">already exists</a> in this thread!'),
'delete_too_soon' => _('You\'ll have to wait another %s before deleting that.'),
'delete_too_late' => _('You cannot delete a post this old.'),
'mime_exploit' => _('MIME type detection XSS exploit (IE) detected; post discarded.'),
'invalid_embed' => _('Couldn\'t make sense of the URL of the video you tried to embed.'),
'captcha' => _('You seem to have mistyped the verification.'),
'flag_undefined' => _('The flag %s is undefined, your PHP version is too old!'),
'flag_wrongtype' => _('defined_flags_accumulate(): The flag %s is of the wrong type!'),
'remote_io_error' => _('IO error while interacting with a remote service.'),
'local_io_error' => _('IO error while interacting with a local resource or service.'),
// Moderator errors
$config['error']['toomanyunban'] = _('You are only allowed to unban %s users at a time. You tried to unban %u users.');
$config['error']['invalid'] = _('Invalid username and/or password.');
$config['error']['notamod'] = _('You are not a mod…');
$config['error']['invalidafter'] = _('Invalid username and/or password. Your user may have been deleted or changed.');
$config['error']['malformed'] = _('Invalid/malformed cookies.');
$config['error']['missedafield'] = _('Your browser didn\'t submit an input when it should have.');
$config['error']['required'] = _('The %s field is required.');
$config['error']['invalidfield'] = _('The %s field was invalid.');
$config['error']['boardexists'] = _('There is already a %s board.');
$config['error']['noaccess'] = _('You don\'t have permission to do that.');
$config['error']['invalidpost'] = _('That post doesn\'t exist…');
$config['error']['404'] = _('Page not found.');
$config['error']['modexists'] = _('That mod <a href="?/users/%d">already exists</a>!');
$config['error']['invalidtheme'] = _('That theme doesn\'t exist!');
$config['error']['csrf'] = _('Invalid security token! Please go back and try again.');
$config['error']['badsyntax'] = _('Your code contained PHP syntax errors. Please go back and correct them. PHP says: ');
// Moderator errors
'toomanyunban' => _('You are only allowed to unban %s users at a time. You tried to unban %u users.'),
'invalid' => _('Invalid username and/or password.'),
'insecure' => _('Login on insecure connections is disabled.'),
'notamod' => _('You are not a mod…'),
'invalidafter' => _('Invalid username and/or password. Your user may have been deleted or changed.'),
'malformed' => _('Invalid/malformed cookies.'),
'missedafield' => _('Your browser didn\'t submit an input when it should have.'),
'required' => _('The %s field is required.'),
'invalidfield' => _('The %s field was invalid.'),
'boardexists' => _('There is already a %s board.'),
'noaccess' => _('You don\'t have permission to do that.'),
'invalidpost' => _('That post doesn\'t exist…'),
'404' => _('Page not found.'),
'modexists' => _('That mod <a href="?/users/%d">already exists</a>!'),
'invalidtheme' => _('That theme doesn\'t exist!'),
'csrf' => _('Invalid security token! Please go back and try again.'),
'badsyntax' => _('Your code contained PHP syntax errors. Please go back and correct them. PHP says: ')
];
/*
* =========================
@@ -1272,8 +1285,8 @@
// The scheme and domain. This is used to get the site's absolute URL (eg. for image identification links).
// If you use the CLI tools, it would be wise to override this setting.
$config['domain'] = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off') ? 'https://' : 'http://';
$config['domain'] .= isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost';
$config['domain'] = ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off') ? 'https://' : 'http://')
. (isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost');
// If for some reason the folders and static HTML index files aren't in the current working direcotry,
// enter the directory path here. Otherwise, keep it false.
@@ -1353,22 +1366,22 @@
// Board directory, followed by a forward-slash (/).
$config['board_path'] = '%s/';
// Misc directories.
$config['dir']['img'] = 'src/';
$config['dir']['thumb'] = 'thumb/';
$config['dir']['res'] = 'res/';
// For load balancing, having a seperate server (and domain/subdomain) for serving static content is
// possible. This can either be a directory or a URL. Defaults to $config['root'] . 'static/'.
// $config['dir']['static'] = 'http://static.example.org/';
// Where to store the .html templates. This folder and the template files must exist.
$config['dir']['template'] = getcwd() . '/templates';
// Location of vichan "themes".
$config['dir']['themes'] = getcwd() . '/templates/themes';
// Same as above, but a URI (accessable by web interface).
$config['dir']['themes_uri'] = 'templates/themes';
// Home directory. Used by themes.
$config['dir']['home'] = '';
$config['dir'] = [
'img' => 'src/',
'thumb' => 'thumb/',
'res' => 'res/',
// For load balancing, having a seperate server (and domain/subdomain) for serving static content is
// possible. This can either be a directory or a URL. Defaults to $config['root'] . 'static/'.
// $config['dir']['static'] = 'http://static.example.org/';
// Where to store the .html templates. This folder and the template files must exist.
'template' => getcwd() . '/templates',
// Location of vichan "themes".
'themes' => getcwd() . '/templates/themes',
// Same as above, but a URI (accessable by web interface).
'themes_uri' => 'templates/themes',
// Home directory. Used by themes.
'home' => ''
];
// Location of a blank 1x1 gif file. Only used when country_flags_condensed is enabled
// $config['image_blank'] = 'static/blank.gif';
@@ -1441,13 +1454,19 @@
// 5. enable smart_build_helper (see below)
// 6. edit the strategies (see inc/functions.php for the builtin ones). You can use lambdas. I will test
// various ones and include one that works best for me.
$config['generation_strategies'] = array();
// Add a sane strategy. It forces to immediately generate a page user is about to land on. Otherwise,
// it has no opinion, so it needs a fallback strategy.
$config['generation_strategies'][] = 'strategy_sane';
// Add an immediate catch-all strategy. This is the default function of imageboards: generate all pages
// on post time.
$config['generation_strategies'][] = 'strategy_immediate';
$config['generation_strategies'] = [
/*
* Add a sane strategy. It forces to immediately generate a page user is about to land on. Otherwise,
* it has no opinion, so it needs a fallback strategy.
*/
'strategy_sane',
/*
* Add an immediate catch-all strategy. This is the default function of imageboards: generate all pages
* on post time.
*/
'strategy_immediate',
];
// NOT RECOMMENDED: Instead of an all-"immediate" strategy, you can use an all-"build_on_load" one (used
// to be initialized using $config['smart_build']; ) for all pages instead of those to be build
// immediately. A rebuild done in this mode should remove all your static files
@@ -1464,7 +1483,7 @@
$config['page_404'] = '/404.html';
// Extra controller entrypoints. Controller is used only by smart_build and advanced build.
$config['controller_entrypoints'] = array();
$config['controller_entrypoints'] = [];
/*
* ====================
@@ -1472,33 +1491,84 @@
* ====================
*/
// Limit how many bans can be removed via the ban list. Set to false (or zero) for no limit.
$config['mod']['unban_limit'] = false;
// Whether or not to lock moderator sessions to IP addresses. This makes cookie theft ineffective.
$config['mod']['lock_ip'] = true;
// The page that is first shown when a moderator logs in. Defaults to the dashboard (?/).
$config['mod']['default'] = '/';
// Mod links (full HTML).
$config['mod']['link_delete'] = '[D]';
$config['mod']['link_ban'] = '[B]';
$config['mod']['link_bandelete'] = '[B&amp;D]';
$config['mod']['link_deletefile'] = '[F]';
$config['mod']['link_spoilerimage'] = '[S]';
$config['mod']['link_deletebyip'] = '[D+]';
$config['mod']['link_deletebyip_global'] = '[D++]';
$config['mod']['link_sticky'] = '[Sticky]';
$config['mod']['link_desticky'] = '[-Sticky]';
$config['mod']['link_lock'] = '[Lock]';
$config['mod']['link_unlock'] = '[-Lock]';
$config['mod']['link_bumplock'] = '[Sage]';
$config['mod']['link_bumpunlock'] = '[-Sage]';
$config['mod']['link_editpost'] = '[Edit]';
$config['mod']['link_move'] = '[Move]';
$config['mod']['link_cycle'] = '[Cycle]';
$config['mod']['link_uncycle'] = '[-Cycle]';
$config['mod'] = [
// Limit how many bans can be removed via the ban list. Set to false (or zero) for no limit.
'unban_limit' => false,
// Whether or not to lock moderator sessions to IP addresses. This makes cookie theft less effective.
'lock_ip' => true,
// The page that is first shown when a moderator logs in. Defaults to the dashboard (?/).
'default' => '/',
// Do DNS lookups on IP addresses to get their hostname for the moderator IP pages (?/IP/x.x.x.x).
'dns_lookup' => true,
// How many recent posts, per board, to show in ?/IP/x.x.x.x.
'ip_recentposts' => 5,
// Number of posts to display on the reports page.
'recent_reports' => 10,
// Number of actions to show per page in the moderation log.
'modlog_page' => 350,
// Number of bans to show per page in the ban list.
'banlist_page'=> 350,
// Number of news entries to display per page.
'news_page' => 40,
// Number of results to display per page.
'search_page' => 200,
// Number of entries to show per page in the moderator noticeboard.
'noticeboard_page' => 50,
// Number of entries to summarize and display on the dashboard.
'noticeboard_dashboard' => 5,
// Check public ban message by default.
'check_ban_message' => false,
// Default public ban message. In public ban messages, %length% is replaced with "for x days" or
// "permanently" (with %LENGTH% being the uppercase equivalent).
'default_ban_message' => _('USER WAS BANNED FOR THIS POST'),
// $config['mod']['default_ban_message'] = 'USER WAS BANNED %LENGTH% FOR THIS POST';
// HTML to append to post bodies for public bans messages (where "%s" is the message).
'ban_message' => '<span class="public_ban">(%s)</span>',
// When moving a thread to another board and choosing to keep a "shadow thread", an automated post (with
// a capcode) will be made, linking to the new location for the thread. "%s" will be replaced with a
// standard cross-board post citation (>>>/board/xxx)
'shadow_mesage' => _('Moved to %s.'),
// Capcode to use when posting the above message.
'shadow_capcode' => 'Mod',
// Name to use when posting the above message. If false, $config['anonymous'] will be used.
'shadow_name' => false,
// PHP time limit for ?/rebuild. A value of 0 should cause PHP to wait indefinitely.
'rebuild_timelimit' => 0,
// PM snippet (for ?/inbox) length in characters.
'snippet_length' => 75,
// Edit raw HTML in posts by default.
'raw_html_default' => false,
// Automatically dismiss all reports regarding a thread when it is locked.
'dismiss_reports_on_lock' => true,
// Replace ?/config with a simple text editor for editing inc/instance-config.php.
'config_editor_php' => false,
'link_delete' => '[D]',
'link_ban' => '[B]',
'link_bandelete' => '[B&amp;D]',
'link_deletefile' => '[F]',
'link_spoilerimage' => '[S]',
'link_deletebyip' => '[D+]',
'link_deletebyip_global' => '[D++]',
'link_sticky' => '[Sticky]',
'link_desticky' => '[-Sticky]',
'link_lock' => '[Lock]',
'link_unlock' => '[-Lock]',
'link_bumplock' => '[Sage]',
'link_bumpunlock' => '[-Sage]',
'link_editpost' => '[Edit]',
'link_move' => '[Move]',
'link_cycle' => '[Cycle]',
'link_uncycle' => '[-Cycle]'
];
// Moderator capcodes.
$config['capcode'] = ' <span class="capcode">## %s</span>';
@@ -1523,63 +1593,6 @@
// Enable the moving of single replies
$config['move_replies'] = false;
// How often (minimum) to purge the ban list of expired bans (which have been seen). Only works when
// $config['cache'] is enabled and working.
$config['purge_bans'] = 60 * 60 * 12; // 12 hours
// Do DNS lookups on IP addresses to get their hostname for the moderator IP pages (?/IP/x.x.x.x).
$config['mod']['dns_lookup'] = true;
// How many recent posts, per board, to show in ?/IP/x.x.x.x.
$config['mod']['ip_recentposts'] = 5;
// Number of posts to display on the reports page.
$config['mod']['recent_reports'] = 10;
// Number of actions to show per page in the moderation log.
$config['mod']['modlog_page'] = 350;
// Number of bans to show per page in the ban list.
$config['mod']['banlist_page'] = 350;
// Number of news entries to display per page.
$config['mod']['news_page'] = 40;
// Number of results to display per page.
$config['mod']['search_page'] = 200;
// Number of entries to show per page in the moderator noticeboard.
$config['mod']['noticeboard_page'] = 50;
// Number of entries to summarize and display on the dashboard.
$config['mod']['noticeboard_dashboard'] = 5;
// Check public ban message by default.
$config['mod']['check_ban_message'] = false;
// Default public ban message. In public ban messages, %length% is replaced with "for x days" or
// "permanently" (with %LENGTH% being the uppercase equivalent).
$config['mod']['default_ban_message'] = _('USER WAS BANNED FOR THIS POST');
// $config['mod']['default_ban_message'] = 'USER WAS BANNED %LENGTH% FOR THIS POST';
// HTML to append to post bodies for public bans messages (where "%s" is the message).
$config['mod']['ban_message'] = '<span class="public_ban">(%s)</span>';
// When moving a thread to another board and choosing to keep a "shadow thread", an automated post (with
// a capcode) will be made, linking to the new location for the thread. "%s" will be replaced with a
// standard cross-board post citation (>>>/board/xxx)
$config['mod']['shadow_mesage'] = _('Moved to %s.');
// Capcode to use when posting the above message.
$config['mod']['shadow_capcode'] = 'Mod';
// Name to use when posting the above message. If false, $config['anonymous'] will be used.
$config['mod']['shadow_name'] = false;
// PHP time limit for ?/rebuild. A value of 0 should cause PHP to wait indefinitely.
$config['mod']['rebuild_timelimit'] = 0;
// PM snippet (for ?/inbox) length in characters.
$config['mod']['snippet_length'] = 75;
// Edit raw HTML in posts by default.
$config['mod']['raw_html_default'] = false;
// Automatically dismiss all reports regarding a thread when it is locked.
$config['mod']['dismiss_reports_on_lock'] = true;
// Replace ?/config with a simple text editor for editing inc/instance-config.php.
$config['mod']['config_editor_php'] = false;
/*
* ====================
* Mod permissions
@@ -1589,13 +1602,13 @@
// Probably best not to change this unless you are smart enough to figure out what you're doing. If you
// decide to change it, remember that it is impossible to redefinite/overwrite groups; you may only add
// new ones.
$config['mod']['groups'] = array(
$config['mod']['groups'] = [
10 => 'Janitor',
20 => 'Mod',
30 => 'Admin',
// 98 => 'God',
99 => 'Disabled'
);
];
// If you add stuff to the above, you'll need to call this function immediately after.
define_groups();
@@ -1605,11 +1618,11 @@
// define_groups();
// Capcode permissions.
$config['mod']['capcode'] = array(
// JANITOR => array('Janitor'),
MOD => array('Mod'),
$config['mod']['capcode'] = [
// JANITOR => [ 'Janitor' ],
MOD => [ 'Mod' ],
ADMIN => true
);
];
// Example: Allow mods to post with "## Moderator" as well
// $config['mod']['capcode'][MOD][] = 'Moderator';
@@ -1811,26 +1824,26 @@
*/
// Public post search settings
$config['search'] = array();
// Enable the search form
$config['search']['enable'] = false;
$config['search'] = [
// Enable the search form
'enable' => false,
// Maximal number of queries per IP address per minutes
'queries_per_minutes' => [ 15, 2 ],
// Global maximal number of queries per minutes
'queries_per_minutes_all' => [ 50, 2 ],
// Limit of search results
'search_limit' => 100,
];
// Enable search in the board index.
$config['board_search'] = false;
// Maximal number of queries per IP address per minutes
$config['search']['queries_per_minutes'] = Array(15, 2);
// Global maximal number of queries per minutes
$config['search']['queries_per_minutes_all'] = Array(50, 2);
// Limit of search results
$config['search']['search_limit'] = 100;
// Boards for searching
//$config['search']['boards'] = array('a', 'b', 'c', 'd', 'e');
// Blacklist boards for searching, basically the opposite of the one above
//$config['search']['disallowed_boards'] = array('j', 'z');
// Enable public logs? 0: NO, 1: YES, 2: YES, but drop names
$config['public_logs'] = 0;
@@ -1879,31 +1892,33 @@
* state. Please join #nntpchan on Rizon in order to peer with someone.
*/
$config['nntpchan'] = array();
// Enable NNTPChan integration
$config['nntpchan']['enabled'] = false;
// NNTP server
$config['nntpchan']['server'] = "localhost:1119";
// Global dispatch array. Add your boards to it to enable them. Please make
// sure that this setting is set in a global context.
$config['nntpchan']['dispatch'] = array(); // 'overchan.test' => 'test'
// Trusted peer - an IP address of your NNTPChan instance. This peer will have
// increased capabilities, eg.: will evade spamfilter.
$config['nntpchan']['trusted_peer'] = '127.0.0.1';
// Salt for message ID generation. Keep it long and secure.
$config['nntpchan']['salt'] = 'change_me+please';
// A local message ID domain. Make sure to change it.
$config['nntpchan']['domain'] = 'example.vichan.net';
// An NNTPChan group name.
// Please set this setting in your board/config.php, not globally.
$config['nntpchan']['group'] = false; // eg. 'overchan.test'
$config['nntpchan'] = [
// Enable NNTPChan integration
'enabled'=> false,
// NNTP server
'server' => "localhost:1119",
/*
* Global dispatch array. Add your boards to it to enable them. Please make
* sure that this setting is set in a global context.
*/
'dispatch' => [
// 'overchan.test' => 'test'
],
/*
* Trusted peer - an IP address of your NNTPChan instance. This peer will have increased capabilities, eg.: will
* evade spamfilter.
*/
'trusted_peer' => '127.0.0.1',
// Salt for message ID generation. Keep it long and secure.
'salt' => 'change_me+please',
// A local message ID domain. Make sure to change it.
'domain' => 'example.vichan.net',
/*
* An NNTPChan group name.
* Please set this setting in your board/config.php, not globally.
*/
'group' => false, // eg. 'overchan.test'
];

91
inc/context.php Normal file
View File

@@ -0,0 +1,91 @@
<?php
namespace Vichan;
use Vichan\Data\Driver\{CacheDriver, HttpDriver, ErrorLogLogDriver, FileLogDriver, LogDriver, StderrLogDriver, SyslogLogDriver};
use Vichan\Service\HCaptchaQuery;
use Vichan\Service\NativeCaptchaQuery;
use Vichan\Service\ReCaptchaQuery;
use Vichan\Service\RemoteCaptchaQuery;
defined('TINYBOARD') or exit;
class Context {
private array $definitions;
public function __construct(array $definitions) {
$this->definitions = $definitions;
}
public function get(string $name){
if (!isset($this->definitions[$name])) {
throw new \RuntimeException("Could not find a dependency named $name");
}
$ret = $this->definitions[$name];
if (is_callable($ret) && !is_string($ret) && !is_array($ret)) {
$ret = $ret($this);
$this->definitions[$name] = $ret;
}
return $ret;
}
}
function build_context(array $config): Context {
return new Context([
'config' => $config,
LogDriver::class => function($c) {
$config = $c->get('config');
$name = $config['log_system']['name'];
$level = $config['debug'] ? LogDriver::DEBUG : LogDriver::NOTICE;
$backend = $config['log_system']['type'];
// Check 'syslog' for backwards compatibility.
if ((isset($config['syslog']) && $config['syslog']) || $backend === 'syslog') {
return new SyslogLogDriver($name, $level, $this->config['log_system']['syslog_stderr']);
} elseif ($backend === 'file') {
return new FileLogDriver($name, $level, $this->config['log_system']['file_path']);
} elseif ($backend === 'stderr') {
return new StderrLogDriver($name, $level);
} else {
return new ErrorLogLogDriver($name, $level);
}
},
HttpDriver::class => function($c) {
$config = $c->get('config');
return new HttpDriver($config['upload_by_url_timeout'], $config['max_filesize']);
},
RemoteCaptchaQuery::class => function($c) {
$config = $c->get('config');
$http = $c->get(HttpDriver::class);
switch ($config['captcha']['provider']) {
case 'recaptcha':
return new ReCaptchaQuery($http, $config['captcha']['recaptcha']['secret']);
case 'hcaptcha':
return new HCaptchaQuery(
$http,
$config['captcha']['hcaptcha']['secret'],
$config['captcha']['hcaptcha']['sitekey']
);
default:
throw new \RuntimeException('No remote captcha service available');
}
},
NativeCaptchaQuery::class => function($c) {
$config = $c->get('config');
if ($config['captcha']['provider'] !== 'native') {
throw new \RuntimeException('No native captcha service available');
}
return new NativeCaptchaQuery(
$c->get(HttpDriver::class),
$config['domain'],
$config['captcha']['native']['provider_check'],
$config['captcha']['native']['extra']
);
},
CacheDriver::class => function($c) {
// Use the global for backwards compatibility.
return \cache::getCache();
}
]);
}

View File

@@ -85,24 +85,24 @@ function sb_api($b) { global $config, $build_pages;
}
function sb_ukko() {
rebuildTheme("ukko", "post-thread");
Vichan\Functions\Theme\rebuild_theme("ukko", "post-thread");
return true;
}
function sb_catalog($b) {
if (!openBoard($b)) return false;
rebuildTheme("catalog", "post-thread", $b);
Vichan\Functions\Theme\rebuild_theme("catalog", "post-thread", $b);
return true;
}
function sb_recent() {
rebuildTheme("recent", "post-thread");
Vichan\Functions\Theme\rebuild_theme("recent", "post-thread");
return true;
}
function sb_sitemap() {
rebuildTheme("sitemap", "all");
Vichan\Functions\Theme\rebuild_theme("sitemap", "all");
return true;
}

View File

@@ -9,7 +9,7 @@ if (realpath($_SERVER['SCRIPT_FILENAME']) == str_replace('\\', '/', __FILE__)) {
exit;
}
/*
/*
joaoptm78@gmail.com
http://www.php.net/manual/en/function.filesize.php#100097
*/
@@ -21,7 +21,7 @@ function format_bytes($size) {
function doBoardListPart($list, $root, &$boards) {
global $config;
$body = '';
foreach ($list as $key => $board) {
if (is_array($board))
@@ -34,21 +34,21 @@ function doBoardListPart($list, $root, &$boards) {
if (isset ($boards[$board])) {
$title = ' title="'.$boards[$board].'"';
}
$body .= ' <a href="' . $root . $board . '/' . $config['file_index'] . '"'.$title.'>' . $board . '</a> /';
}
}
}
$body = preg_replace('/\/$/', '', $body);
return $body;
}
function createBoardlist($mod=false) {
global $config;
if (!isset($config['boards'])) return array('top'=>'','bottom'=>'');
$xboards = listBoards();
$boards = array();
foreach ($xboards as $val) {
@@ -59,26 +59,26 @@ function createBoardlist($mod=false) {
if ($config['boardlist_wrap_bracket'] && !preg_match('/\] $/', $body))
$body = '[' . $body . ']';
$body = trim($body);
// Message compact-boardlist.js faster, so that page looks less ugly during loading
$top = "<script type='text/javascript'>if (typeof do_boardlist != 'undefined') do_boardlist();</script>";
return array(
'top' => '<div class="boardlist">' . $body . '</div>' . $top,
'bottom' => '<div class="boardlist bottom">' . $body . '</div>'
);
}
function error($message, $priority = true, $debug_stuff = false) {
function error($message, $priority = true, $debug_stuff = []) {
global $board, $mod, $config, $db_error;
if ($config['syslog'] && $priority !== false) {
// Use LOG_NOTICE instead of LOG_ERR or LOG_WARNING because most error message are not significant.
_syslog($priority !== true ? $priority : LOG_NOTICE, $message);
}
if (defined('STDIN')) {
// Running from CLI
echo('Error: ' . $message . "\n");
@@ -113,7 +113,7 @@ function error($message, $priority = true, $debug_stuff = false) {
};
if ($debug_stuff)
if ($debug_stuff)
$debug_stuff = array_filter($debug_stuff, $debug_callback);
die(Element($config['file_page_template'], array(
@@ -132,7 +132,7 @@ function error($message, $priority = true, $debug_stuff = false) {
function loginForm($error=false, $username=false, $redirect=false) {
global $config;
die(Element($config['file_page_template'], array(
'index' => $config['root'],
'title' => _('Login'),
@@ -149,34 +149,34 @@ function loginForm($error=false, $username=false, $redirect=false) {
function pm_snippet($body, $len=null) {
global $config;
if (!isset($len))
$len = &$config['mod']['snippet_length'];
// Replace line breaks with some whitespace
$body = preg_replace('@<br/?>@i', ' ', $body);
// Strip tags
$body = strip_tags($body);
// Unescape HTML characters, to avoid splitting them in half
$body = html_entity_decode($body, ENT_COMPAT, 'UTF-8');
// calculate strlen() so we can add "..." after if needed
$strlen = mb_strlen($body);
$body = mb_substr($body, 0, $len);
// Re-escape the characters.
return '<em>' . utf8tohtml($body) . ($strlen > $len ? '&hellip;' : '') . '</em>';
}
function capcode($cap) {
global $config;
if (!$cap)
return false;
$capcode = array();
if (isset($config['custom_capcode'][$cap])) {
if (is_array($config['custom_capcode'][$cap])) {
@@ -191,59 +191,59 @@ function capcode($cap) {
} else {
$capcode['cap'] = sprintf($config['capcode'], $cap);
}
return $capcode;
}
function truncate($body, $url, $max_lines = false, $max_chars = false) {
global $config;
if ($max_lines === false)
$max_lines = $config['body_truncate'];
if ($max_chars === false)
$max_chars = $config['body_truncate_char'];
// We don't want to risk truncating in the middle of an HTML comment.
// It's easiest just to remove them all first.
$body = preg_replace('/<!--.*?-->/s', '', $body);
$original_body = $body;
$lines = substr_count($body, '<br/>');
// Limit line count
if ($lines > $max_lines) {
if (preg_match('/(((.*?)<br\/>){' . $max_lines . '})/', $body, $m))
$body = $m[0];
}
$body = mb_substr($body, 0, $max_chars);
if ($body != $original_body) {
// Remove any corrupt tags at the end
$body = preg_replace('/<([\w]+)?([^>]*)?$/', '', $body);
// Open tags
if (preg_match_all('/<([\w]+)[^>]*>/', $body, $open_tags)) {
$tags = array();
for ($x=0;$x<count($open_tags[0]);$x++) {
if (!preg_match('/\/(\s+)?>$/', $open_tags[0][$x]))
$tags[] = $open_tags[1][$x];
}
// List successfully closed tags
if (preg_match_all('/(<\/([\w]+))>/', $body, $closed_tags)) {
for ($x=0;$x<count($closed_tags[0]);$x++) {
unset($tags[array_search($closed_tags[2][$x], $tags)]);
}
}
// remove broken HTML entity at the end (if existent)
$body = preg_replace('/&[^;]+$/', '', $body);
$tags_no_close_needed = array("colgroup", "dd", "dt", "li", "optgroup", "option", "p", "tbody", "td", "tfoot", "th", "thead", "tr", "br", "img");
// Close any open tags
foreach ($tags as &$tag) {
if (!in_array($tag, $tags_no_close_needed))
@@ -253,10 +253,10 @@ function truncate($body, $url, $max_lines = false, $max_chars = false) {
// remove broken HTML entity at the end (if existent)
$body = preg_replace('/&[^;]*$/', '', $body);
}
$body .= '<span class="toolong">'.sprintf(_('Post too long. Click <a href="%s">here</a> to view the full text.'), $url).'</span>';
}
return $body;
}
@@ -266,21 +266,21 @@ function bidi_cleanup($data) {
$explicits = '\xE2\x80\xAA|\xE2\x80\xAB|\xE2\x80\xAD|\xE2\x80\xAE';
$pdf = '\xE2\x80\xAC';
preg_match_all("!$explicits!", $data, $m1, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
preg_match_all("!$pdf!", $data, $m2, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
if (count($m1) || count($m2)){
$p = array();
foreach ($m1 as $m){ $p[$m[0][1]] = 'push'; }
foreach ($m2 as $m){ $p[$m[0][1]] = 'pop'; }
ksort($p);
$offset = 0;
$stack = 0;
foreach ($p as $pos => $type){
if ($type == 'push'){
$stack++;
}else{
@@ -294,15 +294,15 @@ function bidi_cleanup($data) {
}
}
}
# now add some pops if your stack is bigger than 0
for ($i=0; $i<$stack; $i++){
$data .= "\xE2\x80\xAC";
}
return $data;
}
return $data;
}
@@ -317,24 +317,24 @@ function secure_link($href) {
function embed_html($link) {
global $config;
foreach ($config['embedding'] as $embed) {
if ($html = preg_replace($embed[0], $embed[1], $link)) {
if ($html == $link)
continue; // Nope
$html = str_replace('%%tb_width%%', $config['embed_width'], $html);
$html = str_replace('%%tb_height%%', $config['embed_height'], $html);
return $html;
}
}
if ($link[0] == '<') {
// Prior to v0.9.6-dev-8, HTML code for embedding was stored in the database instead of the link.
return $link;
}
return 'Embedding error.';
}
@@ -343,7 +343,7 @@ class Post {
global $config;
if (!isset($root))
$root = &$config['root'];
foreach ($post as $key => $value) {
$this->{$key} = $value;
}
@@ -351,31 +351,38 @@ class Post {
if (isset($this->files) && $this->files) {
$this->files = is_string($this->files) ? json_decode($this->files) : $this->files;
// Compatibility for posts before individual file hashing
foreach ($this->files as $i => &$file) {
foreach ($this->files as $i => &$file) {
if (empty($file)) {
unset($this->files[$i]);
continue;
}
if (!isset($file->hash))
$file->hash = $this->filehash;
if (is_array($file)) {
if (!isset($file['hash'])) {
$file['hash'] = $this->filehash;
}
} else if (is_object($file)) {
if (!isset($file->hash)) {
$file->hash = $this->filehash;
}
}
}
}
$this->subject = utf8tohtml($this->subject);
$this->name = utf8tohtml($this->name);
$this->mod = $mod;
$this->root = $root;
if ($this->embed)
$this->embed = embed_html($this->embed);
$this->modifiers = extract_modifiers($this->body_nomarkup);
if ($config['always_regenerate_markup']) {
$this->body = $this->body_nomarkup;
markup($this->body);
}
if ($this->mod)
// Fix internal links
// Very complicated regex
@@ -387,14 +394,25 @@ class Post {
}
public function link($pre = '', $page = false) {
global $config, $board;
return $this->root . $board['dir'] . $config['dir']['res'] . link_for((array)$this, $page == '50') . '#' . $pre . $this->id;
}
public function build($index=false) {
global $board, $config;
return Element($config['file_post_reply'], array('config' => $config, 'board' => $board, 'post' => &$this, 'index' => $index, 'mod' => $this->mod));
$options = [
'config' => $config,
'board' => $board,
'post' => &$this,
'index' => $index,
'mod' => $this->mod
];
if ($this->mod) {
$options['pm'] = create_pm_header();
}
return Element($config['file_post_reply'], $options);
}
};
@@ -403,14 +421,14 @@ class Thread {
global $config;
if (!isset($root))
$root = &$config['root'];
foreach ($post as $key => $value) {
$this->{$key} = $value;
}
if (isset($this->files))
$this->files = is_string($this->files) ? json_decode($this->files) : $this->files;
$this->subject = utf8tohtml($this->subject);
$this->name = utf8tohtml($this->name);
$this->mod = $mod;
@@ -420,17 +438,17 @@ class Thread {
$this->posts = array();
$this->omitted = 0;
$this->omitted_images = 0;
if ($this->embed)
$this->embed = embed_html($this->embed);
$this->modifiers = extract_modifiers($this->body_nomarkup);
if ($config['always_regenerate_markup']) {
$this->body = $this->body_nomarkup;
markup($this->body);
}
if ($this->mod)
// Fix internal links
// Very complicated regex
@@ -442,7 +460,7 @@ class Thread {
}
public function link($pre = '', $page = false) {
global $config, $board;
return $this->root . $board['dir'] . $config['dir']['res'] . link_for((array)$this, $page == '50') . '#' . $pre . $this->id;
}
public function add(Post $post) {
@@ -453,15 +471,27 @@ class Thread {
}
public function build($index=false, $isnoko50=false) {
global $board, $config, $debug;
$hasnoko50 = $this->postCount() >= $config['noko50_min'];
event('show-thread', $this);
$options = [
'config' => $config,
'board' => $board,
'post' => &$this,
'index' => $index,
'hasnoko50' => $hasnoko50,
'isnoko50' => $isnoko50,
'mod' => $this->mod
];
if ($this->mod) {
$options['pm'] = create_pm_header();
}
$file = ($index && $config['file_board']) ? $config['file_post_thread_fileboard'] : $config['file_post_thread'];
$built = Element($file, array('config' => $config, 'board' => $board, 'post' => &$this, 'index' => $index, 'hasnoko50' => $hasnoko50, 'isnoko50' => $isnoko50, 'mod' => $this->mod));
$built = Element($file, $options);
return $built;
}
};

View File

@@ -136,6 +136,14 @@ class Filter {
return $post['board'] == $match;
case 'password':
return $post['password'] == $match;
case 'unshorten':
$extracted_urls = get_urls($post['body_nomarkup']);
foreach ($extracted_urls as $url) {
if (preg_match($match, trace_url($url))) {
return true;
}
}
return false;
default:
error('Unknown filter condition: ' . $condition);
}

View File

@@ -21,9 +21,7 @@ loadConfig();
function init_locale($locale, $error='error') {
if (extension_loaded('gettext')) {
if (setlocale(LC_ALL, $locale) === false) {
//$error('The specified locale (' . $locale . ') does not exist on your platform!');
}
setlocale(LC_ALL, $locale);
bindtextdomain('tinyboard', './inc/locale');
bind_textdomain_codeset('tinyboard', 'UTF-8');
textdomain('tinyboard');
@@ -55,8 +53,9 @@ function loadConfig() {
if (isset($config['cache_config']) &&
$config['cache_config'] &&
$config = Cache::get('config_' . $boardsuffix ) ) {
$config['cache_config'] &&
$config = Cache::get('config_' . $boardsuffix))
{
$events = Cache::get('events_' . $boardsuffix );
define_groups();
@@ -66,11 +65,10 @@ function loadConfig() {
}
if ($config['locale'] != $current_locale) {
$current_locale = $config['locale'];
init_locale($config['locale'], $error);
}
}
else {
$current_locale = $config['locale'];
init_locale($config['locale'], $error);
}
} else {
$config = array();
reset_events();
@@ -180,8 +178,8 @@ function loadConfig() {
'(' .
str_replace('%d', '\d+', preg_quote($config['file_page'], '/')) . '|' .
str_replace('%d', '\d+', preg_quote($config['file_page50'], '/')) . '|' .
str_replace(array('%d', '%s'), array('\d+', '[a-z0-9-]+'), preg_quote($config['file_page_slug'], '/')) . '|' .
str_replace(array('%d', '%s'), array('\d+', '[a-z0-9-]+'), preg_quote($config['file_page50_slug'], '/')) .
str_replace(array('%d', '%s'), array('\d+', '[a-z0-9-]+'), preg_quote($config['file_page_slug'], '/')) . '|' .
str_replace(array('%d', '%s'), array('\d+', '[a-z0-9-]+'), preg_quote($config['file_page50_slug'], '/')) .
')' .
'|' .
preg_quote($config['file_mod'], '/') . '\?\/.+' .
@@ -242,12 +240,13 @@ function loadConfig() {
$__version = file_exists('.installed') ? trim(file_get_contents('.installed')) : false;
$config['version'] = $__version;
if ($config['allow_roll'])
event_handler('post', 'diceRoller');
if ($config['allow_roll']) {
event_handler('post', 'email_dice_roll');
}
if (in_array('webm', $config['allowed_ext_files']) ||
in_array('mp4', $config['allowed_ext_files']))
if (in_array('webm', $config['allowed_ext_files']) || in_array('mp4', $config['allowed_ext_files'])) {
event_handler('post', 'postHandler');
}
}
// Effectful config processing below:
@@ -280,8 +279,7 @@ function loadConfig() {
if ($config['cache']['enabled'])
require_once 'inc/cache.php';
if (in_array('webm', $config['allowed_ext_files']) ||
in_array('mp4', $config['allowed_ext_files']))
if (in_array('webm', $config['allowed_ext_files']) || in_array('mp4', $config['allowed_ext_files']))
require_once 'inc/lib/webm/posthandler.php';
event('load-config');
@@ -393,114 +391,6 @@ function define_groups() {
ksort($config['mod']['groups']);
}
function create_antibot($board, $thread = null) {
require_once dirname(__FILE__) . '/anti-bot.php';
return _create_antibot($board, $thread);
}
function rebuildThemes($action, $boardname = false) {
global $config, $board, $current_locale;
// Save the global variables
$_config = $config;
$_board = $board;
// List themes
if ($themes = Cache::get("themes")) {
// OK, we already have themes loaded
}
else {
$query = query("SELECT `theme` FROM ``theme_settings`` WHERE `name` IS NULL AND `value` IS NULL") or error(db_error());
$themes = array();
while ($theme = $query->fetch(PDO::FETCH_ASSOC)) {
$themes[] = $theme;
}
Cache::set("themes", $themes);
}
foreach ($themes as $theme) {
// Restore them
$config = $_config;
$board = $_board;
// Reload the locale
if ($config['locale'] != $current_locale) {
$current_locale = $config['locale'];
init_locale($config['locale']);
}
if (PHP_SAPI === 'cli') {
echo "Rebuilding theme ".$theme['theme']."... ";
}
rebuildTheme($theme['theme'], $action, $boardname);
if (PHP_SAPI === 'cli') {
echo "done\n";
}
}
// Restore them again
$config = $_config;
$board = $_board;
// Reload the locale
if ($config['locale'] != $current_locale) {
$current_locale = $config['locale'];
init_locale($config['locale']);
}
}
function loadThemeConfig($_theme) {
global $config;
if (!file_exists($config['dir']['themes'] . '/' . $_theme . '/info.php'))
return false;
// Load theme information into $theme
include $config['dir']['themes'] . '/' . $_theme . '/info.php';
return $theme;
}
function rebuildTheme($theme, $action, $board = false) {
global $config, $_theme;
$_theme = $theme;
$theme = loadThemeConfig($_theme);
if (file_exists($config['dir']['themes'] . '/' . $_theme . '/theme.php')) {
require_once $config['dir']['themes'] . '/' . $_theme . '/theme.php';
$theme['build_function']($action, themeSettings($_theme), $board);
}
}
function themeSettings($theme) {
if ($settings = Cache::get("theme_settings_".$theme)) {
return $settings;
}
$query = prepare("SELECT `name`, `value` FROM ``theme_settings`` WHERE `theme` = :theme AND `name` IS NOT NULL");
$query->bindValue(':theme', $theme);
$query->execute() or error(db_error($query));
$settings = array();
while ($s = $query->fetch(PDO::FETCH_ASSOC)) {
$settings[$s['name']] = $s['value'];
}
Cache::set("theme_settings_".$theme, $settings);
return $settings;
}
function sprintf3($str, $vars, $delim = '%') {
$replaces = array();
foreach ($vars as $k => $v) {
@@ -517,12 +407,11 @@ function mb_substr_replace($string, $replacement, $start, $length) {
function setupBoard($array) {
global $board, $config;
$board = array(
$board = [
'uri' => $array['uri'],
'title' => $array['title'],
'subtitle' => $array['subtitle'],
#'indexed' => $array['indexed'],
);
];
// older versions
$board['name'] = &$board['title'];
@@ -718,12 +607,18 @@ function file_unlink($path) {
$debug['unlink'][] = $path;
}
$ret = @unlink($path);
if (file_exists($path)) {
$ret = @unlink($path);
} else {
$ret = true;
}
if ($config['gzip_static']) {
$gzpath = "$path.gz";
if ($config['gzip_static']) {
$gzpath = "$path.gz";
@unlink($gzpath);
if (file_exists($gzpath)) {
@unlink($gzpath);
}
}
if (isset($config['purge']) && $path[0] != '/' && isset($_SERVER['HTTP_HOST'])) {
@@ -797,42 +692,6 @@ function listBoards($just_uri = false) {
return $boards;
}
function until($timestamp) {
$difference = $timestamp - time();
switch(TRUE){
case ($difference < 60):
return $difference . ' ' . ngettext('second', 'seconds', $difference);
case ($difference < 3600): //60*60 = 3600
return ($num = round($difference/(60))) . ' ' . ngettext('minute', 'minutes', $num);
case ($difference < 86400): //60*60*24 = 86400
return ($num = round($difference/(3600))) . ' ' . ngettext('hour', 'hours', $num);
case ($difference < 604800): //60*60*24*7 = 604800
return ($num = round($difference/(86400))) . ' ' . ngettext('day', 'days', $num);
case ($difference < 31536000): //60*60*24*365 = 31536000
return ($num = round($difference/(604800))) . ' ' . ngettext('week', 'weeks', $num);
default:
return ($num = round($difference/(31536000))) . ' ' . ngettext('year', 'years', $num);
}
}
function ago($timestamp) {
$difference = time() - $timestamp;
switch(TRUE){
case ($difference < 60) :
return $difference . ' ' . ngettext('second', 'seconds', $difference);
case ($difference < 3600): //60*60 = 3600
return ($num = round($difference/(60))) . ' ' . ngettext('minute', 'minutes', $num);
case ($difference < 86400): //60*60*24 = 86400
return ($num = round($difference/(3600))) . ' ' . ngettext('hour', 'hours', $num);
case ($difference < 604800): //60*60*24*7 = 604800
return ($num = round($difference/(86400))) . ' ' . ngettext('day', 'days', $num);
case ($difference < 31536000): //60*60*24*365 = 31536000
return ($num = round($difference/(604800))) . ' ' . ngettext('week', 'weeks', $num);
default:
return ($num = round($difference/(31536000))) . ' ' . ngettext('year', 'years', $num);
}
}
function displayBan($ban) {
global $config, $board;
@@ -909,11 +768,13 @@ function checkBan($board = false) {
}
foreach ($ips as $ip) {
$bans = Bans::find($ip, $board, $config['show_modname']);
$bans = Bans::find($ip, $board, $config['show_modname'], null, $config['auto_maintenance']);
foreach ($bans as &$ban) {
if ($ban['expires'] && $ban['expires'] < time()) {
Bans::delete($ban['id']);
if ($config['auto_maintenance']) {
Bans::delete($ban['id']);
}
if ($config['require_ban_view'] && !$ban['seen']) {
if (!isset($_POST['json_response'])) {
displayBan($ban);
@@ -933,17 +794,20 @@ function checkBan($board = false) {
}
}
// I'm not sure where else to put this. It doesn't really matter where; it just needs to be called every
// now and then to keep the ban list tidy.
if ($config['cache']['enabled'] && $last_time_purged = cache::get('purged_bans_last')) {
if (time() - $last_time_purged < $config['purge_bans'] )
return;
if ($config['auto_maintenance']) {
// I'm not sure where else to put this. It doesn't really matter where; it just needs to be called every
// now and then to keep the ban list tidy.
if ($config['cache']['enabled']) {
$last_time_purged = cache::get('purged_bans_last');
if ($last_time_purged !== false && time() - $last_time_purged > $config['purge_bans']) {
Bans::purge($config['require_ban_view'], $config['purge_bans']);
cache::set('purged_bans_last', time());
}
} else {
// Purge every time.
Bans::purge($config['require_ban_view'], $config['purge_bans']);
}
}
Bans::purge();
if ($config['cache']['enabled'])
cache::set('purged_bans_last', time());
}
function threadLocked($id) {
@@ -1267,25 +1131,25 @@ function deletePost($id, $error_if_doesnt_exist=true, $rebuild_after=true) {
$query->bindValue(':board', $board['uri']);
$query->execute() or error(db_error($query));
// No need to run on OPs
if ($config['anti_bump_flood'] && isset($thread_id)) {
$query = prepare(sprintf("SELECT `sage` FROM ``posts_%s`` WHERE `id` = :thread", $board['uri']));
$query->bindValue(':thread', $thread_id);
$query->execute() or error(db_error($query));
$bumplocked = (bool)$query->fetchColumn();
// No need to run on OPs
if ($config['anti_bump_flood'] && isset($thread_id)) {
$query = prepare(sprintf("SELECT `sage` FROM ``posts_%s`` WHERE `id` = :thread", $board['uri']));
$query->bindValue(':thread', $thread_id);
$query->execute() or error(db_error($query));
$bumplocked = (bool)$query->fetchColumn();
if (!$bumplocked) {
$query = prepare(sprintf("SELECT `time` FROM ``posts_%s`` WHERE (`thread` = :thread AND NOT email <=> 'sage') OR `id` = :thread ORDER BY `time` DESC LIMIT 1", $board['uri']));
$query->bindValue(':thread', $thread_id);
$query->execute() or error(db_error($query));
$bump = $query->fetchColumn();
if (!$bumplocked) {
$query = prepare(sprintf("SELECT `time` FROM ``posts_%s`` WHERE (`thread` = :thread AND NOT email <=> 'sage') OR `id` = :thread ORDER BY `time` DESC LIMIT 1", $board['uri']));
$query->bindValue(':thread', $thread_id);
$query->execute() or error(db_error($query));
$bump = $query->fetchColumn();
$query = prepare(sprintf("UPDATE ``posts_%s`` SET `bump` = :bump WHERE `id` = :thread", $board['uri']));
$query->bindValue(':bump', $bump);
$query->bindValue(':thread', $thread_id);
$query->execute() or error(db_error($query));
}
}
$query = prepare(sprintf("UPDATE ``posts_%s`` SET `bump` = :bump WHERE `id` = :thread", $board['uri']));
$query->bindValue(':bump', $bump);
$query->bindValue(':thread', $thread_id);
$query->execute() or error(db_error($query));
}
}
if (isset($rebuild) && $rebuild_after) {
buildThread($rebuild);
@@ -1431,7 +1295,14 @@ function index($page, $mod=false, $brief = false) {
}
if ($config['file_board']) {
$body = Element($config['file_fileboard'], array('body' => $body, 'mod' => $mod));
$options = [
'body' => $body,
'mod' => $mod
];
if ($mod) {
$options['pm'] = create_pm_header();
}
$body = Element($config['file_fileboard'], $options);
}
return array(
@@ -1645,34 +1516,10 @@ function checkMute() {
}
}
function _create_antibot($board, $thread) {
global $config, $purged_old_antispam;
$antibot = new AntiBot(array($board, $thread));
if (!isset($purged_old_antispam)) {
$purged_old_antispam = true;
query('DELETE FROM ``antispam`` WHERE `expires` < UNIX_TIMESTAMP()') or error(db_error());
}
if ($thread)
$query = prepare('UPDATE ``antispam`` SET `expires` = UNIX_TIMESTAMP() + :expires WHERE `board` = :board AND `thread` = :thread AND `expires` IS NULL');
else
$query = prepare('UPDATE ``antispam`` SET `expires` = UNIX_TIMESTAMP() + :expires WHERE `board` = :board AND `thread` IS NULL AND `expires` IS NULL');
$query->bindValue(':board', $board);
if ($thread)
$query->bindValue(':thread', $thread);
$query->bindValue(':expires', $config['spam']['hidden_inputs_expire']);
$query->execute() or error(db_error($query));
$query = prepare('INSERT INTO ``antispam`` VALUES (:board, :thread, :hash, UNIX_TIMESTAMP(), NULL, 0)');
$query->bindValue(':board', $board);
$query->bindValue(':thread', $thread);
$query->bindValue(':hash', $antibot->hash());
$query->execute() or error(db_error($query));
return $antibot;
function purge_old_antispam() {
$query = prepare('DELETE FROM ``antispam`` WHERE `expires` < UNIX_TIMESTAMP()');
$query->execute() or error(db_error());
return $query->rowCount();
}
function checkSpam(array $extra_salt = array()) {
@@ -1737,15 +1584,18 @@ function incrementSpamHash($hash) {
}
function buildIndex($global_api = "yes") {
global $board, $config, $build_pages;
global $board, $config, $build_pages, $mod;
$catalog_api_action = generation_strategy('sb_api', array($board['uri']));
$pages = null;
$antibot = null;
if ($config['api']['enabled']) {
$api = new Api();
$api = new Api(
$config['show_filename'],
$config['hide_email'],
$config['country_flags']
);
$catalog = array();
}
@@ -1790,21 +1640,15 @@ function buildIndex($global_api = "yes") {
if ($wont_build_this_page) continue;
}
if ($config['try_smarter']) {
$antibot = create_antibot($board['uri'], 0 - $page);
$content['current_page'] = $page;
}
elseif (!$antibot) {
$antibot = create_antibot($board['uri']);
}
$antibot->reset();
if (!$pages) {
$pages = getPages();
}
$content['pages'] = $pages;
$content['pages'][$page-1]['selected'] = true;
$content['btn'] = getPageButtons($content['pages']);
$content['antibot'] = $antibot;
if ($mod) {
$content['pm'] = create_pm_header();
}
file_write($filename, Element($config['file_board_index'], $content));
}
@@ -2029,7 +1873,7 @@ function extract_modifiers($body) {
}
function remove_modifiers($body) {
return preg_replace('@<tinyboard ([\w\s]+)>(.+?)</tinyboard>@usm', '', $body);
return $body ? preg_replace('@<tinyboard ([\w\s]+)>(.+?)</tinyboard>@usm', '', $body) : null;
}
function markup(&$body, $track_cites = false, $op = false) {
@@ -2298,6 +2142,7 @@ function escape_markup_modifiers($string) {
}
function defined_flags_accumulate($desired_flags) {
global $config;
$output_flags = 0x0;
foreach ($desired_flags as $flagname) {
if (defined($flagname)) {
@@ -2315,7 +2160,7 @@ function defined_flags_accumulate($desired_flags) {
function utf8tohtml($utf8) {
$flags = defined_flags_accumulate(['ENT_NOQUOTES', 'ENT_SUBSTITUTE', 'ENT_DISALLOWED']);
return htmlspecialchars($utf8, $flags, 'UTF-8');
return $utf8 ? htmlspecialchars($utf8, $flags, 'UTF-8') : '';
}
function ordutf8($string, &$offset) {
@@ -2384,9 +2229,8 @@ function buildThread($id, $return = false, $mod = false) {
error($config['error']['nonexistant']);
$hasnoko50 = $thread->postCount() >= $config['noko50_min'];
$antibot = $mod || $return ? false : create_antibot($board['uri'], $id);
$body = Element($config['file_thread'], array(
$options = [
'board' => $board,
'thread' => $thread,
'body' => $thread->build(),
@@ -2395,14 +2239,22 @@ function buildThread($id, $return = false, $mod = false) {
'mod' => $mod,
'hasnoko50' => $hasnoko50,
'isnoko50' => false,
'antibot' => $antibot,
'boardlist' => createBoardlist($mod),
'return' => ($mod ? '?' . $board['url'] . $config['file_index'] : $config['root'] . $board['dir'] . $config['file_index'])
));
];
if ($mod) {
$options['pm'] = create_pm_header();
}
$body = Element($config['file_thread'], $options);
// json api
if ($config['api']['enabled'] && !$mod) {
$api = new Api();
$api = new Api(
$config['show_filename'],
$config['hide_email'],
$config['country_flags']
);
$json = json_encode($api->translateThread($thread));
$jsonFilename = $board['dir'] . $config['dir']['res'] . $id . '.json';
file_write($jsonFilename, $json);
@@ -2423,20 +2275,17 @@ function buildThread($id, $return = false, $mod = false) {
} elseif ($action == 'rebuild') {
$noko50fn = $board['dir'] . $config['dir']['res'] . link_for($thread, true);
if ($hasnoko50 || file_exists($noko50fn)) {
buildThread50($id, $return, $mod, $thread, $antibot);
buildThread50($id, $return, $mod, $thread);
}
file_write($board['dir'] . $config['dir']['res'] . link_for($thread), $body);
}
}
function buildThread50($id, $return = false, $mod = false, $thread = null, $antibot = false) {
global $board, $config, $build_pages;
function buildThread50($id, $return = false, $mod = false, $thread = null) {
global $board, $config;
$id = round($id);
if ($antibot)
$antibot->reset();
if (!$thread) {
$query = prepare(sprintf("SELECT * FROM ``posts_%s`` WHERE (`thread` IS NULL AND `id` = :id) OR `thread` = :id ORDER BY `thread`,`id` DESC LIMIT :limit", $board['uri']));
$query->bindValue(':id', $id, PDO::PARAM_INT);
@@ -2489,7 +2338,7 @@ function buildThread50($id, $return = false, $mod = false, $thread = null, $anti
$hasnoko50 = $thread->postCount() >= $config['noko50_min'];
$body = Element($config['file_thread'], array(
$options = [
'board' => $board,
'thread' => $thread,
'body' => $thread->build(false, true),
@@ -2498,10 +2347,14 @@ function buildThread50($id, $return = false, $mod = false, $thread = null, $anti
'mod' => $mod,
'hasnoko50' => $hasnoko50,
'isnoko50' => true,
'antibot' => $mod ? false : ($antibot ? $antibot : create_antibot($board['uri'], $id)),
'boardlist' => createBoardlist($mod),
'return' => ($mod ? '?' . $board['url'] . $config['file_index'] : $config['root'] . $board['dir'] . $config['file_index'])
));
];
if ($mod) {
$options['pm'] = create_pm_header();
}
$body = Element($config['file_thread'], $options);
if ($return) {
return $body;
@@ -2572,35 +2425,6 @@ function generate_tripcode($name) {
return array($name, $trip);
}
// Highest common factor
function hcf($a, $b){
$gcd = 1;
if ($a>$b) {
$a = $a+$b;
$b = $a-$b;
$a = $a-$b;
}
if ($b==(round($b/$a))*$a)
$gcd=$a;
else {
for ($i=round($a/2);$i;$i--) {
if ($a == round($a/$i)*$i && $b == round($b/$i)*$i) {
$gcd = $i;
$i = false;
}
}
}
return $gcd;
}
function fraction($numerator, $denominator, $sep) {
$gcf = hcf($numerator, $denominator);
$numerator = $numerator / $gcf;
$denominator = $denominator / $gcf;
return "{$numerator}{$sep}{$denominator}";
}
function getPostByHash($hash) {
global $board;
$query = prepare(sprintf("SELECT `id`,`thread` FROM ``posts_%s`` WHERE `filehash` = :hash", $board['uri']));
@@ -2717,65 +2541,6 @@ function shell_exec_error($command, $suppress_stdout = false) {
return $return === 'TB_SUCCESS' ? false : $return;
}
/* Die rolling:
* If "dice XdY+/-Z" is in the email field (where X or +/-Z may be
* missing), X Y-sided dice are rolled and summed, with the modifier Z
* added on. The result is displayed at the top of the post.
*/
function diceRoller($post) {
global $config;
if(strpos(strtolower($post->email), 'dice%20') === 0) {
$dicestr = str_split(substr($post->email, strlen('dice%20')));
// Get params
$diceX = '';
$diceY = '';
$diceZ = '';
$curd = 'diceX';
for($i = 0; $i < count($dicestr); $i ++) {
if(is_numeric($dicestr[$i])) {
$$curd .= $dicestr[$i];
} else if($dicestr[$i] == 'd') {
$curd = 'diceY';
} else if($dicestr[$i] == '-' || $dicestr[$i] == '+') {
$curd = 'diceZ';
$$curd = $dicestr[$i];
}
}
// Default values for X and Z
if($diceX == '') {
$diceX = '1';
}
if($diceZ == '') {
$diceZ = '+0';
}
// Intify them
$diceX = intval($diceX);
$diceY = intval($diceY);
$diceZ = intval($diceZ);
// Continue only if we have valid values
if($diceX > 0 && $diceY > 0) {
$dicerolls = array();
$dicesum = $diceZ;
for($i = 0; $i < $diceX; $i++) {
$roll = rand(1, $diceY);
$dicerolls[] = $roll;
$dicesum += $roll;
}
// Prepend the result to the post body
$modifier = ($diceZ != 0) ? ((($diceZ < 0) ? ' - ' : ' + ') . abs($diceZ)) : '';
$dicesum = ($diceX > 1) ? ' = ' . $dicesum : '';
$post->body = '<table class="diceroll"><tr><td><img src="'.$config['dir']['static'].'d10.svg" alt="Dice roll" width="24"></td><td>Rolled ' . implode(', ', $dicerolls) . $modifier . $dicesum . '</td></tr></table><br/>' . $post->body;
}
}
}
function slugify($post) {
global $config;
@@ -2835,10 +2600,10 @@ function link_for($post, $page50 = false, $foreignlink = false, $thread = false)
if ($slug === false) {
$query = prepare(sprintf("SELECT `slug` FROM ``posts_%s`` WHERE `id` = :id", $b['uri']));
$query->bindValue(':id', $id, PDO::PARAM_INT);
$query->execute() or error(db_error($query));
$query->bindValue(':id', $id, PDO::PARAM_INT);
$query->execute() or error(db_error($query));
$thread = $query->fetch(PDO::FETCH_ASSOC);
$thread = $query->fetch(PDO::FETCH_ASSOC);
$slug = $thread['slug'];
@@ -2854,7 +2619,7 @@ function link_for($post, $page50 = false, $foreignlink = false, $thread = false)
}
if ( $page50 && $slug) $tpl = $config['file_page50_slug'];
if ( $page50 && $slug) $tpl = $config['file_page50_slug'];
else if (!$page50 && $slug) $tpl = $config['file_page_slug'];
else if ( $page50 && !$slug) $tpl = $config['file_page50'];
else if (!$page50 && !$slug) $tpl = $config['file_page'];
@@ -2866,24 +2631,6 @@ function prettify_textarea($s){
return str_replace("\t", '&#09;', str_replace("\n", '&#13;&#10;', htmlentities($s)));
}
/*class HTMLPurifier_URIFilter_NoExternalImages extends HTMLPurifier_URIFilter {
public $name = 'NoExternalImages';
public function filter(&$uri, $c, $context) {
global $config;
$ct = $context->get('CurrentToken');
if (!$ct || $ct->name !== 'img') return true;
if (!isset($uri->host) && !isset($uri->scheme)) return true;
if (!in_array($uri->scheme . '://' . $uri->host . '/', $config['allowed_offsite_urls'])) {
error('No off-site links in board announcement images.');
}
return true;
}
}*/
function purify_html($s) {
global $config;
@@ -2899,7 +2646,6 @@ function purify_html($s) {
function markdown($s) {
$pd = new Parsedown();
$pd->setMarkupEscaped(true);
$pd->setimagesEnabled(false);
return $pd->text($s);
}
@@ -2918,7 +2664,20 @@ function generation_strategy($fun, $array=array()) { global $config;
return 'rebuild';
case 'defer':
// Ok, it gets interesting here :)
get_queue('generate')->push(serialize(array('build', $fun, $array, $action)));
$queue = Queues::get_queue($config, 'generate');
if ($queue === false) {
if ($config['syslog']) {
_syslog(LOG_ERR, "Could not initialize generate queue, falling back to immediate rebuild strategy");
}
return 'rebuild';
}
$ret = $queue->push(serialize(array('build', $fun, $array, $action)));
if ($ret === false) {
if ($config['syslog']) {
_syslog(LOG_ERR, "Could not push item in the queue, falling back to immediate rebuild strategy");
}
return 'rebuild';
}
return 'ignore';
case 'build_on_load':
return 'delete';
@@ -3089,3 +2848,34 @@ function check_thread_limit($post) {
return $r['count'] >= $config['max_threads_per_hour'];
}
}
function hashPassword($password) {
global $config;
return hash('sha3-256', $password . $config['secure_password_salt']);
}
// Thanks to https://gist.github.com/marijn/3901938
function trace_url($url) {
$ch = curl_init($url);
curl_setopt_array($ch, array(
CURLOPT_FOLLOWLOCATION => TRUE, // the magic sauce
CURLOPT_RETURNTRANSFER => TRUE,
CURLOPT_SSL_VERIFYHOST => FALSE, // suppress certain SSL errors
CURLOPT_SSL_VERIFYPEER => FALSE,
CURLOPT_TIMEOUT => 30,
));
curl_exec($ch);
$url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
curl_close($ch);
return $url;
}
// Thanks to https://stackoverflow.com/questions/10002227/linkify-regex-function-php-daring-fireball-method/10002262#10002262
function get_urls($body) {
$regex = '(?xi)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:\'".,<>?«»“”‘’]))';
$result = preg_match_all("#$regex#i", $body, $match);
return $match[0];
}

114
inc/functions/dice.php Normal file
View File

@@ -0,0 +1,114 @@
<?php
namespace Vichan\Functions\Dice;
function _get_or_default_int(array $arr, int $index, int $default) {
return (isset($arr[$index]) && is_numeric($arr[$index])) ? (int)$arr[$index] : $default;
}
/* Die rolling:
* If "dice XdY+/-Z" is in the email field (where X or +/-Z may be
* missing), X Y-sided dice are rolled and summed, with the modifier Z
* added on. The result is displayed at the top of the post.
*/
function email_dice_roll($post) {
global $config;
if(strpos(strtolower($post->email), 'dice%20') === 0) {
$dicestr = str_split(substr($post->email, strlen('dice%20')));
// Get params
$diceX = '';
$diceY = '';
$diceZ = '';
$curd = 'diceX';
for($i = 0; $i < count($dicestr); $i ++) {
if(is_numeric($dicestr[$i])) {
$$curd .= $dicestr[$i];
} else if($dicestr[$i] == 'd') {
$curd = 'diceY';
} else if($dicestr[$i] == '-' || $dicestr[$i] == '+') {
$curd = 'diceZ';
$$curd = $dicestr[$i];
}
}
// Default values for X and Z
if($diceX == '') {
$diceX = '1';
}
if($diceZ == '') {
$diceZ = '+0';
}
// Intify them
$diceX = intval($diceX);
$diceY = intval($diceY);
$diceZ = intval($diceZ);
// Continue only if we have valid values
if($diceX > 0 && $diceY > 0) {
$dicerolls = array();
$dicesum = $diceZ;
for($i = 0; $i < $diceX; $i++) {
$roll = rand(1, $diceY);
$dicerolls[] = $roll;
$dicesum += $roll;
}
// Prepend the result to the post body
$modifier = ($diceZ != 0) ? ((($diceZ < 0) ? ' - ' : ' + ') . abs($diceZ)) : '';
$dicesum = ($diceX > 1) ? ' = ' . $dicesum : '';
$post->body = '<table class="diceroll"><tr><td><img src="'.$config['dir']['static'].'d10.svg" alt="Dice roll" width="24"></td><td>Rolled ' . implode(', ', $dicerolls) . $modifier . $dicesum . '</td></tr></table><br/>' . $post->body;
}
}
}
/**
* Rolls a dice and generates the appropriate html from the markup.
* @param array $matches The array of the matches according to the default configuration.
* 1 -> The number of dices to roll.
* 3 -> The number faces of the dices.
* 4 -> The offset to apply to the dice.
* @param string $img_path Path to the image to use relative to the root. Null if none.
* @return string The html to replace the original markup with.
*/
function inline_dice_roll_markup(array $matches, ?string $img_path): string {
global $config;
$dice_count = _get_or_default_int($matches, 1, 1);
$dice_faces = _get_or_default_int($matches, 3, 6);
$dice_offset = _get_or_default_int($matches, 4, 0);
// Clamp between 1 and max_roll_count.
$dice_count = max(min($dice_count, $config['max_roll_count']), 1);
// Must be at least 2.
if ($dice_faces < 2) {
$dice_faces = 6;
}
$tot = 0;
for ($i = 0; $i < $dice_count; $i++) {
$tot += mt_rand(1, $dice_faces);
}
// Ensure that final result is at least an integer.
$tot = abs((int)($dice_offset + $tot));
if ($img_path !== null) {
$img_text = "<img src='{$config['root']}{$img_path}' alt='dice' title='dice' class=\"inline-dice\"/>";
} else {
$img_text = '';
}
if ($dice_offset === 0) {
$dice_offset_text = '';
} elseif ($dice_offset > 0) {
$dice_offset_text = "+{$dice_offset}";
} else {
$dice_offset_text = (string)$dice_offset;
}
return "<span>$img_text {$dice_count}d{$dice_faces}{$dice_offset_text} = <b>$tot</b></span>";
}

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

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

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

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

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

@@ -0,0 +1,33 @@
<?php
namespace Vichan\Functions\Num;
// Highest common factor
function hcf($a, $b){
$gcd = 1;
if ($a > $b) {
$a = $a+$b;
$b = $a-$b;
$a = $a-$b;
}
if ($b == (round($b / $a)) * $a) {
$gcd = $a;
} else {
for ($i = round($a / 2); $i; $i--) {
if ($a == round($a / $i) * $i && $b == round($b / $i) * $i) {
$gcd = $i;
$i = false;
}
}
}
return $gcd;
}
function fraction($numerator, $denominator, $sep) {
$gcf = hcf($numerator, $denominator);
$numerator = $numerator / $gcf;
$denominator = $denominator / $gcf;
return "{$numerator}{$sep}{$denominator}";
}

98
inc/functions/theme.php Normal file
View File

@@ -0,0 +1,98 @@
<?php
namespace Vichan\Functions\Theme;
function rebuild_themes(string $action, $boardname = false): void {
global $config, $board, $current_locale;
// Save the global variables
$_config = $config;
$_board = $board;
// List themes
if ($themes = \Cache::get("themes")) {
// OK, we already have themes loaded
} else {
$query = query("SELECT `theme` FROM ``theme_settings`` WHERE `name` IS NULL AND `value` IS NULL") or error(db_error());
$themes = $query->fetchAll(\PDO::FETCH_NUM);
\Cache::set("themes", $themes);
}
foreach ($themes as $theme) {
// Restore them
$config = $_config;
$board = $_board;
// Reload the locale
if ($config['locale'] != $current_locale) {
$current_locale = $config['locale'];
init_locale($config['locale']);
}
if (PHP_SAPI === 'cli') {
echo "Rebuilding theme ".$theme[0]."... ";
}
rebuild_theme($theme[0], $action, $boardname);
if (PHP_SAPI === 'cli') {
echo "done\n";
}
}
// Restore them again
$config = $_config;
$board = $_board;
// Reload the locale
if ($config['locale'] != $current_locale) {
$current_locale = $config['locale'];
init_locale($config['locale']);
}
}
function load_theme_config($_theme) {
global $config;
if (!file_exists($config['dir']['themes'] . '/' . $_theme . '/info.php')) {
return false;
}
// Load theme information into $theme
include $config['dir']['themes'] . '/' . $_theme . '/info.php';
return $theme;
}
function rebuild_theme($theme, string $action, $board = false) {
global $config, $_theme;
$_theme = $theme;
$theme = load_theme_config($_theme);
if (file_exists($config['dir']['themes'] . '/' . $_theme . '/theme.php')) {
require_once $config['dir']['themes'] . '/' . $_theme . '/theme.php';
$theme['build_function']($action, theme_settings($_theme), $board);
}
}
function theme_settings($theme): array {
if ($settings = \Cache::get("theme_settings_" . $theme)) {
return $settings;
}
$query = prepare("SELECT `name`, `value` FROM ``theme_settings`` WHERE `theme` = :theme AND `name` IS NOT NULL");
$query->bindValue(':theme', $theme);
$query->execute() or error(db_error($query));
$settings = [];
while ($s = $query->fetch(\PDO::FETCH_ASSOC)) {
$settings[$s['name']] = $s['value'];
}
\Cache::set("theme_settings_".$theme, $settings);
return $settings;
}

View File

@@ -291,6 +291,7 @@ class ImageConvert extends ImageBase {
} else {
rename($this->temp, $src);
chmod($src, 0664);
$this->temp = false;
}
}
public function width() {
@@ -300,8 +301,10 @@ class ImageConvert extends ImageBase {
return $this->height;
}
public function destroy() {
@unlink($this->temp);
$this->temp = false;
if ($this->temp !== false) {
@unlink($this->temp);
$this->temp = false;
}
}
public function resize() {
global $config;

View File

@@ -1,39 +1,76 @@
<?php
class Lock {
function __construct($key) { global $config;
if ($config['lock']['enabled'] == 'fs') {
$key = str_replace('/', '::', $key);
$key = str_replace("\0", '', $key);
class Locks {
private static function filesystem(string $key) {
$key = str_replace('/', '::', $key);
$key = str_replace("\0", '', $key);
$this->f = fopen("tmp/locks/$key", "w");
}
}
$fd = fopen("tmp/locks/$key", "w");
if ($fd === false) {
return false;
}
// Get a shared lock
function get($nonblock = false) { global $config;
if ($config['lock']['enabled'] == 'fs') {
$wouldblock = false;
flock($this->f, LOCK_SH | ($nonblock ? LOCK_NB : 0), $wouldblock);
if ($nonblock && $wouldblock) return false;
}
return $this;
}
return new class($fd) implements Lock {
// Resources have no type in PHP.
private $f;
// Get an exclusive lock
function get_ex($nonblock = false) { global $config;
if ($config['lock']['enabled'] == 'fs') {
$wouldblock = false;
flock($this->f, LOCK_EX | ($nonblock ? LOCK_NB : 0), $wouldblock);
if ($nonblock && $wouldblock) return false;
}
return $this;
}
public function __construct($fd) {
$this->f = $fd;
}
// Free a lock
function free() { global $config;
if ($config['lock']['enabled'] == 'fs') {
flock($this->f, LOCK_UN);
public function get(bool $nonblock = false) {
$wouldblock = false;
flock($this->f, LOCK_SH | ($nonblock ? LOCK_NB : 0), $wouldblock);
if ($nonblock && $wouldblock) {
return false;
}
return $this;
}
public function get_ex(bool $nonblock = false) {
$wouldblock = false;
flock($this->f, LOCK_EX | ($nonblock ? LOCK_NB : 0), $wouldblock);
if ($nonblock && $wouldblock) {
return false;
}
return $this;
}
public function free() {
flock($this->f, LOCK_UN);
return $this;
}
};
}
public static function none() {
return new class() implements Lock {
public function get(bool $nonblock = false) {
return $this;
}
public function get_ex(bool $nonblock = false) {
return $this;
}
public function free() {
return $this;
}
};
}
public static function get_lock(array $config, string $key) {
if ($config['lock']['enabled'] == 'fs') {
return self::filesystem($key);
} else {
return self::none();
}
}
return $this;
}
}
interface Lock {
public function get(bool $nonblock = false);
public function get_ex(bool $nonblock = false);
public function free();
}

View File

@@ -4,19 +4,22 @@
* Copyright (c) 2010-2013 Tinyboard Development Group
*/
use Vichan\Context;
use Vichan\Functions\Net;
defined('TINYBOARD') or exit;
// create a hash/salt pair for validate logins
function mkhash($username, $password, $salt = false) {
function mkhash(string $username, $password = null, $salt = false) {
global $config;
if (!$salt) {
// create some sort of salt for the hash
$salt = substr(base64_encode(sha1(rand() . time(), true) . $config['cookies']['salt']), 0, 15);
$generated_salt = true;
}
// generate hash (method is not important as long as it's strong)
$hash = substr(
base64_encode(
@@ -30,62 +33,59 @@ function mkhash($username, $password, $salt = false) {
)
), 0, 20
);
if (isset($generated_salt))
return array($hash, $salt);
else
if (isset($generated_salt)) {
return [ $hash, $salt ];
} else {
return $hash;
}
}
function crypt_password_old($password) {
$salt = generate_salt();
$password = hash('sha256', $salt . sha1($password));
return array($salt, $password);
}
function crypt_password($password) {
function crypt_password(string $password): array {
global $config;
// `salt` database field is reused as a version value. We don't want it to be 0.
$version = $config['password_crypt_version'] ? $config['password_crypt_version'] : 1;
$new_salt = generate_salt();
$password = crypt($password, $config['password_crypt'] . $new_salt . "$");
return array($version, $password);
return [ $version, $password ];
}
function test_password($password, $salt, $test) {
global $config;
function test_password(string $password, string $salt, string $test): array {
// Version = 0 denotes an old password hashing schema. In the same column, the
// password hash was kept previously
$version = (strlen($salt) <= 8) ? (int) $salt : 0;
$version = strlen($salt) <= 8 ? (int)$salt : 0;
if ($version == 0) {
$comp = hash('sha256', $salt . sha1($test));
}
else {
} else {
$comp = crypt($test, $password);
}
return array($version, hash_equals($password, $comp));
return [ $version, hash_equals($password, $comp) ];
}
function generate_salt() {
// mcrypt_create_iv() was deprecated in PHP 7.1.0, only use it if we're below that version number.
if (PHP_VERSION_ID < 70100) {
// 128 bits of entropy
return strtr(base64_encode(mcrypt_create_iv(16, MCRYPT_DEV_URANDOM)), '+', '.');
}
// Otherwise, use random_bytes()
function generate_salt(): string {
return strtr(base64_encode(random_bytes(16)), '+', '.');
}
function login($username, $password) {
function calc_cookie_name(bool $is_https, bool $is_path_jailed, string $base_name): string {
if ($is_https) {
if ($is_path_jailed) {
return "__Host-$base_name";
} else {
return "__Secure-$base_name";
}
} else {
return $base_name;
}
}
function login(string $username, string $password) {
global $mod, $config;
$query = prepare("SELECT `id`, `type`, `boards`, `password`, `version` FROM ``mods`` WHERE BINARY `username` = :username");
$query->bindValue(':username', $username);
$query->execute() or error(db_error($query));
if ($user = $query->fetch(PDO::FETCH_ASSOC)) {
list($version, $ok) = test_password($user['password'], $user['version'], $password);
@@ -100,40 +100,83 @@ function login($username, $password) {
$query->execute() or error(db_error($query));
}
return $mod = array(
return $mod = [
'id' => $user['id'],
'type' => $user['type'],
'username' => $username,
'hash' => mkhash($username, $user['password']),
'boards' => explode(',', $user['boards'])
);
];
}
}
return false;
}
function setCookies() {
function setCookies(): void {
global $mod, $config;
if (!$mod)
if (!$mod) {
error('setCookies() was called for a non-moderator!');
setcookie($config['cookies']['mod'],
$mod['username'] . // username
':' .
$mod['hash'][0] . // password
':' .
$mod['hash'][1], // salt
time() + $config['cookies']['expire'], $config['cookies']['jail'] ? $config['cookies']['path'] : '/', null, !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off', $config['cookies']['httponly']);
}
$is_https = Net\is_connection_secure($config['cookies']['secure_login_only'] === 1);
$is_path_jailed = $config['cookies']['jail'];
$name = calc_cookie_name($is_https, $is_path_jailed, $config['cookies']['mod']);
// <username>:<password>:<salt>
$value = "{$mod['username']}:{$mod['hash'][0]}:{$mod['hash'][1]}";
$options = [
'expires' => time() + $config['cookies']['expire'],
'path' => $is_path_jailed ? $config['cookies']['path'] : '/',
'secure' => $is_https,
'httponly' => $config['cookies']['httponly'],
'samesite' => 'Strict'
];
setcookie($name, $value, $options);
}
function destroyCookies() {
function destroyCookies(): void {
global $config;
// Delete the cookies
setcookie($config['cookies']['mod'], 'deleted', time() - $config['cookies']['expire'], $config['cookies']['jail']?$config['cookies']['path'] : '/', null, !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off', true);
$base_name = $config['cookies']['mod'];
$del_time = time() - 60 * 60 * 24 * 365; // 1 year.
$jailed_path = $config['cookies']['jail'] ? $config['cookies']['path'] : '/';
$http_only = $config['cookies']['httponly'];
$options_multi = [
$base_name => [
'expires' => $del_time,
'path' => $jailed_path ,
'secure' => false,
'httponly' => $http_only,
'samesite' => 'Strict'
],
"__Host-$base_name" => [
'expires' => $del_time,
'path' => $jailed_path,
'secure' => true,
'httponly' => $http_only,
'samesite' => 'Strict'
],
"__Secure-$base_name" => [
'expires' => $del_time,
'path' => '/',
'secure' => true,
'httponly' => $http_only,
'samesite' => 'Strict'
]
];
foreach ($options_multi as $name => $options) {
if (isset($_COOKIE[$name])) {
setcookie($name, 'deleted', $options);
unset($_COOKIE[$name]);
}
}
}
function modLog($action, $_board=null) {
function modLog(string $action, ?string $_board = null): void {
global $mod, $board, $config;
$query = prepare("INSERT INTO ``modlogs`` VALUES (:id, :ip, :board, :time, :text)");
$query->bindValue(':id', (isset($mod['id']) ? $mod['id'] : -1), PDO::PARAM_INT);
@@ -147,70 +190,84 @@ function modLog($action, $_board=null) {
else
$query->bindValue(':board', null, PDO::PARAM_NULL);
$query->execute() or error(db_error($query));
if ($config['syslog'])
if ($config['syslog']) {
_syslog(LOG_INFO, '[mod/' . $mod['username'] . ']: ' . $action);
}
}
function create_pm_header() {
global $mod, $config;
if ($config['cache']['enabled'] && ($header = cache::get('pm_unread_' . $mod['id'])) != false) {
if ($header === true)
if ($header === true) {
return false;
}
return $header;
}
$query = prepare("SELECT `id` FROM ``pms`` WHERE `to` = :id AND `unread` = 1");
$query->bindValue(':id', $mod['id'], PDO::PARAM_INT);
$query->execute() or error(db_error($query));
if ($pm = $query->fetch(PDO::FETCH_ASSOC))
$header = array('id' => $pm['id'], 'waiting' => $query->rowCount() - 1);
else
if ($pm = $query->fetch(PDO::FETCH_ASSOC)) {
$header = [ 'id' => $pm['id'], 'waiting' => $query->rowCount() - 1 ];
} else {
$header = true;
if ($config['cache']['enabled'])
}
if ($config['cache']['enabled']) {
cache::set('pm_unread_' . $mod['id'], $header);
if ($header === true)
}
if ($header === true) {
return false;
}
return $header;
}
function make_secure_link_token($uri) {
function make_secure_link_token(string $uri): string {
global $mod, $config;
return substr(sha1($config['cookies']['salt'] . '-' . $uri . '-' . $mod['id']), 0, 8);
}
function check_login($prompt = false) {
function check_login(Context $ctx, bool $prompt = false): void {
global $config, $mod;
$is_https = Net\is_connection_secure($config['cookies']['secure_login_only'] === 1);
$is_path_jailed = $config['cookies']['jail'];
$expected_cookie_name = calc_cookie_name($is_https, $is_path_jailed, $config['cookies']['mod']);
// Validate session
if (isset($_COOKIE[$config['cookies']['mod']])) {
if (isset($_COOKIE[$expected_cookie_name])) {
// Should be username:hash:salt
$cookie = explode(':', $_COOKIE[$config['cookies']['mod']]);
$cookie = explode(':', $_COOKIE[$expected_cookie_name]);
if (count($cookie) != 3) {
// Malformed cookies
destroyCookies();
if ($prompt) mod_login();
if ($prompt) {
mod_login($ctx);
}
exit;
}
$query = prepare("SELECT `id`, `type`, `boards`, `password` FROM ``mods`` WHERE `username` = :username");
$query->bindValue(':username', $cookie[0]);
$query->execute() or error(db_error($query));
$user = $query->fetch(PDO::FETCH_ASSOC);
// validate password hash
if ($cookie[1] !== mkhash($cookie[0], $user['password'], $cookie[2])) {
// Malformed cookies
destroyCookies();
if ($prompt) mod_login();
if ($prompt) {
mod_login($ctx);
}
exit;
}
$mod = array(
'id' => (int)$user['id'],
'type' => (int)$user['type'],

View File

@@ -64,6 +64,10 @@ function config_vars() {
$var['comment'][] = $temp_comment;
$temp_comment = false;
}
if (preg_match('!^\s*\$config\[(\'log_system\'|\'captcha\')\]!', $line)) {
continue;
}
if (preg_match('!^\s*// ([^$].*)$!', $line, $matches)) {
if ($var['default'] !== false) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,49 +1,98 @@
<?php
class Queue {
function __construct($key) { global $config;
if ($config['queue']['enabled'] == 'fs') {
$this->lock = new Lock($key);
$key = str_replace('/', '::', $key);
$key = str_replace("\0", '', $key);
$this->key = "tmp/queue/$key/";
}
}
class Queues {
private static $queues = array();
function push($str) { global $config;
if ($config['queue']['enabled'] == 'fs') {
$this->lock->get_ex();
file_put_contents($this->key.microtime(true), $str);
$this->lock->free();
}
return $this;
}
function pop($n = 1) { global $config;
if ($config['queue']['enabled'] == 'fs') {
$this->lock->get_ex();
$dir = opendir($this->key);
$paths = array();
while ($n > 0) {
$path = readdir($dir);
if ($path === FALSE) break;
elseif ($path == '.' || $path == '..') continue;
else { $paths[] = $path; $n--; }
}
$out = array();
foreach ($paths as $v) {
$out []= file_get_contents($this->key.$v);
unlink($this->key.$v);
}
$this->lock->free();
return $out;
}
}
/**
* This queue implementation isn't actually ordered, so it works more as a "bag".
*/
private static function filesystem(string $key, Lock $lock): Queue {
$key = str_replace('/', '::', $key);
$key = str_replace("\0", '', $key);
$key = "tmp/queue/$key/";
return new class($key, $lock) implements Queue {
private Lock $lock;
private string $key;
function __construct(string $key, Lock $lock) {
$this->lock = $lock;
$this->key = $key;
}
public function push(string $str): bool {
$this->lock->get_ex();
$ret = file_put_contents($this->key . microtime(true), $str);
$this->lock->free();
return $ret !== false;
}
public function pop(int $n = 1): array {
$this->lock->get_ex();
$dir = opendir($this->key);
$paths = array();
while ($n > 0) {
$path = readdir($dir);
if ($path === false) {
break;
} elseif ($path == '.' || $path == '..') {
continue;
} else {
$paths[] = $path;
$n--;
}
}
$out = array();
foreach ($paths as $v) {
$out[] = file_get_contents($this->key . $v);
unlink($this->key . $v);
}
$this->lock->free();
return $out;
}
};
}
/**
* No-op. Can be used for mocking.
*/
public static function none(): Queue {
return new class() implements Queue {
public function push(string $str): bool {
return true;
}
public function pop(int $n = 1): array {
return array();
}
};
}
public static function get_queue(array $config, string $name) {
if (!isset(self::$queues[$name])) {
if ($config['queue']['enabled'] == 'fs') {
$lock = Locks::get_lock($config, $name);
if ($lock === false) {
return false;
}
self::$queues[$name] = self::filesystem($name, $lock);
} else {
self::$queues[$name] = self::none();
}
}
return self::$queues[$name];
}
}
// Don't use the constructor. Use the get_queue function.
$queues = array();
interface Queue {
// Push a string in the queue.
public function push(string $str): bool;
function get_queue($name) { global $queues;
return $queues[$name] = isset ($queues[$name]) ? $queues[$name] : new Queue($name);
// Get a string from the queue.
public function pop(int $n = 1): array;
}

View File

@@ -0,0 +1,143 @@
<?php // Verify captchas server side.
namespace Vichan\Service;
use Vichan\Data\Driver\HttpDriver;
defined('TINYBOARD') or exit;
class ReCaptchaQuery implements RemoteCaptchaQuery {
private HttpDriver $http;
private string $secret;
/**
* Creates a new ReCaptchaQuery using the google recaptcha service.
*
* @param HttpDriver $http The http client.
* @param string $secret Server side secret.
* @return ReCaptchaQuery A new ReCaptchaQuery query instance.
*/
public function __construct(HttpDriver $http, string $secret) {
$this->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';
}
}

View File

@@ -11,12 +11,14 @@ $twig = false;
function load_twig() {
global $twig, $config;
$cache_dir = "{$config['dir']['template']}/cache/";
$loader = new Twig\Loader\FilesystemLoader($config['dir']['template']);
$loader->setPaths($config['dir']['template']);
$twig = new Twig\Environment($loader, array(
'autoescape' => false,
'cache' => is_writable('templates') || (is_dir('templates/cache') && is_writable('templates/cache')) ?
new Twig_Cache_TinyboardFilesystem("{$config['dir']['template']}/cache") : false,
'cache' => is_writable('templates/') || (is_dir($cache_dir) && is_writable($cache_dir)) ?
new TinyboardTwigCache($cache_dir) : false,
'debug' => $config['debug'],
'auto_reload' => $config['twig_auto_reload']
));
@@ -28,17 +30,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", '<br/>', utf8tohtml(print_r($_debug, true))) .
'</pre>';
}
// Read the template file
if (@file_get_contents("{$config['dir']['template']}/${templateFile}")) {
if (@file_get_contents("{$config['dir']['template']}/{$templateFile}")) {
$body = $twig->render($templateFile, $options);
if ($config['minify_html'] && preg_match('/\.html$/', $templateFile)) {
$body = trim(preg_replace("/[\t\r\n]/", '', $body));
}
return $body;
} else {
throw new Exception("Template file '${templateFile}' does not exist or is empty in '{$config['dir']['template']}'!");
throw new Exception("Template file '{$templateFile}' does not exist or is empty in '{$config['dir']['template']}'!");
}
}
class TinyboardTwigCache extends Twig\Cache\FilesystemCache {
private string $directory;
public function __construct(string $directory) {
parent::__construct($directory);
$this->directory = $directory;
}
/**
* This function was removed in Twig 2.x due to developer views on the Twig library.
* Who says we can't keep it for ourselves though?
*/
public function clear() {
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->directory),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iter as $file) {
if ($file->isFile()) {
@unlink($file->getPathname());
}
}
}
}
@@ -93,8 +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;