diff --git a/.gitignore b/.gitignore index 93cac6d4..3205c64b 100644 --- a/.gitignore +++ b/.gitignore @@ -70,9 +70,6 @@ tf/ /mod/ /random/ -# Banners -static/banners/* - #Fonts stylesheets/fonts diff --git a/docker-compose.yml b/compose.yml similarity index 81% rename from docker-compose.yml rename to compose.yml index 1f785cb0..526e18c6 100644 --- a/docker-compose.yml +++ b/compose.yml @@ -7,7 +7,7 @@ services: ports: - "9091:80" depends_on: - - leftypol-db + - db volumes: - ./local-instances/${INSTANCE:-0}/www:/var/www/html - ./docker/nginx/leftypol.conf:/etc/nginx/conf.d/default.conf @@ -23,13 +23,11 @@ services: volumes: - ./local-instances/${INSTANCE:-0}/www:/var/www - ./docker/php/www.conf:/usr/local/etc/php-fpm.d/www.conf + - redis-sock:/var/run/redis #MySQL Service - leftypol-db: + db: image: mysql:8.0.35 - container_name: leftypol-db - restart: unless-stopped - tty: true ports: - "3306:3306" environment: @@ -37,3 +35,13 @@ services: MYSQL_ROOT_PASSWORD: password volumes: - ./local-instances/${INSTANCE:-0}/mysql:/var/lib/mysql + + redis: + build: + context: ./ + dockerfile: ./docker/redis/Dockerfile + volumes: + - redis-sock:/var/run/redis + +volumes: + redis-sock: diff --git a/composer.json b/composer.json index e9ceac19..d0345e3b 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,9 @@ "autoload": { "classmap": ["inc/"], "files": [ + "inc/anti-bot.php", "inc/bootstrap.php", + "inc/context.php", "inc/display.php", "inc/template.php", "inc/database.php", diff --git a/docker/redis/Dockerfile b/docker/redis/Dockerfile new file mode 100644 index 00000000..9ab7f7fc --- /dev/null +++ b/docker/redis/Dockerfile @@ -0,0 +1,6 @@ +FROM redis:7.4-alpine + +RUN mkdir -p /var/run/redis && chmod 777 /var/run/redis +COPY ./docker/redis/redis.conf /etc/redis.conf + +ENTRYPOINT [ "docker-entrypoint.sh", "/etc/redis.conf" ] diff --git a/docker/redis/redis.conf b/docker/redis/redis.conf new file mode 100644 index 00000000..609d57ff --- /dev/null +++ b/docker/redis/redis.conf @@ -0,0 +1,16 @@ +# Accept connections on the specified port, default is 6379 (IANA #815344). +# If port 0 is specified Redis will not listen on a TCP socket. +#port 6379 +port 0 + +# Unix socket. +# +# Specify the path for the Unix socket that will be used to listen for +# incoming connections. There is no default, so Redis will not listen +# on a unix socket when not specified. +# +unixsocket /var/run/redis/redis-server.sock +# Executig a socket is a no-op, and we need to share acces to other programs. +# Shared the connection only with programs in the redis group for security. +#unixsocketperm 700 +unixsocketperm 666 diff --git a/inc/Data/Driver/ApcuCacheDriver.php b/inc/Data/Driver/ApcuCacheDriver.php new file mode 100644 index 00000000..a39bb656 --- /dev/null +++ b/inc/Data/Driver/ApcuCacheDriver.php @@ -0,0 +1,28 @@ +name = $name; + $this->level = $level; + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + $lv = $this->levelToString($level); + $line = "{$this->name} $lv: $message"; + \error_log($line, 0, null, null); + } + } +} diff --git a/inc/Data/Driver/FileLogDriver.php b/inc/Data/Driver/FileLogDriver.php new file mode 100644 index 00000000..2c9f14a0 --- /dev/null +++ b/inc/Data/Driver/FileLogDriver.php @@ -0,0 +1,61 @@ +fd = \fopen($file_path, 'a'); + if ($this->fd === false) { + throw new \RuntimeException("Unable to open log file at $file_path"); + } + + $this->name = $name; + $this->level = $level; + + // In some cases PHP does not run the destructor. + \register_shutdown_function([$this, 'close']); + } + + public function __destruct() { + $this->close(); + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + $lv = $this->levelToString($level); + $line = "{$this->name} $lv: $message\n"; + \flock($this->fd, LOCK_EX); + \fwrite($this->fd, $line); + \fflush($this->fd); + \flock($this->fd, LOCK_UN); + } + } + + public function close() { + \flock($this->fd, LOCK_UN); + \fclose($this->fd); + } +} diff --git a/inc/Data/Driver/FsCachedriver.php b/inc/Data/Driver/FsCachedriver.php new file mode 100644 index 00000000..b543cfa6 --- /dev/null +++ b/inc/Data/Driver/FsCachedriver.php @@ -0,0 +1,155 @@ +prefix . $key; + } + + private function sharedLockCache(): void { + \flock($this->lock_fd, LOCK_SH); + } + + private function exclusiveLockCache(): void { + \flock($this->lock_fd, LOCK_EX); + } + + private function unlockCache(): void { + \flock($this->lock_fd, LOCK_UN); + } + + private function collectImpl(): int { + /* + * A read lock is ok, since it's alright if we delete expired items from under the feet of other processes, and + * no other process add new cache items or refresh existing ones. + */ + $files = \glob($this->base_path . $this->prefix . '*', \GLOB_NOSORT); + $count = 0; + foreach ($files as $file) { + $data = \file_get_contents($file); + $wrapped = \json_decode($data, true, 512, \JSON_THROW_ON_ERROR); + if ($wrapped['expires'] !== false && $wrapped['expires'] <= \time()) { + if (@\unlink($file)) { + $count++; + } + } + } + return $count; + } + + private function maybeCollect(): void { + if ($this->collect_chance_den !== false && \mt_rand(0, $this->collect_chance_den - 1) === 0) { + $this->collect_chance_den = false; // Collect only once per instance (aka process). + $this->collectImpl(); + } + } + + public function __construct(string $prefix, string $base_path, string $lock_file, int|false $collect_chance_den) { + if ($base_path[\strlen($base_path) - 1] !== '/') { + $base_path = "$base_path/"; + } + + if (!\is_dir($base_path)) { + throw new \RuntimeException("$base_path is not a directory!"); + } + + if (!\is_writable($base_path)) { + throw new \RuntimeException("$base_path is not writable!"); + } + + $this->lock_fd = \fopen($base_path . $lock_file, 'w'); + if ($this->lock_fd === false) { + throw new \RuntimeException('Unable to open the lock file!'); + } + + $this->prefix = $prefix; + $this->base_path = $base_path; + $this->collect_chance_den = $collect_chance_den; + } + + public function __destruct() { + $this->close(); + } + + public function get(string $key): mixed { + $key = $this->prepareKey($key); + + $this->sharedLockCache(); + + // Collect expired items first so if the target key is expired we shortcut to failure in the next lines. + $this->maybeCollect(); + + $fd = \fopen($this->base_path . $key, 'r'); + if ($fd === false) { + $this->unlockCache(); + return null; + } + + $data = \stream_get_contents($fd); + \fclose($fd); + $this->unlockCache(); + $wrapped = \json_decode($data, true, 512, \JSON_THROW_ON_ERROR); + + if ($wrapped['expires'] !== false && $wrapped['expires'] <= \time()) { + // Already expired, leave it there since we already released the lock and pretend it doesn't exist. + return null; + } else { + return $wrapped['inner']; + } + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + $key = $this->prepareKey($key); + + $wrapped = [ + 'expires' => $expires ? \time() + $expires : false, + 'inner' => $value + ]; + + $data = \json_encode($wrapped); + $this->exclusiveLockCache(); + $this->maybeCollect(); + \file_put_contents($this->base_path . $key, $data); + $this->unlockCache(); + } + + public function delete(string $key): void { + $key = $this->prepareKey($key); + + $this->exclusiveLockCache(); + @\unlink($this->base_path . $key); + $this->maybeCollect(); + $this->unlockCache(); + } + + public function collect(): int { + $this->sharedLockCache(); + $count = $this->collectImpl(); + $this->unlockCache(); + return $count; + } + + public function flush(): void { + $this->exclusiveLockCache(); + $files = \glob($this->base_path . $this->prefix . '*', \GLOB_NOSORT); + foreach ($files as $file) { + @\unlink($file); + } + $this->unlockCache(); + } + + public function close(): void { + \fclose($this->lock_fd); + } +} diff --git a/inc/Data/Driver/LogDriver.php b/inc/Data/Driver/LogDriver.php new file mode 100644 index 00000000..fddc3f27 --- /dev/null +++ b/inc/Data/Driver/LogDriver.php @@ -0,0 +1,22 @@ +inner = new \Memcached(); + if (!$this->inner->setOption(\Memcached::OPT_BINARY_PROTOCOL, true)) { + throw new \RuntimeException('Unable to set the memcached protocol!'); + } + if (!$this->inner->setOption(\Memcached::OPT_PREFIX_KEY, $prefix)) { + throw new \RuntimeException('Unable to set the memcached prefix!'); + } + if (!$this->inner->addServers($memcached_server)) { + throw new \RuntimeException('Unable to add the memcached server!'); + } + } + + public function get(string $key): mixed { + $ret = $this->inner->get($key); + // If the returned value is false but the retrival was a success, then the value stored was a boolean false. + if ($ret === false && $this->inner->getResultCode() !== \Memcached::RES_SUCCESS) { + return null; + } + return $ret; + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + $this->inner->set($key, $value, (int)$expires); + } + + public function delete(string $key): void { + $this->inner->delete($key); + } + + public function flush(): void { + $this->inner->flush(); + } +} diff --git a/inc/Data/Driver/NoneCacheDriver.php b/inc/Data/Driver/NoneCacheDriver.php new file mode 100644 index 00000000..8b260a50 --- /dev/null +++ b/inc/Data/Driver/NoneCacheDriver.php @@ -0,0 +1,26 @@ +inner = new \Redis(); + if (str_starts_with($host, 'unix:') || str_starts_with($host, ':')) { + $ret = \explode(':', $host); + if (count($ret) < 2) { + throw new \RuntimeException("Invalid unix socket path $host"); + } + // Unix socket. + $this->inner->connect($ret[1]); + } elseif ($port === null) { + $this->inner->connect($host); + } else { + // IP + port. + $this->inner->connect($host, $port); + } + if ($password) { + $this->inner->auth($password); + } + if (!$this->inner->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_JSON)) { + throw new \RuntimeException('Unable to configure Redis serializer'); + } + if (!$this->inner->select($database)) { + throw new \RuntimeException('Unable to connect to Redis database!'); + } + + $this->prefix = $prefix; + } + + public function get(string $key): mixed { + $ret = $this->inner->get($this->prefix . $key); + if ($ret === false) { + return null; + } + if ($ret === null) { + return false; + } + return $ret; + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + $value = $value === false ? null : $value; + if ($expires === false) { + $this->inner->set($this->prefix . $key, $value); + } else { + $this->inner->setEx($this->prefix . $key, $expires, $value); + } + } + + public function delete(string $key): void { + $this->inner->del($this->prefix . $key); + } + + public function flush(): void { + if (empty($this->prefix)) { + $this->inner->flushDB(); + } else { + $this->inner->unlink($this->inner->keys("{$this->prefix}*")); + } + } +} diff --git a/inc/Data/Driver/StderrLogDriver.php b/inc/Data/Driver/StderrLogDriver.php new file mode 100644 index 00000000..4c766033 --- /dev/null +++ b/inc/Data/Driver/StderrLogDriver.php @@ -0,0 +1,27 @@ +name = $name; + $this->level = $level; + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + $lv = $this->levelToString($level); + \fwrite(\STDERR, "{$this->name} $lv: $message\n"); + } + } +} diff --git a/inc/Data/Driver/SyslogLogDriver.php b/inc/Data/Driver/SyslogLogDriver.php new file mode 100644 index 00000000..c0df5304 --- /dev/null +++ b/inc/Data/Driver/SyslogLogDriver.php @@ -0,0 +1,35 @@ +level = $level; + } + + public function log(int $level, string $message): void { + if ($level <= $this->level) { + if (isset($_SERVER['REMOTE_ADDR'], $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'])) { + // CGI + \syslog($level, "$message - client: {$_SERVER['REMOTE_ADDR']}, request: \"{$_SERVER['REQUEST_METHOD']} {$_SERVER['REQUEST_URI']}\""); + } else { + \syslog($level, $message); + } + } + } +} diff --git a/inc/Data/IpNoteQueries.php b/inc/Data/IpNoteQueries.php new file mode 100644 index 00000000..ba6fdb15 --- /dev/null +++ b/inc/Data/IpNoteQueries.php @@ -0,0 +1,76 @@ +pdo = $pdo; + $this->cache = $cache; + } + + /** + * Get all the notes relative to an IP. + * + * @param string $ip The IP of the notes. THE STRING IS NOT VALIDATED. + * @return array Returns an array of notes sorted by the most recent. Includes the username of the mods. + */ + public function getByIp(string $ip) { + $ret = $this->cache->get("ip_note_queries_$ip"); + if ($ret !== null) { + return $ret; + } + + $query = $this->pdo->prepare('SELECT `ip_notes`.*, `username` FROM `ip_notes` LEFT JOIN `mods` ON `mod` = `mods`.`id` WHERE `ip` = :ip ORDER BY `time` DESC'); + $query->bindValue(':ip', $ip); + $query->execute(); + $ret = $query->fetchAll(\PDO::FETCH_ASSOC); + + $this->cache->set("ip_note_queries_$ip", $ret); + return $ret; + } + + /** + * Creates a new note relative to the given ip. + * + * @param string $ip The IP of the note. THE STRING IS NOT VALIDATED. + * @param int $mod_id The id of the mod who created the note. + * @param string $body The text of the note. + * @return void + */ + public function add(string $ip, int $mod_id, string $body) { + $query = $this->pdo->prepare('INSERT INTO `ip_notes` (`ip`, `mod`, `time`, `body`) VALUES (:ip, :mod, :time, :body)'); + $query->bindValue(':ip', $ip); + $query->bindValue(':mod', $mod_id); + $query->bindValue(':time', time()); + $query->bindValue(':body', $body); + $query->execute(); + + $this->cache->delete("ip_note_queries_$ip"); + } + + /** + * Delete a note only if it's of a particular IP address. + * + * @param int $id The id of the note. + * @param int $ip The expected IP of the note. THE STRING IS NOT VALIDATED. + * @return bool True if any note was deleted. + */ + public function deleteWhereIp(int $id, string $ip): bool { + $query = $this->pdo->prepare('DELETE FROM `ip_notes` WHERE `ip` = :ip AND `id` = :id'); + $query->bindValue(':ip', $ip); + $query->bindValue(':id', $id); + $query->execute(); + $any = $query->rowCount() != 0; + + if ($any) { + $this->cache->delete("ip_note_queries_$ip"); + } + return $any; + } +} diff --git a/inc/Data/PageFetchResult.php b/inc/Data/PageFetchResult.php new file mode 100644 index 00000000..b33e7ac2 --- /dev/null +++ b/inc/Data/PageFetchResult.php @@ -0,0 +1,15 @@ +pdo->prepare('DELETE FROM `reports` WHERE `post` = :id AND `board` = :board'); + $query->bindValue(':id', $post_id, \PDO::PARAM_INT); + $query->bindValue(':board', $board); + $query->execute(); + } + + private function joinReportPosts(array $raw_reports, ?int $limit): array { + // Group the reports rows by board. + $reports_by_boards = []; + foreach ($raw_reports as $report) { + if (!isset($reports_by_boards[$report['board']])) { + $reports_by_boards[$report['board']] = []; + } + $reports_by_boards[$report['board']][] = $report['post']; + } + + // Join the reports with the actual posts. + $report_posts = []; + foreach ($reports_by_boards as $board => $posts) { + $report_posts[$board] = []; + + $query = $this->pdo->prepare(\sprintf('SELECT * FROM `posts_%s` WHERE `id` IN (' . \implode(',', $posts) . ')', $board)); + $query->execute(); + while ($post = $query->fetch(\PDO::FETCH_ASSOC)) { + $report_posts[$board][$post['id']] = $post; + } + } + + // Filter out the reports without a valid post. + $valid = []; + foreach ($raw_reports as $report) { + if (isset($report_posts[$report['board']][$report['post']])) { + $report['post_data'] = $report_posts[$report['board']][$report['post']]; + $valid[] = $report; + + if ($limit !== null && \count($valid) >= $limit) { + return $valid; + } + } else { + // Invalid report (post has been deleted). + if ($this->auto_maintenance != false) { + $this->deleteReportImpl($report['board'], $report['post']); + } + } + } + return $valid; + } + + /** + * Filters out the invalid reports. + * + * @param array $raw_reports Array with the raw fetched reports. Must include a `board`, `post` and `id` fields. + * @param bool $get_invalid True to reverse the filter and get the invalid reports instead. + * @return array An array of filtered reports. + */ + private function filterReports(array $raw_reports, bool $get_invalid): array { + // Group the reports rows by board. + $reports_by_boards = []; + foreach ($raw_reports as $report) { + if (!isset($reports_by_boards[$report['board']])) { + $reports_by_boards[$report['board']] = []; + } + $reports_by_boards[$report['board']][] = $report['post']; + } + + // Join the reports with the actual posts. + $report_posts = []; + foreach ($reports_by_boards as $board => $posts) { + $report_posts[$board] = []; + + $query = $this->pdo->prepare(\sprintf('SELECT `id` FROM `posts_%s` WHERE `id` IN (' . \implode(',', $posts) . ')', $board)); + $query->execute(); + while ($post = $query->fetch(\PDO::FETCH_ASSOC)) { + $report_posts[$board][$post['id']] = $post; + } + } + + if ($get_invalid) { + // Get the reports without a post. + $invalid = []; + foreach ($raw_reports as $report) { + if (isset($report_posts[$report['board']][$report['post']])) { + $invalid[] = $report; + } + } + return $invalid; + } else { + // Filter out the reports without a valid post. + $valid = []; + foreach ($raw_reports as $report) { + if (isset($report_posts[$report['board']][$report['post']])) { + $valid[] = $report; + } else { + // Invalid report (post has been deleted). + if ($this->auto_maintenance != false) { + $this->deleteReportImpl($report['board'], $report['post']); + } + } + } + return $valid; + } + } + + /** + * @param \PDO $pdo PDO connection. + * @param bool $auto_maintenance If the auto maintenance should be enabled. + */ + public function __construct(\PDO $pdo, bool $auto_maintenance) { + $this->pdo = $pdo; + $this->auto_maintenance = $auto_maintenance; + } + + /** + * Get the number of reports. + * + * @return int The number of reports. + */ + public function getCount(): int { + $query = $this->pdo->prepare('SELECT `board`, `post`, `id` FROM `reports`'); + $query->execute(); + $raw_reports = $query->fetchAll(\PDO::FETCH_ASSOC); + $valid_reports = $this->filterReports($raw_reports, false, null); + $count = \count($valid_reports); + + return $count; + } + + /** + * Get the report with the given id. DOES NOT PERFORM VALIDITY CHECK. + * + * @param int $id The id of the report to fetch. + * @return ?array An array of the given report with the `board` and `ip` fields. Null if no such report exists. + */ + public function getReportById(int $id): ?array { + $query = prepare('SELECT `board`, `ip` FROM ``reports`` WHERE `id` = :id'); + $query->bindValue(':id', $id); + $query->execute(); + + $ret = $query->fetch(\PDO::FETCH_ASSOC); + if ($ret !== false) { + return $ret; + } else { + return null; + } + } + + /** + * Get the reports with the associated post data. + * + * @param int $count The maximum number of rows in the return array. + * @return array The reports with the associated post data. + */ + public function getReportsWithPosts(int $count): array { + $query = $this->pdo->prepare('SELECT * FROM `reports` ORDER BY `time`'); + $query->execute(); + $raw_reports = $query->fetchAll(\PDO::FETCH_ASSOC); + return $this->joinReportPosts($raw_reports, $count); + } + + /** + * Purge the invalid reports. + * + * @return int The number of reports deleted. + */ + public function purge(): int { + $query = $this->pdo->prepare('SELECT `board`, `post`, `id` FROM `reports`'); + $query->execute(); + $raw_reports = $query->fetchAll(\PDO::FETCH_ASSOC); + $invalid_reports = $this->filterReports($raw_reports, true, null); + + foreach ($invalid_reports as $report) { + $this->deleteReportImpl($report['board'], $report['post']); + } + return \count($invalid_reports); + } + + /** + * Deletes the given report. + * + * @param int $id The report id. + */ + public function deleteById(int $id) { + $query = $this->pdo->prepare('DELETE FROM `reports` WHERE `id` = :id'); + $query->bindValue(':id', $id, \PDO::PARAM_INT); + $query->execute(); + } + + /** + * Deletes all reports from the given ip. + * + * @param string $ip The reporter ip. + */ + public function deleteByIp(string $ip) { + $query = $this->pdo->prepare('DELETE FROM `reports` WHERE `ip` = :ip'); + $query->bindValue(':ip', $ip); + $query->execute(); + } + + /** + * Inserts a new report. + * + * @param string $ip Ip of the user sending the report. + * @param string $board_uri Board uri of the reported thread. MUST ALREADY BE SANITIZED. + * @param int $post_id Post reported. + * @param string $reason Reason of the report. + * @return void + */ + public function add(string $ip, string $board_uri, int $post_id, string $reason) { + $query = $this->pdo->prepare('INSERT INTO `reports` VALUES (NULL, :time, :ip, :board, :post, :reason)'); + $query->bindValue(':time', time(), \PDO::PARAM_INT); + $query->bindValue(':ip', $ip); + $query->bindValue(':board', $board_uri); + $query->bindValue(':post', $post_id, \PDO::PARAM_INT); + $query->bindValue(':reason', $reason); + $query->execute(); + } +} diff --git a/inc/Data/UserPostQueries.php b/inc/Data/UserPostQueries.php new file mode 100644 index 00000000..1c203431 --- /dev/null +++ b/inc/Data/UserPostQueries.php @@ -0,0 +1,159 @@ +pdo = $pdo; + } + + private function paginate(array $board_uris, int $page_size, ?string $cursor, callable $callback): PageFetchResult { + // Decode the cursor. + if ($cursor !== null) { + list($cursor_type, $uri_id_cursor_map) = Net\decode_cursor($cursor); + } else { + // Defaults if $cursor is an invalid string. + $cursor_type = null; + $uri_id_cursor_map = []; + } + $next_cursor_map = []; + $prev_cursor_map = []; + $rows = []; + + foreach ($board_uris as $uri) { + // Extract the cursor relative to the board. + $start_id = null; + if ($cursor_type !== null && isset($uri_id_cursor_map[$uri])) { + $value = $uri_id_cursor_map[$uri]; + if (\is_numeric($value)) { + $start_id = (int)$value; + } + } + + $posts = $callback($uri, $cursor_type, $start_id, $page_size); + + $posts_count = \count($posts); + + // By fetching one extra post bellow and/or above the limit, we know if there are any posts beside the current page. + if ($posts_count === $page_size + 2) { + $has_extra_prev_post = true; + $has_extra_end_post = true; + } else { + /* + * If the id we start fetching from is also the first id fetched from the DB, then we exclude it from + * the results, noting that we fetched 1 more posts than we needed, and it was before the current page. + * Hence, we have no extra post at the end and no next page. + */ + $has_extra_prev_post = $start_id !== null && $start_id === (int)$posts[0]['id']; + $has_extra_end_post = !$has_extra_prev_post && $posts_count > $page_size; + } + + // Get the previous cursor, if any. + if ($has_extra_prev_post) { + \array_shift($posts); + $posts_count--; + // Select the most recent post. + $prev_cursor_map[$uri] = $posts[0]['id']; + } + // Get the next cursor, if any. + if ($has_extra_end_post) { + \array_pop($posts); + // Select the oldest post. + $next_cursor_map[$uri] = $posts[$posts_count - 2]['id']; + } + + $rows[$uri] = $posts; + } + + $res = new PageFetchResult(); + $res->by_uri = $rows; + $res->cursor_prev = !empty($prev_cursor_map) ? Net\encode_cursor(self::CURSOR_TYPE_PREV, $prev_cursor_map) : null; + $res->cursor_next = !empty($next_cursor_map) ? Net\encode_cursor(self::CURSOR_TYPE_NEXT, $next_cursor_map) : null; + + return $res; + } + + /** + * Fetch a page of user posts. + * + * @param array $board_uris The uris of the boards that should be included. + * @param string $ip The IP of the target user. + * @param integer $page_size The Number of posts that should be fetched. + * @param string|null $cursor The directional cursor to fetch the next or previous page. Null to start from the beginning. + * @return PageFetchResult + */ + public function fetchPaginatedByIp(array $board_uris, string $ip, int $page_size, ?string $cursor = null): PageFetchResult { + return $this->paginate($board_uris, $page_size, $cursor, function($uri, $cursor_type, $start_id, $page_size) use ($ip) { + if ($cursor_type === null) { + $query = $this->pdo->prepare(sprintf('SELECT * FROM `posts_%s` WHERE `ip` = :ip ORDER BY `sticky` DESC, `id` DESC LIMIT :limit', $uri)); + $query->bindValue(':ip', $ip); + $query->bindValue(':limit', $page_size + 1, \PDO::PARAM_INT); // Always fetch more. + $query->execute(); + return $query->fetchAll(\PDO::FETCH_ASSOC); + } elseif ($cursor_type === self::CURSOR_TYPE_NEXT) { + $query = $this->pdo->prepare(sprintf('SELECT * FROM `posts_%s` WHERE `ip` = :ip AND `id` <= :start_id ORDER BY `sticky` DESC, `id` DESC LIMIT :limit', $uri)); + $query->bindValue(':ip', $ip); + $query->bindValue(':start_id', $start_id, \PDO::PARAM_INT); + $query->bindValue(':limit', $page_size + 2, \PDO::PARAM_INT); // Always fetch more. + $query->execute(); + return $query->fetchAll(\PDO::FETCH_ASSOC); + } elseif ($cursor_type === self::CURSOR_TYPE_PREV) { + $query = $this->pdo->prepare(sprintf('SELECT * FROM `posts_%s` WHERE `ip` = :ip AND `id` >= :start_id ORDER BY `sticky` ASC, `id` ASC LIMIT :limit', $uri)); + $query->bindValue(':ip', $ip); + $query->bindValue(':start_id', $start_id, \PDO::PARAM_INT); + $query->bindValue(':limit', $page_size + 2, \PDO::PARAM_INT); // Always fetch more. + $query->execute(); + return \array_reverse($query->fetchAll(\PDO::FETCH_ASSOC)); + } else { + throw new \RuntimeException("Unknown cursor type '$cursor_type'"); + } + }); + } + + /** + * Fetch a page of user posts. + * + * @param array $board_uris The uris of the boards that should be included. + * @param string $password The password of the target user. + * @param integer $page_size The Number of posts that should be fetched. + * @param string|null $cursor The directional cursor to fetch the next or previous page. Null to start from the beginning. + * @return PageFetchResult + */ + public function fetchPaginateByPassword(array $board_uris, string $password, int $page_size, ?string $cursor = null): PageFetchResult { + return $this->paginate($board_uris, $page_size, $cursor, function($uri, $cursor_type, $start_id, $page_size) use ($password) { + if ($cursor_type === null) { + $query = $this->pdo->prepare(sprintf('SELECT * FROM `posts_%s` WHERE `password` = :password ORDER BY `sticky` DESC, `id` DESC LIMIT :limit', $uri)); + $query->bindValue(':password', $password); + $query->bindValue(':limit', $page_size + 1, \PDO::PARAM_INT); // Always fetch more. + $query->execute(); + return $query->fetchAll(\PDO::FETCH_ASSOC); + } elseif ($cursor_type === self::CURSOR_TYPE_NEXT) { + $query = $this->pdo->prepare(sprintf('SELECT * FROM `posts_%s` WHERE `password` = :password AND `id` <= :start_id ORDER BY `sticky` DESC, `id` DESC LIMIT :limit', $uri)); + $query->bindValue(':password', $password); + $query->bindValue(':start_id', $start_id, \PDO::PARAM_INT); + $query->bindValue(':limit', $page_size + 2, \PDO::PARAM_INT); // Always fetch more. + $query->execute(); + return $query->fetchAll(\PDO::FETCH_ASSOC); + } elseif ($cursor_type === self::CURSOR_TYPE_PREV) { + $query = $this->pdo->prepare(sprintf('SELECT * FROM `posts_%s` WHERE `password` = :password AND `id` >= :start_id ORDER BY `sticky` ASC, `id` ASC LIMIT :limit', $uri)); + $query->bindValue(':password', $password); + $query->bindValue(':start_id', $start_id, \PDO::PARAM_INT); + $query->bindValue(':limit', $page_size + 2, \PDO::PARAM_INT); // Always fetch more. + $query->execute(); + return \array_reverse($query->fetchAll(\PDO::FETCH_ASSOC)); + } else { + throw new \RuntimeException("Unknown cursor type '$cursor_type'"); + } + }); + } +} diff --git a/inc/anti-bot.php b/inc/anti-bot.php index f21b6d5f..cf82dcc8 100644 --- a/inc/anti-bot.php +++ b/inc/anti-bot.php @@ -190,53 +190,62 @@ class AntiBot { } } -function _create_antibot($board, $thread) { +function _create_antibot($pdo, $board, $thread) { global $config, $purged_old_antispam; $antibot = new AntiBot(array($board, $thread)); - // Delete old expired antispam, skipping those with NULL expiration timestamps (infinite lifetime). - if (!isset($purged_old_antispam) && $config['auto_maintenance']) { - $purged_old_antispam = true; - purge_old_antispam(); - } - - retry_on_deadlock(4, function() use($thread, $board, $config) { - // Keep the now invalid timestamps around for a bit to enable users to post if they're still on an old version of - // the HTML page. - // By virtue of existing, we know that we're making a new version of the page, and the user from now on may just reload. - 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']); - // Throws on error. - $query->execute(); - }); - try { - $hash = $antibot->hash(); + retry_on_deadlock(3, function() use ($config, $pdo, $thread, $board, $antibot, $purged_old_antispam) { + try { + $pdo->beginTransaction(); - retry_on_deadlock(2, function() use($board, $thread, $hash) { - // Insert an antispam with infinite life as the HTML page of a thread might last well beyond the expiry date. - $query = prepare('INSERT INTO ``antispam`` VALUES (:board, :thread, :hash, UNIX_TIMESTAMP(), NULL, 0)'); - $query->bindValue(':board', $board); - $query->bindValue(':thread', $thread); - $query->bindValue(':hash', $hash); - // Throws on error. - $query->execute(); + // Delete old expired antispam, skipping those with NULL expiration timestamps (infinite lifetime). + if (!isset($purged_old_antispam) && $config['auto_maintenance']) { + $purged_old_antispam = true; + purge_old_antispam(); + } + + // Keep the now invalid timestamps around for a bit to enable users to post if they're still on an old version of + // the HTML page. + // By virtue of existing, we know that we're making a new version of the page, and the user from now on may just reload. + 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']); + // Throws on error. + $query->execute(); + + + $hash = $antibot->hash(); + + // Insert an antispam with infinite life as the HTML page of a thread might last well beyond the expiry date. + $query = prepare('INSERT INTO ``antispam`` VALUES (:board, :thread, :hash, UNIX_TIMESTAMP(), NULL, 0)'); + $query->bindValue(':board', $board); + $query->bindValue(':thread', $thread); + $query->bindValue(':hash', $hash); + // Throws on error. + $query->execute(); + + $pdo->commit(); + } catch (\Exception $e) { + $pdo->rollBack(); + throw $e; + } }); - } catch(\PDOException $e) { + } catch (\PDOException $e) { + $pdo->rollBack(); if ($e->errorInfo === null || $e->errorInfo[1] != MYSQL_ER_LOCK_DEADLOCK) { throw $e; } else { - error_log('Multiple deadlocks on _create_antibot while inserting, skipping'); + \error_log('5 or more deadlocks on _create_antibot while inserting, skipping'); } } diff --git a/inc/cache.php b/inc/cache.php index a8428846..d26f9201 100644 --- a/inc/cache.php +++ b/inc/cache.php @@ -4,182 +4,91 @@ * 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(); - - $ret = explode(':', $config['cache']['redis'][0]); - if (count($ret) > 0) { - // Unix socket. - self::$cache->connect($ret[1]); - } else { - // IP + port. - self::$cache->connect($ret[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; + $port = $config['cache']['redis'][1]; + $port = empty($port) ? null : intval($port); + return new RedisCacheDriver( + $config['cache']['prefix'], + $config['cache']['redis'][0], + $port, + $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 'apc': - $data = apc_fetch($key); - break; - case 'xcache': - $data = xcache_get($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 'apc': - apc_store($key, $value, $expires); - break; - case 'xcache': - xcache_set($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 'apc': - apc_delete($key); - break; - case 'xcache': - xcache_unset($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 'apc': - return apc_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; } } diff --git a/inc/config.php b/inc/config.php index 3a406a44..71b0fbf4 100644 --- a/inc/config.php +++ b/inc/config.php @@ -63,9 +63,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; @@ -117,18 +137,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. * - * http://tinyboard.org/docs/index.php?p=Config/Cache + * https://github.com/vichan-devel/vichan/wiki/cache */ + // Uses a PHP array. MUST NOT be used in multiprocess environments. $config['cache']['enabled'] = 'php'; - // $config['cache']['enabled'] = 'xcache'; - // $config['cache']['enabled'] = 'apc'; + // 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 @@ -144,7 +172,7 @@ // Redis server to use. Location, port, password, database id. // Note that Tinyboard may clear the database at times, so you may want to pick a database id just for // Tinyboard to use. - $config['cache']['redis'] = array('localhost', 6379, '', 1); + $config['cache']['redis'] = [ 'localhost', 6379, null, 1 ]; // EXPERIMENTAL: Should we cache configs? Warning: this changes board behaviour, i'd say, a lot. // If you have any lambdas/includes present in your config, you should move them to instance-functions.php @@ -192,6 +220,9 @@ // 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 @@ -236,6 +267,9 @@ // To prevent bump atacks; returns the thread to last position after the last post is deleted. $config['anti_bump_flood'] = false; + // Reject thread creation from IPs without any prior post history. + $config['op_require_history'] = false; + /* * Introduction to Tinyboard's spam filter: * @@ -301,9 +335,8 @@ 'lock', 'raw', 'embed', - 'g-recaptcha-response', - 'h-captcha-response', - 'cf-turnstile-response', + 'captcha-response', + 'captcha-form-id', 'spoiler', 'page', 'file_url', @@ -330,33 +363,40 @@ 'answer' => '4' ); */ - /** - * The captcha is dynamically injected on the client if the server replies with the `captcha-required` cookie set - * to 1. - * Use false to disable this configuration, otherwise, set the IP that vichan should check for captcha responses. - */ - $config['dynamic_captcha'] = 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. - $config['hcaptcha'] = false; - - // Public and private key pair for using hCaptcha. - $config['hcaptcha_public'] = '7a4b21e0-dc53-46f2-a9f8-91d2e74b63a0'; - $config['hcaptcha_private'] = '0x4e9A01bE637b51dC41a7Ea9865C3fDe4aB72Cf17'; - - // Enable Cloudflare's Turnstile captcha. - $config['turnstile'] = false; - - // Public and private key pair for turnstile. - $config['turnstile_public'] = ''; - $config['turnstile_private'] = ''; + // Enable a captcha system to make spam even harder. Rarely necessary. + $config['captcha'] = [ + /** + * Select the captcha backend, false to disable. + * Can be false, "recaptcha", "hcaptcha" or "turnstile". + */ + 'mode' => false, + /** + * The captcha is dynamically injected on the client if the server replies with the `captcha-required` cookie set + * to 1. + * Use false to disable this configuration, otherwise, set the IP that vichan should check for captcha responses. + */ + 'dynamic' => false, + // Require to be non-zero if you use js/ajax.js (preferably no more than a few seconds), otherwise weird errors might occur. + 'passthrough_timeout' => 0, + // Configure Google reCAPTCHA. + 'recaptcha' => [ + // Public and private key pair from https://www.google.com/recaptcha/admin/create + 'public' => '6LcXTcUSAAAAAKBxyFWIt2SO8jwx4W7wcSMRoN3f', + 'private' => '6LcXTcUSAAAAAOGVbVdhmEM1_SyRF4xTKe8jbzf_', + ], + // Configure hCaptcha. + 'hcaptcha' => [ + // Public and private key pair for using hCaptcha. + 'public' => '7a4b21e0-dc53-46f2-a9f8-91d2e74b63a0', + 'private' => '0x4e9A01bE637b51dC41a7Ea9865C3fDe4aB72Cf17', + ], + // Configure Cloudflare Turnstile. + 'turnstile' => [ + // Public and private key pair for turnstile. + 'public' => '', + 'private' => '', + ] + ]; // 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; @@ -547,6 +587,10 @@ // Requires $config['strip_combining_chars'] = true; $config['max_combining_chars'] = 0; + // Maximum OP body length. Ignored if force_body_op is set to false. + $config['max_body_op'] = 1800; + // Minimum OP body length. Ignored if force_body_op is set to false. + $config['min_body_op'] = 0; // Maximum post body length. $config['max_body'] = 1800; // Minimum post body length. @@ -709,18 +753,18 @@ // a link to an email address or IRC chat room to appeal the ban. $config['ban_page_extra'] = ''; - // Pre-configured ban reasons that pre-fill the ban form when clicked. - // To disable, set $config['ban_reasons'] = false; - $config['ban_reasons'] = array( - array( 'reason' => 'Low-quality posting', - 'length' => '1d'), - array( 'reason' => 'Off-topic', - 'length' => '1d'), - array( 'reason' => 'Ban evasion', - 'length' => '30d'), - array( 'reason' => 'Illegal content', - 'length' => ''), - ); + // Pre-configured ban reasons that pre-fill the ban form when clicked. + // To disable, set $config['ban_reasons'] = false; + $config['ban_reasons'] = array( + array( 'reason' => 'Low-quality posting', + 'length' => '1d'), + array( 'reason' => 'Off-topic', + 'length' => '1d'), + array( 'reason' => 'Ban evasion', + 'length' => '30d'), + array( 'reason' => 'Illegal content', + 'length' => ''), + ); // How often (minimum) to purge the ban list of expired bans (which have been seen). $config['purge_bans'] = 60 * 60 * 12; // 12 hours @@ -899,10 +943,6 @@ // Location of thumbnail to use for deleted images. $config['image_deleted'] = 'static/deleted.png'; - // When a thumbnailed image is going to be the same (in dimension), just copy the entire file and use - // that as a thumbnail instead of resizing/redrawing. - $config['minimum_copy_resize'] = false; - // Maximum image upload size in bytes. $config['max_filesize'] = 10 * 1024 * 1024; // 10MB // Maximum image dimensions. @@ -941,15 +981,6 @@ // Set this to true if you're using Linux and you can execute `md5sum` binary. $config['gnu_md5'] = false; - // Use Tesseract OCR to retrieve text from images, so you can use it as a spamfilter. - $config['tesseract_ocr'] = false; - - // Tesseract parameters - $config['tesseract_params'] = ''; - - // Tesseract preprocess command - $config['tesseract_preprocess_command'] = 'convert -monochrome %s -'; - // Number of posts in a "View Last X Posts" page $config['noko50_count'] = 50; // Number of posts a thread needs before it gets a "View Last X Posts" page. @@ -1171,10 +1202,22 @@ // Custom embedding (YouTube, vimeo, etc.) // It's very important that you match the entire input (with ^ and $) or things will not work correctly. $config['embedding'] = array( - array( - '/^https?:\/\/(\w+\.)?youtube\.com\/watch\?v=([a-zA-Z0-9\-_]{10,11})(&.+)?$/i', - '' - ), + [ + '/^(?:(?:https?:)?\/\/)?((?:www|m)\.)?(?:(?:youtube(?:-nocookie)?\.com|youtu\.be))(?:\/(?:[\w\-]+\?v=|embed\/|live\/|v\/)?)([\w\-]{11})((?:\?|\&)\S+)?$/i', + '
+ + + +
' + ], + [ + '/^https?:\/\/(\w+\.)?youtube\.com\/shorts\/([a-zA-Z0-9\-_]{10,11})(\?.*)?$/i', + '
+ + + +
' + ], array( '/^https?:\/\/(\w+\.)?vimeo\.com\/(\d{2,10})(\?.+)?$/i', '' @@ -1191,10 +1234,18 @@ '/^https?:\/\/video\.google\.com\/videoplay\?docid=(\d+)([&#](.+)?)?$/i', '' ), - array( + [ '/^https?:\/\/(\w+\.)?vocaroo\.com\/i\/([a-zA-Z0-9]{2,15})$/i', - '' - ) + '' + ], + [ + '/^https?:\/\/(\w+\.)?voca\.ro\/([a-zA-Z0-9]{2,15})$/i', + '' + ], + [ + '/^https?:\/\/(\w+\.)?vocaroo\.com\/([a-zA-Z0-9]{2,15})#?$/i', + '' + ] ); // Embedding width and height. @@ -1210,6 +1261,7 @@ // Error messages $config['error']['bot'] = _('You look like a bot.'); $config['error']['referer'] = _('Your browser sent an invalid or no HTTP referer.'); + $config['error']['opnohistory'] = _('You must post at least once before creating thread.'); $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.'); @@ -1499,8 +1551,8 @@ // 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; + // How many recent posts, per board, to show in ?/user_posts/ip/x.x.x.x. and ?/user_posts/passwd/xxxxxxxx + $config['mod']['recent_user_posts'] = 5; // Number of posts to display on the reports page. $config['mod']['recent_reports'] = 10; @@ -1889,22 +1941,24 @@ */ // Matrix integration for reports - // $config['matrix'] = array( - // 'access_token' => 'ACCESS_TOKEN', - // 'room_id' => '%21askjdlkajsdlka:matrix.org', - // 'host' => 'https://matrix.org', - // 'max_message_length' => 240 - // ); + $config['matrix'] = [ + 'enabled' => false, + 'access_token' => 'ACCESS_TOKEN', + // Note: must be already url-escaped. + 'room_id' => '%21askjdlkajsdlka:matrix.org', + 'host' => 'https://matrix.org', + 'max_message_length' => 240 + ]; - //Securimage captcha - //Note from lainchan PR: "TODO move a bunch of things here" + //Securimage captcha + //Note from lainchan PR: "TODO move a bunch of things here" - $config['spam']['valid_inputs'][]='captcha'; - $config['error']['securimage']=array( - 'missing'=>'The captcha field was missing. Please try again', - 'empty'=>'Please fill out the captcha', - 'bad'=>'Incorrect or expired captcha', - ); + $config['spam']['valid_inputs'][]='captcha'; + $config['error']['securimage']=array( + 'missing'=>'The captcha field was missing. Please try again', + 'empty'=>'Please fill out the captcha', + 'bad'=>'Incorrect or expired captcha', + ); // Meta keywords. It's probably best to include these in per-board configurations. // $config['meta_keywords'] = 'chan,anonymous discussion,imageboard,tinyboard'; @@ -1976,12 +2030,6 @@ // is the absolute maximum, because MySQL cannot handle table names greater than 64 characters. $config['board_regex'] = '[0-9a-zA-Z$_\x{0080}-\x{FFFF}]{1,58}'; - // Youtube.js embed HTML code - $config['youtube_js_html'] = '
'. - ''. - ''. - '
'; - // Slack Report Notification $config['slack'] = false; $config['slack_channel'] = ""; diff --git a/inc/context.php b/inc/context.php new file mode 100644 index 00000000..11a153ec --- /dev/null +++ b/inc/context.php @@ -0,0 +1,82 @@ +definitions = $definitions; + } + + public function get(string $name): mixed { + 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']; + + $legacy_syslog = isset($config['syslog']) && $config['syslog']; + + // Check 'syslog' for backwards compatibility. + if ($legacy_syslog || $backend === 'syslog') { + $log_driver = new SyslogLogDriver($name, $level, $config['log_system']['syslog_stderr']); + if ($legacy_syslog) { + $log_driver->log(LogDriver::NOTICE, 'The configuration setting \'syslog\' is deprecated. Please use \'log_system\' instead'); + } + return $log_driver; + } elseif ($backend === 'file') { + return new FileLogDriver($name, $level, $config['log_system']['file_path']); + } elseif ($backend === 'stderr') { + return new StderrLogDriver($name, $level); + } elseif ($backend === 'error_log') { + return new ErrorLogLogDriver($name, $level); + } else { + $log_driver = new ErrorLogLogDriver($name, $level); + $log_driver->log(LogDriver::ERROR, "Unknown 'log_system' value '$backend', using 'error_log' default"); + return $log_driver; + } + }, + CacheDriver::class => function($c) { + // Use the global for backwards compatibility. + return \cache::getCache(); + }, + \PDO::class => function($c) { + global $pdo; + // Ensure the PDO is initialized. + sql_open(); + return $pdo; + }, + ReportQueries::class => function($c) { + $auto_maintenance = (bool)$c->get('config')['auto_maintenance']; + $pdo = $c->get(\PDO::class); + return new ReportQueries($pdo, $auto_maintenance); + }, + UserPostQueries::class => function($c) { + return new UserPostQueries($c->get(\PDO::class)); + }, + IpNoteQueries::class => fn($c) => new IpNoteQueries($c->get(\PDO::class), $c->get(CacheDriver::class)), + ]); +} diff --git a/inc/database.php b/inc/database.php index 380a0837..f5ea73bd 100644 --- a/inc/database.php +++ b/inc/database.php @@ -72,6 +72,7 @@ function sql_open() { try { $options = [ PDO::ATTR_TIMEOUT => $config['db']['timeout'], + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // Set a consistent error mode between PHP versions. ]; if ($config['db']['type'] == "mysql") @@ -100,12 +101,6 @@ function sql_open() { } } -// 5.6.10 becomes 50610 HACK: hardcoded to be above critical value 50803 due to laziness -function mysql_version() { - // TODO delete all references of this function everywhere - return 80504; -} - function prepare($query) { global $pdo, $debug, $config; diff --git a/inc/filters.php b/inc/filters.php index 2a66cd2a..97cbc524 100644 --- a/inc/filters.php +++ b/inc/filters.php @@ -4,23 +4,26 @@ * Copyright (c) 2010-2013 Tinyboard Development Group */ +use Vichan\Context; +use Vichan\Data\IpNoteQueries; + defined('TINYBOARD') or exit; class Filter { public $flood_check; private $condition; private $post; - + public function __construct(array $arr) { foreach ($arr as $key => $value) - $this->$key = $value; + $this->$key = $value; } - + public function match($condition, $match) { $condition = strtolower($condition); $post = &$this->post; - + switch($condition) { case 'custom': if (!is_callable($match)) @@ -29,11 +32,11 @@ class Filter { case 'flood-match': if (!is_array($match)) error('Filter condition "flood-match" must be an array.'); - + // Filter out "flood" table entries which do not match this filter. - + $flood_check_matched = array(); - + foreach ($this->flood_check as $flood_post) { foreach ($match as $flood_match_arg) { switch ($flood_match_arg) { @@ -69,10 +72,10 @@ class Filter { } $flood_check_matched[] = $flood_post; } - + // is there any reason for this assignment? $this->flood_check = $flood_check_matched; - + return !empty($this->flood_check); case 'flood-time': foreach ($this->flood_check as $flood_post) { @@ -135,46 +138,42 @@ class Filter { error('Unknown filter condition: ' . $condition); } } - - public function action() { + + public function action(Context $ctx) { global $board; $this->add_note = isset($this->add_note) ? $this->add_note : false; if ($this->add_note) { - $query = prepare('INSERT INTO ``ip_notes`` VALUES (NULL, :ip, :mod, :time, :body)'); - $query->bindValue(':ip', $_SERVER['REMOTE_ADDR']); - $query->bindValue(':mod', -1); - $query->bindValue(':time', time()); - $query->bindValue(':body', "Autoban message: ".$this->post['body']); - $query->execute() or error(db_error($query)); - } + $note_queries = $ctx->get(IpNoteQueries::class); + $note_queries->add($_SERVER['REMOTE_ADDR'], -1, 'Autoban message: ' . $this->post['body']); + } if (isset ($this->action)) switch($this->action) { case 'reject': error(isset($this->message) ? $this->message : 'Posting blocked by filter.'); case 'ban': if (!isset($this->reason)) error('The ban action requires a reason.'); - + $this->expires = isset($this->expires) ? $this->expires : false; $this->reject = isset($this->reject) ? $this->reject : true; $this->all_boards = isset($this->all_boards) ? $this->all_boards : false; - + Bans::new_ban($_SERVER['REMOTE_ADDR'], $this->reason, $this->expires, $this->all_boards ? false : $board['uri'], -1); if ($this->reject) { if (isset($this->message)) error($message); - + checkBan($board['uri']); exit; } - + break; default: error('Unknown filter action: ' . $this->action); } } - + public function check(array $post) { $this->post = $post; foreach ($this->condition as $condition => $value) { @@ -184,7 +183,7 @@ class Filter { } else { $NOT = false; } - + if ($this->match($condition, $value) == $NOT) return false; } @@ -194,11 +193,11 @@ class Filter { function purge_flood_table() { global $config; - + // Determine how long we need to keep a cache of posts for flood prevention. Unfortunately, it is not // aware of flood filters in other board configurations. You can solve this problem by settings the // config variable $config['flood_cache'] (seconds). - + if (isset($config['flood_cache'])) { $max_time = &$config['flood_cache']; } else { @@ -208,18 +207,18 @@ function purge_flood_table() { $max_time = max($max_time, $filter['condition']['flood-time']); } } - + $time = time() - $max_time; - + query("DELETE FROM ``flood`` WHERE `time` < $time") or error(db_error()); } -function do_filters(array $post) { +function do_filters(Context $ctx, array $post) { global $config; if (!isset($config['filters']) || empty($config['filters'])) return; - + foreach ($config['filters'] as $filter) { if (isset($filter['condition']['flood-match'])) { $has_flood = true; @@ -232,15 +231,15 @@ function do_filters(array $post) { } else { $flood_check = false; } - + foreach ($config['filters'] as $filter_array) { $filter = new Filter($filter_array); $filter->flood_check = $flood_check; if ($filter->check($post)) { - $filter->action(); + $filter->action($ctx); } } - + purge_flood_table(); } diff --git a/inc/functions.php b/inc/functions.php index 2328c1b7..def00287 100644 --- a/inc/functions.php +++ b/inc/functions.php @@ -355,9 +355,12 @@ function define_groups() { } function create_antibot($board, $thread = null) { - require_once dirname(__FILE__) . '/anti-bot.php'; + global $pdo; - return _create_antibot($board, $thread); + // Ensure $pdo is initialized. + sql_open(); + + return _create_antibot($pdo, $board, $thread); } function rebuildThemes($action, $boardname = false) { @@ -742,24 +745,23 @@ function hasPermission($action = null, $board = null, $_mod = null) { function listBoards($just_uri = false) { global $config; - $just_uri ? $cache_name = 'all_boards_uri' : $cache_name = 'all_boards'; + $cache_name = $just_uri ? 'all_boards_uri' : 'all_boards'; - if ($config['cache']['enabled'] && ($boards = cache::get($cache_name))) + if ($config['cache']['enabled'] && ($boards = cache::get($cache_name))) { return $boards; - - if (!$just_uri) { - $query = query("SELECT * FROM ``boards`` ORDER BY `uri`") or error(db_error()); - $boards = $query->fetchAll(); - } else { - $boards = array(); - $query = query("SELECT `uri` FROM ``boards``") or error(db_error()); - while ($board = $query->fetchColumn()) { - $boards[] = $board; - } } - if ($config['cache']['enabled']) + if (!$just_uri) { + $query = query('SELECT * FROM ``boards`` ORDER BY `uri`'); + $boards = $query->fetchAll(); + } else { + $query = query('SELECT `uri` FROM ``boards``'); + $boards = $query->fetchAll(\PDO::FETCH_COLUMN); + } + + if ($config['cache']['enabled']) { cache::set($cache_name, $boards); + } return $boards; } @@ -918,6 +920,48 @@ function checkBan($board = false) { } } +/** + * Checks if the given IP has any previous posts. + * + * @param string $ip The IP to check. + * @param ?string $passwd If not null, check also by password. + * @return bool True if the ip has already sent at least one post, false otherwise. + */ +function has_any_history(string $ip, ?string $passwd): bool { + global $config; + + if ($config['cache']['enabled']) { + $ret = cache::get("post_history_$ip"); + if ($ret !== false) { + return $ret !== 0x0; + } + } + + foreach (listBoards(true) as $board_uri) { + if ($passwd === null) { + $query = prepare(sprintf('SELECT `id` FROM ``posts_%s`` WHERE `ip` = :ip LIMIT 1', $board_uri)); + $query->bindValue(':ip', $ip); + } else { + $query = prepare(sprintf('SELECT `id` FROM ``posts_%s`` WHERE `ip` = :ip OR `password` = :passwd LIMIT 1', $board_uri)); + $query->bindValue(':ip', $ip); + $query->bindValue(':passwd', $passwd); + } + $query->execute() or error(db_error()); + + if ($query->fetchColumn() !== false) { + // Found a post. + if ($config['cache']['enabled']) { + cache::set("post_history_$ip", 0xA); + } + return true; + } + } + if ($config['cache']['enabled']) { + cache::set("post_history_$ip", 0x0); + } + return false; +} + function threadLocked($id) { global $board; @@ -2025,7 +2069,7 @@ function remove_modifiers($body) { return preg_replace('@(.+?)@usm', '', $body); } -function markup(&$body, $track_cites = false, $op = false) { +function markup(&$body, $track_cites = false) { global $board, $config, $markup_urls; $modifiers = extract_modifiers($body); @@ -2040,9 +2084,6 @@ function markup(&$body, $track_cites = false, $op = false) { $body = str_replace("\r", '', $body); $body = utf8tohtml($body); - if (mysql_version() < 50503) - $body = mb_encode_numericentity($body, array(0x010000, 0xffffff, 0, 0xffffff), 'UTF-8'); - if ($config['markup_code']) { $code_markup = array(); $body = preg_replace_callback($config['markup_code'], function($matches) use (&$code_markup) { @@ -2127,12 +2168,15 @@ function markup(&$body, $track_cites = false, $op = false) { link_for(array('id' => $cite, 'thread' => $cited_posts[$cite])) . '#' . $cite . '">' . '>>' . $cite . ''; + } else { + $replacement = ">>$cite"; + } - $body = mb_substr_replace($body, $matches[1][0] . $replacement . $matches[3][0], $matches[0][1] + $skip_chars, mb_strlen($matches[0][0])); - $skip_chars += mb_strlen($matches[1][0] . $replacement . $matches[3][0]) - mb_strlen($matches[0][0]); + $body = mb_substr_replace($body, $matches[1][0] . $replacement . $matches[3][0], $matches[0][1] + $skip_chars, mb_strlen($matches[0][0])); + $skip_chars += mb_strlen($matches[1][0] . $replacement . $matches[3][0]) - mb_strlen($matches[0][0]); - if ($track_cites && $config['track_cites']) - $tracked_cites[] = array($board['uri'], $cite); + if ($track_cites && $config['track_cites']) { + $tracked_cites[] = array($board['uri'], $cite); } } } @@ -3041,3 +3085,8 @@ function strategy_first($fun, $array) { return array('defer'); } } + +function hashPassword($password) { + global $config; + return hash('sha3-256', $password . $config['secure_password_salt']); +} diff --git a/inc/functions/net.php b/inc/functions/net.php index 4319a3f3..963ea524 100644 --- a/inc/functions/net.php +++ b/inc/functions/net.php @@ -15,3 +15,63 @@ function is_connection_https(): bool { function is_connection_secure(): bool { return is_connection_https() || (!empty($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR'] === '127.0.0.1'); } + +/** + * Encodes a string into a base64 variant without characters illegal in urls. + */ +function base64_url_encode(string $input): string { + return str_replace([ '+', '/', '=' ], [ '-', '_', '' ], base64_encode($input)); +} + +/** + * Decodes a string from a base64 variant without characters illegal in urls. + */ +function base64_url_decode(string $input): string { + return base64_decode(strtr($input, '-_', '+/')); +} + +/** + * Encodes a typed cursor. + * + * @param string $type The type for the cursor. Only the first character is considered. + * @param array $map A map of key-value pairs to encode. + * @return string An encoded string that can be sent through urls. Empty if either parameter is empty. + */ +function encode_cursor(string $type, array $map): string { + if (empty($type) || empty($map)) { + return ''; + } + + $acc = $type[0]; + foreach ($map as $key => $value) { + $acc .= "|$key#$value"; + } + return base64_url_encode($acc); +} + +/** + * Decodes a typed cursor. + * + * @param string $cursor A string emitted by `encode_cursor`. + * @return array An array with the type of the cursor and an array of key-value pairs. The type is null and the map + * empty if either there are no key-value pairs or the encoding is incorrect. + */ +function decode_cursor(string $cursor): array { + $map = []; + $type = ''; + $acc = base64_url_decode($cursor); + if ($acc === false || empty($acc)) { + return [ null, [] ]; + } + + $type = $acc[0]; + foreach (explode('|', substr($acc, 2)) as $pair) { + $pair = explode('#', $pair); + if (count($pair) >= 2) { + $key = $pair[0]; + $value = $pair[1]; + $map[$key] = $value; + } + } + return [ $type, $map ]; +} diff --git a/inc/lib/IP/LICENSE b/inc/lib/IP/LICENSE deleted file mode 100755 index fb315548..00000000 --- a/inc/lib/IP/LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -Copyright (c) 2013 Jason Morriss - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished -to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - diff --git a/inc/lib/IP/Lifo/IP/BC.php b/inc/lib/IP/Lifo/IP/BC.php deleted file mode 100755 index 26a2c2b7..00000000 --- a/inc/lib/IP/Lifo/IP/BC.php +++ /dev/null @@ -1,293 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ -namespace Lifo\IP; - -/** - * BCMath helper class. - * - * Provides a handful of BCMath routines that are not included in the native - * PHP library. - * - * Note: The Bitwise functions operate on fixed byte boundaries. For example, - * comparing the following numbers uses X number of bits: - * 0xFFFF and 0xFF will result in comparison of 16 bits. - * 0xFFFFFFFF and 0xF will result in comparison of 32 bits. - * etc... - * - */ -abstract class BC -{ - // Some common (maybe useless) constants - const MAX_INT_32 = '2147483647'; // 7FFFFFFF - const MAX_UINT_32 = '4294967295'; // FFFFFFFF - const MAX_INT_64 = '9223372036854775807'; // 7FFFFFFFFFFFFFFF - const MAX_UINT_64 = '18446744073709551615'; // FFFFFFFFFFFFFFFF - const MAX_INT_96 = '39614081257132168796771975167'; // 7FFFFFFFFFFFFFFFFFFFFFFF - const MAX_UINT_96 = '79228162514264337593543950335'; // FFFFFFFFFFFFFFFFFFFFFFFF - const MAX_INT_128 = '170141183460469231731687303715884105727'; // 7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF - const MAX_UINT_128 = '340282366920938463463374607431768211455'; // FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF - - /** - * BC Math function to convert a HEX string into a DECIMAL - */ - public static function bchexdec($hex) - { - if (strlen($hex) == 1) { - return hexdec($hex); - } - - $remain = substr($hex, 0, -1); - $last = substr($hex, -1); - return bcadd(bcmul(16, self::bchexdec($remain), 0), hexdec($last), 0); - } - - /** - * BC Math function to convert a DECIMAL string into a BINARY string - */ - public static function bcdecbin($dec, $pad = null) - { - $bin = ''; - while ($dec) { - $m = bcmod($dec, 2); - $dec = bcdiv($dec, 2, 0); - $bin = abs($m) . $bin; - } - return $pad ? sprintf("%0{$pad}s", $bin) : $bin; - } - - /** - * BC Math function to convert a BINARY string into a DECIMAL string - */ - public static function bcbindec($bin) - { - $dec = '0'; - for ($i=0, $j=strlen($bin); $i<$j; $i++) { - $dec = bcmul($dec, '2', 0); - $dec = bcadd($dec, $bin[$i], 0); - } - return $dec; - } - - /** - * BC Math function to convert a BINARY string into a HEX string - */ - public static function bcbinhex($bin, $pad = 0) - { - return self::bcdechex(self::bcbindec($bin)); - } - - /** - * BC Math function to convert a DECIMAL into a HEX string - */ - public static function bcdechex($dec) - { - $last = bcmod($dec, 16); - $remain = bcdiv(bcsub($dec, $last, 0), 16, 0); - return $remain == 0 ? dechex($last) : self::bcdechex($remain) . dechex($last); - } - - /** - * Bitwise AND two arbitrarily large numbers together. - */ - public static function bcand($left, $right) - { - $len = self::_bitwise($left, $right); - - $value = ''; - for ($i=0; $i<$len; $i++) { - $value .= (($left[$i] + 0) & ($right[$i] + 0)) ? '1' : '0'; - } - return self::bcbindec($value != '' ? $value : '0'); - } - - /** - * Bitwise OR two arbitrarily large numbers together. - */ - public static function bcor($left, $right) - { - $len = self::_bitwise($left, $right); - - $value = ''; - for ($i=0; $i<$len; $i++) { - $value .= (($left[$i] + 0) | ($right[$i] + 0)) ? '1' : '0'; - } - return self::bcbindec($value != '' ? $value : '0'); - } - - /** - * Bitwise XOR two arbitrarily large numbers together. - */ - public static function bcxor($left, $right) - { - $len = self::_bitwise($left, $right); - - $value = ''; - for ($i=0; $i<$len; $i++) { - $value .= (($left[$i] + 0) ^ ($right[$i] + 0)) ? '1' : '0'; - } - return self::bcbindec($value != '' ? $value : '0'); - } - - /** - * Bitwise NOT two arbitrarily large numbers together. - */ - public static function bcnot($left, $bits = null) - { - $right = 0; - $len = self::_bitwise($left, $right, $bits); - $value = ''; - for ($i=0; $i<$len; $i++) { - $value .= $left[$i] == '1' ? '0' : '1'; - } - return self::bcbindec($value); - } - - /** - * Shift number to the left - * - * @param integer $bits Total bits to shift - */ - public static function bcleft($num, $bits) { - return bcmul($num, bcpow('2', $bits)); - } - - /** - * Shift number to the right - * - * @param integer $bits Total bits to shift - */ - public static function bcright($num, $bits) { - return bcdiv($num, bcpow('2', $bits)); - } - - /** - * Determine how many bits are needed to store the number rounded to the - * nearest bit boundary. - */ - public static function bits_needed($num, $boundary = 4) - { - $bits = 0; - while ($num > 0) { - $num = bcdiv($num, '2', 0); - $bits++; - } - // round to nearest boundrary - return $boundary ? ceil($bits / $boundary) * $boundary : $bits; - } - - /** - * BC Math function to return an arbitrarily large random number. - */ - public static function bcrand($min, $max = null) - { - if ($max === null) { - $max = $min; - $min = 0; - } - - // swap values if $min > $max - if (bccomp($min, $max) == 1) { - list($min,$max) = array($max,$min); - } - - return bcadd( - bcmul( - bcdiv( - mt_rand(0, mt_getrandmax()), - mt_getrandmax(), - strlen($max) - ), - bcsub( - bcadd($max, '1'), - $min - ) - ), - $min - ); - } - - /** - * Computes the natural logarithm using a series. - * @author Thomas Oldbury. - * @license Public domain. - */ - public static function bclog($num, $iter = 10, $scale = 100) - { - $log = "0.0"; - for($i = 0; $i < $iter; $i++) { - $pow = 1 + (2 * $i); - $mul = bcdiv("1.0", $pow, $scale); - $fraction = bcmul($mul, bcpow(bcsub($num, "1.0", $scale) / bcadd($num, "1.0", $scale), $pow, $scale), $scale); - $log = bcadd($fraction, $log, $scale); - } - return bcmul("2.0", $log, $scale); - } - - /** - * Computes the base2 log using baseN log. - */ - public static function bclog2($num, $iter = 10, $scale = 100) - { - return bcdiv(self::bclog($num, $iter, $scale), self::bclog("2", $iter, $scale), $scale); - } - - public static function bcfloor($num) - { - if (substr($num, 0, 1) == '-') { - return bcsub($num, 1, 0); - } - return bcadd($num, 0, 0); - } - - public static function bcceil($num) - { - if (substr($num, 0, 1) == '-') { - return bcsub($num, 0, 0); - } - return bcadd($num, 1, 0); - } - - /** - * Compare two numbers and return -1, 0, 1 depending if the LEFT number is - * < = > the RIGHT. - * - * @param string|integer $left Left side operand - * @param string|integer $right Right side operand - * @return integer Return -1,0,1 for <=> comparison - */ - public static function cmp($left, $right) - { - // @todo could an optimization be done to determine if a normal 32bit - // comparison could be done instead of using bccomp? But would - // the number verification cause too much overhead to be useful? - return bccomp($left, $right, 0); - } - - /** - * Internal function to prepare for bitwise operations - */ - private static function _bitwise(&$left, &$right, $bits = null) - { - if ($bits === null) { - $bits = max(self::bits_needed($left), self::bits_needed($right)); - } - - $left = self::bcdecbin($left); - $right = self::bcdecbin($right); - - $len = max(strlen($left), strlen($right), (int)$bits); - - $left = sprintf("%0{$len}s", $left); - $right = sprintf("%0{$len}s", $right); - - return $len; - } - -} diff --git a/inc/lib/IP/Lifo/IP/CIDR.php b/inc/lib/IP/Lifo/IP/CIDR.php deleted file mode 100755 index e8fe32ce..00000000 --- a/inc/lib/IP/Lifo/IP/CIDR.php +++ /dev/null @@ -1,706 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ -namespace Lifo\IP; - -/** - * CIDR Block helper class. - * - * Most routines can be used statically or by instantiating an object and - * calling its methods. - * - * Provides routines to do various calculations on IP addresses and ranges. - * Convert to/from CIDR to ranges, etc. - */ -class CIDR -{ - const INTERSECT_NO = 0; - const INTERSECT_YES = 1; - const INTERSECT_LOW = 2; - const INTERSECT_HIGH = 3; - - protected $start; - protected $end; - protected $prefix; - protected $version; - protected $istart; - protected $iend; - - private $cache; - - /** - * Create a new CIDR object. - * - * The IP range can be arbitrary and does not have to fall on a valid CIDR - * range. Some methods will return different values depending if you ignore - * the prefix or not. By default all prefix sensitive methods will assume - * the prefix is used. - * - * @param string $cidr An IP address (1.2.3.4), CIDR block (1.2.3.4/24), - * or range "1.2.3.4-1.2.3.10" - * @param string $end Ending IP in range if no cidr/prefix is given - */ - public function __construct($cidr, $end = null) - { - if ($end !== null) { - $this->setRange($cidr, $end); - } else { - $this->setCidr($cidr); - } - } - - /** - * Returns the string representation of the CIDR block. - */ - public function __toString() - { - // do not include the prefix if its a single IP - try { - if ($this->isTrueCidr() && ( - ($this->version == 4 and $this->prefix != 32) || - ($this->version == 6 and $this->prefix != 128) - ) - ) { - return $this->start . '/' . $this->prefix; - } - } catch (\Exception $e) { - // isTrueCidr() calls getRange which can throw an exception - } - if (strcmp($this->start, $this->end) == 0) { - return $this->start; - } - return $this->start . ' - ' . $this->end; - } - - public function __clone() - { - // do not clone the cache. No real reason why. I just want to keep the - // memory foot print as low as possible, even though this is trivial. - $this->cache = array(); - } - - /** - * Set an arbitrary IP range. - * The closest matching prefix will be calculated but the actual range - * stored in the object can be arbitrary. - * @param string $start Starting IP or combination "start-end" string. - * @param string $end Ending IP or null. - */ - public function setRange($ip, $end = null) - { - if (strpos($ip, '-') !== false) { - list($ip, $end) = array_map('trim', explode('-', $ip, 2)); - } - - if (false === filter_var($ip, FILTER_VALIDATE_IP) || - false === filter_var($end, FILTER_VALIDATE_IP)) { - throw new \InvalidArgumentException("Invalid IP range \"$ip-$end\""); - } - - // determine version (4 or 6) - $this->version = (false === filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ? 6 : 4; - - $this->istart = IP::inet_ptod($ip); - $this->iend = IP::inet_ptod($end); - - // fix order - if (bccomp($this->istart, $this->iend) == 1) { - list($this->istart, $this->iend) = array($this->iend, $this->istart); - list($ip, $end) = array($end, $ip); - } - - $this->start = $ip; - $this->end = $end; - - // calculate real prefix - $len = $this->version == 4 ? 32 : 128; - $this->prefix = $len - strlen(BC::bcdecbin(BC::bcxor($this->istart, $this->iend))); - } - - /** - * Returns true if the current IP is a true cidr block - */ - public function isTrueCidr() - { - return $this->start == $this->getNetwork() && $this->end == $this->getBroadcast(); - } - - /** - * Set the CIDR block. - * - * The prefix length is optional and will default to 32 ot 128 depending on - * the version detected. - * - * @param string $cidr CIDR block string, eg: "192.168.0.0/24" or "2001::1/64" - * @throws \InvalidArgumentException If the CIDR block is invalid - */ - public function setCidr($cidr) - { - if (strpos($cidr, '-') !== false) { - return $this->setRange($cidr); - } - - list($ip, $bits) = array_pad(array_map('trim', explode('/', $cidr, 2)), 2, null); - if (false === filter_var($ip, FILTER_VALIDATE_IP)) { - throw new \InvalidArgumentException("Invalid IP address \"$cidr\""); - } - - // determine version (4 or 6) - $this->version = (false === filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ? 6 : 4; - - $this->start = $ip; - $this->istart = IP::inet_ptod($ip); - - if ($bits !== null and $bits !== '') { - $this->prefix = $bits; - } else { - $this->prefix = $this->version == 4 ? 32 : 128; - } - - if (($this->prefix < 0) - || ($this->prefix > 32 and $this->version == 4) - || ($this->prefix > 128 and $this->version == 6)) { - throw new \InvalidArgumentException("Invalid IP address \"$cidr\""); - } - - $this->end = $this->getBroadcast(); - $this->iend = IP::inet_ptod($this->end); - - $this->cache = array(); - } - - /** - * Get the IP version. 4 or 6. - * - * @return integer - */ - public function getVersion() - { - return $this->version; - } - - /** - * Get the prefix. - * - * Always returns the "proper" prefix, even if the IP range is arbitrary. - * - * @return integer - */ - public function getPrefix() - { - return $this->prefix; - } - - /** - * Return the starting presentational IP or Decimal value. - * - * Ignores prefix - */ - public function getStart($decimal = false) - { - return $decimal ? $this->istart : $this->start; - } - - /** - * Return the ending presentational IP or Decimal value. - * - * Ignores prefix - */ - public function getEnd($decimal = false) - { - return $decimal ? $this->iend : $this->end; - } - - /** - * Return the next presentational IP or Decimal value (following the - * broadcast address of the current CIDR block). - */ - public function getNext($decimal = false) - { - $next = bcadd($this->getEnd(true), '1'); - return $decimal ? $next : new self(IP::inet_dtop($next)); - } - - /** - * Returns true if the IP is an IPv4 - * - * @return boolean - */ - public function isIPv4() - { - return $this->version == 4; - } - - /** - * Returns true if the IP is an IPv6 - * - * @return boolean - */ - public function isIPv6() - { - return $this->version == 6; - } - - /** - * Get the cidr notation for the subnet block. - * - * This is useful for when you want a string representation of the IP/prefix - * and the starting IP is not on a valid network boundrary (eg: Displaying - * an IP from an interface). - * - * @return string IP in CIDR notation "ipaddr/prefix" - */ - public function getCidr() - { - return $this->start . '/' . $this->prefix; - } - - /** - * Get the [low,high] range of the CIDR block - * - * Prefix sensitive. - * - * @param boolean $ignorePrefix If true the arbitrary start-end range is - * returned. default=false. - */ - public function getRange($ignorePrefix = false) - { - $range = $ignorePrefix - ? array($this->start, $this->end) - : self::cidr_to_range($this->start, $this->prefix); - // watch out for IP '0' being converted to IPv6 '::' - if ($range[0] == '::' and strpos($range[1], ':') == false) { - $range[0] = '0.0.0.0'; - } - return $range; - } - - /** - * Return the IP in its fully expanded form. - * - * For example: 2001::1 == 2007:0000:0000:0000:0000:0000:0000:0001 - * - * @see IP::inet_expand - */ - public function getExpanded() - { - return IP::inet_expand($this->start); - } - - /** - * Get network IP of the CIDR block - * - * Prefix sensitive. - * - * @param boolean $ignorePrefix If true the arbitrary start-end range is - * returned. default=false. - */ - public function getNetwork($ignorePrefix = false) - { - // micro-optimization to prevent calling getRange repeatedly - $k = $ignorePrefix ? 1 : 0; - if (!isset($this->cache['range'][$k])) { - $this->cache['range'][$k] = $this->getRange($ignorePrefix); - } - return $this->cache['range'][$k][0]; - } - - /** - * Get broadcast IP of the CIDR block - * - * Prefix sensitive. - * - * @param boolean $ignorePrefix If true the arbitrary start-end range is - * returned. default=false. - */ - public function getBroadcast($ignorePrefix = false) - { - // micro-optimization to prevent calling getRange repeatedly - $k = $ignorePrefix ? 1 : 0; - if (!isset($this->cache['range'][$k])) { - $this->cache['range'][$k] = $this->getRange($ignorePrefix); - } - return $this->cache['range'][$k][1]; - } - - /** - * Get the network mask based on the prefix. - * - */ - public function getMask() - { - return self::prefix_to_mask($this->prefix, $this->version); - } - - /** - * Get total hosts within CIDR range - * - * Prefix sensitive. - * - * @param boolean $ignorePrefix If true the arbitrary start-end range is - * returned. default=false. - */ - public function getTotal($ignorePrefix = false) - { - // micro-optimization to prevent calling getRange repeatedly - $k = $ignorePrefix ? 1 : 0; - if (!isset($this->cache['range'][$k])) { - $this->cache['range'][$k] = $this->getRange($ignorePrefix); - } - return bcadd(bcsub(IP::inet_ptod($this->cache['range'][$k][1]), - IP::inet_ptod($this->cache['range'][$k][0])), '1'); - } - - public function intersects($cidr) - { - return self::cidr_intersect((string)$this, $cidr); - } - - /** - * Determines the intersection between an IP (with optional prefix) and a - * CIDR block. - * - * The IP will be checked against the CIDR block given and will either be - * inside or outside the CIDR completely, or partially. - * - * NOTE: The caller should explicitly check against the INTERSECT_* - * constants because this method will return a value > 1 even for partial - * matches. - * - * @param mixed $ip The IP/cidr to match - * @param mixed $cidr The CIDR block to match within - * @return integer Returns an INTERSECT_* constant - * @throws \InvalidArgumentException if either $ip or $cidr is invalid - */ - public static function cidr_intersect($ip, $cidr) - { - // use fixed length HEX strings so we can easily do STRING comparisons - // instead of using slower bccomp() math. - list($lo,$hi) = array_map(function($v){ return sprintf("%032s", IP::inet_ptoh($v)); }, CIDR::cidr_to_range($ip)); - list($min,$max) = array_map(function($v){ return sprintf("%032s", IP::inet_ptoh($v)); }, CIDR::cidr_to_range($cidr)); - - /** visualization of logic used below - lo-hi = $ip to check - min-max = $cidr block being checked against - --- --- --- lo --- --- hi --- --- --- --- --- IP/prefix to check - --- min --- --- max --- --- --- --- --- --- --- Partial "LOW" match - --- --- --- --- --- min --- --- max --- --- --- Partial "HIGH" match - --- --- --- --- min max --- --- --- --- --- --- No match "NO" - --- --- --- --- --- --- --- --- min --- max --- No match "NO" - min --- max --- --- --- --- --- --- --- --- --- No match "NO" - --- --- min --- --- --- --- max --- --- --- --- Full match "YES" - */ - - // IP is exact match or completely inside the CIDR block - if ($lo >= $min and $hi <= $max) { - return self::INTERSECT_YES; - } - - // IP is completely outside the CIDR block - if ($max < $lo or $min > $hi) { - return self::INTERSECT_NO; - } - - // @todo is it useful to return LOW/HIGH partial matches? - - // IP matches the lower end - if ($max <= $hi and $min <= $lo) { - return self::INTERSECT_LOW; - } - - // IP matches the higher end - if ($min >= $lo and $max >= $hi) { - return self::INTERSECT_HIGH; - } - - return self::INTERSECT_NO; - } - - /** - * Converts an IPv4 or IPv6 CIDR block into its range. - * - * @todo May not be the fastest way to do this. - * - * @static - * @param string $cidr CIDR block or IP address string. - * @param integer|null $bits If /bits is not specified on string they can be - * passed via this parameter instead. - * @return array A 2 element array with the low, high range - */ - public static function cidr_to_range($cidr, $bits = null) - { - if (strpos($cidr, '/') !== false) { - list($ip, $_bits) = array_pad(explode('/', $cidr, 2), 2, null); - } else { - $ip = $cidr; - $_bits = $bits; - } - - if (false === filter_var($ip, FILTER_VALIDATE_IP)) { - throw new \InvalidArgumentException("IP address \"$cidr\" is invalid"); - } - - // force bit length to 32 or 128 depending on type of IP - $bitlen = (false === filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ? 128 : 32; - - if ($bits === null) { - // if no prefix is given use the length of the binary string which - // will give us 32 or 128 and result in a single IP being returned. - $bits = $_bits !== null ? $_bits : $bitlen; - } - - if ($bits > $bitlen) { - throw new \InvalidArgumentException("IP address \"$cidr\" is invalid"); - } - - $ipdec = IP::inet_ptod($ip); - $ipbin = BC::bcdecbin($ipdec, $bitlen); - - // calculate network - $netmask = BC::bcbindec(str_pad(str_repeat('1',$bits), $bitlen, '0')); - $ip1 = BC::bcand($ipdec, $netmask); - - // calculate "broadcast" (not technically a broadcast in IPv6) - $ip2 = BC::bcor($ip1, BC::bcnot($netmask)); - - return array(IP::inet_dtop($ip1), IP::inet_dtop($ip2)); - } - - /** - * Return the CIDR string from the range given - */ - public static function range_to_cidr($start, $end) - { - $cidr = new CIDR($start, $end); - return (string)$cidr; - } - - /** - * Return the maximum prefix length that would fit the IP address given. - * - * This is useful to determine how my bit would be needed to store the IP - * address when you don't already have a prefix for the IP. - * - * @example 216.240.32.0 would return 27 - * - * @param string $ip IP address without prefix - * @param integer $bits Maximum bits to check; defaults to 32 for IPv4 and 128 for IPv6 - */ - public static function max_prefix($ip, $bits = null) - { - static $mask = array(); - - $ver = (false === filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ? 6 : 4; - $max = $ver == 6 ? 128 : 32; - if ($bits === null) { - $bits = $max; - - } - - $int = IP::inet_ptod($ip); - while ($bits > 0) { - // micro-optimization; calculate mask once ... - if (!isset($mask[$ver][$bits-1])) { - // 2^$max - 2^($max - $bits); - if ($ver == 4) { - $mask[$ver][$bits-1] = pow(2, $max) - pow(2, $max - ($bits-1)); - } else { - $mask[$ver][$bits-1] = bcsub(bcpow(2, $max), bcpow(2, $max - ($bits-1))); - } - } - - $m = $mask[$ver][$bits-1]; - //printf("%s/%d: %s & %s == %s\n", $ip, $bits-1, BC::bcdecbin($m, 32), BC::bcdecbin($int, 32), BC::bcdecbin(BC::bcand($int, $m))); - //echo "$ip/", $bits-1, ": ", IP::inet_dtop($m), " ($m) & $int == ", BC::bcand($int, $m), "\n"; - if (bccomp(BC::bcand($int, $m), $int) != 0) { - return $bits; - } - $bits--; - } - return $bits; - } - - /** - * Return a contiguous list of true CIDR blocks that span the range given. - * - * Note: It's not a good idea to call this with IPv6 addresses. While it may - * work for certain ranges this can be very slow. Also an IPv6 list won't be - * as accurate as an IPv4 list. - * - * @example - * range_to_cidrlist(192.168.0.0, 192.168.0.15) == - * 192.168.0.0/28 - * range_to_cidrlist(192.168.0.0, 192.168.0.20) == - * 192.168.0.0/28 - * 192.168.0.16/30 - * 192.168.0.20/32 - */ - public static function range_to_cidrlist($start, $end) - { - $ver = (false === filter_var($start, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ? 6 : 4; - $start = IP::inet_ptod($start); - $end = IP::inet_ptod($end); - - $len = $ver == 4 ? 32 : 128; - $log2 = $ver == 4 ? log(2) : BC::bclog(2); - - $list = array(); - while (BC::cmp($end, $start) >= 0) { // $end >= $start - $prefix = self::max_prefix(IP::inet_dtop($start), $len); - if ($ver == 4) { - $diff = $len - floor( log($end - $start + 1) / $log2 ); - } else { - // this is not as accurate due to the bclog function - $diff = bcsub($len, BC::bcfloor(bcdiv(BC::bclog(bcadd(bcsub($end, $start), '1')), $log2))); - } - - if ($prefix < $diff) { - $prefix = $diff; - } - - $list[] = IP::inet_dtop($start) . "/" . $prefix; - - if ($ver == 4) { - $start += pow(2, $len - $prefix); - } else { - $start = bcadd($start, bcpow(2, $len - $prefix)); - } - } - return $list; - } - - /** - * Return an list of optimized CIDR blocks by collapsing adjacent CIDR - * blocks into larger blocks. - * - * @param array $cidrs List of CIDR block strings or objects - * @param integer $maxPrefix Maximum prefix to allow - * @return array Optimized list of CIDR objects - */ - public static function optimize_cidrlist($cidrs, $maxPrefix = 32) - { - // all indexes must be a CIDR object - $cidrs = array_map(function($o){ return $o instanceof CIDR ? $o : new CIDR($o); }, $cidrs); - // sort CIDR blocks in proper order so we can easily loop over them - $cidrs = self::cidr_sort($cidrs); - - $list = array(); - while ($cidrs) { - $c = array_shift($cidrs); - $start = $c->getStart(); - - $max = bcadd($c->getStart(true), $c->getTotal()); - - // loop through each cidr block until its ending range is more than - // the current maximum. - while (!empty($cidrs) and $cidrs[0]->getStart(true) <= $max) { - $b = array_shift($cidrs); - $newmax = bcadd($b->getStart(true), $b->getTotal()); - if ($newmax > $max) { - $max = $newmax; - } - } - - // add the new cidr range to the optimized list - $list = array_merge($list, self::range_to_cidrlist($start, IP::inet_dtop(bcsub($max, '1')))); - } - - return $list; - } - - /** - * Sort the list of CIDR blocks, optionally with a custom callback function. - * - * @param array $cidrs A list of CIDR blocks (strings or objects) - * @param Closure $callback Optional callback to perform the sorting. - * See PHP usort documentation for more details. - */ - public static function cidr_sort($cidrs, $callback = null) - { - // all indexes must be a CIDR object - $cidrs = array_map(function($o){ return $o instanceof CIDR ? $o : new CIDR($o); }, $cidrs); - - if ($callback === null) { - $callback = function($a, $b) { - if (0 != ($o = BC::cmp($a->getStart(true), $b->getStart(true)))) { - return $o; // < or > - } - if ($a->getPrefix() == $b->getPrefix()) { - return 0; - } - return $a->getPrefix() < $b->getPrefix() ? -1 : 1; - }; - } elseif (!($callback instanceof \Closure) or !is_callable($callback)) { - throw new \InvalidArgumentException("Invalid callback in CIDR::cidr_sort, expected Closure, got " . gettype($callback)); - } - - usort($cidrs, $callback); - return $cidrs; - } - - /** - * Return the Prefix bits from the IPv4 mask given. - * - * This is only valid for IPv4 addresses since IPv6 addressing does not - * have a concept of network masks. - * - * Example: 255.255.255.0 == 24 - * - * @param string $mask IPv4 network mask. - */ - public static function mask_to_prefix($mask) - { - if (false === filter_var($mask, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - throw new \InvalidArgumentException("Invalid IP netmask \"$mask\""); - } - return strrpos(IP::inet_ptob($mask, 32), '1') + 1; - } - - /** - * Return the network mask for the prefix given. - * - * Normally this is only useful for IPv4 addresses but you can generate a - * mask for IPv6 addresses as well, only because its mathematically - * possible. - * - * @param integer $prefix CIDR prefix bits (0-128) - * @param integer $version IP version. If null the version will be detected - * based on the prefix length given. - */ - public static function prefix_to_mask($prefix, $version = null) - { - if ($version === null) { - $version = $prefix > 32 ? 6 : 4; - } - if ($prefix < 0 or $prefix > 128) { - throw new \InvalidArgumentException("Invalid prefix length \"$prefix\""); - } - if ($version != 4 and $version != 6) { - throw new \InvalidArgumentException("Invalid version \"$version\". Must be 4 or 6"); - } - - if ($version == 4) { - return long2ip($prefix == 0 ? 0 : (0xFFFFFFFF >> (32 - $prefix)) << (32 - $prefix)); - } else { - return IP::inet_dtop($prefix == 0 ? 0 : BC::bcleft(BC::bcright(BC::MAX_UINT_128, 128-$prefix), 128-$prefix)); - } - } - - /** - * Return true if the $ip given is a true CIDR block. - * - * A true CIDR block is one where the $ip given is the actual Network - * address and broadcast matches the prefix appropriately. - */ - public static function cidr_is_true($ip) - { - $ip = new CIDR($ip); - return $ip->isTrueCidr(); - } -} diff --git a/inc/lib/IP/Lifo/IP/IP.php b/inc/lib/IP/Lifo/IP/IP.php deleted file mode 100755 index 4d22aa76..00000000 --- a/inc/lib/IP/Lifo/IP/IP.php +++ /dev/null @@ -1,207 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ -namespace Lifo\IP; - -/** - * IP Address helper class. - * - * Provides routines to translate IPv4 and IPv6 addresses between human readable - * strings, decimal, hexidecimal and binary. - * - * Requires BCmath extension and IPv6 PHP support - */ -abstract class IP -{ - /** - * Convert a human readable (presentational) IP address string into a decimal string. - */ - public static function inet_ptod($ip) - { - // shortcut for IPv4 addresses - if (strpos($ip, ':') === false && strpos($ip, '.') !== false) { - return sprintf('%u', ip2long($ip)); - } - - // remove any cidr block notation - if (($o = strpos($ip, '/')) !== false) { - $ip = substr($ip, 0, $o); - } - - // unpack into 4 32bit integers - $parts = unpack('N*', inet_pton($ip)); - foreach ($parts as &$part) { - if ($part < 0) { - // convert signed int into unsigned - $part = sprintf('%u', $part); - //$part = bcadd($part, '4294967296'); - } - } - - // add each 32bit integer to the proper bit location in our big decimal - $decimal = $parts[4]; // << 0 - $decimal = bcadd($decimal, bcmul($parts[3], '4294967296')); // << 32 - $decimal = bcadd($decimal, bcmul($parts[2], '18446744073709551616')); // << 64 - $decimal = bcadd($decimal, bcmul($parts[1], '79228162514264337593543950336')); // << 96 - - return $decimal; - } - - /** - * Convert a decimal string into a human readable IP address. - */ - public static function inet_dtop($decimal, $expand = false) - { - $parts = array(); - $parts[1] = bcdiv($decimal, '79228162514264337593543950336', 0); // >> 96 - $decimal = bcsub($decimal, bcmul($parts[1], '79228162514264337593543950336')); - $parts[2] = bcdiv($decimal, '18446744073709551616', 0); // >> 64 - $decimal = bcsub($decimal, bcmul($parts[2], '18446744073709551616')); - $parts[3] = bcdiv($decimal, '4294967296', 0); // >> 32 - $decimal = bcsub($decimal, bcmul($parts[3], '4294967296')); - $parts[4] = $decimal; // >> 0 - - foreach ($parts as &$part) { - if (bccomp($part, '2147483647') == 1) { - $part = bcsub($part, '4294967296'); - } - $part = (int) $part; - } - - // if the first 96bits is all zeros then we can safely assume we - // actually have an IPv4 address. Even though it's technically possible - // you're not really ever going to see an IPv6 address in the range: - // ::0 - ::ffff - // It's feasible to see an IPv6 address of "::", in which case the - // caller is going to have to account for that on their own. - if (($parts[1] | $parts[2] | $parts[3]) == 0) { - $ip = long2ip($parts[4]); - } else { - $packed = pack('N4', $parts[1], $parts[2], $parts[3], $parts[4]); - $ip = inet_ntop($packed); - } - - // Turn IPv6 to IPv4 if it's IPv4 - if (preg_match('/^::\d+\./', $ip)) { - return substr($ip, 2); - } - - return $expand ? self::inet_expand($ip) : $ip; - } - - /** - * Convert a human readable (presentational) IP address into a HEX string. - */ - public static function inet_ptoh($ip) - { - return bin2hex(inet_pton($ip)); - //return BC::bcdechex(self::inet_ptod($ip)); - } - - /** - * Convert a human readable (presentational) IP address into a BINARY string. - */ - public static function inet_ptob($ip, $bits = 128) - { - return BC::bcdecbin(self::inet_ptod($ip), $bits); - } - - /** - * Convert a binary string into an IP address (presentational) string. - */ - public static function inet_btop($bin) - { - return self::inet_dtop(BC::bcbindec($bin)); - } - - /** - * Convert a HEX string into a human readable (presentational) IP address - */ - public static function inet_htop($hex) - { - return self::inet_dtop(BC::bchexdec($hex)); - } - - /** - * Expand an IP address. IPv4 addresses are returned as-is. - * - * Example: - * 2001::1 expands to 2001:0000:0000:0000:0000:0000:0000:0001 - * ::127.0.0.1 expands to 0000:0000:0000:0000:0000:0000:7f00:0001 - * 127.0.0.1 expands to 127.0.0.1 - */ - public static function inet_expand($ip) - { - // strip possible cidr notation off - if (($pos = strpos($ip, '/')) !== false) { - $ip = substr($ip, 0, $pos); - } - $bytes = unpack('n*', inet_pton($ip)); - if (count($bytes) > 2) { - return implode(':', array_map(function ($b) { - return sprintf("%04x", $b); - }, $bytes)); - } - return $ip; - } - - /** - * Convert an IPv4 address into an IPv6 address. - * - * One use-case for this is IP 6to4 tunnels used in networking. - * - * @example - * to_ipv4("10.10.10.10") == a0a:a0a - * - * @param string $ip IPv4 address. - * @param boolean $mapped If true a Full IPv6 address is returned within the - * official ipv4to6 mapped space "0:0:0:0:0:ffff:x:x" - */ - public static function to_ipv6($ip, $mapped = false) - { - if (!self::isIPv4($ip)) { - throw new \InvalidArgumentException("Invalid IPv4 address \"$ip\""); - } - - $num = IP::inet_ptod($ip); - $o1 = dechex($num >> 16); - $o2 = dechex($num & 0x0000FFFF); - - return $mapped ? "0:0:0:0:0:ffff:$o1:$o2" : "$o1:$o2"; - } - - /** - * Returns true if the IP address is a valid IPv4 address - */ - public static function isIPv4($ip) - { - return $ip === filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); - } - - /** - * Returns true if the IP address is a valid IPv6 address - */ - public static function isIPv6($ip) - { - return $ip === filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); - } - - /** - * Compare two IP's (v4 or v6) and return -1, 0, 1 if the first is < = > - * the second. - * - * @param string $ip1 IP address - * @param string $ip2 IP address to compare against - * @return integer Return -1,0,1 depending if $ip1 is <=> $ip2 - */ - public static function cmp($ip1, $ip2) - { - return bccomp(self::inet_ptod($ip1), self::inet_ptod($ip2), 0); - } -} diff --git a/inc/lib/twig/extensions/Extension/Tinyboard.php b/inc/lib/twig/extensions/Extension/Tinyboard.php index 97fecb20..5fb99b11 100644 --- a/inc/lib/twig/extensions/Extension/Tinyboard.php +++ b/inc/lib/twig/extensions/Extension/Tinyboard.php @@ -32,7 +32,7 @@ class Twig_Extensions_Extension_Tinyboard extends Twig_Extension new Twig_SimpleFilter('addslashes', 'addslashes'), ); } - + /** * Returns a list of functions to add to the existing list. * @@ -52,7 +52,7 @@ class Twig_Extensions_Extension_Tinyboard extends Twig_Extension new Twig_SimpleFunction('link_for', 'link_for') ); } - + /** * Returns the name of the extension. * @@ -88,7 +88,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; } @@ -113,7 +113,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; @@ -127,11 +127,11 @@ function twig_filename_truncate_filter($value, $length = 30, $separator = '…') function twig_ratio_function($w, $h) { return fraction($w, $h, ':'); } -function twig_secure_link_confirm($text, $title, $confirm_message, $href) { - global $config; +function twig_secure_link_confirm($text, $title, $confirm_message, $href) { return '' . $text . ''; } + function twig_secure_link($href) { return $href . '/' . make_secure_link_token($href); } diff --git a/inc/mod/auth.php b/inc/mod/auth.php index 958b7ba5..01b234a1 100644 --- a/inc/mod/auth.php +++ b/inc/mod/auth.php @@ -4,6 +4,7 @@ * Copyright (c) 2010-2013 Tinyboard Development Group */ +use Vichan\Context; use Vichan\Functions\Net; defined('TINYBOARD') or exit; @@ -177,7 +178,7 @@ function make_secure_link_token($uri) { return substr(sha1($config['cookies']['salt'] . '-' . $uri . '-' . $mod['id']), 0, 8); } -function check_login($prompt = false) { +function check_login(Context $ctx, $prompt = false) { global $config, $mod; // Validate session @@ -187,7 +188,7 @@ function check_login($prompt = false) { if (count($cookie) != 3) { // Malformed cookies destroyCookies(); - if ($prompt) mod_login(); + if ($prompt) mod_login($ctx); exit; } @@ -200,7 +201,7 @@ function check_login($prompt = false) { if ($cookie[1] !== mkhash($cookie[0], $user['password'], $cookie[2])) { // Malformed cookies destroyCookies(); - if ($prompt) mod_login(); + if ($prompt) mod_login($ctx); exit; } diff --git a/inc/mod/pages.php b/inc/mod/pages.php index 1bbe60d0..155c19a6 100644 --- a/inc/mod/pages.php +++ b/inc/mod/pages.php @@ -1,19 +1,22 @@ get(LogDriver::class)->log(LogDriver::NOTICE, "Failed to link() $target to $link. FAlling back to copy()"); + return \copy($target, $link); + } + return true; + }; } function mod_page($title, $template, $args, $subtitle = false) { @@ -42,7 +45,7 @@ function clone_wrapped_with_exist_check($clonefn, $src, $dest) { } } -function mod_login($redirect = false) { +function mod_login(Context $ctx, $redirect = false) { global $config; $args = []; @@ -54,8 +57,7 @@ function mod_login($redirect = false) { if (!isset($_POST['username'], $_POST['password']) || $_POST['username'] == '' || $_POST['password'] == '') { $args['error'] = $config['error']['invalid']; } elseif (!login($_POST['username'], $_POST['password'])) { - if ($config['syslog']) - _syslog(LOG_WARNING, 'Unauthorized login attempt!'); + $ctx->get(LogDriver::class)->log(LogDriver::INFO, 'Unauthorized login attempt!'); $args['error'] = $config['error']['invalid']; } else { @@ -78,25 +80,27 @@ function mod_login($redirect = false) { mod_page(_('Login'), 'mod/login.html', $args); } -function mod_confirm($request) { - $args = array('request' => $request, 'token' => make_secure_link_token($request)); +function mod_confirm(Context $ctx, $request) { + $args = [ 'request' => $request, 'token' => make_secure_link_token($request) ]; if(isset($_GET['thread'])) { $args['rest'] = 'thread=' . $_GET['thread']; } mod_page(_('Confirm action'), 'mod/confirm.html', $args); } -function mod_logout() { +function mod_logout(Context $ctx) { global $config; destroyCookies(); header('Location: ?/', true, $config['redirect_http']); } -function mod_dashboard() { +function mod_dashboard(Context $ctx) { global $config, $mod; - $args = array(); + $report_queries = $ctx->get(ReportQueries::class); + + $args = []; $args['boards'] = listBoards(); @@ -122,8 +126,7 @@ function mod_dashboard() { cache::set('pm_unreadcount_' . $mod['id'], $args['unread_pms']); } - $query = query('SELECT COUNT(*) FROM ``reports``') or error(db_error($query)); - $args['reports'] = $query->fetchColumn(); + $args['reports'] = $report_queries->getCount(); $query = query('SELECT COUNT(*) FROM ``ban_appeals`` WHERE denied = 0') or error(db_error($query)); $args['appeals'] = $query->fetchColumn(); @@ -187,7 +190,7 @@ function mod_dashboard() { mod_page(_('Dashboard'), 'mod/dashboard.html', $args); } -function mod_search_redirect() { +function mod_search_redirect(Context $ctx) { global $config; if (!hasPermission($config['mod']['search'])) @@ -210,7 +213,7 @@ function mod_search_redirect() { } } -function mod_search($type, $search_query_escaped, $page_no = 1) { +function mod_search(Context $ctx, $type, $search_query_escaped, $page_no = 1) { global $pdo, $config; if (!hasPermission($config['mod']['search'])) @@ -236,7 +239,7 @@ function mod_search($type, $search_query_escaped, $page_no = 1) { $query = str_replace('`', '!`', $query); // Array of phrases to match - $match = array(); + $match = []; // Exact phrases ("like this") if (preg_match_all('/"(.+?)"/', $query, $exact_phrases)) { @@ -365,7 +368,7 @@ function mod_search($type, $search_query_escaped, $page_no = 1) { )); } -function mod_edit_board($boardName) { +function mod_edit_board(Context $ctx, $boardName) { global $board, $config; if (!openBoard($boardName)) @@ -467,7 +470,7 @@ function mod_edit_board($boardName) { } } -function mod_new_board() { +function mod_new_board(Context $ctx) { global $config, $board; if (!hasPermission($config['mod']['newboard'])) @@ -517,9 +520,6 @@ function mod_new_board() { $query = Element('posts.sql', array('board' => $board['uri'])); - if (mysql_version() < 50503) - $query = preg_replace('/(CHARSET=|CHARACTER SET )utf8mb4/', '$1utf8', $query); - query($query) or error(db_error()); if ($config['cache']['enabled']) @@ -536,7 +536,7 @@ function mod_new_board() { mod_page(_('New board'), 'mod/board.html', array('new' => true, 'token' => make_secure_link_token('new-board'))); } -function mod_noticeboard($page_no = 1) { +function mod_noticeboard(Context $ctx, $page_no = 1) { global $config, $pdo, $mod; if ($page_no < 1) @@ -591,7 +591,7 @@ function mod_noticeboard($page_no = 1) { )); } -function mod_noticeboard_delete($id) { +function mod_noticeboard_delete(Context $ctx, $id) { global $config; if (!hasPermission($config['mod']['noticeboard_delete'])) @@ -609,7 +609,7 @@ function mod_noticeboard_delete($id) { header('Location: ?/noticeboard', true, $config['redirect_http']); } -function mod_news($page_no = 1) { +function mod_news(Context $ctx, $page_no = 1) { global $config, $pdo, $mod; if ($page_no < 1) @@ -656,7 +656,7 @@ function mod_news($page_no = 1) { mod_page(_('News'), 'mod/news.html', array('news' => $news, 'count' => $count, 'token' => make_secure_link_token('edit_news'))); } -function mod_news_delete($id) { +function mod_news_delete(Context $ctx, $id) { global $config; if (!hasPermission($config['mod']['news_delete'])) @@ -671,7 +671,7 @@ function mod_news_delete($id) { header('Location: ?/edit_news', true, $config['redirect_http']); } -function mod_log($page_no = 1) { +function mod_log(Context $ctx, $page_no = 1) { global $config; if ($page_no < 1) @@ -696,7 +696,7 @@ function mod_log($page_no = 1) { mod_page(_('Moderation log'), 'mod/log.html', array('logs' => $logs, 'count' => $count)); } -function mod_user_log($username, $page_no = 1) { +function mod_user_log(Context $ctx, $username, $page_no = 1) { global $config; if ($page_no < 1) @@ -733,7 +733,7 @@ function protect_ip($entry) { return preg_replace(array($ipv4_link_regex, $ipv6_link_regex), "xxxx", $entry); } -function mod_board_log($board, $page_no = 1, $hide_names = false, $public = false) { +function mod_board_log(Context $ctx, $board, $page_no = 1, $hide_names = false, $public = false) { global $config; if ($page_no < 1) @@ -766,10 +766,13 @@ function mod_board_log($board, $page_no = 1, $hide_names = false, $public = fals mod_page(_('Board log'), 'mod/log.html', array('logs' => $logs, 'count' => $count, 'board' => $board, 'hide_names' => $hide_names, 'public' => $public)); } -function mod_view_catalog($boardName) { - global $config, $mod; +function mod_view_catalog(Context $ctx, $boardName) { + global $mod; + + $config = $ctx->get('config'); + require_once($config['dir']['themes'].'/catalog/theme.php'); - $settings = array(); + $settings = []; $settings['boards'] = $boardName; $settings['update_on_posts'] = true; $settings['title'] = 'Catalog'; @@ -798,7 +801,7 @@ function mod_view_catalog($boardName) { } } -function mod_view_board($boardName, $page_no = 1) { +function mod_view_board(Context $ctx, $boardName, $page_no = 1) { global $config, $mod; if (!openBoard($boardName)){ @@ -829,7 +832,7 @@ function mod_view_board($boardName, $page_no = 1) { echo Element('index.html', $page); } -function mod_view_thread($boardName, $thread) { +function mod_view_thread(Context $ctx, $boardName, $thread) { global $config, $mod; if (!openBoard($boardName)) @@ -839,7 +842,7 @@ function mod_view_thread($boardName, $thread) { echo $page; } -function mod_view_thread50($boardName, $thread) { +function mod_view_thread50(Context $ctx, $boardName, $thread) { global $config, $mod; if (!openBoard($boardName)) @@ -849,111 +852,119 @@ function mod_view_thread50($boardName, $thread) { echo $page; } -function mod_ip_remove_note($ip, $id) { - global $config, $mod; +function mod_ip_remove_note(Context $ctx, $ip, $id) { + $config = $ctx->get('config'); - if (!hasPermission($config['mod']['remove_notes'])) - error($config['error']['noaccess']); + if (!hasPermission($config['mod']['remove_notes'])) { + error($config['error']['noaccess']); + } - if (filter_var($ip, FILTER_VALIDATE_IP) === false) - error("Invalid IP address."); + if (filter_var($ip, \FILTER_VALIDATE_IP) === false) { + error('Invalid IP address'); + } - $query = prepare('DELETE FROM ``ip_notes`` WHERE `ip` = :ip AND `id` = :id'); - $query->bindValue(':ip', $ip); - $query->bindValue(':id', $id); - $query->execute() or error(db_error($query)); + if (!is_numeric($id)) { + error('Invalid note ID'); + } - modLog("Removed a note for {$ip}"); + $queries = $ctx->get(IpNoteQueries::class); + $deleted = $queries->deleteWhereIp((int)$id, $ip); - header('Location: ?/IP/' . $ip . '#notes', true, $config['redirect_http']); + if (!$deleted) { + error("Note $id does not exist for $ip"); + } + + modLog("Removed a note for {$ip}"); + + \header("Location: ?/user_posts/ip/$ip#notes", true, $config['redirect_http']); } -function mod_page_ip($ip) { - global $config, $mod; +function mod_ip(Context $ctx, $ip, string $encoded_cursor = null) { + global $mod; + $config = $ctx->get('config'); - if (filter_var($ip, FILTER_VALIDATE_IP) === false) - error("Invalid IP address."); + if (filter_var($ip, FILTER_VALIDATE_IP) === false) { + error('Invalid IP address'); + } if (isset($_POST['ban_id'], $_POST['unban'])) { - if (!hasPermission($config['mod']['unban'])) + if (!hasPermission($config['mod']['unban'])) { error($config['error']['noaccess']); + } Bans::delete($_POST['ban_id'], true, $mod['boards']); - header('Location: ?/IP/' . $ip . '#bans', true, $config['redirect_http']); + if (empty($encoded_cursor)) { + \header("Location: ?/user_posts/ip/$ip#bans", true, $config['redirect_http']); + } else { + \header("Location: ?/user_posts/ip/$ip/cursor/$encoded_cursor#bans", true, $config['redirect_http']); + } return; } if (isset($_POST['note'])) { - if (!hasPermission($config['mod']['create_notes'])) + if (!hasPermission($config['mod']['create_notes'])) { error($config['error']['noaccess']); + } $_POST['note'] = escape_markup_modifiers($_POST['note']); markup($_POST['note']); - $query = prepare('INSERT INTO ``ip_notes`` VALUES (NULL, :ip, :mod, :time, :body)'); - $query->bindValue(':ip', $ip); - $query->bindValue(':mod', $mod['id']); - $query->bindValue(':time', time()); - $query->bindValue(':body', $_POST['note']); - $query->execute() or error(db_error($query)); + + $note_queries = $ctx->get(IpNoteQueries::class); + $note_queries->add($ip, $mod['id'], $_POST['note']); Cache::delete("mod_page_ip_view_notes_$ip"); - modLog("Added a note for {$ip}"); + modLog("Added a note for {$ip}"); - header('Location: ?/IP/' . $ip . '#notes', true, $config['redirect_http']); + if (empty($encoded_cursor)) { + \header("Location: ?/user_posts/ip/$ip#notes", true, $config['redirect_http']); + } else { + \header("Location: ?/user_posts/ip/$ip/cursor/$encoded_cursor#notes", true, $config['redirect_http']); + } return; } + // Temporary Redirect so to not to break the note and unban system. + if (empty($encoded_cursor)) { + \header("Location: ?/user_posts/ip/$ip", true, 307); + } else { + \header("Location: ?/user_posts/ip/$ip/cursor/$encoded_cursor", true, 307); + } +} + +function mod_user_posts_by_ip(Context $ctx, string $ip, string $encoded_cursor = null) { + global $mod; + + if (\filter_var($ip, \FILTER_VALIDATE_IP) === false){ + error('Invalid IP address'); + } + + $config = $ctx->get('config'); + $args = [ 'ip' => $ip, 'posts' => [] ]; - if ($config['mod']['dns_lookup']) - $args['hostname'] = rDNS($ip); - - $boards = listBoards(); - foreach ($boards as $board) { - openBoard($board['uri']); - if (!hasPermission($config['mod']['show_ip'], $board['uri'])) - continue; - $query = prepare(sprintf('SELECT * FROM ``posts_%s`` WHERE `ip` = :ip ORDER BY `sticky` DESC, `id` DESC LIMIT :limit', $board['uri'])); - $query->bindValue(':ip', $ip); - $query->bindValue(':limit', $config['mod']['ip_recentposts'], PDO::PARAM_INT); - $query->execute() or error(db_error($query)); - - while ($post = $query->fetch(PDO::FETCH_ASSOC)) { - if (!$post['thread']) { - $po = new Thread($post, '?/', $mod, false); - } else { - $po = new Post($post, '?/', $mod); - } - - if (!isset($args['posts'][$board['uri']])) { - $args['posts'][$board['uri']] = [ 'board' => $board, 'posts' => [] ]; - } - $args['posts'][$board['uri']]['posts'][] = $po->build(true); - } + if (isset($config['mod']['ip_recentposts'])) { + // TODO log to migrate. + $page_size = $config['mod']['ip_recentposts']; + } else { + $page_size = $config['mod']['recent_user_posts']; } - $args['boards'] = $boards; - $args['token'] = make_secure_link_token('ban'); + if ($config['mod']['dns_lookup']) { + $args['hostname'] = rDNS($ip); + } if (hasPermission($config['mod']['view_ban'])) { $args['bans'] = Bans::find($ip, false, true, $config['auto_maintenance']); } if (hasPermission($config['mod']['view_notes'])) { - $ret = Cache::get("mod_page_ip_view_notes_$ip"); - if (!$ret) { - $query = prepare("SELECT ``ip_notes``.*, `username` FROM ``ip_notes`` LEFT JOIN ``mods`` ON `mod` = ``mods``.`id` WHERE `ip` = :ip ORDER BY `time` DESC"); - $query->bindValue(':ip', $ip); - $query->execute() or error(db_error($query)); - $ret = $query->fetchAll(PDO::FETCH_ASSOC); - Cache::set("mod_page_ip_view_notes_$ip", $ret, 900); - } - $args['notes'] = $ret; + $note_queries = $ctx->get(IpNoteQueries::class); + $args['notes'] = $note_queries->getByIp($ip); } if (hasPermission($config['mod']['modlog_ip'])) { @@ -970,12 +981,120 @@ function mod_page_ip($ip) { $args['logs'] = []; } - $args['security_token'] = make_secure_link_token('IP/' . $ip); + $boards = listBoards(); - mod_page(sprintf('%s: %s', _('IP'), htmlspecialchars($ip)), 'mod/view_ip.html', $args, $args['hostname']); + $queryable_uris = []; + foreach ($boards as $board) { + $uri = $board['uri']; + if (hasPermission($config['mod']['show_ip'], $uri)) { + $queryable_uris[] = $uri; + } + } + + $queries = $ctx->get(UserPostQueries::class); + $result = $queries->fetchPaginatedByIp($queryable_uris, $ip, $page_size, $encoded_cursor); + + $args['cursor_prev'] = $result->cursor_prev; + $args['cursor_next'] = $result->cursor_next; + + foreach($boards as $board) { + $uri = $board['uri']; + // The Thread and Post classes rely on some implicit board parameter set by openBoard. + openBoard($uri); + + // Finally load the post contents and build them. + foreach ($result->by_uri[$uri] as $post) { + if (!$post['thread']) { + $po = new Thread($post, '?/', $mod, false); + } else { + $po = new Post($post, '?/', $mod); + } + + if (!isset($args['posts'][$uri])) { + $args['posts'][$uri] = [ 'board' => $board, 'posts' => [] ]; + } + $args['posts'][$uri]['posts'][] = $po->build(true); + } + } + + $args['boards'] = $boards; + $args['token'] = make_secure_link_token('ban'); + + // Since the security token is only used to send requests to create notes and remove bans, use "?/IP/" as the url. + if (empty($encoded_cursor)) { + $args['security_token'] = make_secure_link_token("IP/$ip"); + } else { + $args['security_token'] = make_secure_link_token("IP/$ip/cursor/$encoded_cursor"); + } + + mod_page(\sprintf('%s: %s', _('IP'), \htmlspecialchars($ip)), 'mod/view_ip.html', $args, $args['hostname']); } -function mod_ban() { +function mod_user_posts_by_passwd(Context $ctx, string $passwd, string $encoded_cursor = null) { + global $mod; + + // The current hashPassword implementation uses sha3-256, which has a 64 character output in non-binary mode. + if (\strlen($passwd) != 64) { + error('Invalid password'); + } + + $config = $ctx->get('config'); + + $args = [ + 'passwd' => $passwd, + 'posts' => [] + ]; + + if (isset($config['mod']['ip_recentposts'])) { + // TODO log to migrate. + $page_size = $config['mod']['ip_recentposts']; + } else { + $page_size = $config['mod']['recent_user_posts']; + } + + $boards = listBoards(); + + $queryable_uris = []; + foreach ($boards as $board) { + $uri = $board['uri']; + if (hasPermission($config['mod']['show_ip'], $uri)) { + $queryable_uris[] = $uri; + } + } + + $queries = $ctx->get(UserPostQueries::class); + $result = $queries->fetchPaginateByPassword($queryable_uris, $passwd, $page_size, $encoded_cursor); + + $args['cursor_prev'] = $result->cursor_prev; + $args['cursor_next'] = $result->cursor_next; + + foreach($boards as $board) { + $uri = $board['uri']; + // The Thread and Post classes rely on some implicit board parameter set by openBoard. + openBoard($uri); + + // Finally load the post contents and build them. + foreach ($result->by_uri[$uri] as $post) { + if (!$post['thread']) { + $po = new Thread($post, '?/', $mod, false); + } else { + $po = new Post($post, '?/', $mod); + } + + if (!isset($args['posts'][$uri])) { + $args['posts'][$uri] = [ 'board' => $board, 'posts' => [] ]; + } + $args['posts'][$uri]['posts'][] = $po->build(true); + } + } + + $args['boards'] = $boards; + $args['token'] = make_secure_link_token('ban'); + + mod_page(\sprintf('%s: %s', _('Password'), \htmlspecialchars($passwd)), 'mod/view_passwd.html', $args); +} + +function mod_ban(Context $ctx) { global $config; if (!hasPermission($config['mod']['ban'])) @@ -996,7 +1115,7 @@ function mod_ban() { header('Location: ?/', true, $config['redirect_http']); } -function mod_warning() { +function mod_warning(Context $ctx) { global $config; if (!hasPermission($config['mod']['warning'])) @@ -1013,7 +1132,7 @@ function mod_warning() { header('Location: ?/', true, $config['redirect_http']); } -function mod_bans() { +function mod_bans(Context $ctx) { global $config; global $mod; @@ -1024,7 +1143,7 @@ function mod_bans() { if (!hasPermission($config['mod']['unban'])) error($config['error']['noaccess']); - $unban = array(); + $unban = []; foreach ($_POST as $name => $unused) { if (preg_match('/^ban_(\d+)$/', $name, $match)) $unban[] = $match[1]; @@ -1048,7 +1167,7 @@ function mod_bans() { )); } -function mod_bans_json() { +function mod_bans_json(Context $ctx) { global $config, $mod; if (!hasPermission($config['mod']['ban'])) @@ -1060,7 +1179,7 @@ function mod_bans_json() { Bans::stream_json(false, false, !hasPermission($config['mod']['view_banstaff']), $mod['boards']); } -function mod_ban_appeals() { +function mod_ban_appeals(Context $ctx) { global $config, $board; if (!hasPermission($config['mod']['view_ban_appeals'])) @@ -1113,16 +1232,16 @@ function mod_ban_appeals() { $query = query(sprintf("SELECT `num_files`, `files` FROM ``posts_%s`` WHERE `id` = " . (int)$ban['post']['id'], $board['uri'])); if ($_post = $query->fetch(PDO::FETCH_ASSOC)) { - $_post['files'] = $_post['files'] ? json_decode($_post['files']) : array(); + $_post['files'] = $_post['files'] ? json_decode($_post['files']) : []; $ban['post'] = array_merge($ban['post'], $_post); } else { - $ban['post']['files'] = array(array()); + $ban['post']['files'] = array([]); $ban['post']['files'][0]['file'] = 'deleted'; $ban['post']['files'][0]['thumb'] = false; $ban['post']['num_files'] = 1; } } else { - $ban['post']['files'] = array(array()); + $ban['post']['files'] = array([]); $ban['post']['files'][0]['file'] = 'deleted'; $ban['post']['files'][0]['thumb'] = false; $ban['post']['num_files'] = 1; @@ -1142,7 +1261,7 @@ function mod_ban_appeals() { )); } -function mod_lock($board, $unlock, $post) { +function mod_lock(Context $ctx, $board, $unlock, $post) { global $config; if (!openBoard($board)) @@ -1178,7 +1297,7 @@ function mod_lock($board, $unlock, $post) { event('lock', $post); } -function mod_sticky($board, $unsticky, $post) { +function mod_sticky(Context $ctx, $board, $unsticky, $post) { global $config; if (!openBoard($board)) @@ -1202,7 +1321,7 @@ function mod_sticky($board, $unsticky, $post) { header('Location: ?/' . sprintf($config['board_path'], $board) . $config['file_index'], true, $config['redirect_http']); } -function mod_cycle($board, $uncycle, $post) { +function mod_cycle(Context $ctx, $board, $uncycle, $post) { global $config; if (!openBoard($board)) @@ -1224,7 +1343,7 @@ function mod_cycle($board, $uncycle, $post) { header('Location: ?/' . sprintf($config['board_path'], $board) . $config['file_index'], true, $config['redirect_http']); } -function mod_bumplock($board, $unbumplock, $post) { +function mod_bumplock(Context $ctx, $board, $unbumplock, $post) { global $config; if (!openBoard($board)) @@ -1246,8 +1365,8 @@ function mod_bumplock($board, $unbumplock, $post) { header('Location: ?/' . sprintf($config['board_path'], $board) . $config['file_index'], true, $config['redirect_http']); } -function mod_move_reply($originBoard, $postID) { - global $board, $config, $mod; +function mod_move_reply(Context $ctx, $originBoard, $postID) { + global $board, $config; if (!openBoard($originBoard)) error($config['error']['noboard']); @@ -1350,8 +1469,8 @@ function mod_move_reply($originBoard, $postID) { } -function mod_move($originBoard, $postID) { - global $board, $config, $mod, $pdo; +function mod_move(Context $ctx, $originBoard, $postID) { + global $board, $config, $pdo; if (!openBoard($originBoard)) error($config['error']['noboard']); @@ -1372,8 +1491,9 @@ function mod_move($originBoard, $postID) { if ($targetBoard === $originBoard) error(_('Target and source board are the same.')); + $_link_or_copy = _link_or_copy_factory($ctx); // link() if leaving a shadow thread behind; else, rename(). - $clone = $shadow ? '_link_or_copy' : 'rename'; + $clone = $shadow ? $_link_or_copy : 'rename'; // indicate that the post is a thread $post['op'] = true; @@ -1420,7 +1540,7 @@ function mod_move($originBoard, $postID) { $query->bindValue(':id', $postID, PDO::PARAM_INT); $query->execute() or error(db_error($query)); - $replies = array(); + $replies = []; while ($post = $query->fetch(PDO::FETCH_ASSOC)) { $post['mod'] = true; @@ -1487,7 +1607,7 @@ function mod_move($originBoard, $postID) { if (!empty($post['tracked_cites'])) { - $insert_rows = array(); + $insert_rows = []; foreach ($post['tracked_cites'] as $cite) { $insert_rows[] = '(' . $pdo->quote($board['uri']) . ', ' . $newPostID . ', ' . @@ -1563,8 +1683,8 @@ function mod_move($originBoard, $postID) { mod_page(_('Move thread'), 'mod/move.html', array('post' => $postID, 'board' => $originBoard, 'boards' => $boards, 'token' => $security_token)); } -function mod_merge($originBoard, $postID) { - global $board, $config, $mod, $pdo; +function mod_merge(Context $ctx, $originBoard, $postID) { + global $board, $config, $pdo; if (!openBoard($originBoard)) error($config['error']['noboard']); @@ -1573,10 +1693,11 @@ function mod_merge($originBoard, $postID) { error($config['error']['noaccess']); $query = prepare(sprintf('SELECT * FROM ``posts_%s`` WHERE `id` = :id AND `thread` IS NULL', $originBoard)); - $query->bindValue(':id', $postID); + $query->bindValue(':id', (int)$postID, \PDO::PARAM_INT); $query->execute() or error(db_error($query)); - if (!$post = $query->fetch(PDO::FETCH_ASSOC)) + if (!$post = $query->fetch(PDO::FETCH_ASSOC)) { error($config['error']['404']); + } $sourceOp = ""; if ($post['thread']){ $sourceOp = $post['thread']; @@ -1666,7 +1787,8 @@ function mod_merge($originBoard, $postID) { $op = $post; $op['id'] = $newID; - $clone = $shadow ? '_link_or_copy' : 'rename'; + $_link_or_copy = _link_or_copy_factory($ctx); + $clone = $shadow ? $_link_or_copy : 'rename'; if ($post['has_file']) { // copy image @@ -1685,7 +1807,7 @@ function mod_merge($originBoard, $postID) { $query->bindValue(':id', $postID, PDO::PARAM_INT); $query->execute() or error(db_error($query)); - $replies = array(); + $replies = []; while ($post = $query->fetch(PDO::FETCH_ASSOC)) { $post['mod'] = true; @@ -1748,7 +1870,7 @@ function mod_merge($originBoard, $postID) { if (!empty($post['tracked_cites'])) { - $insert_rows = array(); + $insert_rows = []; foreach ($post['tracked_cites'] as $cite) { $insert_rows[] = '(' . $pdo->quote($board['uri']) . ', ' . $newPostID . ', ' . @@ -1804,7 +1926,7 @@ function mod_merge($originBoard, $postID) { mod_page(_('Merge thread'), 'mod/merge.html', array('post' => $postID, 'board' => $originBoard, 'boards' => $boards, 'token' => $security_token)); } -function mod_ban_post($board, $delete, $post, $token = false) { +function mod_ban_post(Context $ctx, $board, $delete, $post, $token = false) { global $config, $mod; if (!openBoard($board)) @@ -1869,7 +1991,7 @@ function mod_ban_post($board, $delete, $post, $token = false) { $name = $mypost["name"]; $subject = $mypost["subject"]; $filehash = $mypost["filehash"]; - $mypost['files'] = $mypost['files'] ? json_decode($mypost['files']) : array(); + $mypost['files'] = $mypost['files'] ? json_decode($mypost['files']) : []; // For each file append file name for ($file_count = 0; $file_count < $mypost["num_files"];$file_count++){ $filename .= $mypost['files'][$file_count]->name . "\r\n"; @@ -1882,14 +2004,10 @@ function mod_ban_post($board, $delete, $post, $token = false) { $autotag .= "/${board}/" . " " . $filehash . " " . $filename ."\r\n"; $autotag .= $body . "\r\n"; $autotag = escape_markup_modifiers($autotag); - markup($autotag); - $query = prepare('INSERT INTO ``ip_notes`` VALUES (NULL, :ip, :mod, :time, :body)'); - $query->bindValue(':ip', $ip); - $query->bindValue(':mod', $mod['id']); - $query->bindValue(':time', time()); - $query->bindValue(':body', $autotag); - $query->execute() or error(db_error($query)); - modLog("Added a note for {$ip}"); + + $note_queries = $ctx->get(IpNoteQueries::class); + $note_queries->add($ip, $mod['id'], $autotag); + modLog("Added a note for {$ip}"); } } deletePost($post); @@ -1916,7 +2034,7 @@ function mod_ban_post($board, $delete, $post, $token = false) { 'board' => $board, 'delete' => (bool)$delete, 'boards' => listBoards(), - 'reasons' => $config['ban_reasons'], + 'reasons' => $config['ban_reasons'], 'token' => $security_token ); @@ -1927,7 +2045,7 @@ function mod_ban_post($board, $delete, $post, $token = false) { mod_page(_('New ban'), 'mod/ban_form.html', $args); } -function mod_warning_post($board,$post, $token = false) { +function mod_warning_post(Context $ctx, $board, $post, $token = false) { global $config, $mod; if (!openBoard($board)) @@ -1980,7 +2098,7 @@ function mod_warning_post($board,$post, $token = false) { $name = $mypost["name"]; $subject = $mypost["subject"]; $filehash = $mypost["filehash"]; - $mypost['files'] = $mypost['files'] ? json_decode($mypost['files']) : array(); + $mypost['files'] = $mypost['files'] ? json_decode($mypost['files']) : []; // For each file append file name for ($file_count = 0; $file_count < $mypost["num_files"];$file_count++){ $filename .= $mypost['files'][$file_count]->name . "\r\n"; @@ -1994,13 +2112,10 @@ function mod_warning_post($board,$post, $token = false) { $autotag .= $body . "\r\n"; $autotag = escape_markup_modifiers($autotag); markup($autotag); - $query = prepare('INSERT INTO ``ip_notes`` VALUES (NULL, :ip, :mod, :time, :body)'); - $query->bindValue(':ip', $ip); - $query->bindValue(':mod', $mod['id']); - $query->bindValue(':time', time()); - $query->bindValue(':body', $autotag); - $query->execute() or error(db_error($query)); - modLog("Added a note for {$ip}"); + + $note_queries = $ctx->get(IpNoteQueries::class); + $note_queries->add($ip, $mod['id'], $autotag); + modLog("Added a note for {$ip}"); } } } @@ -2027,8 +2142,8 @@ function mod_warning_post($board,$post, $token = false) { mod_page(_('New warning'), 'mod/warning_form.html', $args); } -function mod_edit_post($board, $edit_raw_html, $postID) { - global $config, $mod; +function mod_edit_post(Context $ctx, $board, $edit_raw_html, $postID) { + global $config; if (!openBoard($board)) error($config['error']['noboard']); @@ -2104,8 +2219,8 @@ function mod_edit_post($board, $edit_raw_html, $postID) { } } -function mod_delete($board, $post) { - global $config, $mod; +function mod_delete(Context $ctx, $board, $post) { + global $config, $mod; if (!openBoard($board)) error($config['error']['noboard']); @@ -2132,7 +2247,7 @@ function mod_delete($board, $post) { $name = $mypost["name"]; $subject = $mypost["subject"]; $filehash = $mypost["filehash"]; - $mypost['files'] = $mypost['files'] ? json_decode($mypost['files']) : array(); + $mypost['files'] = $mypost['files'] ? json_decode($mypost['files']) : []; // For each file append file name for ($file_count = 0; $file_count < $mypost["num_files"];$file_count++){ $filename .= $mypost['files'][$file_count]->name . "\r\n"; @@ -2146,13 +2261,10 @@ function mod_delete($board, $post) { $autotag .= $body . "\r\n"; $autotag = escape_markup_modifiers($autotag); markup($autotag); - $query = prepare('INSERT INTO ``ip_notes`` VALUES (NULL, :ip, :mod, :time, :body)'); - $query->bindValue(':ip', $ip); - $query->bindValue(':mod', $mod['id']); - $query->bindValue(':time', time()); - $query->bindValue(':body', $autotag); - $query->execute() or error(db_error($query)); - modLog("Added a note for {$ip}"); + + $note_queries = $ctx->get(IpNoteQueries::class); + $note_queries->add($ip, $mod['id'], $autotag); + modLog("Added a note for {$ip}"); } } deletePost($post); @@ -2170,8 +2282,8 @@ function mod_delete($board, $post) { } } -function mod_deletefile($board, $post, $file) { - global $config, $mod; +function mod_deletefile(Context $ctx, $board, $post, $file) { + global $config; if (!openBoard($board)) error($config['error']['noboard']); @@ -2193,8 +2305,8 @@ function mod_deletefile($board, $post, $file) { header('Location: ?/' . sprintf($config['board_path'], $board) . $config['file_index'], true, $config['redirect_http']); } -function mod_spoiler_image($board, $post, $file) { - global $config, $mod; +function mod_spoiler_image(Context $ctx, $board, $post, $file) { + global $config; if (!openBoard($board)) error($config['error']['noboard']); @@ -2238,8 +2350,8 @@ function mod_spoiler_image($board, $post, $file) { header('Location: ?/' . sprintf($config['board_path'], $board) . $config['file_index'], true, $config['redirect_http']); } -function mod_deletebyip($boardName, $post, $global = false) { - global $config, $mod, $board; +function mod_deletebyip(Context $ctx, $boardName, $post, $global = false) { + global $config, $board, $mod; $global = (bool)$global; @@ -2279,9 +2391,9 @@ function mod_deletebyip($boardName, $post, $global = false) { @set_time_limit($config['mod']['rebuild_timelimit']); - $boards_to_rebuild = array(); - $threads_to_rebuild = array(); - $threads_deleted = array(); + $boards_to_rebuild = []; + $threads_to_rebuild = []; + $threads_deleted = []; while ($post = $query->fetch(PDO::FETCH_ASSOC)) { $boards_to_rebuild[$post['board']] = true; openBoard($post['board']); @@ -2303,7 +2415,7 @@ function mod_deletebyip($boardName, $post, $global = false) { $name = $mypost["name"]; $subject = $mypost["subject"]; $filehash = $mypost["filehash"]; - $mypost['files'] = $mypost['files'] ? json_decode($mypost['files']) : array(); + $mypost['files'] = $mypost['files'] ? json_decode($mypost['files']) : []; // For each file append file name for ($file_count = 0; $file_count < $mypost["num_files"];$file_count++){ $filename .= $mypost['files'][$file_count]->name . "\r\n"; @@ -2317,13 +2429,10 @@ function mod_deletebyip($boardName, $post, $global = false) { $autotag .= $body . "\r\n"; $autotag = escape_markup_modifiers($autotag); markup($autotag); - $query2 = prepare('INSERT INTO ``ip_notes`` VALUES (NULL, :ip, :mod, :time, :body)'); - $query2->bindValue(':ip', $ip); - $query2->bindValue(':mod', $mod['id']); - $query2->bindValue(':time', time()); - $query2->bindValue(':body', $autotag); - $query2->execute() or error(db_error($query2)); - modLog("Added a note for {$ip}"); + + $note_queries = $ctx->get(IpNoteQueries::class); + $note_queries->add($ip, $mod['id'], $autotag); + modLog("Added a note for {$ip}"); } } @@ -2354,13 +2463,13 @@ function mod_deletebyip($boardName, $post, $global = false) { } // Record the action - modLog("Deleted all posts by IP address: $ip"); + modLog("Deleted all posts by IP address: $ip"); // Redirect header('Location: ?/' . sprintf($config['board_path'], $boardName) . $config['file_index'], true, $config['redirect_http']); } -function mod_user($uid) { +function mod_user(Context $ctx, $uid) { global $config, $mod; if (!hasPermission($config['mod']['editusers']) && !(hasPermission($config['mod']['change_password']) && $uid == $mod['id'])) @@ -2381,7 +2490,7 @@ function mod_user($uid) { $board = $board['uri']; } - $boards = array(); + $boards = []; foreach ($_POST as $name => $value) { if (preg_match('/^board_(' . $config['board_regex'] . ')$/u', $name, $matches) && in_array($matches[1], $_boards)) $boards[] = $matches[1]; @@ -2472,7 +2581,7 @@ function mod_user($uid) { $query->execute() or error(db_error($query)); $log = $query->fetchAll(PDO::FETCH_ASSOC); } else { - $log = array(); + $log = []; } $user['boards'] = explode(',', $user['boards']); @@ -2485,7 +2594,7 @@ function mod_user($uid) { )); } -function mod_user_new() { +function mod_user_new(Context $ctx) { global $pdo, $config; if (!hasPermission($config['mod']['createusers'])) @@ -2505,7 +2614,7 @@ function mod_user_new() { $board = $board['uri']; } - $boards = array(); + $boards = []; foreach ($_POST as $name => $value) { if (preg_match('/^board_(' . $config['board_regex'] . ')$/u', $name, $matches) && in_array($matches[1], $_boards)) $boards[] = $matches[1]; @@ -2538,7 +2647,7 @@ function mod_user_new() { } -function mod_users() { +function mod_users(Context $ctx) { global $config; if (!hasPermission($config['mod']['manageusers'])) @@ -2559,7 +2668,7 @@ function mod_users() { mod_page(sprintf('%s (%d)', _('Manage users'), count($users)), 'mod/users.html', array('users' => $users)); } -function mod_user_promote($uid, $action) { +function mod_user_promote(Context $ctx, $uid, $action) { global $config; if (!hasPermission($config['mod']['promoteusers'])) @@ -2602,7 +2711,7 @@ function mod_user_promote($uid, $action) { header('Location: ?/users', true, $config['redirect_http']); } -function mod_pm($id, $reply = false) { +function mod_pm(Context $ctx, $id, $reply = false) { global $mod, $config; if ($reply && !hasPermission($config['mod']['create_pm'])) @@ -2657,7 +2766,7 @@ function mod_pm($id, $reply = false) { } } -function mod_inbox() { +function mod_inbox(Context $ctx) { global $config, $mod; $query = prepare('SELECT `unread`,``pms``.`id`, `time`, `sender`, `to`, `message`, `username` FROM ``pms`` LEFT JOIN ``mods`` ON ``mods``.`id` = `sender` WHERE `to` = :mod ORDER BY `unread` DESC, `time` DESC'); @@ -2681,7 +2790,7 @@ function mod_inbox() { } -function mod_new_pm($username) { +function mod_new_pm(Context $ctx, $username) { global $config, $mod; if (!hasPermission($config['mod']['create_pm'])) @@ -2729,7 +2838,7 @@ function mod_new_pm($username) { )); } -function mod_rebuild() { +function mod_rebuild(Context $ctx) { global $config, $twig; if (!hasPermission($config['mod']['rebuild'])) @@ -2738,9 +2847,9 @@ function mod_rebuild() { if (isset($_POST['rebuild'])) { @set_time_limit($config['mod']['rebuild_timelimit']); - $log = array(); + $log = []; $boards = listBoards(); - $rebuilt_scripts = array(); + $rebuilt_scripts = []; if (isset($_POST['rebuild_cache'])) { if ($config['cache']['enabled']) { @@ -2801,49 +2910,28 @@ function mod_rebuild() { )); } -function mod_reports() { +function mod_reports(Context $ctx) { global $config, $mod; if (!hasPermission($config['mod']['reports'])) error($config['error']['noaccess']); - $query = prepare("SELECT * FROM ``reports`` ORDER BY `time` DESC LIMIT :limit"); - $query->bindValue(':limit', $config['mod']['recent_reports'], PDO::PARAM_INT); - $query->execute() or error(db_error($query)); - $reports = $query->fetchAll(PDO::FETCH_ASSOC); + $reports_limit = $config['mod']['recent_reports']; + $report_queries = $ctx->get(ReportQueries::class); + $report_rows = $report_queries->getReportsWithPosts($reports_limit); - $report_queries = array(); - foreach ($reports as $report) { - if (!isset($report_queries[$report['board']])) - $report_queries[$report['board']] = array(); - $report_queries[$report['board']][] = $report['post']; + if (\count($report_rows) > $reports_limit) { + \array_pop($report_rows); + $has_extra = true; + } else { + $has_extra = false; } - $report_posts = array(); - foreach ($report_queries as $board => $posts) { - $report_posts[$board] = array(); - - $query = query(sprintf('SELECT * FROM ``posts_%s`` WHERE `id` = ' . implode(' OR `id` = ', $posts), $board)) or error(db_error()); - while ($post = $query->fetch(PDO::FETCH_ASSOC)) { - $report_posts[$board][$post['id']] = $post; - } - } - - $count = 0; $body = ''; - foreach ($reports as $report) { - if (!isset($report_posts[$report['board']][$report['post']])) { - // // Invalid report (post has since been deleted) - $query = prepare("DELETE FROM ``reports`` WHERE `post` = :id AND `board` = :board"); - $query->bindValue(':id', $report['post'], PDO::PARAM_INT); - $query->bindValue(':board', $report['board']); - $query->execute() or error(db_error($query)); - continue; - } - + foreach ($report_rows as $report) { openBoard($report['board']); - $post = &$report_posts[$report['board']][$report['post']]; + $post = $report['post_data']; if (!$post['thread']) { // Still need to fix this: @@ -2852,7 +2940,7 @@ function mod_reports() { $po = new Post($post, '?/', $mod); } - // a little messy and inefficient + // A little messy and inefficient. $append_html = Element('mod/report.html', array( 'report' => $report, 'config' => $config, @@ -2864,63 +2952,64 @@ function mod_reports() { // Bug fix for https://github.com/savetheinternet/Tinyboard/issues/21 $po->body = truncate($po->body, $po->link(), $config['body_truncate'] - substr_count($append_html, '
')); - if (mb_strlen($po->body) + mb_strlen($append_html) > $config['body_truncate_char']) { + if (\mb_strlen($po->body) + \mb_strlen($append_html) > $config['body_truncate_char']) { // still too long; temporarily increase limit in the config $__old_body_truncate_char = $config['body_truncate_char']; - $config['body_truncate_char'] = mb_strlen($po->body) + mb_strlen($append_html); + $config['body_truncate_char'] = \mb_strlen($po->body) + \mb_strlen($append_html); } $po->body .= $append_html; $body .= $po->build(true) . '
'; - if (isset($__old_body_truncate_char)) + if (isset($__old_body_truncate_char)) { $config['body_truncate_char'] = $__old_body_truncate_char; - - $count++; + } } - mod_page(sprintf('%s (%d)', _('Report queue'), $count), 'mod/reports.html', array('reports' => $body, 'count' => $count)); + $count = \count($report_rows); + $header_count = $has_extra ? "{$count}+" : (string)$count; + + mod_page( + \sprintf('%s (%s)', _('Report queue'), $header_count), + 'mod/reports.html', + [ 'reports' => $body, 'count' => $count ] + ); } -function mod_report_dismiss($id, $all = false) { +function mod_report_dismiss(Context $ctx, $id, $all = false) { global $config; - $query = prepare("SELECT `post`, `board`, `ip` FROM ``reports`` WHERE `id` = :id"); - $query->bindValue(':id', $id); - $query->execute() or error(db_error($query)); - if ($report = $query->fetch(PDO::FETCH_ASSOC)) { - $ip = $report['ip']; - $board = $report['board']; - $post = $report['post']; - } else + $report_queries = $ctx->get(ReportQueries::class); + $report = $report_queries->getReportById($id); + + if ($report === null) { error($config['error']['404']); + } - if (!$all && !hasPermission($config['mod']['report_dismiss'], $board)) - error($config['error']['noaccess']); + $ip = $report['ip']; + $board = $report['board']; - if ($all && !hasPermission($config['mod']['report_dismiss_ip'], $board)) + if (!$all && !hasPermission($config['mod']['report_dismiss'], $board)) { error($config['error']['noaccess']); + } + + if ($all && !hasPermission($config['mod']['report_dismiss_ip'], $board)) { + error($config['error']['noaccess']); + } if ($all) { - $query = prepare("DELETE FROM ``reports`` WHERE `ip` = :ip"); - $query->bindValue(':ip', $ip); + $report_queries->deleteByIp($ip); + modLog("Dismissed all reports by $ip"); } else { - $query = prepare("DELETE FROM ``reports`` WHERE `id` = :id"); - $query->bindValue(':id', $id); - } - $query->execute() or error(db_error($query)); - - - if ($all) - modLog("Dismissed all reports by $ip"); - else + $report_queries->deleteById($id); modLog("Dismissed a report for post #{$id}", $board); + } header('Location: ?/reports', true, $config['redirect_http']); } -function mod_recent_posts($lim,$board_list = false,$json=false) { +function mod_recent_posts(Context $ctx, $lim, $board_list = false, $json = false) { global $config, $mod, $pdo; if (!hasPermission($config['mod']['recent'])) @@ -2929,7 +3018,7 @@ function mod_recent_posts($lim,$board_list = false,$json=false) { $limit = (is_numeric($lim))? $lim : 25; $last_time = (isset($_GET['last']) && is_numeric($_GET['last'])) ? $_GET['last'] : 0; - $mod_boards = array(); + $mod_boards = []; $boards = listBoards(); //if not all boards @@ -2943,7 +3032,7 @@ function mod_recent_posts($lim,$board_list = false,$json=false) { } if ($board_list != false){ $board_array = explode(",",$board_list); - $new_board_array = array(); + $new_board_array = []; foreach ($board_array as $board) { if (array_key_exists($board,$config['boards_alias'])){ $newboard = $config['boards_alias'][$board]; @@ -2953,7 +3042,7 @@ function mod_recent_posts($lim,$board_list = false,$json=false) { } $new_board_array[] = $newboard; } - $mod_boards = array(); + $mod_boards = []; foreach ($boards as $board) { if (in_array($board['uri'], $new_board_array)){ $mod_boards[] = $board; @@ -2974,7 +3063,7 @@ function mod_recent_posts($lim,$board_list = false,$json=false) { $posts = $query->fetchAll(PDO::FETCH_ASSOC); if ($config['api']['enabled']) { - $apithreads = array(); + $apithreads = []; } foreach ($posts as &$post) { @@ -3018,7 +3107,7 @@ function mod_recent_posts($lim,$board_list = false,$json=false) { } } -function mod_config($board_config = false) { +function mod_config(Context $ctx, $board_config = false) { global $config, $mod, $board; if ($board_config && !openBoard($board_config)) @@ -3130,7 +3219,7 @@ function mod_config($board_config = false) { if ($config['minify_html']) $config_append = str_replace("\n", ' ', $config_append); - $page = array(); + $page = []; $page['title'] = 'Cannot write to file!'; $page['config'] = $config; $page['body'] = ' @@ -3158,7 +3247,7 @@ function mod_config($board_config = false) { )); } -function mod_themes_list() { +function mod_themes_list(Context $ctx) { global $config; if (!hasPermission($config['mod']['themes'])) @@ -3173,7 +3262,7 @@ function mod_themes_list() { $themes_in_use = $query->fetchAll(PDO::FETCH_COLUMN); // Scan directory for themes - $themes = array(); + $themes = []; while ($file = readdir($dir)) { if ($file[0] != '.' && is_dir($config['dir']['themes'] . '/' . $file)) { $themes[$file] = loadThemeConfig($file); @@ -3192,7 +3281,7 @@ function mod_themes_list() { )); } -function mod_theme_configure($theme_name) { +function mod_theme_configure(Context $ctx, $theme_name) { global $config; if (!hasPermission($config['mod']['themes'])) @@ -3274,7 +3363,7 @@ function mod_theme_configure($theme_name) { )); } -function mod_theme_uninstall($theme_name) { +function mod_theme_uninstall(Context $ctx, $theme_name) { global $config; if (!hasPermission($config['mod']['themes'])) @@ -3291,7 +3380,7 @@ function mod_theme_uninstall($theme_name) { header('Location: ?/themes', true, $config['redirect_http']); } -function mod_theme_rebuild($theme_name) { +function mod_theme_rebuild(Context $ctx, $theme_name) { global $config; if (!hasPermission($config['mod']['themes'])) @@ -3332,15 +3421,15 @@ function delete_page_base($page = '', $board = false) { header('Location: ?/edit_pages' . ($board ? ('/' . $board) : ''), true, $config['redirect_http']); } -function mod_delete_page($page = '') { - delete_page_base($page); +function mod_delete_page(Context $ctx, $page = '') { + delete_page_base($ctx, $page); } -function mod_delete_page_board($page = '', $board = false) { - delete_page_base($page, $board); +function mod_delete_page_board(Context $ctx, $page = '', $board = false) { + delete_page_base($ctx, $page, $board); } -function mod_edit_page($id) { +function mod_edit_page(Context $ctx, $id) { global $config, $mod, $board; $query = prepare('SELECT * FROM ``pages`` WHERE `id` = :id'); @@ -3411,7 +3500,7 @@ function mod_edit_page($id) { mod_page(sprintf(_('Editing static page: %s'), $page['name']), 'mod/edit_page.html', array('page' => $page, 'token' => make_secure_link_token("edit_page/$id"), 'content' => prettify_textarea($content), 'board' => $board)); } -function mod_pages($board = false) { +function mod_pages(Context $ctx, $board = false) { global $config, $mod, $pdo; if (empty($board)) @@ -3465,10 +3554,10 @@ function mod_pages($board = false) { mod_page(_('Pages'), 'mod/pages.html', array('pages' => $pages, 'token' => make_secure_link_token('edit_pages' . ($board ? ('/' . $board) : '')), 'board' => $board)); } -function mod_debug_antispam() { +function mod_debug_antispam(Context $ctx) { global $pdo, $config; - $args = array(); + $args = []; if (isset($_POST['board'], $_POST['thread'])) { $where = '`board` = ' . $pdo->quote($_POST['board']); @@ -3502,7 +3591,7 @@ function mod_debug_antispam() { mod_page(_('Debug: Anti-spam'), 'mod/debug/antispam.html', $args); } -function mod_debug_recent_posts() { +function mod_debug_recent_posts(Context $ctx) { global $pdo, $config; $limit = 500; @@ -3536,7 +3625,7 @@ function mod_debug_recent_posts() { mod_page(_('Debug: Recent posts'), 'mod/debug/recent_posts.html', array('posts' => $posts, 'flood_posts' => $flood_posts)); } -function mod_debug_sql() { +function mod_debug_sql(Context $ctx) { global $config; if (!hasPermission($config['mod']['debug_sql'])) @@ -3559,25 +3648,3 @@ function mod_debug_sql() { mod_page(_('Debug: SQL'), 'mod/debug/sql.html', $args); } - -function mod_debug_apc() { - global $config; - - if (!hasPermission($config['mod']['debug_apc'])) - error($config['error']['noaccess']); - - if ($config['cache']['enabled'] != 'apc') - error('APC is not enabled.'); - - $cache_info = apc_cache_info('user'); - - // $cached_vars = new APCIterator('user', '/^' . $config['cache']['prefix'] . '/'); - $cached_vars = array(); - foreach ($cache_info['cache_list'] as $var) { - if ($config['cache']['prefix'] != '' && strpos(isset($var['key']) ? $var['key'] : $var['info'], $config['cache']['prefix']) !== 0) - continue; - $cached_vars[] = $var; - } - - mod_page(_('Debug: APC'), 'mod/debug/apc.html', array('cached_vars' => $cached_vars)); -} diff --git a/inc/polyfill.php b/inc/polyfill.php index 2b14c4dc..4368c8e8 100644 --- a/inc/polyfill.php +++ b/inc/polyfill.php @@ -1,187 +1,10 @@ $i ? $i : 0]) ^ ord($theirs[$i]); - } - - return $answer === 0 && $olen === $tlen; - } -} - -if (!function_exists('imagecreatefrombmp')) { - /*********************************************/ - /* Fonction: imagecreatefrombmp */ - /* Author: DHKold */ - /* Contact: admin@dhkold.com */ - /* Date: The 15th of June 2005 */ - /* Version: 2.0B */ - /*********************************************/ - function imagecreatefrombmp($filename) { - if (! $f1 = fopen($filename,"rb")) return FALSE; - $FILE = unpack("vfile_type/Vfile_size/Vreserved/Vbitmap_offset", fread($f1,14)); - if ($FILE['file_type'] != 19778) return FALSE; - $BMP = unpack('Vheader_size/Vwidth/Vheight/vplanes/vbits_per_pixel'. - '/Vcompression/Vsize_bitmap/Vhoriz_resolution'. - '/Vvert_resolution/Vcolors_used/Vcolors_important', fread($f1,40)); - $BMP['colors'] = pow(2,$BMP['bits_per_pixel']); - if ($BMP['size_bitmap'] == 0) $BMP['size_bitmap'] = $FILE['file_size'] - $FILE['bitmap_offset']; - $BMP['bytes_per_pixel'] = $BMP['bits_per_pixel']/8; - $BMP['bytes_per_pixel2'] = ceil($BMP['bytes_per_pixel']); - $BMP['decal'] = ($BMP['width']*$BMP['bytes_per_pixel']/4); - $BMP['decal'] -= floor($BMP['width']*$BMP['bytes_per_pixel']/4); - $BMP['decal'] = 4-(4*$BMP['decal']); - if ($BMP['decal'] == 4) $BMP['decal'] = 0; - $PALETTE = array(); - if ($BMP['colors'] < 16777216) - { - $PALETTE = unpack('V'.$BMP['colors'], fread($f1,$BMP['colors']*4)); - } - $IMG = fread($f1,$BMP['size_bitmap']); - $VIDE = chr(0); - $res = imagecreatetruecolor($BMP['width'],$BMP['height']); - $P = 0; - $Y = $BMP['height']-1; - while ($Y >= 0) - { - $X=0; - while ($X < $BMP['width']) - { - if ($BMP['bits_per_pixel'] == 24) - $COLOR = unpack("V",substr($IMG,$P,3).$VIDE); - elseif ($BMP['bits_per_pixel'] == 16) - { - $COLOR = unpack("n",substr($IMG,$P,2)); - $COLOR[1] = $PALETTE[$COLOR[1]+1]; - } - elseif ($BMP['bits_per_pixel'] == 8) - { - $COLOR = unpack("n",$VIDE.substr($IMG,$P,1)); - $COLOR[1] = $PALETTE[$COLOR[1]+1]; - } - elseif ($BMP['bits_per_pixel'] == 4) - { - $COLOR = unpack("n",$VIDE.substr($IMG,floor($P),1)); - if (($P*2)%2 == 0) $COLOR[1] = ($COLOR[1] >> 4) ; else $COLOR[1] = ($COLOR[1] & 0x0F); - $COLOR[1] = $PALETTE[$COLOR[1]+1]; - } - elseif ($BMP['bits_per_pixel'] == 1) - { - $COLOR = unpack("n",$VIDE.substr($IMG,floor($P),1)); - if (($P*8)%8 == 0) $COLOR[1] = $COLOR[1] >>7; - elseif (($P*8)%8 == 1) $COLOR[1] = ($COLOR[1] & 0x40)>>6; - elseif (($P*8)%8 == 2) $COLOR[1] = ($COLOR[1] & 0x20)>>5; - elseif (($P*8)%8 == 3) $COLOR[1] = ($COLOR[1] & 0x10)>>4; - elseif (($P*8)%8 == 4) $COLOR[1] = ($COLOR[1] & 0x8)>>3; - elseif (($P*8)%8 == 5) $COLOR[1] = ($COLOR[1] & 0x4)>>2; - elseif (($P*8)%8 == 6) $COLOR[1] = ($COLOR[1] & 0x2)>>1; - elseif (($P*8)%8 == 7) $COLOR[1] = ($COLOR[1] & 0x1); - $COLOR[1] = $PALETTE[$COLOR[1]+1]; - } - else - return FALSE; - imagesetpixel($res,$X,$Y,$COLOR[1]); - $X++; - $P += $BMP['bytes_per_pixel']; - } - $Y--; - $P+=$BMP['decal']; - } - fclose($f1); - return $res; - } -} - -if (!function_exists('imagebmp')) { - function imagebmp(&$img, $filename='') { - $widthOrig = imagesx($img); - $widthFloor = ((floor($widthOrig/16))*16); - $widthCeil = ((ceil($widthOrig/16))*16); - $height = imagesy($img); - $size = ($widthCeil*$height*3)+54; - // Bitmap File Header - $result = 'BM'; // header (2b) - $result .= int_to_dword($size); // size of file (4b) - $result .= int_to_dword(0); // reserved (4b) - $result .= int_to_dword(54); // byte location in the file which is first byte of IMAGE (4b) - // Bitmap Info Header - $result .= int_to_dword(40); // Size of BITMAPINFOHEADER (4b) - $result .= int_to_dword($widthCeil); // width of bitmap (4b) - $result .= int_to_dword($height); // height of bitmap (4b) - $result .= int_to_word(1); // biPlanes = 1 (2b) - $result .= int_to_word(24); // biBitCount = {1 (mono) or 4 (16 clr ) or 8 (256 clr) or 24 (16 Mil)} (2b - $result .= int_to_dword(0); // RLE COMPRESSION (4b) - $result .= int_to_dword(0); // width x height (4b) - $result .= int_to_dword(0); // biXPelsPerMeter (4b) - $result .= int_to_dword(0); // biYPelsPerMeter (4b) - $result .= int_to_dword(0); // Number of palettes used (4b) - $result .= int_to_dword(0); // Number of important colour (4b) - // is faster than chr() - $arrChr = array(); - for ($i=0; $i<256; $i++){ - $arrChr[$i] = chr($i); - } - // creates image data - $bgfillcolor = array('red'=>0, 'green'=>0, 'blue'=>0); - // bottom to top - left to right - attention blue green red !!! - $y=$height-1; - for ($y2=0; $y2<$height; $y2++) { - for ($x=0; $x<$widthFloor; ) { - $rgb = imagecolorsforindex($img, imagecolorat($img, $x++, $y)); - $result .= $arrChr[$rgb['blue']].$arrChr[$rgb['green']].$arrChr[$rgb['red']]; - $rgb = imagecolorsforindex($img, imagecolorat($img, $x++, $y)); - $result .= $arrChr[$rgb['blue']].$arrChr[$rgb['green']].$arrChr[$rgb['red']]; - $rgb = imagecolorsforindex($img, imagecolorat($img, $x++, $y)); - $result .= $arrChr[$rgb['blue']].$arrChr[$rgb['green']].$arrChr[$rgb['red']]; - $rgb = imagecolorsforindex($img, imagecolorat($img, $x++, $y)); - $result .= $arrChr[$rgb['blue']].$arrChr[$rgb['green']].$arrChr[$rgb['red']]; - $rgb = imagecolorsforindex($img, imagecolorat($img, $x++, $y)); - $result .= $arrChr[$rgb['blue']].$arrChr[$rgb['green']].$arrChr[$rgb['red']]; - $rgb = imagecolorsforindex($img, imagecolorat($img, $x++, $y)); - $result .= $arrChr[$rgb['blue']].$arrChr[$rgb['green']].$arrChr[$rgb['red']]; - $rgb = imagecolorsforindex($img, imagecolorat($img, $x++, $y)); - $result .= $arrChr[$rgb['blue']].$arrChr[$rgb['green']].$arrChr[$rgb['red']]; - $rgb = imagecolorsforindex($img, imagecolorat($img, $x++, $y)); - $result .= $arrChr[$rgb['blue']].$arrChr[$rgb['green']].$arrChr[$rgb['red']]; - } - for ($x=$widthFloor; $x<$widthCeil; $x++) { - $rgb = ($x<$widthOrig) ? imagecolorsforindex($img, imagecolorat($img, $x, $y)) : $bgfillcolor; - $result .= $arrChr[$rgb['blue']].$arrChr[$rgb['green']].$arrChr[$rgb['red']]; - } - $y--; - } - // see imagegif - if ($filename == '') { - echo $result; - } else { - $file = fopen($filename, 'wb'); - fwrite($file, $result); - fclose($file); - } - } - // imagebmp helpers - function int_to_dword($n) { - return chr($n & 255).chr(($n >> 8) & 255).chr(($n >> 16) & 255).chr(($n >> 24) & 255); - } - function int_to_word($n) { - return chr($n & 255).chr(($n >> 8) & 255); +if (!function_exists('str_starts_with')) { + function str_starts_with(string $haystack, string $needle): bool { + // https://wiki.php.net/rfc/add_str_starts_with_and_ends_with_functions#str_starts_with + return \strncmp($haystack, $needle, \strlen($needle)) === 0; } } diff --git a/install.php b/install.php index 94a589b1..7663a503 100644 --- a/install.php +++ b/install.php @@ -52,11 +52,7 @@ if (file_exists($config['has_installed'])) { function __query($sql) { sql_open(); - - if (mysql_version() >= 50503) - return query($sql); - else - return query(str_replace('utf8mb4', 'utf8', $sql)); + return query($sql); } $boards = listBoards(); @@ -885,6 +881,7 @@ if ($step == 0) { $config['cookies']['salt'] = substr(base64_encode(sha1(rand())), 0, 30); $config['secure_trip_salt'] = substr(base64_encode(sha1(rand())), 0, 30); + $config['secure_password_salt'] = substr(base64_encode(sha1(rand())), 0, 30); echo Element('page.html', array( 'body' => Element('installer/config.html', array( @@ -939,7 +936,6 @@ if ($step == 0) { $sql = @file_get_contents('install.sql') or error("Couldn't load install.sql."); sql_open(); - $mysql_version = mysql_version(); // This code is probably horrible, but what I'm trying // to do is find all of the SQL queires and put them @@ -952,8 +948,6 @@ if ($step == 0) { $sql_errors = ''; $sql_err_count = 0; foreach ($queries as $query) { - if ($mysql_version < 50503) - $query = preg_replace('/(CHARSET=|CHARACTER SET )utf8mb4/', '$1utf8', $query); $query = preg_replace('/^([\w\s]*)`([0-9a-zA-Z$_\x{0080}-\x{FFFF}]+)`/u', '$1``$2``', $query); if (!query($query)) { $sql_err_count++; diff --git a/js/ajax.js b/js/ajax.js index af795a57..3cb06bf1 100644 --- a/js/ajax.js +++ b/js/ajax.js @@ -18,16 +18,25 @@ $(window).ready(function() { // Enable submit button if disabled (cache problem) $('input[type="submit"]').removeAttr('disabled'); - + var setup_form = function($form) { $form.submit(function() { if (do_not_ajax) return true; + + // If the captcha is present, halt if it does not have a response. + if (captchaMode === 'static' || (captchaMode === 'dynamic' && isDynamicCaptchaEnabled())) { + if (captcha_renderer && postCaptchaId && !captcha_renderer.hasResponse(postCaptchaId)) { + captcha_renderer.execute(postCaptchaId); + return false; + } + } + var form = this; var submit_txt = $(this).find('input[type="submit"]').val(); if (window.FormData === undefined) return true; - + var formData = new FormData(this); formData.append('json_response', '1'); formData.append('post', submit_txt); @@ -94,15 +103,15 @@ $(window).ready(function() { setTimeout(function() { $(window).trigger("scroll"); }, 100); } }); - + highlightReply(post_response.id); window.location.hash = post_response.id; $(window).scrollTop($(document).height()); - + $(form).find('input[type="submit"]').val(submit_txt); $(form).find('input[type="submit"]').removeAttr('disabled'); $(form).find('input[name="subject"],input[name="file_url"],\ - textarea[name="body"],input[type="file"]').val('').change(); + textarea[name="body"],input[type="file"],input[name="embed"]').val('').change(); }, cache: false, contentType: false, @@ -114,7 +123,7 @@ $(window).ready(function() { $(form).find('input[type="submit"]').val(submit_txt); $(form).find('input[type="submit"]').removeAttr('disabled'); $(form).find('input[name="subject"],input[name="file_url"],\ - textarea[name="body"],input[type="file"]').val('').change(); + textarea[name="body"],input[type="file"],input[name="embed"]').val('').change(); } else { alert(_('An unknown error occured when posting!')); $(form).find('input[type="submit"]').val(submit_txt); @@ -132,10 +141,10 @@ $(window).ready(function() { contentType: false, processData: false }, 'json'); - + $(form).find('input[type="submit"]').val(_('Posting...')); $(form).find('input[type="submit"]').attr('disabled', true); - + return false; }); }; diff --git a/js/catalog.js b/js/catalog.js index c620918c..49fe413c 100644 --- a/js/catalog.js +++ b/js/catalog.js @@ -1,3 +1,9 @@ +/** + * Usage: + * $config['additional_javascript'][] = 'js/jquery.min.js'; + * $config['additional_javascript'][] = 'js/jquery.mixitup.min.js'; + * $config['additional_javascript'][] = 'js/catalog.js'; + */ if (active_page == 'catalog') $(function(){ if (localStorage.catalog !== undefined) { var catalog = JSON.parse(localStorage.catalog); diff --git a/js/inline-expanding.js b/js/inline-expanding.js index 41625d2d..c44843b0 100644 --- a/js/inline-expanding.js +++ b/js/inline-expanding.js @@ -17,6 +17,10 @@ $(document).ready(function() { // Default maximum image loads. const DEFAULT_MAX = 5; + if (localStorage.inline_expand_fit_height !== 'false') { + $('').appendTo($('head')); + } + let inline_expand_post = function() { let link = this.getElementsByTagName('a'); @@ -56,12 +60,12 @@ $(document).ready(function() { }, add: function(ele) { ele.deferred = $.Deferred(); - ele.deferred.done(function () { + ele.deferred.done(function() { let $loadstart = $.Deferred(); let thumb = ele.childNodes[0]; let img = ele.childNodes[1]; - let onLoadStart = function (img) { + let onLoadStart = function(img) { if (img.naturalWidth) { $loadstart.resolve(img, thumb); } else { @@ -69,15 +73,15 @@ $(document).ready(function() { } }; - $(img).one('load', function () { - $.when($loadstart).done(function () { - // Once fully loaded, update the waiting queue. + $(img).one('load', function() { + $.when($loadstart).done(function() { + // once fully loaded, update the waiting queue --loading; $(ele).data('imageLoading', 'false'); update(); }); }); - $loadstart.done(function (img, thumb) { + $loadstart.done(function(img, thumb) { thumb.style.display = 'none'; img.style.display = ''; }); @@ -202,6 +206,8 @@ $(document).ready(function() { Options.extend_tab('general', '' + _('Number of simultaneous image downloads (0 to disable): ') + ''); + Options.extend_tab('general', ''); + $('#inline-expand-max input') .css('width', '50px') .val(localStorage.inline_expand_max || DEFAULT_MAX) @@ -212,6 +218,21 @@ $(document).ready(function() { localStorage.inline_expand_max = val; }); + + $('#inline-expand-fit-height input').on('change', function() { + if (localStorage.inline_expand_fit_height !== 'false') { + localStorage.inline_expand_fit_height = 'false'; + $('#expand-fit-height-style').remove(); + } + else { + localStorage.inline_expand_fit_height = 'true'; + $('').appendTo($('head')); + } + }); + + if (localStorage.inline_expand_fit_height !== 'false') { + $('#inline-expand-fit-height input').prop('checked', true); + } } if (window.jQuery) { diff --git a/js/options/general.js b/js/options/general.js index c0652269..6715ae1d 100644 --- a/js/options/general.js +++ b/js/options/general.js @@ -43,9 +43,6 @@ $(function(){ document.location.reload(); } }); - - - $("#style-select").detach().css({float:"none","margin-bottom":0}).appendTo(tab.content); }); }(); diff --git a/js/post-filter.js b/js/post-filter.js index f3f161c6..78fd35ce 100644 --- a/js/post-filter.js +++ b/js/post-filter.js @@ -237,12 +237,8 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata var postUid = $ele.find('.poster_id').text(); } - let postName; - let postTrip = ''; - if (!pageData.forcedAnon) { - postName = (typeof $ele.find('.name').contents()[0] == 'undefined') ? '' : nameSpanToString($ele.find('.name')[0]); - postTrip = $ele.find('.trip').text(); - } + let postName = (typeof $ele.find('.name').contents()[0] == 'undefined') ? '' : nameSpanToString($ele.find('.name')[0]); + let postTrip = $ele.find('.trip').text(); /* display logic and bind click handlers */ @@ -297,39 +293,34 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata } // name - if (!pageData.forcedAnon && !$ele.data('hiddenByName')) { + if (!$ele.data('hiddenByName')) { $buffer.find('#filter-add-name').click(function () { addFilter('name', postName, false); }); $buffer.find('#filter-remove-name').addClass('hidden'); - } else if (!pageData.forcedAnon) { + } else { $buffer.find('#filter-remove-name').click(function () { removeFilter('name', postName, false); }); - $buffer.find('#filter-add-name').addClass('hidden'); - } else { - // board has forced anon - $buffer.find('#filter-remove-name').addClass('hidden'); $buffer.find('#filter-add-name').addClass('hidden'); } // tripcode - if (!pageData.forcedAnon && !$ele.data('hiddenByTrip') && postTrip !== '') { + if (!$ele.data('hiddenByTrip') && postTrip !== '') { $buffer.find('#filter-add-trip').click(function () { addFilter('trip', postTrip, false); }); $buffer.find('#filter-remove-trip').addClass('hidden'); - } else if (!pageData.forcedAnon && postTrip !== '') { + } else if (postTrip !== '') { $buffer.find('#filter-remove-trip').click(function () { removeFilter('trip', postTrip, false); }); $buffer.find('#filter-add-trip').addClass('hidden'); } else { - // board has forced anon $buffer.find('#filter-remove-trip').addClass('hidden'); $buffer.find('#filter-add-trip').addClass('hidden'); } @@ -391,7 +382,6 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata var localList = pageData.localList; var noReplyList = pageData.noReplyList; var hasUID = pageData.hasUID; - var forcedAnon = pageData.forcedAnon; var hasTrip = ($post.find('.trip').length > 0); var hasSub = ($post.find('.subject').length > 0); @@ -432,9 +422,8 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata } // matches generalFilter - if (!forcedAnon) - name = (typeof $post.find('.name').contents()[0] == 'undefined') ? '' : nameSpanToString($post.find('.name')[0]); - if (!forcedAnon && hasTrip) + name = (typeof $post.find('.name').contents()[0] == 'undefined') ? '' : nameSpanToString($post.find('.name')[0]); + if (hasTrip) trip = $post.find('.trip').text(); if (hasSub) subject = $post.find('.subject').text(); @@ -455,13 +444,13 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata pattern = new RegExp(rule.value); switch (rule.type) { case 'name': - if (!forcedAnon && pattern.test(name)) { + if (pattern.test(name)) { $post.data('hiddenByName', true); hide(post); } break; case 'trip': - if (!forcedAnon && hasTrip && pattern.test(trip)) { + if (hasTrip && pattern.test(trip)) { $post.data('hiddenByTrip', true); hide(post); } @@ -488,13 +477,13 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata } else { switch (rule.type) { case 'name': - if (!forcedAnon && rule.value == name) { + if (rule.value == name) { $post.data('hiddenByName', true); hide(post); } break; case 'trip': - if (!forcedAnon && hasTrip && rule.value == trip) { + if (hasTrip && rule.value == trip) { $post.data('hiddenByTrip', true); hide(post); } @@ -827,8 +816,7 @@ if (active_page === 'thread' || active_page === 'index' || active_page === 'cata boardId: board_name, // get the id from the global variable localList: [], // all the blacklisted post IDs or UIDs that apply to the current page noReplyList: [], // any posts that replies to the contents of this list shall be hidden - hasUID: (document.getElementsByClassName('poster_id').length > 0), - forcedAnon: ($('th:contains(Name)').length === 0) // tests by looking for the Name label on the reply form + hasUID: (document.getElementsByClassName('poster_id').length > 0) }; initStyle(); diff --git a/js/post-menu.js b/js/post-menu.js index 79cfd868..c2155c00 100644 --- a/js/post-menu.js +++ b/js/post-menu.js @@ -104,8 +104,10 @@ function buildMenu(e) { function addButton(post) { var $ele = $(post); + // Use unicode code with ascii variant selector + // https://stackoverflow.com/questions/37906969/how-to-prevent-ios-from-converting-ascii-into-emoji $ele.find('input.delete').after( - $('', {href: '#', class: 'post-btn', title: 'Post menu'}).text('►') + $('', {href: '#', class: 'post-btn', title: 'Post menu'}).text('\u{25B6}\u{fe0e}') ); } diff --git a/js/show-backlinks.js b/js/show-backlinks.js index 5924124e..607c24ab 100644 --- a/js/show-backlinks.js +++ b/js/show-backlinks.js @@ -15,7 +15,7 @@ $(document).ready(function() { let showBackLinks = function() { - let replyId = $(this).attr('id').replace(/^reply_/, ''); + let replyId = $(this).attr('id').split('_')[1]; $(this).find('div.body a:not([rel="nofollow"])').each(function() { let id = $(this).text().match(/^>>(\d+)$/); @@ -25,13 +25,15 @@ $(document).ready(function() { return; } - let post = $('#reply_' + id); - if(post.length == 0) + let post = $('#reply_' + id + ', #op_' + id); + if (post.length == 0) { return; + } let mentioned = post.find('.head div.mentioned'); if (mentioned.length === 0) { - mentioned = $('
').prependTo(post.find('.head')); + // The op has two "head"s divs, use the second. + mentioned = $('
').prependTo(post.find('.head').last()); } if (mentioned.find('a.mentioned-' + replyId).length !== 0) { @@ -48,13 +50,13 @@ $(document).ready(function() { }); }; - $('div.post.reply').each(showBackLinks); + $('div.post').each(showBackLinks); $(document).on('new_post', function(e, post) { - if ($(post).hasClass('reply')) { + if ($(post).hasClass('reply') || $(post).hasClass('op')) { showBackLinks.call(post); } else { - $(post).find('div.post.reply').each(showBackLinks); + $(post).find('div.post').each(showBackLinks); } }); }); diff --git a/js/style-select-simple.js b/js/style-select-simple.js new file mode 100644 index 00000000..8b59fa0a --- /dev/null +++ b/js/style-select-simple.js @@ -0,0 +1,36 @@ +/* + * style-select-simple.js + * + * Changes the stylesheet chooser links to a ').css({float:"none"}); - var options = []; - - var i = 1; - stylesDiv.children().each(function() { - var name = this.innerHTML.replace(/(^\[|\]$)/g, ''); - var opt = $('') - .html(name) - .val(i); - if ($(this).hasClass('selected')) - opt.attr('selected', true); - options.push ([name.toUpperCase (), opt]); - $(this).attr('id', 'style-select-' + i); - i++; - }); + let pages = $('div.pages'); + let stylesSelect = $('').css({float:"none"}); + let options = []; - options.sort ((a, b) => { + let i = 1; + for (styleName in styles) { + if (styleName) { + let opt = $('') + .html(styleName) + .val(i); + if (selectedstyle == styleName) { + opt.attr('selected', true); + } + opt.attr('id', 'style-select-' + i); + options.push([styleName.toUpperCase (), opt]); + i++; + } + } + + options.sort((a, b) => { const keya = a [0]; const keyb = b [0]; - if (keya < keyb) { return -1; } - if (keya > keyb) { return 1; } + if (keya < keyb) { + return -1; + } + if (keya > keyb) { + return 1; + } return 0; - }).forEach (([key, opt]) => { + }).forEach(([key, opt]) => { stylesSelect.append(opt); }); - + stylesSelect.change(function() { - $('#style-select-' + $(this).val()).click(); + let sel = $(this).find(":selected")[0]; + let styleName = sel.innerHTML; + changeStyle(styleName, sel); }); - - stylesDiv.hide() + pages.after( $('
') .append(_('Select theme: '), stylesSelect) diff --git a/js/youtube.js b/js/youtube.js index 4c31ed09..4a5a5afe 100644 --- a/js/youtube.js +++ b/js/youtube.js @@ -1,41 +1,41 @@ /* -* youtube -* https://github.com/savetheinternet/Tinyboard/blob/master/js/youtube.js -* -* Don't load the YouTube player unless the video image is clicked. -* This increases performance issues when many videos are embedded on the same page. -* Currently only compatiable with YouTube. -* -* Proof of concept. -* -* Released under the MIT license -* Copyright (c) 2013 Michael Save -* Copyright (c) 2013-2014 Marcin Łabanowski -* -* Usage: -* $config['embedding'] = array(); -* $config['embedding'][0] = array( -* '/^https?:\/\/(\w+\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9\-_]{10,11})(&.+)?$/i', -* $config['youtube_js_html']); -* $config['additional_javascript'][] = 'js/jquery.min.js'; -* $config['additional_javascript'][] = 'js/youtube.js'; -* -*/ + * Don't load the 3rd party embedded content player unless the image is clicked. + * This increases performance issues when many videos are embedded on the same page. + * + * Released under the MIT license + * Copyright (c) 2013 Michael Save + * Copyright (c) 2013-2014 Marcin Łabanowski + * Copyright (c) 2025 Zankaria Auxa + * + * Usage: + * $config['embedding'] = array(); + * $config['embedding'][0] = array( + * '/^https?:\/\/(\w+\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9\-_]{10,11})(&.+)?$/i', + * $config['youtube_js_html']); + * $config['additional_javascript'][] = 'js/jquery.min.js'; + * $config['additional_javascript'][] = 'js/youtube.js'; + */ -$(document).ready(function(){ - // Adds Options panel item +$(document).ready(function() { + const ON = '[Remove]'; + const YOUTUBE = 'www.youtube.com'; + + function makeEmbedNode(embedHost, videoId, width, height) { + return $(`